Skip to content

Commit b1c8525

Browse files
authored
Merge pull request #164 from contentstack/development
DX | 22-06-2026 | Release
2 parents dec0e0d + f5be3cb commit b1c8525

9 files changed

Lines changed: 701 additions & 10 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,4 +137,5 @@ tests/resources/.DS_Store
137137
tests/.DS_Store
138138
tests/resources/.DS_Store
139139
.DS_Store
140+
*/data/regions.json
140141
.talismanrc

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
# CHANGELOG
22

33
## Content Management SDK For Python
4+
---
5+
## v1.10.0
6+
7+
#### Date: 22 June 2026
8+
9+
- Dynamic region endpoint resolution via the Contentstack Regions Registry (`regions.json`).
10+
- Added `Endpoint` class with 3-tier resolution: in-memory cache → bundled `data/regions.json` → live CDN download.
11+
- Exposed `contentstack_management.get_contentstack_endpoint(region, service, omit_https)` module-level proxy.
12+
- `Client` now resolves the `contentManagement` endpoint from the registry instead of a hardcoded host pattern.
13+
- Bundled `contentstack_management/data/regions.json` included in `package_data` — always present after `pip install`.
14+
- `setup.py` auto-refreshes `regions.json` at build time via a custom `BuildPyWithRegions` command; network failures warn but never block the build.
15+
- Runtime fallback: if `regions.json` is absent, the SDK downloads it live on the first `Endpoint` call.
16+
- New regions and services require no SDK code changes — registry update is sufficient.
17+
- Added `refresh_regions()` utility to programmatically download the latest regions manifest from the Contentstack CDN and overwrite the bundled `data/regions.json` (`from contentstack_management import refresh_regions`).
18+
- Added `python3 -m contentstack_management.region_refresh` CLI command for refreshing the registry after `pip install` (source-tree script `scripts/download_regions.py` is for contributors only).
19+
420
---
521
## v1.9.0
622

contentstack_management/__init__.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from .entries.entry import Entry
1919
from .entry_variants.entry_variants import EntryVariants
2020
from .contentstack import Client, Region
21+
from .endpoint import Endpoint
2122
from ._api_client import _APIClient
2223
from .common import Parameter
2324
from ._errors import ArgumentException
@@ -36,11 +37,13 @@
3637
from .variants.variants import Variants
3738
from .oauth.oauth_handler import OAuthHandler
3839
from .oauth.oauth_interceptor import OAuthInterceptor
40+
from .region_refresh import refresh_regions
3941

4042

4143
__all__ = (
4244
"Client",
4345
"Region",
46+
"Endpoint",
4447
"_APIClient",
4548
"Parameter",
4649
"ArgumentException",
@@ -75,14 +78,31 @@
7578
"VariantGroup",
7679
"Variants",
7780
"OAuthHandler",
78-
"OAuthInterceptor"
81+
"OAuthInterceptor",
82+
"refresh_regions",
7983
)
8084

85+
def get_contentstack_endpoint(region='us', service='', omit_https=False):
86+
"""
87+
Resolve a Contentstack service endpoint URL for a given region.
88+
89+
Proxy to :class:`Endpoint.get_contentstack_endpoint` for convenience —
90+
mirrors ``Contentstack::getContentstackEndpoint()`` in the PHP SDK.
91+
92+
:param region: Region ID or alias ('us', 'eu', 'azure-na', 'gcp-eu', ...).
93+
:param service: Service key ('contentDelivery', 'contentManagement', ...).
94+
When empty, returns a dict of all endpoints for the region.
95+
:param omit_https: When True, strips 'https://' from the returned URL(s).
96+
:returns: str when service is provided, dict[str,str] otherwise.
97+
"""
98+
return Endpoint.get_contentstack_endpoint(region, service, omit_https)
99+
100+
81101
__title__ = 'contentstack-management-python'
82102
__author__ = 'dev-ex'
83103
__status__ = 'debug'
84104
__region__ = 'na'
85-
__version__ = '1.9.0'
105+
__version__ = '1.10.0'
86106
__host__ = 'api.contentstack.io'
87107
__protocol__ = 'https://'
88108
__api_version__ = 'v3'

contentstack_management/contentstack.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33
import pyotp
44
from ._api_client import _APIClient
5+
from .endpoint import Endpoint
56
from contentstack_management.organizations import organization
67
from contentstack_management.stack import stack
78
from contentstack_management.user_session import user_session
@@ -37,16 +38,25 @@ def __init__(self, host: str = 'api.contentstack.io', scheme: str = 'https://',
3738
authtoken: str = None , management_token=None, headers: dict = None,
3839
region: Region = Region.US.value, version='v3', timeout=2, max_retries: int = 18, early_access: list = None,
3940
oauth_config: dict = None, **kwargs):
40-
self.endpoint = 'https://api.contentstack.io/v3/'
41-
42-
if region is not None and region is not Region.US.value:
43-
if host is not None and host != 'api.contentstack.io':
41+
_DEFAULT_HOST = 'api.contentstack.io'
42+
self.endpoint = f'{scheme}{_DEFAULT_HOST}/{version}/'
43+
44+
if host is None or host == _DEFAULT_HOST:
45+
# No custom host — resolve via Endpoint (regions.json-driven)
46+
try:
47+
base = Endpoint.get_contentstack_endpoint(
48+
region or 'us', 'contentManagement', omit_https=True)
49+
self.endpoint = f'{scheme}{base}/{version}/'
50+
except (ValueError, RuntimeError):
51+
# Unknown/custom region string — fall back to legacy pattern
52+
if region and region != Region.US.value:
53+
self.endpoint = f'{scheme}{region}-api.contentstack.com/{version}/'
54+
else:
55+
# Explicit custom host always wins; apply region prefix when non-US
56+
if region and region != Region.US.value:
4457
self.endpoint = f'{scheme}{region}-api.{host}/{version}/'
4558
else:
46-
host = 'api.contentstack.com'
47-
self.endpoint = f'{scheme}{region}-{host}/{version}/'
48-
elif host is not None and host != 'api.contentstack.io':
49-
self.endpoint = f'{scheme}{host}/{version}/'
59+
self.endpoint = f'{scheme}{host}/{version}/'
5060
if headers is None:
5161
headers = {}
5262
if early_access is not None:
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
"""
2+
Endpoint — Contentstack region-to-URL resolver for the Management SDK.
3+
4+
Resolves Contentstack service endpoint URLs for any supported region.
5+
Region data is loaded from contentstack_management/data/regions.json (bundled)
6+
and cached in-memory for the lifetime of the process. When the bundled file is
7+
absent the class attempts a live download from the Contentstack CDN so the
8+
SDK continues to work even when the file was not created during installation.
9+
"""
10+
11+
import json
12+
import os
13+
import re
14+
15+
REGIONS_URL = 'https://artifacts.contentstack.com/regions.json'
16+
17+
18+
class Endpoint:
19+
"""
20+
Resolves Contentstack service endpoint URLs for any supported region.
21+
22+
Usage::
23+
24+
from contentstack_management.endpoint import Endpoint
25+
26+
# Single service URL
27+
url = Endpoint.get_contentstack_endpoint('eu', 'contentManagement')
28+
# 'https://eu-api.contentstack.com'
29+
30+
# All services for a region
31+
endpoints = Endpoint.get_contentstack_endpoint('azure-na')
32+
# {'contentDelivery': '...', 'contentManagement': '...', ...}
33+
34+
# Strip scheme (useful when building endpoint strings manually)
35+
host = Endpoint.get_contentstack_endpoint('gcp-eu', 'contentManagement', omit_https=True)
36+
# 'gcp-eu-api.contentstack.com'
37+
"""
38+
39+
_regions_data = None # in-memory cache — shared across all instances
40+
41+
@staticmethod
42+
def get_contentstack_endpoint(region='us', service='', omit_https=False):
43+
"""
44+
Resolve a Contentstack service endpoint URL for a given region.
45+
46+
:param region: Region ID or alias ('us', 'eu', 'azure-na', 'gcp-eu', etc.).
47+
Defaults to 'us' (AWS North America).
48+
:param service: Service key ('contentDelivery', 'contentManagement', ...).
49+
When empty, returns a dict of all endpoints for the region.
50+
:param omit_https: When True, strips 'https://' prefix from returned URL(s).
51+
:returns: str when service is provided, dict[str,str] otherwise.
52+
:raises ValueError: When region is empty, unknown, or service is not found.
53+
:raises RuntimeError: When regions.json cannot be read or parsed.
54+
"""
55+
if not region:
56+
raise ValueError('Empty region provided. Please put valid region.')
57+
58+
data = Endpoint._load_regions()
59+
normalized = region.strip().lower()
60+
region_row = Endpoint._find_region(data['regions'], normalized)
61+
62+
if region_row is None:
63+
raise ValueError(f'Invalid region: {region}')
64+
65+
if service:
66+
if service not in region_row['endpoints']:
67+
raise ValueError(
68+
f'Service "{service}" not found for region "{region_row["id"]}"'
69+
)
70+
url = region_row['endpoints'][service]
71+
return Endpoint._strip_https(url) if omit_https else url
72+
73+
endpoints = region_row['endpoints']
74+
if omit_https:
75+
return {k: Endpoint._strip_https(v) for k, v in endpoints.items()}
76+
return dict(endpoints)
77+
78+
@staticmethod
79+
def _load_regions():
80+
"""
81+
Load and cache regions.json.
82+
83+
Resolution order:
84+
1. In-memory static cache (zero I/O after first call)
85+
2. contentstack_management/data/regions.json on disk (written by download script)
86+
3. Live download from artifacts.contentstack.com (fallback)
87+
"""
88+
if Endpoint._regions_data is not None:
89+
return Endpoint._regions_data
90+
91+
data_dir = os.path.join(os.path.dirname(__file__), 'data')
92+
path = os.path.join(data_dir, 'regions.json')
93+
94+
if not os.path.exists(path):
95+
Endpoint._download_and_save(path)
96+
97+
if not os.path.exists(path):
98+
raise RuntimeError(
99+
'contentstack-management: regions.json not found and could not be downloaded. '
100+
'Run "python scripts/download_regions.py" and ensure network access.'
101+
)
102+
103+
try:
104+
with open(path, 'r', encoding='utf-8') as f:
105+
decoded = json.load(f)
106+
except (OSError, json.JSONDecodeError) as exc:
107+
raise RuntimeError(
108+
f'contentstack-management: Could not read or parse regions.json: {exc}. '
109+
'Run "python scripts/download_regions.py" to re-download it.'
110+
) from exc
111+
112+
if not isinstance(decoded, dict) or 'regions' not in decoded:
113+
raise RuntimeError(
114+
'contentstack-management: regions.json is corrupt. '
115+
'Run "python scripts/download_regions.py" to re-download it.'
116+
)
117+
118+
Endpoint._regions_data = decoded
119+
return Endpoint._regions_data
120+
121+
@staticmethod
122+
def _download_and_save(dest):
123+
"""
124+
Download regions.json from the Contentstack CDN and save to disk.
125+
Uses the requests library (already an SDK dependency).
126+
Silent on failure — the caller decides whether a missing file is fatal.
127+
128+
:param dest: Absolute path to write the file to.
129+
"""
130+
os.makedirs(os.path.dirname(dest), exist_ok=True)
131+
132+
try:
133+
import requests
134+
response = requests.get(REGIONS_URL, timeout=30)
135+
response.raise_for_status()
136+
data = response.text
137+
except Exception: # noqa: BLE001
138+
return
139+
140+
try:
141+
decoded = json.loads(data)
142+
except json.JSONDecodeError:
143+
return
144+
145+
if isinstance(decoded, dict) and 'regions' in decoded:
146+
try:
147+
with open(dest, 'w', encoding='utf-8') as f:
148+
f.write(data)
149+
except OSError:
150+
pass
151+
152+
@staticmethod
153+
def _find_region(regions, input_str):
154+
"""
155+
Find a region entry by its id or any alias (case-insensitive).
156+
157+
Two-pass: exact id match first, then alias[] scan — mirrors PHP implementation.
158+
159+
:param regions: list of region dicts from regions.json
160+
:param input_str: already-lowercased input
161+
:returns: region dict or None
162+
"""
163+
for row in regions:
164+
if row['id'] == input_str:
165+
return row
166+
for row in regions:
167+
for alias in row.get('alias', []):
168+
if alias.lower() == input_str:
169+
return row
170+
return None
171+
172+
@staticmethod
173+
def _strip_https(url):
174+
"""Strip the https:// (or http://) scheme from a URL string."""
175+
return re.sub(r'^https?://', '', url)
176+
177+
@staticmethod
178+
def reset_cache():
179+
"""Reset the internal region cache. Intended for testing only."""
180+
Endpoint._regions_data = None
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""
2+
Utility to pull the latest regions.json from the Contentstack CDN and
3+
overwrite the bundled copy at contentstack_management/data/regions.json.
4+
5+
Exposed as a package-level function so tooling and CI pipelines can call it
6+
programmatically instead of invoking the script directly:
7+
8+
from contentstack_management import refresh_regions
9+
refresh_regions()
10+
"""
11+
12+
import json
13+
import os
14+
import sys
15+
import urllib.request
16+
17+
_REGIONS_URL = "https://artifacts.contentstack.com/regions.json"
18+
_ASSET_PATH = os.path.join(os.path.dirname(__file__), "data", "regions.json")
19+
20+
21+
def refresh_regions(
22+
url: str = _REGIONS_URL,
23+
dest: str = _ASSET_PATH,
24+
*,
25+
timeout: int = 30,
26+
silent: bool = False,
27+
) -> dict:
28+
"""
29+
Download the latest regions manifest from the Contentstack CDN and write
30+
it to the bundled data file so all consumers get the update.
31+
32+
@param url - URL to fetch regions.json from (defaults to Contentstack CDN)
33+
@param dest - Destination file path (defaults to contentstack_management/data/regions.json)
34+
@param timeout - HTTP request timeout in seconds
35+
@param silent - Suppress progress output when True
36+
@returns The parsed regions dict on success
37+
@raises RuntimeError on download failure, invalid JSON, or unexpected schema
38+
"""
39+
dest = os.path.normpath(dest)
40+
41+
if not silent:
42+
print(f"Fetching {url} ...")
43+
44+
try:
45+
with urllib.request.urlopen(url, timeout=timeout) as resp:
46+
data = resp.read().decode("utf-8")
47+
except Exception as exc:
48+
raise RuntimeError(f"Could not download regions.json: {exc}") from exc
49+
50+
try:
51+
decoded = json.loads(data)
52+
except json.JSONDecodeError as exc:
53+
raise RuntimeError(f"Downloaded content is not valid JSON: {exc}") from exc
54+
55+
if not isinstance(decoded, dict) or "regions" not in decoded:
56+
raise RuntimeError("Downloaded JSON does not contain a 'regions' key.")
57+
58+
os.makedirs(os.path.dirname(dest), exist_ok=True)
59+
with open(dest, "w", encoding="utf-8") as fh:
60+
json.dump(decoded, fh, indent=2, ensure_ascii=False)
61+
fh.write("\n")
62+
63+
region_count = len(decoded["regions"])
64+
if not silent:
65+
print(f"OK: Wrote {region_count} regions to {dest}")
66+
67+
return decoded
68+
69+
70+
def _cli_main() -> int:
71+
"""Entry point kept for backward compatibility with the scripts/ invocation."""
72+
try:
73+
refresh_regions()
74+
return 0
75+
except RuntimeError as exc:
76+
print(f"ERROR: {exc}", file=sys.stderr)
77+
return 1
78+
79+
80+
if __name__ == "__main__":
81+
sys.exit(_cli_main())

0 commit comments

Comments
 (0)