diff --git a/.github/workflows/back-merge-pr.yml b/.github/workflows/back-merge-pr.yml new file mode 100644 index 0000000..02b378c --- /dev/null +++ b/.github/workflows/back-merge-pr.yml @@ -0,0 +1,54 @@ +name: Back-merge master to development + +on: + push: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +jobs: + open-back-merge-pr: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Open back-merge PR if needed + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + BASE_BRANCH="development" + SOURCE_BRANCH="master" + + git fetch origin "$BASE_BRANCH" "$SOURCE_BRANCH" + + if ! git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then + echo "Base branch '$BASE_BRANCH' does not exist on origin; skipping." + exit 0 + fi + + SOURCE_SHA=$(git rev-parse "origin/$SOURCE_BRANCH") + BASE_SHA=$(git rev-parse "origin/$BASE_BRANCH") + + if [ "$SOURCE_SHA" = "$BASE_SHA" ]; then + echo "$SOURCE_BRANCH and $BASE_BRANCH are at the same commit; nothing to back-merge." + exit 0 + fi + + EXISTING=$(gh pr list --repo "${{ github.repository }}" --base "$BASE_BRANCH" --head "$SOURCE_BRANCH" --state open --json number --jq 'length') + + if [ "$EXISTING" -gt 0 ]; then + echo "An open PR from $SOURCE_BRANCH to $BASE_BRANCH already exists; skipping." + exit 0 + fi + + gh pr create --repo "${{ github.repository }}" --base "$BASE_BRANCH" --head "$SOURCE_BRANCH" --title "chore: back-merge $SOURCE_BRANCH into $BASE_BRANCH" --body "Automated back-merge after changes landed on \\`$SOURCE_BRANCH\\`. Review and merge to keep \\`$BASE_BRANCH\\` in sync." + + echo "Created back-merge PR $SOURCE_BRANCH -> $BASE_BRANCH." diff --git a/.github/workflows/check-branch.yml b/.github/workflows/check-branch.yml deleted file mode 100644 index e79864e..0000000 --- a/.github/workflows/check-branch.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: 'Check Branch' - -on: - pull_request: - -jobs: - check_branch: - runs-on: ubuntu-latest - steps: - - name: Comment PR - if: github.base_ref == 'master' && github.head_ref != 'staging' - uses: thollander/actions-comment-pull-request@v2 - with: - message: | - We regret to inform you that you are currently not able to merge your changes into the master branch due to restrictions applied by our SRE team. To proceed with merging your changes, we kindly request that you create a pull request from the next branch. Our team will then review the changes and work with you to ensure a successful merge into the master branch. - - name: Check branch - if: github.base_ref == 'master' && github.head_ref != 'staging' - run: | - echo "ERROR: We regret to inform you that you are currently not able to merge your changes into the master branch due to restrictions applied by our SRE team. To proceed with merging your changes, we kindly request that you create a pull request from the next branch. Our team will then review the changes and work with you to ensure a successful merge into the master branch." - exit 1 \ No newline at end of file diff --git a/.github/workflows/check-version-bump.yml b/.github/workflows/check-version-bump.yml new file mode 100644 index 0000000..8e71000 --- /dev/null +++ b/.github/workflows/check-version-bump.yml @@ -0,0 +1,86 @@ +name: Check Version Bump + +on: + pull_request: + +jobs: + version-bump: + name: Version & Changelog bump + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect changed files and version bump + id: detect + run: | + if git rev-parse HEAD^2 >/dev/null 2>&1; then + FILES=$(git diff --name-only HEAD^1 HEAD^2) + else + FILES=$(git diff --name-only HEAD~1 HEAD) + fi + VERSION_FILES_CHANGED=false + echo "$FILES" | grep -qx 'package.json' && VERSION_FILES_CHANGED=true + echo "$FILES" | grep -qx 'CHANGELOG.md' && VERSION_FILES_CHANGED=true + echo "version_files_changed=$VERSION_FILES_CHANGED" >> $GITHUB_OUTPUT + # Only lib/, webpack/, dist/, package.json count as release-affecting; .github/ and test/ do not + CODE_CHANGED=false + echo "$FILES" | grep -qE '^lib/|^webpack/|^dist/' && CODE_CHANGED=true + echo "$FILES" | grep -qx 'package.json' && CODE_CHANGED=true + echo "code_changed=$CODE_CHANGED" >> $GITHUB_OUTPUT + + - name: Skip when only test/docs/.github changed + if: steps.detect.outputs.code_changed != 'true' + run: | + echo "No release-affecting files changed (e.g. only test/docs/.github). Skipping version-bump check." + exit 0 + + - name: Fail when version bump was missed + if: steps.detect.outputs.code_changed == 'true' && steps.detect.outputs.version_files_changed != 'true' + run: | + echo "::error::This PR has code changes but no version bump. Please bump the version in package.json and add an entry in CHANGELOG.md." + exit 1 + + - name: Setup Node + if: steps.detect.outputs.code_changed == 'true' && steps.detect.outputs.version_files_changed == 'true' + uses: actions/setup-node@v4 + with: + node-version: '22.x' + + - name: Check version bump + if: steps.detect.outputs.code_changed == 'true' && steps.detect.outputs.version_files_changed == 'true' + run: | + set -e + PKG_VERSION=$(node -p "require('./package.json').version.replace(/^v/, '')") + if [ -z "$PKG_VERSION" ]; then + echo "::error::Could not read version from package.json" + exit 1 + fi + git fetch --tags --force 2>/dev/null || true + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true) + if [ -z "$LATEST_TAG" ]; then + echo "No existing tags found. Skipping version-bump check (first release)." + exit 0 + fi + LATEST_VERSION="${LATEST_TAG#v}" + LATEST_VERSION="${LATEST_VERSION%%-*}" + if [ "$(printf '%s\n' "$LATEST_VERSION" "$PKG_VERSION" | sort -V | tail -1)" != "$PKG_VERSION" ]; then + echo "::error::Version bump required: package.json version ($PKG_VERSION) is not greater than latest tag ($LATEST_TAG). Please bump the version in package.json." + exit 1 + fi + if [ "$PKG_VERSION" = "$LATEST_VERSION" ]; then + echo "::error::Version bump required: package.json version ($PKG_VERSION) equals latest tag ($LATEST_TAG). Please bump the version in package.json." + exit 1 + fi + CHANGELOG_VERSION=$(sed -nE 's/^## \[v?([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' CHANGELOG.md | head -1) + if [ -z "$CHANGELOG_VERSION" ]; then + echo "::error::Could not find a version entry in CHANGELOG.md (expected line like '## [v1.0.0](...)')." + exit 1 + fi + if [ "$CHANGELOG_VERSION" != "$PKG_VERSION" ]; then + echo "::error::CHANGELOG version mismatch: CHANGELOG.md top version ($CHANGELOG_VERSION) does not match package.json version ($PKG_VERSION). Please add or update the CHANGELOG entry for $PKG_VERSION." + exit 1 + fi + echo "Version bump check passed: package.json and CHANGELOG.md are at $PKG_VERSION (latest tag: $LATEST_TAG)." diff --git a/.gitignore b/.gitignore index 58d48ef..fc493a4 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 c82a852..b83025e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,19 @@ # CHANGELOG -## _v2.5.3_ - -### **Date: 08-June-2026** - -- Fixed security issues. - -## _v2.5.2_ - -### **Date: 18-May-2026** - -- Bumped urllib3 in development requirements to address reported vulnerabilities. +## _v2.6.0_ + +### **Date: 22-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. +- Bundled `contentstack/assets/regions.json` included in `package_data` — always present after `pip install`. +- `setup.py` auto-refreshes `regions.json` at build time via a custom `BuildPyWithRegions` command; network failures warn but never block the build. +- Runtime fallback: if `regions.json` is absent, the SDK downloads it live on the first `Endpoint` call. +- Added `refresh_regions()` utility to programmatically download the latest regions manifest from the Contentstack CDN and overwrite the bundled `assets/regions.json` (`from contentstack import refresh_regions`). +- Added `python3 -m contentstack.region_refresh` CLI command for refreshing the registry after `pip install` (source-tree script `scripts/download_regions.py` is for contributors only). ## _v2.5.1_ diff --git a/contentstack/__init__.py b/contentstack/__init__.py index 4d6b815..97350db 100644 --- a/contentstack/__init__.py +++ b/contentstack/__init__.py @@ -6,23 +6,43 @@ 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 +from .region_refresh import refresh_regions __all__ = ( "Entry", "Asset", "ContentType", +"Endpoint", "HTTPSConnection", "Stack", -"Utils" +"Utils", +"refresh_regions", ) + +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.3' +__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 1423216..1455159 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 0000000..937b603 --- /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 b0bae6b..3df1e0d 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/region_refresh.py b/contentstack/region_refresh.py new file mode 100644 index 0000000..5579502 --- /dev/null +++ b/contentstack/region_refresh.py @@ -0,0 +1,81 @@ +""" +Utility to pull the latest regions.json from the Contentstack CDN and +overwrite the bundled copy at contentstack/assets/regions.json. + +Exposed as a package-level function so tooling and CI pipelines can call it +programmatically instead of invoking the script directly: + + from contentstack import refresh_regions + refresh_regions() +""" + +import json +import os +import sys +import urllib.request + +_REGIONS_URL = "https://artifacts.contentstack.com/regions.json" +_ASSET_PATH = os.path.join(os.path.dirname(__file__), "assets", "regions.json") + + +def refresh_regions( + url: str = _REGIONS_URL, + dest: str = _ASSET_PATH, + *, + timeout: int = 30, + silent: bool = False, +) -> dict: + """ + Download the latest regions manifest from the Contentstack CDN and write + it to the bundled assets file so all consumers get the update. + + @param url - URL to fetch regions.json from (defaults to Contentstack CDN) + @param dest - Destination file path (defaults to contentstack/assets/regions.json) + @param timeout - HTTP request timeout in seconds + @param silent - Suppress progress output when True + @returns The parsed regions dict on success + @raises RuntimeError on download failure, invalid JSON, or unexpected schema + """ + dest = os.path.normpath(dest) + + if not silent: + print(f"Fetching {url} ...") + + try: + with urllib.request.urlopen(url, timeout=timeout) as resp: + data = resp.read().decode("utf-8") + except Exception as exc: + raise RuntimeError(f"Could not download regions.json: {exc}") from exc + + try: + decoded = json.loads(data) + except json.JSONDecodeError as exc: + raise RuntimeError(f"Downloaded content is not valid JSON: {exc}") from exc + + if not isinstance(decoded, dict) or "regions" not in decoded: + raise RuntimeError("Downloaded JSON does not contain a 'regions' key.") + + 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") + + region_count = len(decoded["regions"]) + if not silent: + print(f"OK: Wrote {region_count} regions to {dest}") + + return decoded + + +def _cli_main() -> int: + """Entry point kept for backward compatibility with the scripts/ invocation.""" + try: + refresh_regions() + return 0 + except RuntimeError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(_cli_main()) diff --git a/contentstack/stack.py b/contentstack/stack.py index 269df06..13c2002 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/requirements.txt b/requirements.txt index b6f9601..0d688a3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ twython==3.9.1 setuptools==80.3.1 -contentstack-utils==1.3.0 +contentstack-utils==1.6.0 python-dateutil==2.8.2 requests==2.33.0 coverage==7.6.0 @@ -27,7 +27,7 @@ filelock~=3.20.1 pluggy~=1.5.0 six~=1.16.0 packaging>=24.0 -pytest==7.3.1 +pytest>=8.1.0 dill~=0.3.8 pytz==2024.1 Babel==2.14.0 diff --git a/scripts/download_regions.py b/scripts/download_regions.py new file mode 100644 index 0000000..3a13c1e --- /dev/null +++ b/scripts/download_regions.py @@ -0,0 +1,70 @@ +""" +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 +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', 'assets', 'regions.json' +) + + +def download(): + dest_dir = os.path.dirname(DEST) + os.makedirs(dest_dir, exist_ok=True) + + 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 b86919a..943b229 100644 --- a/setup.py +++ b/setup.py @@ -9,11 +9,25 @@ try: from setuptools import setup, find_packages + from setuptools.command.build_py import build_py except ImportError: from distutils.core import setup package = "contentstack" + +class BuildPyWithRegions(build_py): + """Fetch latest regions.json from Contentstack CDN before packaging.""" + + def run(self): + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + try: + from contentstack.region_refresh import refresh_regions + refresh_regions() + except Exception as exc: + print(f"WARNING: Could not refresh regions.json: {exc}", file=sys.stderr) + super().run() + def get_version(package): """ Return package version as listed in `__version__` in `init.py`. @@ -32,6 +46,7 @@ def get_version(package): ] setup( + cmdclass={"build_py": BuildPyWithRegions}, title="contentstack-python", name="Contentstack", status="Active", @@ -46,6 +61,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 0000000..2958cb6 --- /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()