Skip to content

Commit 057e873

Browse files
authored
Merge pull request #159 from contentstack/enhc/DX-7277
feat: Added Endpoint Integration for CMA Python SDK
2 parents b3eb74a + 22b4750 commit 057e873

8 files changed

Lines changed: 597 additions & 9 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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
# CHANGELOG
22

33
## Content Management SDK For Python
4+
---
5+
## v1.10.0
6+
7+
#### Date: 08 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+
- Added `scripts/download_regions.py` to refresh the bundled registry file.
14+
- New regions and services require no SDK code changes — registry update is sufficient.
15+
416
---
517
## v1.9.0
618

contentstack_management/__init__.py

Lines changed: 19 additions & 1 deletion
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
@@ -41,6 +42,7 @@
4142
__all__ = (
4243
"Client",
4344
"Region",
45+
"Endpoint",
4446
"_APIClient",
4547
"Parameter",
4648
"ArgumentException",
@@ -78,11 +80,27 @@
7880
"OAuthInterceptor"
7981
)
8082

83+
def get_contentstack_endpoint(region='us', service='', omit_https=False):
84+
"""
85+
Resolve a Contentstack service endpoint URL for a given region.
86+
87+
Proxy to :class:`Endpoint.get_contentstack_endpoint` for convenience —
88+
mirrors ``Contentstack::getContentstackEndpoint()`` in the PHP SDK.
89+
90+
:param region: Region ID or alias ('us', 'eu', 'azure-na', 'gcp-eu', ...).
91+
:param service: Service key ('contentDelivery', 'contentManagement', ...).
92+
When empty, returns a dict of all endpoints for the region.
93+
:param omit_https: When True, strips 'https://' from the returned URL(s).
94+
:returns: str when service is provided, dict[str,str] otherwise.
95+
"""
96+
return Endpoint.get_contentstack_endpoint(region, service, omit_https)
97+
98+
8199
__title__ = 'contentstack-management-python'
82100
__author__ = 'dev-ex'
83101
__status__ = 'debug'
84102
__region__ = 'na'
85-
__version__ = '1.9.0'
103+
__version__ = '1.10.0'
86104
__host__ = 'api.contentstack.io'
87105
__protocol__ = 'https://'
88106
__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

scripts/download_regions.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""
2+
Downloads the Contentstack regions registry from the official source and
3+
saves it to contentstack_management/data/regions.json.
4+
5+
Run manually:
6+
python3 scripts/download_regions.py
7+
"""
8+
9+
import json
10+
import os
11+
import sys
12+
import requests
13+
14+
REGIONS_URL = 'https://artifacts.contentstack.com/regions.json'
15+
16+
DEST = os.path.join(
17+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
18+
'contentstack_management', 'data', 'regions.json'
19+
)
20+
21+
22+
def download():
23+
dest_dir = os.path.dirname(DEST)
24+
os.makedirs(dest_dir, exist_ok=True)
25+
26+
print(f'contentstack-management: Downloading regions.json from {REGIONS_URL} ...')
27+
28+
try:
29+
response = requests.get(REGIONS_URL, timeout=30)
30+
response.raise_for_status()
31+
data = response.text
32+
except Exception as exc:
33+
sys.stderr.write(
34+
f'contentstack-management: Warning — could not download regions.json: {exc}. '
35+
'The SDK will attempt to download it at runtime on first use.\n'
36+
)
37+
sys.exit(0)
38+
39+
try:
40+
decoded = json.loads(data)
41+
except json.JSONDecodeError:
42+
sys.stderr.write(
43+
'contentstack-management: Warning — downloaded data is not valid JSON.\n'
44+
)
45+
sys.exit(0)
46+
47+
if not isinstance(decoded, dict) or 'regions' not in decoded or \
48+
not isinstance(decoded['regions'], list):
49+
sys.stderr.write(
50+
'contentstack-management: Warning — downloaded data is not a valid regions.json.\n'
51+
)
52+
sys.exit(0)
53+
54+
try:
55+
with open(DEST, 'w', encoding='utf-8') as f:
56+
f.write(data)
57+
except OSError as exc:
58+
sys.stderr.write(
59+
f'contentstack-management: Warning — could not write regions.json to {DEST}: {exc}\n'
60+
)
61+
sys.exit(0)
62+
63+
region_count = len(decoded['regions'])
64+
print(f'contentstack-management: regions.json downloaded ({region_count} regions) → {DEST}')
65+
66+
67+
if __name__ == '__main__':
68+
download()

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ def get_author_email(package):
3636
name="contentstack-management",
3737
version=get_version(package),
3838
packages=find_packages(exclude=['tests']),
39+
package_data={'contentstack_management': ['data/regions.json']},
3940
py_modules=['_api_client', 'contentstack','common','_errors','_constant'],
4041
description="Contentstack API Client Library for Python",
4142
long_description=long_description,

0 commit comments

Comments
 (0)