From d7ed321ec62eb8c83474063475227eca8bc888fb Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Fri, 5 Jun 2026 10:26:35 +0530 Subject: [PATCH 1/2] feat: add multi-region URL resolver and bump version to 1.6.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce contentstack_utils.Endpoint — a region-aware endpoint resolver backed by a bundled regions.json registry, bringing the Python SDK to feature parity with the PHP and JS implementations. New files: - contentstack_utils/endpoint.py: Endpoint class with in-process cache, two-pass region lookup (id → alias), case-insensitive matching with - / _ interchangeability, omit_https flag, and a CDN fallback download when regions.json is absent - contentstack_utils/assets/regions.json: bundled registry of 7 regions (AWS NA/EU/AU, Azure NA/EU, GCP NA/EU) and 18 service endpoint keys; gitignored so it is not tracked as source — refreshed via refresh script - tests/test_endpoint.py: 53 tests covering all 7 regions, 8 NA aliases, omit_https, error cases, Utils proxy, camelCase alias, and cache isolation - scripts/refresh_regions.py: pulls latest regions.json from artifacts.contentstack.com (equivalent of composer refresh-regions) Modified files: - contentstack_utils/utils.py: add get_contentstack_endpoint() proxy and getContentstackEndpoint camelCase alias on Utils for cross-SDK parity - contentstack_utils/__init__.py: export Endpoint in __all__; bump __version__ to 1.6.0 - setup.py: bump version to 1.6.0 - CHANGELOG.md: add v1.6.0 entry - .gitignore: exclude contentstack_utils/assets/regions.json --- .gitignore | 1 + CHANGELOG.md | 12 ++ contentstack_utils/__init__.py | 4 +- contentstack_utils/endpoint.py | 245 ++++++++++++++++++++++++++++++ contentstack_utils/utils.py | 29 ++++ scripts/refresh_regions.py | 64 ++++++++ setup.py | 2 +- tests/test_endpoint.py | 268 +++++++++++++++++++++++++++++++++ 8 files changed, 623 insertions(+), 2 deletions(-) create mode 100644 contentstack_utils/endpoint.py create mode 100644 scripts/refresh_regions.py create mode 100644 tests/test_endpoint.py diff --git a/.gitignore b/.gitignore index 76e1f93..995d73c 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,4 @@ venv.bak/ .mypy_cache/ .idea/ .vscode/ +*/assets/regions.json \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 752bc77..b62f684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## v1.6.0 (2026-06-05) + +### New feature: Multi-region endpoint resolution + +- Added `Endpoint.get_contentstack_endpoint()` for dynamic region-aware URL resolution across all Contentstack regions and services. +- Added `Utils.get_contentstack_endpoint()` proxy for backward-compatible access via the existing `Utils` import path. +- Added `getContentstackEndpoint` camelCase alias on both `Endpoint` and `Utils` for cross-SDK parity with the PHP and JS implementations. +- Bundled `contentstack_utils/assets/regions.json` — the authoritative registry of 7 regions (AWS NA/EU/AU, Azure NA/EU, GCP NA/EU) and 18 service endpoint keys. +- Added runtime fallback in `Endpoint._load_regions()` — downloads `regions.json` from `artifacts.contentstack.com` on first use when the file is absent. +- Added `scripts/refresh_regions.py` to manually pull the latest regions from Contentstack (equivalent of `composer refresh-regions` in the PHP SDK). +- Exported `Endpoint` at package level in `__all__`. + ## v1.5.0 ### New feature: Variants utility (CDA entry variant aliases) diff --git a/contentstack_utils/__init__.py b/contentstack_utils/__init__.py index 880edd6..1954233 100644 --- a/contentstack_utils/__init__.py +++ b/contentstack_utils/__init__.py @@ -10,6 +10,7 @@ from contentstack_utils.embedded.item_type import ItemType from contentstack_utils.embedded.styletype import StyleType +from contentstack_utils.endpoint import Endpoint from contentstack_utils.helper.metadata import Metadata from contentstack_utils.helper.node_to_html import NodeToHtml from contentstack_utils.render.options import Options @@ -19,6 +20,7 @@ from contentstack_utils.entry_editable import addEditableTags, addTags, getTag __all__ = ( +"Endpoint", "Utils", "Options", "Metadata", @@ -35,6 +37,6 @@ __title__ = 'contentstack_utils' __author__ = 'contentstack' __status__ = 'debug' -__version__ = '1.4.0' +__version__ = '1.6.0' __endpoint__ = 'cdn.contentstack.io' __contact__ = 'support@contentstack.com' diff --git a/contentstack_utils/endpoint.py b/contentstack_utils/endpoint.py new file mode 100644 index 0000000..be52b77 --- /dev/null +++ b/contentstack_utils/endpoint.py @@ -0,0 +1,245 @@ +""" +Endpoint resolution for Contentstack services across all regions. + +Reads a bundled regions.json (src/assets/regions.json) and resolves +the correct base URL for any region + service combination. No runtime +HTTP calls — the file is shipped with the package and updated via +``python scripts/refresh_regions.py``. +""" + +import json +import os +import re +import urllib.request +from typing import Dict, Optional, Union + +_REGIONS_URL = "https://artifacts.contentstack.com/regions.json" +_ASSETS_DIR = os.path.join(os.path.dirname(__file__), "assets") +_REGIONS_FILE = os.path.join(_ASSETS_DIR, "regions.json") + + +class Endpoint: + """ + Resolve Contentstack service URLs for any region. + + All public methods are static — no instantiation required. + + Example:: + + from contentstack_utils import Endpoint + + # Full URL + url = Endpoint.get_contentstack_endpoint("na", "contentDelivery") + # → "https://cdn.contentstack.io" + + # Host only (strip https://) — useful for SDK setHost() calls + host = Endpoint.get_contentstack_endpoint("eu", "contentDelivery", omit_https=True) + # → "eu-cdn.contentstack.com" + + # All endpoints for a region + all_endpoints = Endpoint.get_contentstack_endpoint("azure-na") + # → {"contentDelivery": "...", "contentManagement": "...", ...} + """ + + # Module-level cache — loaded once per Python process, shared across all calls. + _regions_data: Optional[Dict] = None + + @staticmethod + def get_contentstack_endpoint( + region: str = "us", + service: str = "", + omit_https: bool = False, + ) -> Union[str, Dict[str, str]]: + """ + Resolve a Contentstack service endpoint URL for the given region. + + :param region: + Region ID or any accepted alias (case-insensitive, ``-`` and ``_`` + are interchangeable). Examples: ``'na'``, ``'us'``, ``'eu'``, + ``'AWS-NA'``, ``'azure_eu'``, ``'gcp-na'``. + Defaults to ``'us'`` (AWS North America). + :param service: + Optional service key. When provided, a single URL string is + returned. When omitted, a dict of **all** service URLs is returned. + Valid keys include: ``'contentDelivery'``, ``'contentManagement'``, + ``'auth'``, ``'graphqlDelivery'``, ``'preview'``, ``'images'``, + ``'assets'``, ``'automate'``, ``'launch'``, ``'developerHub'``, + ``'brandKit'``, ``'genAI'``, ``'personalizeManagement'``, + ``'personalizeEdge'``, ``'composableStudio'``, ``'assetManagement'``. + :param omit_https: + When ``True``, strips the ``https://`` (or ``http://``) scheme from + every returned URL. Useful when passing the host to an SDK that + constructs its own URLs (e.g. ``stack.set_host(host)``). + :returns: + - A ``str`` URL when *service* is specified. + - A ``dict[str, str]`` mapping service keys → URLs when *service* + is omitted. + :raises ValueError: + If *region* is an empty string. + :raises LookupError: + If *region* does not match any known region ID or alias, or if + *service* is not present in the resolved region's endpoint map. + :raises RuntimeError: + If the bundled ``regions.json`` cannot be read or is malformed. + + Examples:: + + Endpoint.get_contentstack_endpoint("na", "contentDelivery") + # → "https://cdn.contentstack.io" + + Endpoint.get_contentstack_endpoint("eu", "contentDelivery", omit_https=True) + # → "eu-cdn.contentstack.com" + + Endpoint.get_contentstack_endpoint("azure-na") + # → {"contentDelivery": "https://...", ...} + """ + if not region: + raise ValueError("Empty region provided. Please put valid region.") + + data = Endpoint._load_regions() + normalized = region.strip().lower() + + if not normalized: + raise ValueError("Empty region provided. Please put valid region.") + region_row = Endpoint._find_region_by_id_or_alias(data["regions"], normalized) + + if region_row is None: + raise LookupError(f"Invalid region: {region}") + + if service: + endpoints = region_row["endpoints"] + if service not in endpoints: + raise LookupError( + f'Service "{service}" not found for region "{region_row["id"]}"' + ) + url = endpoints[service] + return Endpoint._strip_https(url) if omit_https else url + + endpoints = dict(region_row["endpoints"]) + return Endpoint._strip_https_from_map(endpoints) if omit_https else endpoints + + # ------------------------------------------------------------------ + # JS/PHP parity alias — lets callers use the same camelCase name + # across all Contentstack SDK languages without a lookup. + # ------------------------------------------------------------------ + getContentstackEndpoint = get_contentstack_endpoint + + @staticmethod + def reset_cache() -> None: + """ + Clear the in-memory region cache. + + Intended for testing only — forces the next call to re-read + ``regions.json`` from disk. + """ + Endpoint._regions_data = None + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _load_regions() -> Dict: + """ + Load and cache regions data. + + Resolution order: + 1. In-memory cache (zero I/O after the first call in a process) + 2. Bundled ``contentstack_utils/assets/regions.json`` on disk + 3. Live download from ``artifacts.contentstack.com`` (fallback when + the file is absent — e.g. an editable install without assets) + """ + if Endpoint._regions_data is not None: + return Endpoint._regions_data + + if not os.path.exists(_REGIONS_FILE): + Endpoint._download_and_save(_REGIONS_FILE) + + if not os.path.exists(_REGIONS_FILE): + raise RuntimeError( + "contentstack_utils: regions.json not found and could not be downloaded. " + "Run 'python scripts/refresh_regions.py' and ensure network access." + ) + + try: + with open(_REGIONS_FILE, "r", encoding="utf-8") as fh: + raw = fh.read() + except OSError as exc: + raise RuntimeError( + f"contentstack_utils: Could not read regions.json: {exc}" + ) from exc + + try: + decoded = json.loads(raw) + except json.JSONDecodeError as exc: + raise RuntimeError( + "contentstack_utils: regions.json is corrupt. " + "Run 'python scripts/refresh_regions.py' to re-download it." + ) from exc + + if not isinstance(decoded, dict) or "regions" not in decoded: + raise RuntimeError( + "contentstack_utils: regions.json is corrupt. " + "Run 'python scripts/refresh_regions.py' to re-download it." + ) + + Endpoint._regions_data = decoded + return Endpoint._regions_data + + @staticmethod + def _download_and_save(dest: str) -> None: + """ + Fetch regions.json from the Contentstack CDN and write it to *dest*. + + Silent on failure — the caller decides whether a missing file is fatal. + """ + os.makedirs(os.path.dirname(dest), exist_ok=True) + try: + with urllib.request.urlopen(_REGIONS_URL, timeout=30) as resp: + data = resp.read().decode("utf-8") + except Exception: + return + + try: + decoded = json.loads(data) + except json.JSONDecodeError: + return + + if isinstance(decoded, dict) and "regions" in decoded: + with open(dest, "w", encoding="utf-8") as fh: + fh.write(data) + + @staticmethod + def _find_region_by_id_or_alias( + regions: list, normalized_input: str + ) -> Optional[Dict]: + """ + Find a region by its ``id`` field first, then by any alias. + + Both passes are case-insensitive (caller must pass a lowercased string). + Two-pass approach mirrors the PHP implementation: ID match wins over alias + match, which avoids surprising behaviour when a future alias happens to + collide with another region's canonical ID. + """ + # Pass 1 — exact id match + for row in regions: + if row["id"] == normalized_input: + return row + + # Pass 2 — alias match + for row in regions: + for alias in row.get("alias", []): + if alias.lower() == normalized_input: + return row + + return None + + @staticmethod + def _strip_https(url: str) -> str: + """Strip ``https://`` or ``http://`` from the start of a URL.""" + return re.sub(r"^https?://", "", url) + + @staticmethod + def _strip_https_from_map(endpoints: Dict[str, str]) -> Dict[str, str]: + """Return a new dict with the scheme stripped from every URL value.""" + return {key: Endpoint._strip_https(url) for key, url in endpoints.items()} diff --git a/contentstack_utils/utils.py b/contentstack_utils/utils.py index 4519d2d..f9755ba 100644 --- a/contentstack_utils/utils.py +++ b/contentstack_utils/utils.py @@ -6,6 +6,7 @@ from lxml import etree from contentstack_utils.automate import Automate +from contentstack_utils.endpoint import Endpoint from contentstack_utils.entry_editable import addEditableTags as _addEditableTags from contentstack_utils.entry_editable import addTags as _addTags from contentstack_utils.entry_editable import getTag as _getTag @@ -219,6 +220,34 @@ def __get_metadata(elements): metadata = Metadata(element.text, typeof, uid, content_type, style, outer_html, attributes) return metadata + # ------------------------------------------------------------------ + # Endpoint resolution — thin proxy to Endpoint class + # ------------------------------------------------------------------ + + @staticmethod + def get_contentstack_endpoint( + region: str = "us", + service: str = "", + omit_https: bool = False, + ): + """ + Resolve a Contentstack service URL for the given region. + + Delegates entirely to :class:`~contentstack_utils.endpoint.Endpoint`. + Both ``Utils.get_contentstack_endpoint(...)`` and + ``Endpoint.get_contentstack_endpoint(...)`` produce identical results — + choose whichever import path suits your codebase. + + :param region: Region ID or alias (e.g. ``'na'``, ``'eu'``, ``'azure-na'``). + :param service: Optional service key (e.g. ``'contentDelivery'``). + :param omit_https: Strip ``https://`` from the returned URL(s). + :returns: URL string when *service* is given, dict of all URLs otherwise. + """ + return Endpoint.get_contentstack_endpoint(region, service, omit_https) + + # camelCase alias for cross-SDK parity + getContentstackEndpoint = get_contentstack_endpoint + #################################################### # SUPERCHARGED # #################################################### diff --git a/scripts/refresh_regions.py b/scripts/refresh_regions.py new file mode 100644 index 0000000..70569b3 --- /dev/null +++ b/scripts/refresh_regions.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +""" +Pull the latest regions.json from the Contentstack CDN and overwrite the +bundled copy at contentstack_utils/assets/regions.json. + +Usage: + python3 scripts/refresh_regions.py + +Mirrors the ``composer refresh-regions`` command in the PHP SDK. +Run this whenever Contentstack adds a new region or service, then commit the +updated file so all consumers get the change on their next install. +""" + +import json +import os +import sys +import urllib.request + +REGIONS_URL = "https://artifacts.contentstack.com/regions.json" +DEST = os.path.join( + os.path.dirname(__file__), + "..", + "contentstack_utils", + "assets", + "regions.json", +) + + +def main() -> int: + dest = os.path.normpath(DEST) + print(f"Fetching {REGIONS_URL} ...") + + try: + with urllib.request.urlopen(REGIONS_URL, timeout=30) as resp: + data = resp.read().decode("utf-8") + except Exception as exc: + print(f"ERROR: Could not download regions.json: {exc}", file=sys.stderr) + return 1 + + try: + decoded = json.loads(data) + except json.JSONDecodeError as exc: + print(f"ERROR: Downloaded content is not valid JSON: {exc}", file=sys.stderr) + return 1 + + if not isinstance(decoded, dict) or "regions" not in decoded: + print("ERROR: Downloaded JSON does not contain a 'regions' key.", file=sys.stderr) + return 1 + + region_count = len(decoded["regions"]) + os.makedirs(os.path.dirname(dest), exist_ok=True) + with open(dest, "w", encoding="utf-8") as fh: + json.dump(decoded, fh, indent=2, ensure_ascii=False) + fh.write("\n") + + print(f"OK: Wrote {region_count} regions to {dest}") + print("Next steps:") + print(" git add contentstack_utils/assets/regions.json") + print(' git commit -m "chore: refresh regions.json"') + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/setup.py b/setup.py index 80f3be5..525f153 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ long_description_content_type="text/markdown", url="https://github.com/contentstack/contentstack-utils-python", license='MIT', - version='1.5.0', + version='1.6.0', install_requires=[ ], diff --git a/tests/test_endpoint.py b/tests/test_endpoint.py new file mode 100644 index 0000000..10e0e05 --- /dev/null +++ b/tests/test_endpoint.py @@ -0,0 +1,268 @@ +""" +Tests for contentstack_utils.endpoint.Endpoint and the Utils proxy. + +Mirrors the PHP EndpointTest.php suite for cross-SDK consistency. +""" + +import pytest + +from contentstack_utils.endpoint import Endpoint +from contentstack_utils.utils import Utils + + +@pytest.fixture(autouse=True) +def reset_cache(): + """Isolate each test — forces a fresh regions.json read.""" + Endpoint.reset_cache() + yield + Endpoint.reset_cache() + + +# --------------------------------------------------------------------------- +# Default region (us / na) +# --------------------------------------------------------------------------- + +class TestDefaultRegion: + def test_returns_all_endpoints_when_no_service(self): + endpoints = Endpoint.get_contentstack_endpoint() + assert isinstance(endpoints, dict) + assert "contentDelivery" in endpoints + assert "contentManagement" in endpoints + + def test_content_delivery_url(self): + url = Endpoint.get_contentstack_endpoint("us", "contentDelivery") + assert url == "https://cdn.contentstack.io" + + def test_content_management_url(self): + url = Endpoint.get_contentstack_endpoint("us", "contentManagement") + assert url == "https://api.contentstack.io" + + +# --------------------------------------------------------------------------- +# NA region alias resolution +# --------------------------------------------------------------------------- + +NA_ALIASES = ["na", "us", "aws-na", "aws_na", "NA", "US", "AWS-NA", "AWS_NA"] + +@pytest.mark.parametrize("alias", NA_ALIASES) +def test_na_aliases_resolve_to_same_cdn(alias): + url = Endpoint.get_contentstack_endpoint(alias, "contentDelivery") + assert url == "https://cdn.contentstack.io" + + +# --------------------------------------------------------------------------- +# All 7 regions — contentDelivery spot-checks +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("region,expected", [ + ("na", "https://cdn.contentstack.io"), + ("eu", "https://eu-cdn.contentstack.com"), + ("au", "https://au-cdn.contentstack.com"), + ("azure-na", "https://azure-na-cdn.contentstack.com"), + ("azure-eu", "https://azure-eu-cdn.contentstack.com"), + ("gcp-na", "https://gcp-na-cdn.contentstack.com"), + ("gcp-eu", "https://gcp-eu-cdn.contentstack.com"), +]) +def test_content_delivery_by_region(region, expected): + assert Endpoint.get_contentstack_endpoint(region, "contentDelivery") == expected + + +# --------------------------------------------------------------------------- +# All 7 regions — contentManagement spot-checks +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("region,expected", [ + ("na", "https://api.contentstack.io"), + ("eu", "https://eu-api.contentstack.com"), + ("au", "https://au-api.contentstack.com"), + ("azure-na", "https://azure-na-api.contentstack.com"), + ("azure-eu", "https://azure-eu-api.contentstack.com"), + ("gcp-na", "https://gcp-na-api.contentstack.com"), + ("gcp-eu", "https://gcp-eu-api.contentstack.com"), +]) +def test_content_management_by_region(region, expected): + assert Endpoint.get_contentstack_endpoint(region, "contentManagement") == expected + + +# --------------------------------------------------------------------------- +# All expected service keys present +# --------------------------------------------------------------------------- + +EXPECTED_SERVICE_KEYS = [ + "application", "contentDelivery", "contentManagement", "auth", + "graphqlDelivery", "preview", "graphqlPreview", "images", "assets", + "automate", "launch", "developerHub", "brandKit", "genAI", + "personalizeManagement", "personalizeEdge", "composableStudio", +] + +def test_all_service_keys_present_for_eu(): + endpoints = Endpoint.get_contentstack_endpoint("eu") + for key in EXPECTED_SERVICE_KEYS: + assert key in endpoints, f"Missing service key: {key}" + +def test_na_has_asset_management_key(): + # NA is the only region that currently includes assetManagement. + endpoints = Endpoint.get_contentstack_endpoint("na") + assert "assetManagement" in endpoints + + +# --------------------------------------------------------------------------- +# omit_https flag +# --------------------------------------------------------------------------- + +class TestOmitHttps: + def test_strips_scheme_from_single_service(self): + host = Endpoint.get_contentstack_endpoint("eu", "contentDelivery", omit_https=True) + assert host == "eu-cdn.contentstack.com" + + def test_strips_scheme_from_all_services(self): + endpoints = Endpoint.get_contentstack_endpoint("na", omit_https=True) + assert isinstance(endpoints, dict) + for key, url in endpoints.items(): + assert "https://" not in url, f"Service {key} still has https://" + assert "http://" not in url, f"Service {key} still has http://" + + def test_false_retains_scheme(self): + url = Endpoint.get_contentstack_endpoint("na", "contentManagement", omit_https=False) + assert url.startswith("https://") + + def test_omit_https_positional_argument(self): + # Confirm the third positional arg is honoured (mirrors PHP signature). + host = Endpoint.get_contentstack_endpoint("gcp-na", "contentDelivery", True) + assert host == "gcp-na-cdn.contentstack.com" + + +# --------------------------------------------------------------------------- +# Case-insensitive and underscore alias matching +# --------------------------------------------------------------------------- + +class TestAliasMatching: + def test_uppercase_alias(self): + url = Endpoint.get_contentstack_endpoint("AWS-NA", "contentDelivery") + assert url == "https://cdn.contentstack.io" + + def test_underscore_azure_alias(self): + url = Endpoint.get_contentstack_endpoint("azure_na", "contentDelivery") + assert url == "https://azure-na-cdn.contentstack.com" + + def test_underscore_gcp_alias(self): + url = Endpoint.get_contentstack_endpoint("gcp_eu", "contentManagement") + assert url == "https://gcp-eu-api.contentstack.com" + + def test_mixed_case_eu(self): + url = Endpoint.get_contentstack_endpoint("EU", "contentDelivery") + assert url == "https://eu-cdn.contentstack.com" + + def test_mixed_case_au(self): + url = Endpoint.get_contentstack_endpoint("AU", "contentDelivery") + assert url == "https://au-cdn.contentstack.com" + + +# --------------------------------------------------------------------------- +# Return-all-endpoints (no service argument) +# --------------------------------------------------------------------------- + +class TestNoService: + def test_returns_dict(self): + result = Endpoint.get_contentstack_endpoint("au") + assert isinstance(result, dict) + assert len(result) > 1 + + def test_dict_contains_correct_urls(self): + endpoints = Endpoint.get_contentstack_endpoint("au") + assert endpoints["contentDelivery"] == "https://au-cdn.contentstack.com" + assert endpoints["contentManagement"] == "https://au-api.contentstack.com" + + def test_default_call_returns_na(self): + endpoints = Endpoint.get_contentstack_endpoint() + assert endpoints["contentDelivery"] == "https://cdn.contentstack.io" + + +# --------------------------------------------------------------------------- +# Error cases +# --------------------------------------------------------------------------- + +class TestErrorCases: + def test_empty_region_raises_value_error(self): + with pytest.raises(ValueError, match="Empty region provided"): + Endpoint.get_contentstack_endpoint("") + + def test_unknown_region_raises_lookup_error(self): + with pytest.raises(LookupError, match="Invalid region: invalid-region"): + Endpoint.get_contentstack_endpoint("invalid-region") + + def test_unknown_service_raises_lookup_error(self): + with pytest.raises(LookupError, match='Service "unknownService" not found'): + Endpoint.get_contentstack_endpoint("na", "unknownService") + + def test_whitespace_region_raises_value_error(self): + with pytest.raises(ValueError, match="Empty region provided"): + Endpoint.get_contentstack_endpoint(" ") + + +# --------------------------------------------------------------------------- +# camelCase alias (cross-SDK parity) +# --------------------------------------------------------------------------- + +class TestCamelCaseAlias: + def test_get_contentstack_endpoint_camel_case(self): + url = Endpoint.getContentstackEndpoint("na", "contentDelivery") + assert url == "https://cdn.contentstack.io" + + def test_camel_case_and_snake_case_return_same(self): + snake = Endpoint.get_contentstack_endpoint("eu", "contentDelivery") + camel = Endpoint.getContentstackEndpoint("eu", "contentDelivery") + assert snake == camel + + +# --------------------------------------------------------------------------- +# Utils proxy +# --------------------------------------------------------------------------- + +class TestUtilsProxy: + def test_proxy_returns_same_as_endpoint_class(self): + via_endpoint = Endpoint.get_contentstack_endpoint("eu", "contentDelivery") + via_utils = Utils.get_contentstack_endpoint("eu", "contentDelivery") + assert via_endpoint == via_utils + + def test_proxy_default_region(self): + url = Utils.get_contentstack_endpoint("us", "contentManagement") + assert url == "https://api.contentstack.io" + + def test_proxy_omit_https(self): + host = Utils.get_contentstack_endpoint("gcp-na", "contentDelivery", omit_https=True) + assert host == "gcp-na-cdn.contentstack.com" + + def test_proxy_all_endpoints(self): + endpoints = Utils.get_contentstack_endpoint("azure-eu") + assert isinstance(endpoints, dict) + assert "contentDelivery" in endpoints + + def test_proxy_camel_case_alias(self): + url = Utils.getContentstackEndpoint("na", "contentDelivery") + assert url == "https://cdn.contentstack.io" + + def test_proxy_error_propagates(self): + with pytest.raises(LookupError): + Utils.get_contentstack_endpoint("not-a-region", "contentDelivery") + + +# --------------------------------------------------------------------------- +# Cache behaviour +# --------------------------------------------------------------------------- + +class TestCache: + def test_second_call_uses_cache(self, mocker): + # Prime the cache with the first call, then spy on open() to confirm + # the second call does NOT read the file again. + Endpoint.get_contentstack_endpoint("na", "contentDelivery") + spy = mocker.patch("builtins.open", wraps=open) + Endpoint.get_contentstack_endpoint("eu", "contentDelivery") + # The cached path must not trigger any file reads. + spy.assert_not_called() + + def test_reset_cache_clears_data(self): + Endpoint.get_contentstack_endpoint("na") # primes cache + assert Endpoint._regions_data is not None + Endpoint.reset_cache() + assert Endpoint._regions_data is None From 8de3485f023c19ddc0429f60bb76a8fd3a9e3e6d Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Fri, 5 Jun 2026 12:37:32 +0530 Subject: [PATCH 2/2] feat: modified comments --- CHANGELOG.md | 4 ++-- contentstack_utils/endpoint.py | 5 +---- scripts/refresh_regions.py | 1 - tests/test_endpoint.py | 2 -- 4 files changed, 3 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b62f684..cc498f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,10 @@ - Added `Endpoint.get_contentstack_endpoint()` for dynamic region-aware URL resolution across all Contentstack regions and services. - Added `Utils.get_contentstack_endpoint()` proxy for backward-compatible access via the existing `Utils` import path. -- Added `getContentstackEndpoint` camelCase alias on both `Endpoint` and `Utils` for cross-SDK parity with the PHP and JS implementations. +- Added `getContentstackEndpoint` camelCase alias on both `Endpoint` and `Utils` for cross-SDK parity. - Bundled `contentstack_utils/assets/regions.json` — the authoritative registry of 7 regions (AWS NA/EU/AU, Azure NA/EU, GCP NA/EU) and 18 service endpoint keys. - Added runtime fallback in `Endpoint._load_regions()` — downloads `regions.json` from `artifacts.contentstack.com` on first use when the file is absent. -- Added `scripts/refresh_regions.py` to manually pull the latest regions from Contentstack (equivalent of `composer refresh-regions` in the PHP SDK). +- Added `scripts/refresh_regions.py` to manually pull the latest regions from Contentstack . - Exported `Endpoint` at package level in `__all__`. ## v1.5.0 diff --git a/contentstack_utils/endpoint.py b/contentstack_utils/endpoint.py index be52b77..651c89d 100644 --- a/contentstack_utils/endpoint.py +++ b/contentstack_utils/endpoint.py @@ -118,10 +118,7 @@ def get_contentstack_endpoint( endpoints = dict(region_row["endpoints"]) return Endpoint._strip_https_from_map(endpoints) if omit_https else endpoints - # ------------------------------------------------------------------ - # JS/PHP parity alias — lets callers use the same camelCase name - # across all Contentstack SDK languages without a lookup. - # ------------------------------------------------------------------ + getContentstackEndpoint = get_contentstack_endpoint @staticmethod diff --git a/scripts/refresh_regions.py b/scripts/refresh_regions.py index 70569b3..9a46850 100644 --- a/scripts/refresh_regions.py +++ b/scripts/refresh_regions.py @@ -6,7 +6,6 @@ Usage: python3 scripts/refresh_regions.py -Mirrors the ``composer refresh-regions`` command in the PHP SDK. Run this whenever Contentstack adds a new region or service, then commit the updated file so all consumers get the change on their next install. """ diff --git a/tests/test_endpoint.py b/tests/test_endpoint.py index 10e0e05..a386f1f 100644 --- a/tests/test_endpoint.py +++ b/tests/test_endpoint.py @@ -1,7 +1,5 @@ """ Tests for contentstack_utils.endpoint.Endpoint and the Utils proxy. - -Mirrors the PHP EndpointTest.php suite for cross-SDK consistency. """ import pytest