Skip to content

Commit d7ed321

Browse files
committed
feat: add multi-region URL resolver and bump version to 1.6.0
Introduce contentstack_utils.Endpoint — a region-aware endpoint resolver backed by a bundled regions.json registry, bringing the Python SDK to feature parity with the PHP and JS implementations. New files: - contentstack_utils/endpoint.py: Endpoint class with in-process cache, two-pass region lookup (id → alias), case-insensitive matching with - / _ interchangeability, omit_https flag, and a CDN fallback download when regions.json is absent - contentstack_utils/assets/regions.json: bundled registry of 7 regions (AWS NA/EU/AU, Azure NA/EU, GCP NA/EU) and 18 service endpoint keys; gitignored so it is not tracked as source — refreshed via refresh script - tests/test_endpoint.py: 53 tests covering all 7 regions, 8 NA aliases, omit_https, error cases, Utils proxy, camelCase alias, and cache isolation - scripts/refresh_regions.py: pulls latest regions.json from artifacts.contentstack.com (equivalent of composer refresh-regions) Modified files: - contentstack_utils/utils.py: add get_contentstack_endpoint() proxy and getContentstackEndpoint camelCase alias on Utils for cross-SDK parity - contentstack_utils/__init__.py: export Endpoint in __all__; bump __version__ to 1.6.0 - setup.py: bump version to 1.6.0 - CHANGELOG.md: add v1.6.0 entry - .gitignore: exclude contentstack_utils/assets/regions.json
1 parent 9a54524 commit d7ed321

8 files changed

Lines changed: 623 additions & 2 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,4 @@ venv.bak/
113113
.mypy_cache/
114114
.idea/
115115
.vscode/
116+
*/assets/regions.json

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## v1.6.0 (2026-06-05)
4+
5+
### New feature: Multi-region endpoint resolution
6+
7+
- Added `Endpoint.get_contentstack_endpoint()` for dynamic region-aware URL resolution across all Contentstack regions and services.
8+
- Added `Utils.get_contentstack_endpoint()` proxy for backward-compatible access via the existing `Utils` import path.
9+
- Added `getContentstackEndpoint` camelCase alias on both `Endpoint` and `Utils` for cross-SDK parity with the PHP and JS implementations.
10+
- Bundled `contentstack_utils/assets/regions.json` — the authoritative registry of 7 regions (AWS NA/EU/AU, Azure NA/EU, GCP NA/EU) and 18 service endpoint keys.
11+
- Added runtime fallback in `Endpoint._load_regions()` — downloads `regions.json` from `artifacts.contentstack.com` on first use when the file is absent.
12+
- Added `scripts/refresh_regions.py` to manually pull the latest regions from Contentstack (equivalent of `composer refresh-regions` in the PHP SDK).
13+
- Exported `Endpoint` at package level in `__all__`.
14+
315
## v1.5.0
416

517
### New feature: Variants utility (CDA entry variant aliases)

contentstack_utils/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from contentstack_utils.embedded.item_type import ItemType
1212
from contentstack_utils.embedded.styletype import StyleType
13+
from contentstack_utils.endpoint import Endpoint
1314
from contentstack_utils.helper.metadata import Metadata
1415
from contentstack_utils.helper.node_to_html import NodeToHtml
1516
from contentstack_utils.render.options import Options
@@ -19,6 +20,7 @@
1920
from contentstack_utils.entry_editable import addEditableTags, addTags, getTag
2021

2122
__all__ = (
23+
"Endpoint",
2224
"Utils",
2325
"Options",
2426
"Metadata",
@@ -35,6 +37,6 @@
3537
__title__ = 'contentstack_utils'
3638
__author__ = 'contentstack'
3739
__status__ = 'debug'
38-
__version__ = '1.4.0'
40+
__version__ = '1.6.0'
3941
__endpoint__ = 'cdn.contentstack.io'
4042
__contact__ = 'support@contentstack.com'

contentstack_utils/endpoint.py

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
"""
2+
Endpoint resolution for Contentstack services across all regions.
3+
4+
Reads a bundled regions.json (src/assets/regions.json) and resolves
5+
the correct base URL for any region + service combination. No runtime
6+
HTTP calls — the file is shipped with the package and updated via
7+
``python scripts/refresh_regions.py``.
8+
"""
9+
10+
import json
11+
import os
12+
import re
13+
import urllib.request
14+
from typing import Dict, Optional, Union
15+
16+
_REGIONS_URL = "https://artifacts.contentstack.com/regions.json"
17+
_ASSETS_DIR = os.path.join(os.path.dirname(__file__), "assets")
18+
_REGIONS_FILE = os.path.join(_ASSETS_DIR, "regions.json")
19+
20+
21+
class Endpoint:
22+
"""
23+
Resolve Contentstack service URLs for any region.
24+
25+
All public methods are static — no instantiation required.
26+
27+
Example::
28+
29+
from contentstack_utils import Endpoint
30+
31+
# Full URL
32+
url = Endpoint.get_contentstack_endpoint("na", "contentDelivery")
33+
# → "https://cdn.contentstack.io"
34+
35+
# Host only (strip https://) — useful for SDK setHost() calls
36+
host = Endpoint.get_contentstack_endpoint("eu", "contentDelivery", omit_https=True)
37+
# → "eu-cdn.contentstack.com"
38+
39+
# All endpoints for a region
40+
all_endpoints = Endpoint.get_contentstack_endpoint("azure-na")
41+
# → {"contentDelivery": "...", "contentManagement": "...", ...}
42+
"""
43+
44+
# Module-level cache — loaded once per Python process, shared across all calls.
45+
_regions_data: Optional[Dict] = None
46+
47+
@staticmethod
48+
def get_contentstack_endpoint(
49+
region: str = "us",
50+
service: str = "",
51+
omit_https: bool = False,
52+
) -> Union[str, Dict[str, str]]:
53+
"""
54+
Resolve a Contentstack service endpoint URL for the given region.
55+
56+
:param region:
57+
Region ID or any accepted alias (case-insensitive, ``-`` and ``_``
58+
are interchangeable). Examples: ``'na'``, ``'us'``, ``'eu'``,
59+
``'AWS-NA'``, ``'azure_eu'``, ``'gcp-na'``.
60+
Defaults to ``'us'`` (AWS North America).
61+
:param service:
62+
Optional service key. When provided, a single URL string is
63+
returned. When omitted, a dict of **all** service URLs is returned.
64+
Valid keys include: ``'contentDelivery'``, ``'contentManagement'``,
65+
``'auth'``, ``'graphqlDelivery'``, ``'preview'``, ``'images'``,
66+
``'assets'``, ``'automate'``, ``'launch'``, ``'developerHub'``,
67+
``'brandKit'``, ``'genAI'``, ``'personalizeManagement'``,
68+
``'personalizeEdge'``, ``'composableStudio'``, ``'assetManagement'``.
69+
:param omit_https:
70+
When ``True``, strips the ``https://`` (or ``http://``) scheme from
71+
every returned URL. Useful when passing the host to an SDK that
72+
constructs its own URLs (e.g. ``stack.set_host(host)``).
73+
:returns:
74+
- A ``str`` URL when *service* is specified.
75+
- A ``dict[str, str]`` mapping service keys → URLs when *service*
76+
is omitted.
77+
:raises ValueError:
78+
If *region* is an empty string.
79+
:raises LookupError:
80+
If *region* does not match any known region ID or alias, or if
81+
*service* is not present in the resolved region's endpoint map.
82+
:raises RuntimeError:
83+
If the bundled ``regions.json`` cannot be read or is malformed.
84+
85+
Examples::
86+
87+
Endpoint.get_contentstack_endpoint("na", "contentDelivery")
88+
# → "https://cdn.contentstack.io"
89+
90+
Endpoint.get_contentstack_endpoint("eu", "contentDelivery", omit_https=True)
91+
# → "eu-cdn.contentstack.com"
92+
93+
Endpoint.get_contentstack_endpoint("azure-na")
94+
# → {"contentDelivery": "https://...", ...}
95+
"""
96+
if not region:
97+
raise ValueError("Empty region provided. Please put valid region.")
98+
99+
data = Endpoint._load_regions()
100+
normalized = region.strip().lower()
101+
102+
if not normalized:
103+
raise ValueError("Empty region provided. Please put valid region.")
104+
region_row = Endpoint._find_region_by_id_or_alias(data["regions"], normalized)
105+
106+
if region_row is None:
107+
raise LookupError(f"Invalid region: {region}")
108+
109+
if service:
110+
endpoints = region_row["endpoints"]
111+
if service not in endpoints:
112+
raise LookupError(
113+
f'Service "{service}" not found for region "{region_row["id"]}"'
114+
)
115+
url = endpoints[service]
116+
return Endpoint._strip_https(url) if omit_https else url
117+
118+
endpoints = dict(region_row["endpoints"])
119+
return Endpoint._strip_https_from_map(endpoints) if omit_https else endpoints
120+
121+
# ------------------------------------------------------------------
122+
# JS/PHP parity alias — lets callers use the same camelCase name
123+
# across all Contentstack SDK languages without a lookup.
124+
# ------------------------------------------------------------------
125+
getContentstackEndpoint = get_contentstack_endpoint
126+
127+
@staticmethod
128+
def reset_cache() -> None:
129+
"""
130+
Clear the in-memory region cache.
131+
132+
Intended for testing only — forces the next call to re-read
133+
``regions.json`` from disk.
134+
"""
135+
Endpoint._regions_data = None
136+
137+
# ------------------------------------------------------------------
138+
# Internal helpers
139+
# ------------------------------------------------------------------
140+
141+
@staticmethod
142+
def _load_regions() -> Dict:
143+
"""
144+
Load and cache regions data.
145+
146+
Resolution order:
147+
1. In-memory cache (zero I/O after the first call in a process)
148+
2. Bundled ``contentstack_utils/assets/regions.json`` on disk
149+
3. Live download from ``artifacts.contentstack.com`` (fallback when
150+
the file is absent — e.g. an editable install without assets)
151+
"""
152+
if Endpoint._regions_data is not None:
153+
return Endpoint._regions_data
154+
155+
if not os.path.exists(_REGIONS_FILE):
156+
Endpoint._download_and_save(_REGIONS_FILE)
157+
158+
if not os.path.exists(_REGIONS_FILE):
159+
raise RuntimeError(
160+
"contentstack_utils: regions.json not found and could not be downloaded. "
161+
"Run 'python scripts/refresh_regions.py' and ensure network access."
162+
)
163+
164+
try:
165+
with open(_REGIONS_FILE, "r", encoding="utf-8") as fh:
166+
raw = fh.read()
167+
except OSError as exc:
168+
raise RuntimeError(
169+
f"contentstack_utils: Could not read regions.json: {exc}"
170+
) from exc
171+
172+
try:
173+
decoded = json.loads(raw)
174+
except json.JSONDecodeError as exc:
175+
raise RuntimeError(
176+
"contentstack_utils: regions.json is corrupt. "
177+
"Run 'python scripts/refresh_regions.py' to re-download it."
178+
) from exc
179+
180+
if not isinstance(decoded, dict) or "regions" not in decoded:
181+
raise RuntimeError(
182+
"contentstack_utils: regions.json is corrupt. "
183+
"Run 'python scripts/refresh_regions.py' to re-download it."
184+
)
185+
186+
Endpoint._regions_data = decoded
187+
return Endpoint._regions_data
188+
189+
@staticmethod
190+
def _download_and_save(dest: str) -> None:
191+
"""
192+
Fetch regions.json from the Contentstack CDN and write it to *dest*.
193+
194+
Silent on failure — the caller decides whether a missing file is fatal.
195+
"""
196+
os.makedirs(os.path.dirname(dest), exist_ok=True)
197+
try:
198+
with urllib.request.urlopen(_REGIONS_URL, timeout=30) as resp:
199+
data = resp.read().decode("utf-8")
200+
except Exception:
201+
return
202+
203+
try:
204+
decoded = json.loads(data)
205+
except json.JSONDecodeError:
206+
return
207+
208+
if isinstance(decoded, dict) and "regions" in decoded:
209+
with open(dest, "w", encoding="utf-8") as fh:
210+
fh.write(data)
211+
212+
@staticmethod
213+
def _find_region_by_id_or_alias(
214+
regions: list, normalized_input: str
215+
) -> Optional[Dict]:
216+
"""
217+
Find a region by its ``id`` field first, then by any alias.
218+
219+
Both passes are case-insensitive (caller must pass a lowercased string).
220+
Two-pass approach mirrors the PHP implementation: ID match wins over alias
221+
match, which avoids surprising behaviour when a future alias happens to
222+
collide with another region's canonical ID.
223+
"""
224+
# Pass 1 — exact id match
225+
for row in regions:
226+
if row["id"] == normalized_input:
227+
return row
228+
229+
# Pass 2 — alias match
230+
for row in regions:
231+
for alias in row.get("alias", []):
232+
if alias.lower() == normalized_input:
233+
return row
234+
235+
return None
236+
237+
@staticmethod
238+
def _strip_https(url: str) -> str:
239+
"""Strip ``https://`` or ``http://`` from the start of a URL."""
240+
return re.sub(r"^https?://", "", url)
241+
242+
@staticmethod
243+
def _strip_https_from_map(endpoints: Dict[str, str]) -> Dict[str, str]:
244+
"""Return a new dict with the scheme stripped from every URL value."""
245+
return {key: Endpoint._strip_https(url) for key, url in endpoints.items()}

contentstack_utils/utils.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from lxml import etree
77

88
from contentstack_utils.automate import Automate
9+
from contentstack_utils.endpoint import Endpoint
910
from contentstack_utils.entry_editable import addEditableTags as _addEditableTags
1011
from contentstack_utils.entry_editable import addTags as _addTags
1112
from contentstack_utils.entry_editable import getTag as _getTag
@@ -219,6 +220,34 @@ def __get_metadata(elements):
219220
metadata = Metadata(element.text, typeof, uid, content_type, style, outer_html, attributes)
220221
return metadata
221222

223+
# ------------------------------------------------------------------
224+
# Endpoint resolution — thin proxy to Endpoint class
225+
# ------------------------------------------------------------------
226+
227+
@staticmethod
228+
def get_contentstack_endpoint(
229+
region: str = "us",
230+
service: str = "",
231+
omit_https: bool = False,
232+
):
233+
"""
234+
Resolve a Contentstack service URL for the given region.
235+
236+
Delegates entirely to :class:`~contentstack_utils.endpoint.Endpoint`.
237+
Both ``Utils.get_contentstack_endpoint(...)`` and
238+
``Endpoint.get_contentstack_endpoint(...)`` produce identical results —
239+
choose whichever import path suits your codebase.
240+
241+
:param region: Region ID or alias (e.g. ``'na'``, ``'eu'``, ``'azure-na'``).
242+
:param service: Optional service key (e.g. ``'contentDelivery'``).
243+
:param omit_https: Strip ``https://`` from the returned URL(s).
244+
:returns: URL string when *service* is given, dict of all URLs otherwise.
245+
"""
246+
return Endpoint.get_contentstack_endpoint(region, service, omit_https)
247+
248+
# camelCase alias for cross-SDK parity
249+
getContentstackEndpoint = get_contentstack_endpoint
250+
222251
####################################################
223252
# SUPERCHARGED #
224253
####################################################

0 commit comments

Comments
 (0)