Skip to content

Commit d8dbbec

Browse files
committed
feat: Added support for endpoint integration
1 parent 9d196f6 commit d8dbbec

10 files changed

Lines changed: 598 additions & 17 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,4 @@ venv.bak/
118118
.mypy_cache/
119119
.idea/
120120
.vscode/
121+
*/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+
## _v2.6.0_
4+
5+
### **Date: 07-June-2026**
6+
7+
- Dynamic endpoint resolution via new `Endpoint` class.
8+
- Region-to-URL mapping is now loaded from a bundled `regions.json` (sourced from `artifacts.contentstack.com`) instead of hardcoded `if/elif` chains.
9+
- Added `Endpoint.get_contentstack_endpoint(region, service, omit_https)` — resolves any supported region to its `contentDelivery`, `contentManagement`, or other service URL.
10+
- Added `contentstack.get_contentstack_endpoint()` module-level proxy.
11+
- `Stack` now auto-resolves `host` and `live_preview` management host via `Endpoint` on initialization.
12+
- Added `scripts/download_regions.py` to pre-populate `regions.json` at install time.
13+
- Runtime fallback: if `regions.json` is absent, the SDK downloads it live on first use.
14+
315
## _v2.5.1_
416

517
### **Date: 15-April-2026**

contentstack/__init__.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .entry import Entry
77
from .asset import Asset
88
from .contenttype import ContentType
9+
from .endpoint import Endpoint
910
from .https_connection import HTTPSConnection
1011
from contentstack.stack import Stack
1112
from .utility import Utils
@@ -14,15 +15,32 @@
1415
"Entry",
1516
"Asset",
1617
"ContentType",
18+
"Endpoint",
1719
"HTTPSConnection",
1820
"Stack",
1921
"Utils"
2022
)
2123

24+
25+
def get_contentstack_endpoint(region='us', service='', omit_https=False):
26+
"""
27+
Resolve a Contentstack service endpoint URL for a given region.
28+
29+
Proxy to :class:`Endpoint.get_contentstack_endpoint` for convenience —
30+
mirrors ``Contentstack::getContentstackEndpoint()`` in the PHP SDK.
31+
32+
:param region: Region ID or alias ('us', 'eu', 'azure-na', 'gcp-eu', ...).
33+
:param service: Service key ('contentDelivery', 'contentManagement', ...).
34+
When empty, returns a dict of all endpoints for the region.
35+
:param omit_https: When True, strips 'https://' from the returned URL(s).
36+
:returns: str when service is provided, dict[str,str] otherwise.
37+
"""
38+
return Endpoint.get_contentstack_endpoint(region, service, omit_https)
39+
2240
__title__ = 'contentstack-delivery-python'
2341
__author__ = 'contentstack'
2442
__status__ = 'debug'
25-
__version__ = 'v2.5.1'
43+
__version__ = 'v2.6.0'
2644
__endpoint__ = 'cdn.contentstack.io'
2745
__email__ = 'support@contentstack.com'
2846
__developer_email__ = 'mobile@contentstack.com'

contentstack/contenttype.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
# ************* Module ContentType **************
99
# Your code has been rated at 10.00/10 by pylint
10+
from __future__ import annotations
1011
import json
1112
import logging
1213
from urllib import parse

contentstack/endpoint.py

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.
3+
4+
Resolves Contentstack service endpoint URLs for any supported region.
5+
Region data is loaded from contentstack/assets/regions.json (bundled) and
6+
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.endpoint import Endpoint
25+
26+
# Single service URL
27+
url = Endpoint.get_contentstack_endpoint('eu', 'contentDelivery')
28+
# 'https://eu-cdn.contentstack.com'
29+
30+
# All services for a region
31+
endpoints = Endpoint.get_contentstack_endpoint('azure-na')
32+
# {'contentDelivery': 'https://...', 'contentManagement': 'https://...', ...}
33+
34+
# Strip scheme (useful when setting host directly)
35+
host = Endpoint.get_contentstack_endpoint('gcp-eu', 'contentDelivery', omit_https=True)
36+
# 'gcp-eu-cdn.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/assets/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+
assets_dir = os.path.join(os.path.dirname(__file__), 'assets')
92+
path = os.path.join(assets_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: 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: 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: 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

contentstack/entry.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
API Reference: https://www.contentstack.com/docs/developers/apis/content-delivery-api/#single-entry
44
"""
55
#min-similarity-lines=10
6+
from __future__ import annotations
67
import logging
78
from urllib import parse
89
from contentstack.error_messages import ErrorMessages

contentstack/stack.py

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from contentstack.asset import Asset
88
from contentstack.assetquery import AssetQuery
99
from contentstack.contenttype import ContentType
10+
from contentstack.endpoint import Endpoint
1011
from contentstack.taxonomy import Taxonomy
1112
from contentstack.globalfields import GlobalField
1213
from contentstack.https_connection import HTTPSConnection
@@ -119,20 +120,15 @@ def _validate_stack(self):
119120
if self.environment is None or self.environment == "":
120121
raise PermissionError(ErrorMessages.INVALID_ENVIRONMENT_TOKEN)
121122

122-
if self.region.value == 'eu' and self.host == DEFAULT_HOST:
123-
self.host = 'eu-cdn.contentstack.com'
124-
elif self.region.value == 'au' and self.host == DEFAULT_HOST:
125-
self.host = 'au-cdn.contentstack.com'
126-
elif self.region.value == 'azure-na' and self.host == DEFAULT_HOST:
127-
self.host = 'azure-na-cdn.contentstack.com'
128-
elif self.region.value == 'azure-eu' and self.host == DEFAULT_HOST:
129-
self.host = 'azure-eu-cdn.contentstack.com'
130-
elif self.region.value == 'gcp-na' and self.host == DEFAULT_HOST:
131-
self.host = 'gcp-na-cdn.contentstack.com'
132-
elif self.region.value == 'gcp-eu' and self.host == DEFAULT_HOST:
133-
self.host = 'gcp-eu-cdn.contentstack.com'
134-
elif self.region.value != 'us':
135-
self.host = f'{self.region.value}-{DEFAULT_HOST}'
123+
if self.host == DEFAULT_HOST:
124+
try:
125+
self.host = Endpoint.get_contentstack_endpoint(
126+
self.region.value, 'contentDelivery', omit_https=True)
127+
except (ValueError, RuntimeError):
128+
# Unknown/custom region — fall back to legacy pattern so
129+
# code written before this feature was added continues to work.
130+
if self.region.value != 'us':
131+
self.host = f'{self.region.value}-{DEFAULT_HOST}'
136132
self.endpoint = f'https://{self.host}/{self.version}'
137133

138134
def _setup_headers(self):
@@ -365,8 +361,14 @@ def image_transform(self, image_url, **kwargs):
365361

366362
def _setup_live_preview(self):
367363
if self.live_preview and self.live_preview.get("enable"):
368-
region_prefix = "" if self.region.value == "us" else f"{self.region.value}-"
369-
self.live_preview["host"] = f"{region_prefix}rest-preview.contentstack.com"
364+
if not self.live_preview.get("host"):
365+
try:
366+
mgmt_host = Endpoint.get_contentstack_endpoint(
367+
self.region.value, 'contentManagement', omit_https=True)
368+
except (ValueError, RuntimeError):
369+
region_prefix = "" if self.region.value == "us" else f"{self.region.value}-"
370+
mgmt_host = f"{region_prefix}api.contentstack.io"
371+
self.live_preview["host"] = mgmt_host
370372

371373
if self.live_preview.get("preview_token"):
372374
self.headers["preview_token"] = self.live_preview["preview_token"]

scripts/download_regions.py

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

0 commit comments

Comments
 (0)