Skip to content

Commit fcee676

Browse files
authored
Merge pull request #79 from contentstack/fix/DX-9042
fix: add refresh_regions utility and auto-refresh regions.json at build time
2 parents 41ae250 + c0d2146 commit fcee676

5 files changed

Lines changed: 117 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Changelog
22

3-
## v1.6.0 (2026-06-05)
3+
## v1.6.0 (2026-06-22)
44

55
### New feature: Multi-region endpoint resolution
66

@@ -9,8 +9,12 @@
99
- Added `getContentstackEndpoint` camelCase alias on both `Endpoint` and `Utils` for cross-SDK parity.
1010
- 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.
1111
- Added runtime fallback in `Endpoint._load_regions()` — downloads `regions.json` from `artifacts.contentstack.com` on first use when the file is absent.
12-
- Added `scripts/refresh_regions.py` to manually pull the latest regions from Contentstack .
12+
- Added `scripts/refresh_regions.py` (source-tree only) and `python3 -m contentstack_utils.region_refresh` (installed package CLI) to manually pull the latest regions from Contentstack.
1313
- Exported `Endpoint` at package level in `__all__`.
14+
- Added `refresh_regions()` utility to programmatically download the latest regions manifest from the Contentstack CDN and overwrite the bundled `assets/regions.json`.
15+
- Exposed `refresh_regions` at the package level (`from contentstack_utils import refresh_regions`) for use in CI pipelines and tooling.
16+
- `setup.py` now auto-refreshes `regions.json` at build time via a custom `BuildPyWithRegions` command; network failures warn but never block the build.
17+
- Added `assets/regions.json` to `package_data` so the bundled file is correctly shipped in the sdist/wheel.
1418

1519
## v1.5.0
1620

contentstack_utils/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from contentstack_utils.gql import GQL
1919
from contentstack_utils.automate import Automate
2020
from contentstack_utils.entry_editable import addEditableTags, addTags, getTag
21+
from contentstack_utils.region_refresh import refresh_regions
2122

2223
__all__ = (
2324
"Endpoint",
@@ -32,6 +33,7 @@
3233
"addEditableTags",
3334
"addTags",
3435
"getTag",
36+
"refresh_regions",
3537
)
3638

3739
__title__ = 'contentstack_utils'
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""
2+
Utility to pull the latest regions.json from the Contentstack CDN and
3+
overwrite the bundled copy at contentstack_utils/assets/regions.json.
4+
5+
Exposed as a package-level function so tooling and CI pipelines can call it
6+
programmatically instead of invoking the script directly:
7+
8+
from contentstack_utils import refresh_regions
9+
refresh_regions()
10+
"""
11+
12+
import json
13+
import os
14+
import sys
15+
import urllib.request
16+
17+
_REGIONS_URL = "https://artifacts.contentstack.com/regions.json"
18+
_ASSET_PATH = os.path.join(os.path.dirname(__file__), "assets", "regions.json")
19+
20+
21+
def refresh_regions(
22+
url: str = _REGIONS_URL,
23+
dest: str = _ASSET_PATH,
24+
*,
25+
timeout: int = 30,
26+
silent: bool = False,
27+
) -> dict:
28+
"""
29+
Download the latest regions manifest from the Contentstack CDN and write
30+
it to the bundled assets file so all consumers get the update.
31+
32+
@param url - URL to fetch regions.json from (defaults to Contentstack CDN)
33+
@param dest - Destination file path (defaults to contentstack_utils/assets/regions.json)
34+
@param timeout - HTTP request timeout in seconds
35+
@param silent - Suppress progress output when True
36+
@returns The parsed regions dict on success
37+
@raises RuntimeError on download failure, invalid JSON, or unexpected schema
38+
"""
39+
dest = os.path.normpath(dest)
40+
41+
if not silent:
42+
print(f"Fetching {url} ...")
43+
44+
try:
45+
with urllib.request.urlopen(url, timeout=timeout) as resp:
46+
data = resp.read().decode("utf-8")
47+
except Exception as exc:
48+
raise RuntimeError(f"Could not download regions.json: {exc}") from exc
49+
50+
try:
51+
decoded = json.loads(data)
52+
except json.JSONDecodeError as exc:
53+
raise RuntimeError(f"Downloaded content is not valid JSON: {exc}") from exc
54+
55+
if not isinstance(decoded, dict) or "regions" not in decoded:
56+
raise RuntimeError("Downloaded JSON does not contain a 'regions' key.")
57+
58+
os.makedirs(os.path.dirname(dest), exist_ok=True)
59+
with open(dest, "w", encoding="utf-8") as fh:
60+
json.dump(decoded, fh, indent=2, ensure_ascii=False)
61+
fh.write("\n")
62+
63+
region_count = len(decoded["regions"])
64+
if not silent:
65+
print(f"OK: Wrote {region_count} regions to {dest}")
66+
67+
return decoded
68+
69+
70+
def _cli_main() -> int:
71+
"""Entry point kept for backward compatibility with the scripts/ invocation."""
72+
try:
73+
refresh_regions()
74+
print("Next steps:")
75+
return 0
76+
except RuntimeError as exc:
77+
print(f"ERROR: {exc}", file=sys.stderr)
78+
return 1
79+
80+
81+
if __name__ == "__main__":
82+
sys.exit(_cli_main())

setup.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,33 @@
11
import os
2+
import sys
23

34
from setuptools import setup, find_packages
4-
from distutils.core import setup
5+
from setuptools.command.build_py import build_py
6+
7+
8+
class BuildPyWithRegions(build_py):
9+
"""Fetch latest regions.json from Contentstack CDN before packaging."""
10+
11+
def run(self):
12+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
13+
try:
14+
from contentstack_utils.region_refresh import refresh_regions
15+
refresh_regions()
16+
except Exception as exc:
17+
# Never block a build over a network failure — warn and continue.
18+
print(f"WARNING: Could not refresh regions.json: {exc}", file=sys.stderr)
19+
super().run()
20+
521

622
with open(os.path.join(os.path.dirname(__file__), 'README.md')) as readme:
723
long_description = readme.read()
824

925
setup(
1026
name='contentstack_utils',
1127
packages=find_packages(),
28+
package_data={
29+
"contentstack_utils": ["assets/regions.json"],
30+
},
1231
description="contentstack_utils is a Utility package for Contentstack headless CMS with an API-first approach.",
1332
author='contentstack',
1433
long_description=long_description,
@@ -17,11 +36,12 @@
1736
license='MIT',
1837
version='1.6.0',
1938
install_requires=[
20-
39+
2140
],
2241
setup_requires=['pytest-runner'],
2342
tests_require=['pytest==4.4.1'],
2443
test_suite='tests',
44+
cmdclass={"build_py": BuildPyWithRegions},
2545
classifiers=[
2646
"License :: OSI Approved :: MIT License",
2747
"Operating System :: OS Independent",

tests/test_endpoint.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -250,14 +250,12 @@ def test_proxy_error_propagates(self):
250250
# ---------------------------------------------------------------------------
251251

252252
class TestCache:
253-
def test_second_call_uses_cache(self, mocker):
254-
# Prime the cache with the first call, then spy on open() to confirm
255-
# the second call does NOT read the file again.
253+
def test_second_call_uses_cache(self, reset_cache):
254+
from unittest.mock import patch, MagicMock
256255
Endpoint.get_contentstack_endpoint("na", "contentDelivery")
257-
spy = mocker.patch("builtins.open", wraps=open)
258-
Endpoint.get_contentstack_endpoint("eu", "contentDelivery")
259-
# The cached path must not trigger any file reads.
260-
spy.assert_not_called()
256+
with patch("builtins.open", wraps=open) as spy:
257+
Endpoint.get_contentstack_endpoint("eu", "contentDelivery")
258+
spy.assert_not_called()
261259

262260
def test_reset_cache_clears_data(self):
263261
Endpoint.get_contentstack_endpoint("na") # primes cache

0 commit comments

Comments
 (0)