contentstack-utils 1.4.0__tar.gz → 1.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/PKG-INFO +1 -1
  2. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/contentstack_utils/__init__.py +10 -2
  3. contentstack_utils-1.6.0/contentstack_utils/endpoint.py +242 -0
  4. contentstack_utils-1.6.0/contentstack_utils/entry_editable.py +248 -0
  5. contentstack_utils-1.6.0/contentstack_utils/region_refresh.py +82 -0
  6. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/contentstack_utils/utils.py +82 -1
  7. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/contentstack_utils.egg-info/PKG-INFO +1 -1
  8. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/contentstack_utils.egg-info/SOURCES.txt +5 -0
  9. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/setup.py +23 -3
  10. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/tests/convert_style.py +1 -1
  11. contentstack_utils-1.6.0/tests/test_editable_tags.py +56 -0
  12. contentstack_utils-1.6.0/tests/test_endpoint.py +264 -0
  13. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/LICENSE +0 -0
  14. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/README.md +0 -0
  15. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/contentstack_utils/automate.py +0 -0
  16. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/contentstack_utils/embedded/__init__.py +0 -0
  17. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/contentstack_utils/embedded/item_type.py +0 -0
  18. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/contentstack_utils/embedded/styletype.py +0 -0
  19. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/contentstack_utils/gql.py +0 -0
  20. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/contentstack_utils/helper/__init__.py +0 -0
  21. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/contentstack_utils/helper/converter.py +0 -0
  22. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/contentstack_utils/helper/metadata.py +0 -0
  23. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/contentstack_utils/helper/node_to_html.py +0 -0
  24. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/contentstack_utils/render/__init__.py +0 -0
  25. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/contentstack_utils/render/options.py +0 -0
  26. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/contentstack_utils.egg-info/dependency_links.txt +0 -0
  27. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/contentstack_utils.egg-info/top_level.txt +0 -0
  28. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/setup.cfg +0 -0
  29. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/tests/__init__.py +0 -0
  30. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/tests/test_default_opt_others.py +0 -0
  31. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/tests/test_gql_to_html_func.py +0 -0
  32. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/tests/test_helper_node_to_html.py +0 -0
  33. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/tests/test_item_types.py +0 -0
  34. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/tests/test_metadata.py +0 -0
  35. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/tests/test_option_render_mark.py +0 -0
  36. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/tests/test_render_default_options.py +0 -0
  37. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/tests/test_render_options.py +0 -0
  38. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/tests/test_style_type.py +0 -0
  39. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/tests/test_util_srte.py +0 -0
  40. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/tests/test_utils.py +0 -0
  41. {contentstack_utils-1.4.0 → contentstack_utils-1.6.0}/tests/test_variant_aliases.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contentstack_utils
3
- Version: 1.4.0
3
+ Version: 1.6.0
4
4
  Summary: contentstack_utils is a Utility package for Contentstack headless CMS with an API-first approach.
5
5
  Home-page: https://github.com/contentstack/contentstack-utils-python
6
6
  Author: contentstack
@@ -10,14 +10,18 @@ __author__, __status__, __version__, __endpoint__ and __email__
10
10
 
11
11
  from contentstack_utils.embedded.item_type import ItemType
12
12
  from contentstack_utils.embedded.styletype import StyleType
13
+ from contentstack_utils.endpoint import Endpoint
13
14
  from contentstack_utils.helper.metadata import Metadata
14
15
  from contentstack_utils.helper.node_to_html import NodeToHtml
15
16
  from contentstack_utils.render.options import Options
16
17
  from contentstack_utils.utils import Utils
17
18
  from contentstack_utils.gql import GQL
18
19
  from contentstack_utils.automate import Automate
20
+ from contentstack_utils.entry_editable import addEditableTags, addTags, getTag
21
+ from contentstack_utils.region_refresh import refresh_regions
19
22
 
20
23
  __all__ = (
24
+ "Endpoint",
21
25
  "Utils",
22
26
  "Options",
23
27
  "Metadata",
@@ -25,12 +29,16 @@ __all__ = (
25
29
  "Automate",
26
30
  "StyleType",
27
31
  "ItemType",
28
- "NodeToHtml"
32
+ "NodeToHtml",
33
+ "addEditableTags",
34
+ "addTags",
35
+ "getTag",
36
+ "refresh_regions",
29
37
  )
30
38
 
31
39
  __title__ = 'contentstack_utils'
32
40
  __author__ = 'contentstack'
33
41
  __status__ = 'debug'
34
- __version__ = '1.4.0'
42
+ __version__ = '1.6.0'
35
43
  __endpoint__ = 'cdn.contentstack.io'
36
44
  __contact__ = 'support@contentstack.com'
@@ -0,0 +1,242 @@
1
+ """
2
+ Endpoint resolution for Contentstack services across all regions.
3
+
4
+ Reads a bundled regions.json (src/assets/regions.json) and resolves
5
+ the correct base URL for any region + service combination. No runtime
6
+ HTTP calls — the file is shipped with the package and updated via
7
+ ``python scripts/refresh_regions.py``.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ import urllib.request
14
+ from typing import Dict, Optional, Union
15
+
16
+ _REGIONS_URL = "https://artifacts.contentstack.com/regions.json"
17
+ _ASSETS_DIR = os.path.join(os.path.dirname(__file__), "assets")
18
+ _REGIONS_FILE = os.path.join(_ASSETS_DIR, "regions.json")
19
+
20
+
21
+ class Endpoint:
22
+ """
23
+ Resolve Contentstack service URLs for any region.
24
+
25
+ All public methods are static — no instantiation required.
26
+
27
+ Example::
28
+
29
+ from contentstack_utils import Endpoint
30
+
31
+ # Full URL
32
+ url = Endpoint.get_contentstack_endpoint("na", "contentDelivery")
33
+ # → "https://cdn.contentstack.io"
34
+
35
+ # Host only (strip https://) — useful for SDK setHost() calls
36
+ host = Endpoint.get_contentstack_endpoint("eu", "contentDelivery", omit_https=True)
37
+ # → "eu-cdn.contentstack.com"
38
+
39
+ # All endpoints for a region
40
+ all_endpoints = Endpoint.get_contentstack_endpoint("azure-na")
41
+ # → {"contentDelivery": "...", "contentManagement": "...", ...}
42
+ """
43
+
44
+ # Module-level cache — loaded once per Python process, shared across all calls.
45
+ _regions_data: Optional[Dict] = None
46
+
47
+ @staticmethod
48
+ def get_contentstack_endpoint(
49
+ region: str = "us",
50
+ service: str = "",
51
+ omit_https: bool = False,
52
+ ) -> Union[str, Dict[str, str]]:
53
+ """
54
+ Resolve a Contentstack service endpoint URL for the given region.
55
+
56
+ :param region:
57
+ Region ID or any accepted alias (case-insensitive, ``-`` and ``_``
58
+ are interchangeable). Examples: ``'na'``, ``'us'``, ``'eu'``,
59
+ ``'AWS-NA'``, ``'azure_eu'``, ``'gcp-na'``.
60
+ Defaults to ``'us'`` (AWS North America).
61
+ :param service:
62
+ Optional service key. When provided, a single URL string is
63
+ returned. When omitted, a dict of **all** service URLs is returned.
64
+ Valid keys include: ``'contentDelivery'``, ``'contentManagement'``,
65
+ ``'auth'``, ``'graphqlDelivery'``, ``'preview'``, ``'images'``,
66
+ ``'assets'``, ``'automate'``, ``'launch'``, ``'developerHub'``,
67
+ ``'brandKit'``, ``'genAI'``, ``'personalizeManagement'``,
68
+ ``'personalizeEdge'``, ``'composableStudio'``, ``'assetManagement'``.
69
+ :param omit_https:
70
+ When ``True``, strips the ``https://`` (or ``http://``) scheme from
71
+ every returned URL. Useful when passing the host to an SDK that
72
+ constructs its own URLs (e.g. ``stack.set_host(host)``).
73
+ :returns:
74
+ - A ``str`` URL when *service* is specified.
75
+ - A ``dict[str, str]`` mapping service keys → URLs when *service*
76
+ is omitted.
77
+ :raises ValueError:
78
+ If *region* is an empty string.
79
+ :raises LookupError:
80
+ If *region* does not match any known region ID or alias, or if
81
+ *service* is not present in the resolved region's endpoint map.
82
+ :raises RuntimeError:
83
+ If the bundled ``regions.json`` cannot be read or is malformed.
84
+
85
+ Examples::
86
+
87
+ Endpoint.get_contentstack_endpoint("na", "contentDelivery")
88
+ # → "https://cdn.contentstack.io"
89
+
90
+ Endpoint.get_contentstack_endpoint("eu", "contentDelivery", omit_https=True)
91
+ # → "eu-cdn.contentstack.com"
92
+
93
+ Endpoint.get_contentstack_endpoint("azure-na")
94
+ # → {"contentDelivery": "https://...", ...}
95
+ """
96
+ if not region:
97
+ raise ValueError("Empty region provided. Please put valid region.")
98
+
99
+ data = Endpoint._load_regions()
100
+ normalized = region.strip().lower()
101
+
102
+ if not normalized:
103
+ raise ValueError("Empty region provided. Please put valid region.")
104
+ region_row = Endpoint._find_region_by_id_or_alias(data["regions"], normalized)
105
+
106
+ if region_row is None:
107
+ raise LookupError(f"Invalid region: {region}")
108
+
109
+ if service:
110
+ endpoints = region_row["endpoints"]
111
+ if service not in endpoints:
112
+ raise LookupError(
113
+ f'Service "{service}" not found for region "{region_row["id"]}"'
114
+ )
115
+ url = endpoints[service]
116
+ return Endpoint._strip_https(url) if omit_https else url
117
+
118
+ endpoints = dict(region_row["endpoints"])
119
+ return Endpoint._strip_https_from_map(endpoints) if omit_https else endpoints
120
+
121
+
122
+ getContentstackEndpoint = get_contentstack_endpoint
123
+
124
+ @staticmethod
125
+ def reset_cache() -> None:
126
+ """
127
+ Clear the in-memory region cache.
128
+
129
+ Intended for testing only — forces the next call to re-read
130
+ ``regions.json`` from disk.
131
+ """
132
+ Endpoint._regions_data = None
133
+
134
+ # ------------------------------------------------------------------
135
+ # Internal helpers
136
+ # ------------------------------------------------------------------
137
+
138
+ @staticmethod
139
+ def _load_regions() -> Dict:
140
+ """
141
+ Load and cache regions data.
142
+
143
+ Resolution order:
144
+ 1. In-memory cache (zero I/O after the first call in a process)
145
+ 2. Bundled ``contentstack_utils/assets/regions.json`` on disk
146
+ 3. Live download from ``artifacts.contentstack.com`` (fallback when
147
+ the file is absent — e.g. an editable install without assets)
148
+ """
149
+ if Endpoint._regions_data is not None:
150
+ return Endpoint._regions_data
151
+
152
+ if not os.path.exists(_REGIONS_FILE):
153
+ Endpoint._download_and_save(_REGIONS_FILE)
154
+
155
+ if not os.path.exists(_REGIONS_FILE):
156
+ raise RuntimeError(
157
+ "contentstack_utils: regions.json not found and could not be downloaded. "
158
+ "Run 'python scripts/refresh_regions.py' and ensure network access."
159
+ )
160
+
161
+ try:
162
+ with open(_REGIONS_FILE, "r", encoding="utf-8") as fh:
163
+ raw = fh.read()
164
+ except OSError as exc:
165
+ raise RuntimeError(
166
+ f"contentstack_utils: Could not read regions.json: {exc}"
167
+ ) from exc
168
+
169
+ try:
170
+ decoded = json.loads(raw)
171
+ except json.JSONDecodeError as exc:
172
+ raise RuntimeError(
173
+ "contentstack_utils: regions.json is corrupt. "
174
+ "Run 'python scripts/refresh_regions.py' to re-download it."
175
+ ) from exc
176
+
177
+ if not isinstance(decoded, dict) or "regions" not in decoded:
178
+ raise RuntimeError(
179
+ "contentstack_utils: regions.json is corrupt. "
180
+ "Run 'python scripts/refresh_regions.py' to re-download it."
181
+ )
182
+
183
+ Endpoint._regions_data = decoded
184
+ return Endpoint._regions_data
185
+
186
+ @staticmethod
187
+ def _download_and_save(dest: str) -> None:
188
+ """
189
+ Fetch regions.json from the Contentstack CDN and write it to *dest*.
190
+
191
+ Silent on failure — the caller decides whether a missing file is fatal.
192
+ """
193
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
194
+ try:
195
+ with urllib.request.urlopen(_REGIONS_URL, timeout=30) as resp:
196
+ data = resp.read().decode("utf-8")
197
+ except Exception:
198
+ return
199
+
200
+ try:
201
+ decoded = json.loads(data)
202
+ except json.JSONDecodeError:
203
+ return
204
+
205
+ if isinstance(decoded, dict) and "regions" in decoded:
206
+ with open(dest, "w", encoding="utf-8") as fh:
207
+ fh.write(data)
208
+
209
+ @staticmethod
210
+ def _find_region_by_id_or_alias(
211
+ regions: list, normalized_input: str
212
+ ) -> Optional[Dict]:
213
+ """
214
+ Find a region by its ``id`` field first, then by any alias.
215
+
216
+ Both passes are case-insensitive (caller must pass a lowercased string).
217
+ Two-pass approach mirrors the PHP implementation: ID match wins over alias
218
+ match, which avoids surprising behaviour when a future alias happens to
219
+ collide with another region's canonical ID.
220
+ """
221
+ # Pass 1 — exact id match
222
+ for row in regions:
223
+ if row["id"] == normalized_input:
224
+ return row
225
+
226
+ # Pass 2 — alias match
227
+ for row in regions:
228
+ for alias in row.get("alias", []):
229
+ if alias.lower() == normalized_input:
230
+ return row
231
+
232
+ return None
233
+
234
+ @staticmethod
235
+ def _strip_https(url: str) -> str:
236
+ """Strip ``https://`` or ``http://`` from the start of a URL."""
237
+ return re.sub(r"^https?://", "", url)
238
+
239
+ @staticmethod
240
+ def _strip_https_from_map(endpoints: Dict[str, str]) -> Dict[str, str]:
241
+ """Return a new dict with the scheme stripped from every URL value."""
242
+ return {key: Endpoint._strip_https(url) for key, url in endpoints.items()}
@@ -0,0 +1,248 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Optional, Union, cast
4
+
5
+
6
+ AppliedVariants = Optional[Dict[str, Any]]
7
+ TagValue = Union[str, Dict[str, str]]
8
+
9
+
10
+ def _get_parent_variantised_path(applied_variants: Dict[str, Any], meta_key: str) -> str:
11
+ """
12
+ Port of JS getParentVariantisedPath().
13
+ Finds the longest variantised field path that is a prefix of meta_key.
14
+ """
15
+ try:
16
+ if not meta_key:
17
+ return ""
18
+ variantised_field_paths = sorted(applied_variants.keys(), key=len, reverse=True)
19
+ child_fragments = meta_key.split(".")
20
+ if not child_fragments or not variantised_field_paths:
21
+ return ""
22
+ for path in variantised_field_paths:
23
+ parent_fragments = str(path).split(".")
24
+ if len(parent_fragments) > len(child_fragments):
25
+ continue
26
+ if all(child_fragments[i] == parent_fragments[i] for i in range(len(parent_fragments))):
27
+ return str(path)
28
+ return ""
29
+ except Exception:
30
+ return ""
31
+
32
+
33
+ def _apply_variant_to_data_value(data_value: str, applied_variants: AppliedVariants, meta_key: str, should_apply_variant: bool) -> str:
34
+ """
35
+ Port of JS applyVariantToDataValue().
36
+
37
+ If the current field (or its parent field path) is variantised, prefixes with
38
+ 'v2:' and appends `_{variant}` to the entry uid segment of the dot-path.
39
+ """
40
+ if not should_apply_variant or not applied_variants or not meta_key or not isinstance(applied_variants, dict):
41
+ return data_value
42
+
43
+ variant: Optional[str] = None
44
+ if meta_key in applied_variants:
45
+ variant = str(applied_variants[meta_key])
46
+ else:
47
+ parent_path = _get_parent_variantised_path(applied_variants, meta_key)
48
+ if parent_path:
49
+ variant = str(applied_variants.get(parent_path))
50
+
51
+ if not variant:
52
+ return data_value
53
+
54
+ parts = ("v2:" + data_value).split(".")
55
+ if len(parts) >= 2:
56
+ parts[1] = parts[1] + "_" + variant
57
+ return ".".join(parts)
58
+
59
+
60
+ def _tags_value(data_value: str, tags_as_object: bool, applied_variants: AppliedVariants, meta_key: str, should_apply_variant: bool) -> TagValue:
61
+ resolved = _apply_variant_to_data_value(data_value, applied_variants, meta_key, should_apply_variant)
62
+ if tags_as_object:
63
+ return {"data-cslp": resolved}
64
+ return f"data-cslp={resolved}"
65
+
66
+
67
+ def _parent_tags_value(data_value: str, tags_as_object: bool) -> TagValue:
68
+ if tags_as_object:
69
+ return {"data-cslp-parent-field": data_value}
70
+ return f"data-cslp-parent-field={data_value}"
71
+
72
+
73
+ def getTag( # pylint: disable=invalid-name
74
+ content: Any,
75
+ prefix: str,
76
+ tags_as_object: bool,
77
+ locale: str,
78
+ applied_variants: AppliedVariants,
79
+ should_apply_variant: bool,
80
+ meta_key: str = "",
81
+ ) -> Dict[str, Any]:
82
+ """
83
+ Port of JS getTag() from `src/entry-editable.ts`.
84
+
85
+ Returns a dict mapping field keys to CSLP tag values, and mutates nested objects/refs
86
+ by attaching their own `$` tag maps.
87
+ """
88
+ if content is None or not isinstance(content, dict):
89
+ return {}
90
+
91
+ tags: Dict[str, Any] = {}
92
+ for key, value in content.items():
93
+ if key == "$":
94
+ continue
95
+
96
+ meta_uid = ""
97
+ if isinstance(value, dict):
98
+ meta = value.get("_metadata")
99
+ if isinstance(meta, dict) and meta.get("uid"):
100
+ meta_uid = str(meta.get("uid"))
101
+
102
+ meta_key_prefix = (meta_key + ".") if meta_key else ""
103
+ updated_meta_key = f"{meta_key_prefix}{key}" if should_apply_variant else ""
104
+ if meta_uid and updated_meta_key:
105
+ updated_meta_key = updated_meta_key + "." + meta_uid
106
+
107
+ if isinstance(value, list):
108
+ for index, obj in enumerate(value):
109
+ if obj is None:
110
+ continue
111
+
112
+ child_key = f"{key}__{index}"
113
+ parent_key = f"{key}__parent"
114
+
115
+ obj_meta_uid = ""
116
+ if isinstance(obj, dict):
117
+ meta = obj.get("_metadata")
118
+ if isinstance(meta, dict) and meta.get("uid"):
119
+ obj_meta_uid = str(meta.get("uid"))
120
+
121
+ array_meta_key = f"{meta_key_prefix}{key}" if should_apply_variant else ""
122
+ if obj_meta_uid and array_meta_key:
123
+ array_meta_key = array_meta_key + "." + obj_meta_uid
124
+
125
+ tags[child_key] = _tags_value(
126
+ f"{prefix}.{key}.{index}",
127
+ tags_as_object,
128
+ applied_variants,
129
+ array_meta_key,
130
+ should_apply_variant,
131
+ )
132
+ tags[parent_key] = _parent_tags_value(f"{prefix}.{key}", tags_as_object)
133
+
134
+ # Reference entries in array
135
+ if isinstance(obj, dict) and obj.get("_content_type_uid") is not None and obj.get("uid") is not None:
136
+ new_applied_variants = obj.get("_applied_variants")
137
+ if new_applied_variants is None and isinstance(obj.get("system"), dict):
138
+ new_applied_variants = cast(dict, obj["system"]).get("applied_variants")
139
+ new_should_apply_variant = bool(new_applied_variants)
140
+
141
+ obj_locale = obj.get("locale") or locale
142
+ obj["$"] = getTag(
143
+ obj,
144
+ f"{obj.get('_content_type_uid')}.{obj.get('uid')}.{obj_locale}",
145
+ tags_as_object,
146
+ locale,
147
+ cast(AppliedVariants, new_applied_variants),
148
+ new_should_apply_variant,
149
+ meta_key="",
150
+ )
151
+ continue
152
+
153
+ if isinstance(obj, dict):
154
+ obj["$"] = getTag(
155
+ obj,
156
+ f"{prefix}.{key}.{index}",
157
+ tags_as_object,
158
+ locale,
159
+ applied_variants,
160
+ should_apply_variant,
161
+ meta_key=array_meta_key,
162
+ )
163
+
164
+ tags[key] = _tags_value(
165
+ f"{prefix}.{key}",
166
+ tags_as_object,
167
+ applied_variants,
168
+ updated_meta_key,
169
+ should_apply_variant,
170
+ )
171
+ continue
172
+
173
+ if isinstance(value, dict):
174
+ value["$"] = getTag(
175
+ value,
176
+ f"{prefix}.{key}",
177
+ tags_as_object,
178
+ locale,
179
+ applied_variants,
180
+ should_apply_variant,
181
+ meta_key=updated_meta_key,
182
+ )
183
+ tags[key] = _tags_value(
184
+ f"{prefix}.{key}",
185
+ tags_as_object,
186
+ applied_variants,
187
+ updated_meta_key,
188
+ should_apply_variant,
189
+ )
190
+ continue
191
+
192
+ tags[key] = _tags_value(
193
+ f"{prefix}.{key}",
194
+ tags_as_object,
195
+ applied_variants,
196
+ updated_meta_key,
197
+ should_apply_variant,
198
+ )
199
+
200
+ return tags
201
+
202
+
203
+ def addTags( # pylint: disable=invalid-name
204
+ entry: Optional[dict],
205
+ contentTypeUid: str,
206
+ tagsAsObject: bool,
207
+ locale: str = "en-us",
208
+ options: Optional[dict] = None,
209
+ ) -> None:
210
+ """
211
+ Port of JS addTags() from `src/entry-editable.ts`.
212
+ Mutates `entry` by attaching a `$` dict of CSLP tags.
213
+ """
214
+ if not entry:
215
+ return
216
+
217
+ use_lower_case_locale = True
218
+ if isinstance(options, dict) and "useLowerCaseLocale" in options:
219
+ use_lower_case_locale = bool(options.get("useLowerCaseLocale"))
220
+
221
+ content_type_uid = (contentTypeUid or "").lower()
222
+ resolved_locale = (locale or "en-us")
223
+ if use_lower_case_locale:
224
+ resolved_locale = resolved_locale.lower()
225
+
226
+ applied_variants = entry.get("_applied_variants")
227
+ if applied_variants is None and isinstance(entry.get("system"), dict):
228
+ applied_variants = cast(dict, entry["system"]).get("applied_variants")
229
+ should_apply_variant = bool(applied_variants)
230
+
231
+ entry["$"] = getTag(
232
+ entry,
233
+ f"{content_type_uid}.{entry.get('uid')}.{resolved_locale}",
234
+ tagsAsObject,
235
+ resolved_locale,
236
+ cast(AppliedVariants, applied_variants),
237
+ should_apply_variant,
238
+ meta_key="",
239
+ )
240
+
241
+
242
+ # JS parity export name
243
+ addEditableTags = addTags # pylint: disable=invalid-name
244
+
245
+ # Pythonic aliases
246
+ add_tags = addTags
247
+ get_tags = getTag
248
+
@@ -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())
@@ -1,17 +1,70 @@
1
1
  # pylint: disable=missing-function-docstring
2
2
 
3
3
  import json
4
- from typing import Any, Dict, List, Union
4
+ from typing import Any, Dict, List, Optional, Union
5
5
 
6
6
  from lxml import etree
7
7
 
8
8
  from contentstack_utils.automate import Automate
9
+ from contentstack_utils.endpoint import Endpoint
10
+ from contentstack_utils.entry_editable import addEditableTags as _addEditableTags
11
+ from contentstack_utils.entry_editable import addTags as _addTags
12
+ from contentstack_utils.entry_editable import getTag as _getTag
9
13
  from contentstack_utils.helper.converter import convert_style
10
14
  from contentstack_utils.helper.metadata import Metadata
11
15
  from contentstack_utils.render.options import Options
12
16
 
13
17
 
14
18
  class Utils(Automate):
19
+ # JS parity helpers (moved to `contentstack_utils/entry_editable.py`)
20
+ @staticmethod
21
+ def addTags( # pylint: disable=invalid-name
22
+ entry: dict,
23
+ contentTypeUid: str,
24
+ tagsAsObject: Optional[bool] = None,
25
+ locale: str = "en-us",
26
+ options: Optional[dict] = None,
27
+ **kwargs,
28
+ ) -> None:
29
+ # Support pythonic kwarg name too (backward compatibility with earlier port).
30
+ if tagsAsObject is None and "tags_as_object" in kwargs:
31
+ tagsAsObject = bool(kwargs["tags_as_object"])
32
+ if tagsAsObject is None:
33
+ tagsAsObject = False
34
+ return _addTags(entry, contentTypeUid, tagsAsObject, locale, options)
35
+
36
+ @staticmethod
37
+ def addEditableTags( # pylint: disable=invalid-name
38
+ entry: dict,
39
+ contentTypeUid: str,
40
+ tagsAsObject: Optional[bool] = None,
41
+ locale: str = "en-us",
42
+ options: Optional[dict] = None,
43
+ **kwargs,
44
+ ) -> None:
45
+ if tagsAsObject is None and "tags_as_object" in kwargs:
46
+ tagsAsObject = bool(kwargs["tags_as_object"])
47
+ if tagsAsObject is None:
48
+ tagsAsObject = False
49
+ return _addEditableTags(entry, contentTypeUid, tagsAsObject, locale, options)
50
+
51
+ @staticmethod
52
+ def getTag( # pylint: disable=invalid-name
53
+ content: Any,
54
+ prefix: str,
55
+ tagsAsObject: bool,
56
+ locale: str,
57
+ appliedVariants: Optional[dict],
58
+ shouldApplyVariant: bool,
59
+ metaKey: str = "",
60
+ ) -> Dict[str, Any]:
61
+ # Keep JS argument names for parity.
62
+ return _getTag(content, prefix, tagsAsObject, locale, appliedVariants, shouldApplyVariant, metaKey)
63
+
64
+ # Pythonic aliases
65
+ add_tags = addTags
66
+ get_tags = getTag
67
+ get_tag = getTag
15
68
 
16
69
  @staticmethod
17
70
  def _variants_map_from_entry(entry: dict) -> dict:
@@ -167,6 +220,34 @@ class Utils(Automate):
167
220
  metadata = Metadata(element.text, typeof, uid, content_type, style, outer_html, attributes)
168
221
  return metadata
169
222
 
223
+ # ------------------------------------------------------------------
224
+ # Endpoint resolution — thin proxy to Endpoint class
225
+ # ------------------------------------------------------------------
226
+
227
+ @staticmethod
228
+ def get_contentstack_endpoint(
229
+ region: str = "us",
230
+ service: str = "",
231
+ omit_https: bool = False,
232
+ ):
233
+ """
234
+ Resolve a Contentstack service URL for the given region.
235
+
236
+ Delegates entirely to :class:`~contentstack_utils.endpoint.Endpoint`.
237
+ Both ``Utils.get_contentstack_endpoint(...)`` and
238
+ ``Endpoint.get_contentstack_endpoint(...)`` produce identical results —
239
+ choose whichever import path suits your codebase.
240
+
241
+ :param region: Region ID or alias (e.g. ``'na'``, ``'eu'``, ``'azure-na'``).
242
+ :param service: Optional service key (e.g. ``'contentDelivery'``).
243
+ :param omit_https: Strip ``https://`` from the returned URL(s).
244
+ :returns: URL string when *service* is given, dict of all URLs otherwise.
245
+ """
246
+ return Endpoint.get_contentstack_endpoint(region, service, omit_https)
247
+
248
+ # camelCase alias for cross-SDK parity
249
+ getContentstackEndpoint = get_contentstack_endpoint
250
+
170
251
  ####################################################
171
252
  # SUPERCHARGED #
172
253
  ####################################################
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: contentstack_utils
3
- Version: 1.4.0
3
+ Version: 1.6.0
4
4
  Summary: contentstack_utils is a Utility package for Contentstack headless CMS with an API-first approach.
5
5
  Home-page: https://github.com/contentstack/contentstack-utils-python
6
6
  Author: contentstack
@@ -3,7 +3,10 @@ README.md
3
3
  setup.py
4
4
  contentstack_utils/__init__.py
5
5
  contentstack_utils/automate.py
6
+ contentstack_utils/endpoint.py
7
+ contentstack_utils/entry_editable.py
6
8
  contentstack_utils/gql.py
9
+ contentstack_utils/region_refresh.py
7
10
  contentstack_utils/utils.py
8
11
  contentstack_utils.egg-info/PKG-INFO
9
12
  contentstack_utils.egg-info/SOURCES.txt
@@ -21,6 +24,8 @@ contentstack_utils/render/options.py
21
24
  tests/__init__.py
22
25
  tests/convert_style.py
23
26
  tests/test_default_opt_others.py
27
+ tests/test_editable_tags.py
28
+ tests/test_endpoint.py
24
29
  tests/test_gql_to_html_func.py
25
30
  tests/test_helper_node_to_html.py
26
31
  tests/test_item_types.py
@@ -1,7 +1,23 @@
1
1
  import os
2
+ import sys
2
3
 
3
4
  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
+
5
21
 
6
22
  with open(os.path.join(os.path.dirname(__file__), 'README.md')) as readme:
7
23
  long_description = readme.read()
@@ -9,19 +25,23 @@ with open(os.path.join(os.path.dirname(__file__), 'README.md')) as readme:
9
25
  setup(
10
26
  name='contentstack_utils',
11
27
  packages=find_packages(),
28
+ package_data={
29
+ "contentstack_utils": ["assets/regions.json"],
30
+ },
12
31
  description="contentstack_utils is a Utility package for Contentstack headless CMS with an API-first approach.",
13
32
  author='contentstack',
14
33
  long_description=long_description,
15
34
  long_description_content_type="text/markdown",
16
35
  url="https://github.com/contentstack/contentstack-utils-python",
17
36
  license='MIT',
18
- version='1.4.0',
37
+ version='1.6.0',
19
38
  install_requires=[
20
-
39
+
21
40
  ],
22
41
  setup_requires=['pytest-runner'],
23
42
  tests_require=['pytest==4.4.1'],
24
43
  test_suite='tests',
44
+ cmdclass={"build_py": BuildPyWithRegions},
25
45
  classifiers=[
26
46
  "License :: OSI Approved :: MIT License",
27
47
  "Operating System :: OS Independent",
@@ -10,7 +10,7 @@ class TestConvertStyle(unittest.TestCase):
10
10
 
11
11
  def test_converter_style_block(self):
12
12
  _returns = converter.convert_style('block')
13
- self.assertEquals(StyleType.BLOCK, _returns)
13
+ self.assertEqual(StyleType.BLOCK, _returns)
14
14
 
15
15
  def test_converter_style_inline(self):
16
16
  _returns = converter.convert_style('inline')
@@ -0,0 +1,56 @@
1
+ import unittest
2
+
3
+ from contentstack_utils.utils import Utils
4
+
5
+
6
+ class TestEditableTags(unittest.TestCase):
7
+ def test_add_tags_mutates_entry_with_dollar_map(self):
8
+ entry = {"uid": "e1", "title": "Hello", "count": 1}
9
+ Utils.addTags(entry, "Blog_Post", tags_as_object=True, locale="EN-us")
10
+ self.assertIn("$", entry)
11
+ self.assertEqual(entry["$"]["title"], {"data-cslp": "blog_post.e1.en-us.title"})
12
+ self.assertEqual(entry["$"]["count"], {"data-cslp": "blog_post.e1.en-us.count"})
13
+
14
+ def test_add_tags_string_mode(self):
15
+ entry = {"uid": "e1", "title": "Hello"}
16
+ Utils.addTags(entry, "blog_post", tags_as_object=False, locale="en-us")
17
+ self.assertEqual(entry["$"]["title"], "data-cslp=blog_post.e1.en-us.title")
18
+
19
+ def test_array_tags_add_index_and_parent_keys(self):
20
+ entry = {"uid": "e1", "array": ["hello", "world"]}
21
+ Utils.addTags(entry, "blog", tags_as_object=True, locale="en-us")
22
+ self.assertEqual(entry["$"]["array"], {"data-cslp": "blog.e1.en-us.array"})
23
+ self.assertEqual(entry["$"]["array__0"], {"data-cslp": "blog.e1.en-us.array.0"})
24
+ self.assertEqual(entry["$"]["array__1"], {"data-cslp": "blog.e1.en-us.array.1"})
25
+ self.assertEqual(entry["$"]["array__parent"], {"data-cslp-parent-field": "blog.e1.en-us.array"})
26
+
27
+ def test_reference_entry_inside_array_gets_own_dollar(self):
28
+ entry = {
29
+ "uid": "e1",
30
+ "refs": [
31
+ {
32
+ "uid": "r1",
33
+ "_content_type_uid": "ref_ct",
34
+ "title": "Ref Title",
35
+ }
36
+ ],
37
+ }
38
+ Utils.addTags(entry, "blog", tags_as_object=True, locale="en-us")
39
+ ref = entry["refs"][0]
40
+ self.assertIn("$", ref)
41
+ self.assertEqual(ref["$"]["title"], {"data-cslp": "ref_ct.r1.en-us.title"})
42
+
43
+ def test_variantised_field_applies_v2_prefix_and_uid_suffix(self):
44
+ entry = {
45
+ "uid": "e1",
46
+ "_applied_variants": {"title": "v123"},
47
+ "title": {"value": "Hello"},
48
+ }
49
+ Utils.addTags(entry, "blog", tags_as_object=True, locale="en-us")
50
+ # title is an object; ensure we tag the object itself (like JS) and apply variant.
51
+ self.assertEqual(entry["$"]["title"], {"data-cslp": "v2:blog.e1_v123.en-us.title"})
52
+
53
+
54
+ if __name__ == "__main__":
55
+ unittest.main()
56
+
@@ -0,0 +1,264 @@
1
+ """
2
+ Tests for contentstack_utils.endpoint.Endpoint and the Utils proxy.
3
+ """
4
+
5
+ import pytest
6
+
7
+ from contentstack_utils.endpoint import Endpoint
8
+ from contentstack_utils.utils import Utils
9
+
10
+
11
+ @pytest.fixture(autouse=True)
12
+ def reset_cache():
13
+ """Isolate each test — forces a fresh regions.json read."""
14
+ Endpoint.reset_cache()
15
+ yield
16
+ Endpoint.reset_cache()
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Default region (us / na)
21
+ # ---------------------------------------------------------------------------
22
+
23
+ class TestDefaultRegion:
24
+ def test_returns_all_endpoints_when_no_service(self):
25
+ endpoints = Endpoint.get_contentstack_endpoint()
26
+ assert isinstance(endpoints, dict)
27
+ assert "contentDelivery" in endpoints
28
+ assert "contentManagement" in endpoints
29
+
30
+ def test_content_delivery_url(self):
31
+ url = Endpoint.get_contentstack_endpoint("us", "contentDelivery")
32
+ assert url == "https://cdn.contentstack.io"
33
+
34
+ def test_content_management_url(self):
35
+ url = Endpoint.get_contentstack_endpoint("us", "contentManagement")
36
+ assert url == "https://api.contentstack.io"
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # NA region alias resolution
41
+ # ---------------------------------------------------------------------------
42
+
43
+ NA_ALIASES = ["na", "us", "aws-na", "aws_na", "NA", "US", "AWS-NA", "AWS_NA"]
44
+
45
+ @pytest.mark.parametrize("alias", NA_ALIASES)
46
+ def test_na_aliases_resolve_to_same_cdn(alias):
47
+ url = Endpoint.get_contentstack_endpoint(alias, "contentDelivery")
48
+ assert url == "https://cdn.contentstack.io"
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # All 7 regions — contentDelivery spot-checks
53
+ # ---------------------------------------------------------------------------
54
+
55
+ @pytest.mark.parametrize("region,expected", [
56
+ ("na", "https://cdn.contentstack.io"),
57
+ ("eu", "https://eu-cdn.contentstack.com"),
58
+ ("au", "https://au-cdn.contentstack.com"),
59
+ ("azure-na", "https://azure-na-cdn.contentstack.com"),
60
+ ("azure-eu", "https://azure-eu-cdn.contentstack.com"),
61
+ ("gcp-na", "https://gcp-na-cdn.contentstack.com"),
62
+ ("gcp-eu", "https://gcp-eu-cdn.contentstack.com"),
63
+ ])
64
+ def test_content_delivery_by_region(region, expected):
65
+ assert Endpoint.get_contentstack_endpoint(region, "contentDelivery") == expected
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # All 7 regions — contentManagement spot-checks
70
+ # ---------------------------------------------------------------------------
71
+
72
+ @pytest.mark.parametrize("region,expected", [
73
+ ("na", "https://api.contentstack.io"),
74
+ ("eu", "https://eu-api.contentstack.com"),
75
+ ("au", "https://au-api.contentstack.com"),
76
+ ("azure-na", "https://azure-na-api.contentstack.com"),
77
+ ("azure-eu", "https://azure-eu-api.contentstack.com"),
78
+ ("gcp-na", "https://gcp-na-api.contentstack.com"),
79
+ ("gcp-eu", "https://gcp-eu-api.contentstack.com"),
80
+ ])
81
+ def test_content_management_by_region(region, expected):
82
+ assert Endpoint.get_contentstack_endpoint(region, "contentManagement") == expected
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # All expected service keys present
87
+ # ---------------------------------------------------------------------------
88
+
89
+ EXPECTED_SERVICE_KEYS = [
90
+ "application", "contentDelivery", "contentManagement", "auth",
91
+ "graphqlDelivery", "preview", "graphqlPreview", "images", "assets",
92
+ "automate", "launch", "developerHub", "brandKit", "genAI",
93
+ "personalizeManagement", "personalizeEdge", "composableStudio",
94
+ ]
95
+
96
+ def test_all_service_keys_present_for_eu():
97
+ endpoints = Endpoint.get_contentstack_endpoint("eu")
98
+ for key in EXPECTED_SERVICE_KEYS:
99
+ assert key in endpoints, f"Missing service key: {key}"
100
+
101
+ def test_na_has_asset_management_key():
102
+ # NA is the only region that currently includes assetManagement.
103
+ endpoints = Endpoint.get_contentstack_endpoint("na")
104
+ assert "assetManagement" in endpoints
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # omit_https flag
109
+ # ---------------------------------------------------------------------------
110
+
111
+ class TestOmitHttps:
112
+ def test_strips_scheme_from_single_service(self):
113
+ host = Endpoint.get_contentstack_endpoint("eu", "contentDelivery", omit_https=True)
114
+ assert host == "eu-cdn.contentstack.com"
115
+
116
+ def test_strips_scheme_from_all_services(self):
117
+ endpoints = Endpoint.get_contentstack_endpoint("na", omit_https=True)
118
+ assert isinstance(endpoints, dict)
119
+ for key, url in endpoints.items():
120
+ assert "https://" not in url, f"Service {key} still has https://"
121
+ assert "http://" not in url, f"Service {key} still has http://"
122
+
123
+ def test_false_retains_scheme(self):
124
+ url = Endpoint.get_contentstack_endpoint("na", "contentManagement", omit_https=False)
125
+ assert url.startswith("https://")
126
+
127
+ def test_omit_https_positional_argument(self):
128
+ # Confirm the third positional arg is honoured (mirrors PHP signature).
129
+ host = Endpoint.get_contentstack_endpoint("gcp-na", "contentDelivery", True)
130
+ assert host == "gcp-na-cdn.contentstack.com"
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Case-insensitive and underscore alias matching
135
+ # ---------------------------------------------------------------------------
136
+
137
+ class TestAliasMatching:
138
+ def test_uppercase_alias(self):
139
+ url = Endpoint.get_contentstack_endpoint("AWS-NA", "contentDelivery")
140
+ assert url == "https://cdn.contentstack.io"
141
+
142
+ def test_underscore_azure_alias(self):
143
+ url = Endpoint.get_contentstack_endpoint("azure_na", "contentDelivery")
144
+ assert url == "https://azure-na-cdn.contentstack.com"
145
+
146
+ def test_underscore_gcp_alias(self):
147
+ url = Endpoint.get_contentstack_endpoint("gcp_eu", "contentManagement")
148
+ assert url == "https://gcp-eu-api.contentstack.com"
149
+
150
+ def test_mixed_case_eu(self):
151
+ url = Endpoint.get_contentstack_endpoint("EU", "contentDelivery")
152
+ assert url == "https://eu-cdn.contentstack.com"
153
+
154
+ def test_mixed_case_au(self):
155
+ url = Endpoint.get_contentstack_endpoint("AU", "contentDelivery")
156
+ assert url == "https://au-cdn.contentstack.com"
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Return-all-endpoints (no service argument)
161
+ # ---------------------------------------------------------------------------
162
+
163
+ class TestNoService:
164
+ def test_returns_dict(self):
165
+ result = Endpoint.get_contentstack_endpoint("au")
166
+ assert isinstance(result, dict)
167
+ assert len(result) > 1
168
+
169
+ def test_dict_contains_correct_urls(self):
170
+ endpoints = Endpoint.get_contentstack_endpoint("au")
171
+ assert endpoints["contentDelivery"] == "https://au-cdn.contentstack.com"
172
+ assert endpoints["contentManagement"] == "https://au-api.contentstack.com"
173
+
174
+ def test_default_call_returns_na(self):
175
+ endpoints = Endpoint.get_contentstack_endpoint()
176
+ assert endpoints["contentDelivery"] == "https://cdn.contentstack.io"
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Error cases
181
+ # ---------------------------------------------------------------------------
182
+
183
+ class TestErrorCases:
184
+ def test_empty_region_raises_value_error(self):
185
+ with pytest.raises(ValueError, match="Empty region provided"):
186
+ Endpoint.get_contentstack_endpoint("")
187
+
188
+ def test_unknown_region_raises_lookup_error(self):
189
+ with pytest.raises(LookupError, match="Invalid region: invalid-region"):
190
+ Endpoint.get_contentstack_endpoint("invalid-region")
191
+
192
+ def test_unknown_service_raises_lookup_error(self):
193
+ with pytest.raises(LookupError, match='Service "unknownService" not found'):
194
+ Endpoint.get_contentstack_endpoint("na", "unknownService")
195
+
196
+ def test_whitespace_region_raises_value_error(self):
197
+ with pytest.raises(ValueError, match="Empty region provided"):
198
+ Endpoint.get_contentstack_endpoint(" ")
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # camelCase alias (cross-SDK parity)
203
+ # ---------------------------------------------------------------------------
204
+
205
+ class TestCamelCaseAlias:
206
+ def test_get_contentstack_endpoint_camel_case(self):
207
+ url = Endpoint.getContentstackEndpoint("na", "contentDelivery")
208
+ assert url == "https://cdn.contentstack.io"
209
+
210
+ def test_camel_case_and_snake_case_return_same(self):
211
+ snake = Endpoint.get_contentstack_endpoint("eu", "contentDelivery")
212
+ camel = Endpoint.getContentstackEndpoint("eu", "contentDelivery")
213
+ assert snake == camel
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Utils proxy
218
+ # ---------------------------------------------------------------------------
219
+
220
+ class TestUtilsProxy:
221
+ def test_proxy_returns_same_as_endpoint_class(self):
222
+ via_endpoint = Endpoint.get_contentstack_endpoint("eu", "contentDelivery")
223
+ via_utils = Utils.get_contentstack_endpoint("eu", "contentDelivery")
224
+ assert via_endpoint == via_utils
225
+
226
+ def test_proxy_default_region(self):
227
+ url = Utils.get_contentstack_endpoint("us", "contentManagement")
228
+ assert url == "https://api.contentstack.io"
229
+
230
+ def test_proxy_omit_https(self):
231
+ host = Utils.get_contentstack_endpoint("gcp-na", "contentDelivery", omit_https=True)
232
+ assert host == "gcp-na-cdn.contentstack.com"
233
+
234
+ def test_proxy_all_endpoints(self):
235
+ endpoints = Utils.get_contentstack_endpoint("azure-eu")
236
+ assert isinstance(endpoints, dict)
237
+ assert "contentDelivery" in endpoints
238
+
239
+ def test_proxy_camel_case_alias(self):
240
+ url = Utils.getContentstackEndpoint("na", "contentDelivery")
241
+ assert url == "https://cdn.contentstack.io"
242
+
243
+ def test_proxy_error_propagates(self):
244
+ with pytest.raises(LookupError):
245
+ Utils.get_contentstack_endpoint("not-a-region", "contentDelivery")
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Cache behaviour
250
+ # ---------------------------------------------------------------------------
251
+
252
+ class TestCache:
253
+ def test_second_call_uses_cache(self, reset_cache):
254
+ from unittest.mock import patch, MagicMock
255
+ Endpoint.get_contentstack_endpoint("na", "contentDelivery")
256
+ with patch("builtins.open", wraps=open) as spy:
257
+ Endpoint.get_contentstack_endpoint("eu", "contentDelivery")
258
+ spy.assert_not_called()
259
+
260
+ def test_reset_cache_clears_data(self):
261
+ Endpoint.get_contentstack_endpoint("na") # primes cache
262
+ assert Endpoint._regions_data is not None
263
+ Endpoint.reset_cache()
264
+ assert Endpoint._regions_data is None