From d8dbbecd9f33bbafdb34508ec29316af6b992a68 Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Sun, 7 Jun 2026 14:58:04 +0530 Subject: [PATCH 1/3] feat: Added support for endpoint integration --- .gitignore | 1 + CHANGELOG.md | 12 ++ contentstack/__init__.py | 20 ++- contentstack/contenttype.py | 1 + contentstack/endpoint.py | 180 ++++++++++++++++++++++ contentstack/entry.py | 1 + contentstack/stack.py | 34 +++-- scripts/download_regions.py | 78 ++++++++++ setup.py | 1 + tests/test_endpoint.py | 287 ++++++++++++++++++++++++++++++++++++ 10 files changed, 598 insertions(+), 17 deletions(-) create mode 100644 contentstack/endpoint.py create mode 100644 scripts/download_regions.py create mode 100644 tests/test_endpoint.py diff --git a/.gitignore b/.gitignore index 58d48ef8..fc493a48 100644 --- a/.gitignore +++ b/.gitignore @@ -118,3 +118,4 @@ venv.bak/ .mypy_cache/ .idea/ .vscode/ +*/assets/regions.json diff --git a/CHANGELOG.md b/CHANGELOG.md index ada53b33..30202b96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # CHANGELOG +## _v2.6.0_ + +### **Date: 07-June-2026** + +- Dynamic endpoint resolution via new `Endpoint` class. +- Region-to-URL mapping is now loaded from a bundled `regions.json` (sourced from `artifacts.contentstack.com`) instead of hardcoded `if/elif` chains. +- Added `Endpoint.get_contentstack_endpoint(region, service, omit_https)` — resolves any supported region to its `contentDelivery`, `contentManagement`, or other service URL. +- Added `contentstack.get_contentstack_endpoint()` module-level proxy. +- `Stack` now auto-resolves `host` and `live_preview` management host via `Endpoint` on initialization. +- Added `scripts/download_regions.py` to pre-populate `regions.json` at install time. +- Runtime fallback: if `regions.json` is absent, the SDK downloads it live on first use. + ## _v2.5.1_ ### **Date: 15-April-2026** diff --git a/contentstack/__init__.py b/contentstack/__init__.py index dea0db0d..8a17aed3 100644 --- a/contentstack/__init__.py +++ b/contentstack/__init__.py @@ -6,6 +6,7 @@ from .entry import Entry from .asset import Asset from .contenttype import ContentType +from .endpoint import Endpoint from .https_connection import HTTPSConnection from contentstack.stack import Stack from .utility import Utils @@ -14,15 +15,32 @@ "Entry", "Asset", "ContentType", +"Endpoint", "HTTPSConnection", "Stack", "Utils" ) + +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-delivery-python' __author__ = 'contentstack' __status__ = 'debug' -__version__ = 'v2.5.1' +__version__ = 'v2.6.0' __endpoint__ = 'cdn.contentstack.io' __email__ = 'support@contentstack.com' __developer_email__ = 'mobile@contentstack.com' diff --git a/contentstack/contenttype.py b/contentstack/contenttype.py index 1423216d..14551595 100644 --- a/contentstack/contenttype.py +++ b/contentstack/contenttype.py @@ -7,6 +7,7 @@ # ************* Module ContentType ************** # Your code has been rated at 10.00/10 by pylint +from __future__ import annotations import json import logging from urllib import parse diff --git a/contentstack/endpoint.py b/contentstack/endpoint.py new file mode 100644 index 00000000..937b603d --- /dev/null +++ b/contentstack/endpoint.py @@ -0,0 +1,180 @@ +""" +Endpoint — Contentstack region-to-URL resolver. + +Resolves Contentstack service endpoint URLs for any supported region. +Region data is loaded from contentstack/assets/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.endpoint import Endpoint + + # Single service URL + url = Endpoint.get_contentstack_endpoint('eu', 'contentDelivery') + # 'https://eu-cdn.contentstack.com' + + # All services for a region + endpoints = Endpoint.get_contentstack_endpoint('azure-na') + # {'contentDelivery': 'https://...', 'contentManagement': 'https://...', ...} + + # Strip scheme (useful when setting host directly) + host = Endpoint.get_contentstack_endpoint('gcp-eu', 'contentDelivery', omit_https=True) + # 'gcp-eu-cdn.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/assets/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 + + assets_dir = os.path.join(os.path.dirname(__file__), 'assets') + path = os.path.join(assets_dir, 'regions.json') + + if not os.path.exists(path): + Endpoint._download_and_save(path) + + if not os.path.exists(path): + raise RuntimeError( + 'contentstack: 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: 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: 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 diff --git a/contentstack/entry.py b/contentstack/entry.py index b0bae6b8..3df1e0d9 100644 --- a/contentstack/entry.py +++ b/contentstack/entry.py @@ -3,6 +3,7 @@ API Reference: https://www.contentstack.com/docs/developers/apis/content-delivery-api/#single-entry """ #min-similarity-lines=10 +from __future__ import annotations import logging from urllib import parse from contentstack.error_messages import ErrorMessages diff --git a/contentstack/stack.py b/contentstack/stack.py index 269df06c..13c20028 100644 --- a/contentstack/stack.py +++ b/contentstack/stack.py @@ -7,6 +7,7 @@ from contentstack.asset import Asset from contentstack.assetquery import AssetQuery from contentstack.contenttype import ContentType +from contentstack.endpoint import Endpoint from contentstack.taxonomy import Taxonomy from contentstack.globalfields import GlobalField from contentstack.https_connection import HTTPSConnection @@ -119,20 +120,15 @@ def _validate_stack(self): if self.environment is None or self.environment == "": raise PermissionError(ErrorMessages.INVALID_ENVIRONMENT_TOKEN) - if self.region.value == 'eu' and self.host == DEFAULT_HOST: - self.host = 'eu-cdn.contentstack.com' - elif self.region.value == 'au' and self.host == DEFAULT_HOST: - self.host = 'au-cdn.contentstack.com' - elif self.region.value == 'azure-na' and self.host == DEFAULT_HOST: - self.host = 'azure-na-cdn.contentstack.com' - elif self.region.value == 'azure-eu' and self.host == DEFAULT_HOST: - self.host = 'azure-eu-cdn.contentstack.com' - elif self.region.value == 'gcp-na' and self.host == DEFAULT_HOST: - self.host = 'gcp-na-cdn.contentstack.com' - elif self.region.value == 'gcp-eu' and self.host == DEFAULT_HOST: - self.host = 'gcp-eu-cdn.contentstack.com' - elif self.region.value != 'us': - self.host = f'{self.region.value}-{DEFAULT_HOST}' + if self.host == DEFAULT_HOST: + try: + self.host = Endpoint.get_contentstack_endpoint( + self.region.value, 'contentDelivery', omit_https=True) + except (ValueError, RuntimeError): + # Unknown/custom region — fall back to legacy pattern so + # code written before this feature was added continues to work. + if self.region.value != 'us': + self.host = f'{self.region.value}-{DEFAULT_HOST}' self.endpoint = f'https://{self.host}/{self.version}' def _setup_headers(self): @@ -365,8 +361,14 @@ def image_transform(self, image_url, **kwargs): def _setup_live_preview(self): if self.live_preview and self.live_preview.get("enable"): - region_prefix = "" if self.region.value == "us" else f"{self.region.value}-" - self.live_preview["host"] = f"{region_prefix}rest-preview.contentstack.com" + if not self.live_preview.get("host"): + try: + mgmt_host = Endpoint.get_contentstack_endpoint( + self.region.value, 'contentManagement', omit_https=True) + except (ValueError, RuntimeError): + region_prefix = "" if self.region.value == "us" else f"{self.region.value}-" + mgmt_host = f"{region_prefix}api.contentstack.io" + self.live_preview["host"] = mgmt_host if self.live_preview.get("preview_token"): self.headers["preview_token"] = self.live_preview["preview_token"] diff --git a/scripts/download_regions.py b/scripts/download_regions.py new file mode 100644 index 00000000..d48ac7d3 --- /dev/null +++ b/scripts/download_regions.py @@ -0,0 +1,78 @@ +""" +Downloads the Contentstack regions registry from the official source and +saves it to contentstack/assets/regions.json. + +Run manually: + python scripts/download_regions.py + +Can also be wired into setup.py post-install hooks or tox envsetup if needed. +""" + +import json +import os +import sys + +REGIONS_URL = 'https://artifacts.contentstack.com/regions.json' + +DEST = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'contentstack', 'assets', 'regions.json' +) + + +def download(): + dest_dir = os.path.dirname(DEST) + os.makedirs(dest_dir, exist_ok=True) + + try: + import requests + except ImportError: + sys.stderr.write( + 'contentstack: requests library not available. ' + 'Install dependencies first: pip install -r requirements.txt\n' + ) + sys.exit(1) + + print(f'contentstack: 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: Warning — could not download regions.json: {exc}. ' + 'The SDK will attempt to download it at runtime on first use.\n' + ) + sys.exit(0) # non-fatal + + try: + decoded = json.loads(data) + except json.JSONDecodeError: + sys.stderr.write( + 'contentstack: 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: 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: Warning — could not write regions.json to {DEST}: {exc}\n' + ) + sys.exit(0) + + region_count = len(decoded['regions']) + print(f'contentstack: regions.json downloaded ({region_count} regions) → {DEST}') + + +if __name__ == '__main__': + download() diff --git a/setup.py b/setup.py index b86919a0..e83c0b79 100644 --- a/setup.py +++ b/setup.py @@ -46,6 +46,7 @@ def get_version(package): long_description_content_type="text/markdown", url="https://github.com/contentstack/contentstack-python", packages=['contentstack'], + package_data={'contentstack': ['assets/regions.json']}, license='MIT', test_suite='tests', install_requires=requirements, diff --git a/tests/test_endpoint.py b/tests/test_endpoint.py new file mode 100644 index 00000000..2958cb69 --- /dev/null +++ b/tests/test_endpoint.py @@ -0,0 +1,287 @@ +import unittest + +import contentstack +from contentstack.endpoint import Endpoint +from contentstack.stack import ContentstackRegion, Stack + +API_KEY = 'test_api_key' +DELIVERY_TOKEN = 'test_delivery_token' +ENVIRONMENT = 'test_environment' + + +class TestEndpoint(unittest.TestCase): + + def setUp(self): + Endpoint.reset_cache() + + # ------------------------------------------------------------------------- + # Default region (us / na) + # ------------------------------------------------------------------------- + + def test_default_region_returns_all_endpoints(self): + endpoints = Endpoint.get_contentstack_endpoint() + self.assertIsInstance(endpoints, dict) + self.assertIn('contentDelivery', endpoints) + self.assertIn('contentManagement', endpoints) + + def test_default_region_content_delivery(self): + url = Endpoint.get_contentstack_endpoint('us', 'contentDelivery') + self.assertEqual('https://cdn.contentstack.io', url) + + def test_default_region_content_management(self): + url = Endpoint.get_contentstack_endpoint('us', 'contentManagement') + self.assertEqual('https://api.contentstack.io', url) + + # ------------------------------------------------------------------------- + # All 7 regions — contentDelivery spot-checks + # ------------------------------------------------------------------------- + + def test_content_delivery_na(self): + url = Endpoint.get_contentstack_endpoint('na', 'contentDelivery') + self.assertEqual('https://cdn.contentstack.io', url) + + def test_content_delivery_eu(self): + url = Endpoint.get_contentstack_endpoint('eu', 'contentDelivery') + self.assertEqual('https://eu-cdn.contentstack.com', url) + + def test_content_delivery_au(self): + url = Endpoint.get_contentstack_endpoint('au', 'contentDelivery') + self.assertEqual('https://au-cdn.contentstack.com', url) + + def test_content_delivery_azure_na(self): + url = Endpoint.get_contentstack_endpoint('azure-na', 'contentDelivery') + self.assertEqual('https://azure-na-cdn.contentstack.com', url) + + def test_content_delivery_azure_eu(self): + url = Endpoint.get_contentstack_endpoint('azure-eu', 'contentDelivery') + self.assertEqual('https://azure-eu-cdn.contentstack.com', url) + + def test_content_delivery_gcp_na(self): + url = Endpoint.get_contentstack_endpoint('gcp-na', 'contentDelivery') + self.assertEqual('https://gcp-na-cdn.contentstack.com', url) + + def test_content_delivery_gcp_eu(self): + url = Endpoint.get_contentstack_endpoint('gcp-eu', 'contentDelivery') + self.assertEqual('https://gcp-eu-cdn.contentstack.com', url) + + # ------------------------------------------------------------------------- + # All 7 regions — contentManagement spot-checks + # ------------------------------------------------------------------------- + + def test_content_management_na(self): + url = Endpoint.get_contentstack_endpoint('na', 'contentManagement') + self.assertEqual('https://api.contentstack.io', url) + + def test_content_management_eu(self): + url = Endpoint.get_contentstack_endpoint('eu', 'contentManagement') + self.assertEqual('https://eu-api.contentstack.com', url) + + def test_content_management_au(self): + url = Endpoint.get_contentstack_endpoint('au', 'contentManagement') + self.assertEqual('https://au-api.contentstack.com', url) + + def test_content_management_azure_na(self): + url = Endpoint.get_contentstack_endpoint('azure-na', 'contentManagement') + self.assertEqual('https://azure-na-api.contentstack.com', url) + + def test_content_management_azure_eu(self): + url = Endpoint.get_contentstack_endpoint('azure-eu', 'contentManagement') + self.assertEqual('https://azure-eu-api.contentstack.com', url) + + def test_content_management_gcp_na(self): + url = Endpoint.get_contentstack_endpoint('gcp-na', 'contentManagement') + self.assertEqual('https://gcp-na-api.contentstack.com', url) + + def test_content_management_gcp_eu(self): + url = Endpoint.get_contentstack_endpoint('gcp-eu', 'contentManagement') + self.assertEqual('https://gcp-eu-api.contentstack.com', url) + + # ------------------------------------------------------------------------- + # NA aliases all resolve to the same endpoint + # ------------------------------------------------------------------------- + + def test_alias_na(self): + url = Endpoint.get_contentstack_endpoint('na', 'contentDelivery') + self.assertEqual('https://cdn.contentstack.io', url) + + def test_alias_us(self): + url = Endpoint.get_contentstack_endpoint('us', 'contentDelivery') + self.assertEqual('https://cdn.contentstack.io', url) + + def test_alias_aws_na_hyphen(self): + url = Endpoint.get_contentstack_endpoint('aws-na', 'contentDelivery') + self.assertEqual('https://cdn.contentstack.io', url) + + def test_alias_aws_na_underscore(self): + url = Endpoint.get_contentstack_endpoint('aws_na', 'contentDelivery') + self.assertEqual('https://cdn.contentstack.io', url) + + def test_alias_na_uppercase(self): + url = Endpoint.get_contentstack_endpoint('NA', 'contentDelivery') + self.assertEqual('https://cdn.contentstack.io', url) + + def test_alias_us_uppercase(self): + url = Endpoint.get_contentstack_endpoint('US', 'contentDelivery') + self.assertEqual('https://cdn.contentstack.io', url) + + # ------------------------------------------------------------------------- + # Case-insensitive alias matching for other regions + # ------------------------------------------------------------------------- + + def test_alias_aws_na_uppercase(self): + url = Endpoint.get_contentstack_endpoint('AWS-NA', 'contentDelivery') + self.assertEqual('https://cdn.contentstack.io', url) + + def test_alias_azure_na_underscore(self): + url = Endpoint.get_contentstack_endpoint('azure_na', 'contentDelivery') + self.assertEqual('https://azure-na-cdn.contentstack.com', url) + + def test_alias_gcp_eu_underscore(self): + url = Endpoint.get_contentstack_endpoint('gcp_eu', 'contentManagement') + self.assertEqual('https://gcp-eu-api.contentstack.com', url) + + # ------------------------------------------------------------------------- + # ContentstackRegion enum constants resolve correctly + # ------------------------------------------------------------------------- + + def test_region_constant_us(self): + url = Endpoint.get_contentstack_endpoint(ContentstackRegion.US.value, 'contentDelivery') + self.assertEqual('https://cdn.contentstack.io', url) + + def test_region_constant_eu(self): + url = Endpoint.get_contentstack_endpoint(ContentstackRegion.EU.value, 'contentDelivery') + self.assertEqual('https://eu-cdn.contentstack.com', url) + + def test_region_constant_au(self): + url = Endpoint.get_contentstack_endpoint(ContentstackRegion.AU.value, 'contentDelivery') + self.assertEqual('https://au-cdn.contentstack.com', url) + + def test_region_constant_azure_na(self): + url = Endpoint.get_contentstack_endpoint(ContentstackRegion.AZURE_NA.value, 'contentDelivery') + self.assertEqual('https://azure-na-cdn.contentstack.com', url) + + def test_region_constant_azure_eu(self): + url = Endpoint.get_contentstack_endpoint(ContentstackRegion.AZURE_EU.value, 'contentDelivery') + self.assertEqual('https://azure-eu-cdn.contentstack.com', url) + + def test_region_constant_gcp_na(self): + url = Endpoint.get_contentstack_endpoint(ContentstackRegion.GCP_NA.value, 'contentDelivery') + self.assertEqual('https://gcp-na-cdn.contentstack.com', url) + + def test_region_constant_gcp_eu(self): + url = Endpoint.get_contentstack_endpoint(ContentstackRegion.GCP_EU.value, 'contentDelivery') + self.assertEqual('https://gcp-eu-cdn.contentstack.com', url) + + # ------------------------------------------------------------------------- + # omit_https flag + # ------------------------------------------------------------------------- + + def test_omit_https_strips_scheme_single_service(self): + url = Endpoint.get_contentstack_endpoint('eu', 'contentDelivery', omit_https=True) + self.assertEqual('eu-cdn.contentstack.com', url) + + def test_omit_https_strips_scheme_all_services(self): + endpoints = Endpoint.get_contentstack_endpoint('na', omit_https=True) + self.assertIsInstance(endpoints, dict) + for key, url in endpoints.items(): + self.assertNotIn('https://', url, f'Service {key} still has https://') + self.assertNotIn('http://', url, f'Service {key} still has http://') + + def test_omit_https_false_retains_scheme(self): + url = Endpoint.get_contentstack_endpoint('na', 'contentManagement', omit_https=False) + self.assertTrue(url.startswith('https://')) + + # ------------------------------------------------------------------------- + # No service — returns full dict + # ------------------------------------------------------------------------- + + def test_no_service_returns_dict(self): + result = Endpoint.get_contentstack_endpoint('au') + self.assertIsInstance(result, dict) + self.assertGreater(len(result), 1) + + def test_no_service_contains_correct_urls(self): + endpoints = Endpoint.get_contentstack_endpoint('au') + self.assertEqual('https://au-cdn.contentstack.com', endpoints['contentDelivery']) + self.assertEqual('https://au-api.contentstack.com', endpoints['contentManagement']) + + # ------------------------------------------------------------------------- + # Error cases + # ------------------------------------------------------------------------- + + def test_empty_region_raises_value_error(self): + with self.assertRaises(ValueError) as ctx: + Endpoint.get_contentstack_endpoint('') + self.assertIn('Empty region', str(ctx.exception)) + + def test_unknown_region_raises_value_error(self): + with self.assertRaises(ValueError) as ctx: + Endpoint.get_contentstack_endpoint('invalid-region') + self.assertIn('Invalid region', str(ctx.exception)) + + def test_unknown_service_raises_value_error(self): + with self.assertRaises(ValueError) as ctx: + Endpoint.get_contentstack_endpoint('na', 'unknownService') + self.assertIn('unknownService', str(ctx.exception)) + + # ------------------------------------------------------------------------- + # contentstack.get_contentstack_endpoint() module-level proxy + # ------------------------------------------------------------------------- + + def test_module_proxy_returns_same_result(self): + via_class = Endpoint.get_contentstack_endpoint('eu', 'contentDelivery') + via_module = contentstack.get_contentstack_endpoint('eu', 'contentDelivery') + self.assertEqual(via_class, via_module) + + def test_module_proxy_default_region(self): + url = contentstack.get_contentstack_endpoint('us', 'contentManagement') + self.assertEqual('https://api.contentstack.io', url) + + def test_module_proxy_omit_https(self): + url = contentstack.get_contentstack_endpoint('gcp-na', 'contentDelivery', omit_https=True) + self.assertEqual('gcp-na-cdn.contentstack.com', url) + + def test_module_proxy_all_endpoints(self): + endpoints = contentstack.get_contentstack_endpoint('azure-eu') + self.assertIsInstance(endpoints, dict) + self.assertIn('contentDelivery', endpoints) + + # ------------------------------------------------------------------------- + # Stack host resolution via Endpoint + # ------------------------------------------------------------------------- + + def test_stack_us_host_resolves_to_default_cdn(self): + stack = Stack(API_KEY, DELIVERY_TOKEN, ENVIRONMENT, region=ContentstackRegion.US) + self.assertEqual('cdn.contentstack.io', stack.host) + + def test_stack_eu_host_resolves_via_endpoint(self): + stack = Stack(API_KEY, DELIVERY_TOKEN, ENVIRONMENT, region=ContentstackRegion.EU) + self.assertEqual('eu-cdn.contentstack.com', stack.host) + + def test_stack_au_host_resolves_via_endpoint(self): + stack = Stack(API_KEY, DELIVERY_TOKEN, ENVIRONMENT, region=ContentstackRegion.AU) + self.assertEqual('au-cdn.contentstack.com', stack.host) + + def test_stack_azure_na_host_resolves_via_endpoint(self): + stack = Stack(API_KEY, DELIVERY_TOKEN, ENVIRONMENT, region=ContentstackRegion.AZURE_NA) + self.assertEqual('azure-na-cdn.contentstack.com', stack.host) + + def test_stack_gcp_eu_host_resolves_via_endpoint(self): + stack = Stack(API_KEY, DELIVERY_TOKEN, ENVIRONMENT, region=ContentstackRegion.GCP_EU) + self.assertEqual('gcp-eu-cdn.contentstack.com', stack.host) + + def test_stack_explicit_host_overrides_region(self): + stack = Stack( + API_KEY, DELIVERY_TOKEN, ENVIRONMENT, + host='custom.cdn.example.com', + region=ContentstackRegion.EU + ) + self.assertEqual('custom.cdn.example.com', stack.host) + + def test_stack_endpoint_built_from_resolved_host(self): + stack = Stack(API_KEY, DELIVERY_TOKEN, ENVIRONMENT, region=ContentstackRegion.EU) + self.assertEqual('https://eu-cdn.contentstack.com/v3', stack.endpoint) + + +if __name__ == '__main__': + unittest.main() From ad705810aab97c2fe1b518a5fbce6bbeecf22510 Mon Sep 17 00:00:00 2001 From: OM PAWAR Date: Mon, 8 Jun 2026 11:41:31 +0530 Subject: [PATCH 2/3] Update CHANGELOG for v2.6.0 release details Updated release date for version 2.6.0 and added details about dynamic endpoint resolution and region-to-URL mapping. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30202b96..85d5c7f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## _v2.6.0_ -### **Date: 07-June-2026** +### **Date: 15-June-2026** - Dynamic endpoint resolution via new `Endpoint` class. - Region-to-URL mapping is now loaded from a bundled `regions.json` (sourced from `artifacts.contentstack.com`) instead of hardcoded `if/elif` chains. From fb8b87cc045a027b661a5d8a7f31ff97bb0d33c7 Mon Sep 17 00:00:00 2001 From: OM PAWAR Date: Mon, 8 Jun 2026 13:03:43 +0530 Subject: [PATCH 3/3] Refactor requests import handling in download_regions.py Removed the import error handling for the requests library and added the import statement at the top of the file. --- scripts/download_regions.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/scripts/download_regions.py b/scripts/download_regions.py index d48ac7d3..3a13c1ed 100644 --- a/scripts/download_regions.py +++ b/scripts/download_regions.py @@ -11,6 +11,7 @@ import json import os import sys +import requests REGIONS_URL = 'https://artifacts.contentstack.com/regions.json' @@ -24,15 +25,6 @@ def download(): dest_dir = os.path.dirname(DEST) os.makedirs(dest_dir, exist_ok=True) - try: - import requests - except ImportError: - sys.stderr.write( - 'contentstack: requests library not available. ' - 'Install dependencies first: pip install -r requirements.txt\n' - ) - sys.exit(1) - print(f'contentstack: Downloading regions.json from {REGIONS_URL} ...') try: