Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,5 @@ tests/resources/.DS_Store
tests/.DS_Store
tests/resources/.DS_Store
.DS_Store
*/data/regions.json
.talismanrc
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
# CHANGELOG

## Content Management SDK For Python
---
## v1.10.0

#### Date: 08 June 2026

- Dynamic region endpoint resolution via the Contentstack Regions Registry (`regions.json`).
- Added `Endpoint` class with 3-tier resolution: in-memory cache → bundled `data/regions.json` → live CDN download.
- Exposed `contentstack_management.get_contentstack_endpoint(region, service, omit_https)` module-level proxy.
- `Client` now resolves the `contentManagement` endpoint from the registry instead of a hardcoded host pattern.
- Added `scripts/download_regions.py` to refresh the bundled registry file.
- New regions and services require no SDK code changes — registry update is sufficient.

---
## v1.9.0

Expand Down
20 changes: 19 additions & 1 deletion contentstack_management/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from .entries.entry import Entry
from .entry_variants.entry_variants import EntryVariants
from .contentstack import Client, Region
from .endpoint import Endpoint
from ._api_client import _APIClient
from .common import Parameter
from ._errors import ArgumentException
Expand All @@ -41,6 +42,7 @@
__all__ = (
"Client",
"Region",
"Endpoint",
"_APIClient",
"Parameter",
"ArgumentException",
Expand Down Expand Up @@ -78,11 +80,27 @@
"OAuthInterceptor"
)

def get_contentstack_endpoint(region='us', service='', omit_https=False):
"""
Resolve a Contentstack service endpoint URL for a given region.

Proxy to :class:`Endpoint.get_contentstack_endpoint` for convenience —
mirrors ``Contentstack::getContentstackEndpoint()`` in the PHP SDK.

:param region: Region ID or alias ('us', 'eu', 'azure-na', 'gcp-eu', ...).
:param service: Service key ('contentDelivery', 'contentManagement', ...).
When empty, returns a dict of all endpoints for the region.
:param omit_https: When True, strips 'https://' from the returned URL(s).
:returns: str when service is provided, dict[str,str] otherwise.
"""
return Endpoint.get_contentstack_endpoint(region, service, omit_https)


__title__ = 'contentstack-management-python'
__author__ = 'dev-ex'
__status__ = 'debug'
__region__ = 'na'
__version__ = '1.9.0'
__version__ = '1.10.0'
__host__ = 'api.contentstack.io'
__protocol__ = 'https://'
__api_version__ = 'v3'
Expand Down
26 changes: 18 additions & 8 deletions contentstack_management/contentstack.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os
import pyotp
from ._api_client import _APIClient
from .endpoint import Endpoint
from contentstack_management.organizations import organization
from contentstack_management.stack import stack
from contentstack_management.user_session import user_session
Expand Down Expand Up @@ -37,16 +38,25 @@ def __init__(self, host: str = 'api.contentstack.io', scheme: str = 'https://',
authtoken: str = None , management_token=None, headers: dict = None,
region: Region = Region.US.value, version='v3', timeout=2, max_retries: int = 18, early_access: list = None,
oauth_config: dict = None, **kwargs):
self.endpoint = 'https://api.contentstack.io/v3/'

if region is not None and region is not Region.US.value:
if host is not None and host != 'api.contentstack.io':
_DEFAULT_HOST = 'api.contentstack.io'
self.endpoint = f'{scheme}{_DEFAULT_HOST}/{version}/'

if host is None or host == _DEFAULT_HOST:
# No custom host — resolve via Endpoint (regions.json-driven)
try:
base = Endpoint.get_contentstack_endpoint(
region or 'us', 'contentManagement', omit_https=True)
self.endpoint = f'{scheme}{base}/{version}/'
except (ValueError, RuntimeError):
# Unknown/custom region string — fall back to legacy pattern
if region and region != Region.US.value:
self.endpoint = f'{scheme}{region}-api.contentstack.com/{version}/'
else:
# Explicit custom host always wins; apply region prefix when non-US
if region and region != Region.US.value:
self.endpoint = f'{scheme}{region}-api.{host}/{version}/'
else:
host = 'api.contentstack.com'
self.endpoint = f'{scheme}{region}-{host}/{version}/'
elif host is not None and host != 'api.contentstack.io':
self.endpoint = f'{scheme}{host}/{version}/'
self.endpoint = f'{scheme}{host}/{version}/'
if headers is None:
headers = {}
if early_access is not None:
Expand Down
180 changes: 180 additions & 0 deletions contentstack_management/endpoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
"""
Endpoint — Contentstack region-to-URL resolver for the Management SDK.

Resolves Contentstack service endpoint URLs for any supported region.
Region data is loaded from contentstack_management/data/regions.json (bundled)
and cached in-memory for the lifetime of the process. When the bundled file is
absent the class attempts a live download from the Contentstack CDN so the
SDK continues to work even when the file was not created during installation.
"""

import json
import os
import re

REGIONS_URL = 'https://artifacts.contentstack.com/regions.json'


class Endpoint:
"""
Resolves Contentstack service endpoint URLs for any supported region.

Usage::

from contentstack_management.endpoint import Endpoint

# Single service URL
url = Endpoint.get_contentstack_endpoint('eu', 'contentManagement')
# 'https://eu-api.contentstack.com'

# All services for a region
endpoints = Endpoint.get_contentstack_endpoint('azure-na')
# {'contentDelivery': '...', 'contentManagement': '...', ...}

# Strip scheme (useful when building endpoint strings manually)
host = Endpoint.get_contentstack_endpoint('gcp-eu', 'contentManagement', omit_https=True)
# 'gcp-eu-api.contentstack.com'
"""

_regions_data = None # in-memory cache — shared across all instances

@staticmethod
def get_contentstack_endpoint(region='us', service='', omit_https=False):
"""
Resolve a Contentstack service endpoint URL for a given region.

:param region: Region ID or alias ('us', 'eu', 'azure-na', 'gcp-eu', etc.).
Defaults to 'us' (AWS North America).
:param service: Service key ('contentDelivery', 'contentManagement', ...).
When empty, returns a dict of all endpoints for the region.
:param omit_https: When True, strips 'https://' prefix from returned URL(s).
:returns: str when service is provided, dict[str,str] otherwise.
:raises ValueError: When region is empty, unknown, or service is not found.
:raises RuntimeError: When regions.json cannot be read or parsed.
"""
if not region:
raise ValueError('Empty region provided. Please put valid region.')

data = Endpoint._load_regions()
normalized = region.strip().lower()
region_row = Endpoint._find_region(data['regions'], normalized)

if region_row is None:
raise ValueError(f'Invalid region: {region}')

if service:
if service not in region_row['endpoints']:
raise ValueError(
f'Service "{service}" not found for region "{region_row["id"]}"'
)
url = region_row['endpoints'][service]
return Endpoint._strip_https(url) if omit_https else url

endpoints = region_row['endpoints']
if omit_https:
return {k: Endpoint._strip_https(v) for k, v in endpoints.items()}
return dict(endpoints)

@staticmethod
def _load_regions():
"""
Load and cache regions.json.

Resolution order:
1. In-memory static cache (zero I/O after first call)
2. contentstack_management/data/regions.json on disk (written by download script)
3. Live download from artifacts.contentstack.com (fallback)
"""
if Endpoint._regions_data is not None:
return Endpoint._regions_data

data_dir = os.path.join(os.path.dirname(__file__), 'data')
path = os.path.join(data_dir, 'regions.json')

if not os.path.exists(path):
Endpoint._download_and_save(path)

if not os.path.exists(path):
raise RuntimeError(
'contentstack-management: regions.json not found and could not be downloaded. '
'Run "python scripts/download_regions.py" and ensure network access.'
)

try:
with open(path, 'r', encoding='utf-8') as f:
decoded = json.load(f)
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError(
f'contentstack-management: Could not read or parse regions.json: {exc}. '
'Run "python scripts/download_regions.py" to re-download it.'
) from exc

if not isinstance(decoded, dict) or 'regions' not in decoded:
raise RuntimeError(
'contentstack-management: regions.json is corrupt. '
'Run "python scripts/download_regions.py" to re-download it.'
)

Endpoint._regions_data = decoded
return Endpoint._regions_data

@staticmethod
def _download_and_save(dest):
"""
Download regions.json from the Contentstack CDN and save to disk.
Uses the requests library (already an SDK dependency).
Silent on failure — the caller decides whether a missing file is fatal.

:param dest: Absolute path to write the file to.
"""
os.makedirs(os.path.dirname(dest), exist_ok=True)

try:
import requests
response = requests.get(REGIONS_URL, timeout=30)
response.raise_for_status()
data = response.text
except Exception: # noqa: BLE001
return

try:
decoded = json.loads(data)
except json.JSONDecodeError:
return

if isinstance(decoded, dict) and 'regions' in decoded:
try:
with open(dest, 'w', encoding='utf-8') as f:
f.write(data)
except OSError:
pass

@staticmethod
def _find_region(regions, input_str):
"""
Find a region entry by its id or any alias (case-insensitive).

Two-pass: exact id match first, then alias[] scan — mirrors PHP implementation.

:param regions: list of region dicts from regions.json
:param input_str: already-lowercased input
:returns: region dict or None
"""
for row in regions:
if row['id'] == input_str:
return row
for row in regions:
for alias in row.get('alias', []):
if alias.lower() == input_str:
return row
return None

@staticmethod
def _strip_https(url):
"""Strip the https:// (or http://) scheme from a URL string."""
return re.sub(r'^https?://', '', url)

@staticmethod
def reset_cache():
"""Reset the internal region cache. Intended for testing only."""
Endpoint._regions_data = None
68 changes: 68 additions & 0 deletions scripts/download_regions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""
Downloads the Contentstack regions registry from the official source and
saves it to contentstack_management/data/regions.json.

Run manually:
python3 scripts/download_regions.py
"""

import json
import os
import sys
import requests

REGIONS_URL = 'https://artifacts.contentstack.com/regions.json'

DEST = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'contentstack_management', 'data', 'regions.json'
)


def download():
dest_dir = os.path.dirname(DEST)
os.makedirs(dest_dir, exist_ok=True)

print(f'contentstack-management: Downloading regions.json from {REGIONS_URL} ...')

try:
response = requests.get(REGIONS_URL, timeout=30)
response.raise_for_status()
data = response.text
except Exception as exc:
sys.stderr.write(
f'contentstack-management: Warning — could not download regions.json: {exc}. '
'The SDK will attempt to download it at runtime on first use.\n'
)
sys.exit(0)

try:
decoded = json.loads(data)
except json.JSONDecodeError:
sys.stderr.write(
'contentstack-management: Warning — downloaded data is not valid JSON.\n'
)
sys.exit(0)

if not isinstance(decoded, dict) or 'regions' not in decoded or \
not isinstance(decoded['regions'], list):
sys.stderr.write(
'contentstack-management: Warning — downloaded data is not a valid regions.json.\n'
)
sys.exit(0)

try:
with open(DEST, 'w', encoding='utf-8') as f:
f.write(data)
except OSError as exc:
sys.stderr.write(
f'contentstack-management: Warning — could not write regions.json to {DEST}: {exc}\n'
)
sys.exit(0)

region_count = len(decoded['regions'])
print(f'contentstack-management: regions.json downloaded ({region_count} regions) → {DEST}')


if __name__ == '__main__':
download()
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def get_author_email(package):
name="contentstack-management",
version=get_version(package),
packages=find_packages(exclude=['tests']),
package_data={'contentstack_management': ['data/regions.json']},
py_modules=['_api_client', 'contentstack','common','_errors','_constant'],
description="Contentstack API Client Library for Python",
long_description=long_description,
Expand Down
Loading
Loading