Contentstack 2.6.1__tar.gz → 2.7.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 (43) hide show
  1. {contentstack-2.6.1 → contentstack-2.7.0}/Contentstack.egg-info/PKG-INFO +1 -1
  2. {contentstack-2.6.1 → contentstack-2.7.0}/Contentstack.egg-info/SOURCES.txt +2 -1
  3. {contentstack-2.6.1 → contentstack-2.7.0}/PKG-INFO +1 -1
  4. {contentstack-2.6.1 → contentstack-2.7.0}/contentstack/__init__.py +1 -1
  5. {contentstack-2.6.1 → contentstack-2.7.0}/contentstack/contenttype.py +6 -3
  6. {contentstack-2.6.1 → contentstack-2.7.0}/contentstack/entry.py +6 -3
  7. {contentstack-2.6.1 → contentstack-2.7.0}/contentstack/variants.py +30 -16
  8. {contentstack-2.6.1 → contentstack-2.7.0}/tests/test_entry.py +43 -0
  9. contentstack-2.7.0/tests/test_variants.py +199 -0
  10. {contentstack-2.6.1 → contentstack-2.7.0}/Contentstack.egg-info/dependency_links.txt +0 -0
  11. {contentstack-2.6.1 → contentstack-2.7.0}/Contentstack.egg-info/not-zip-safe +0 -0
  12. {contentstack-2.6.1 → contentstack-2.7.0}/Contentstack.egg-info/requires.txt +0 -0
  13. {contentstack-2.6.1 → contentstack-2.7.0}/Contentstack.egg-info/top_level.txt +0 -0
  14. {contentstack-2.6.1 → contentstack-2.7.0}/LICENSE +0 -0
  15. {contentstack-2.6.1 → contentstack-2.7.0}/README.md +0 -0
  16. {contentstack-2.6.1 → contentstack-2.7.0}/contentstack/asset.py +0 -0
  17. {contentstack-2.6.1 → contentstack-2.7.0}/contentstack/assetquery.py +0 -0
  18. {contentstack-2.6.1 → contentstack-2.7.0}/contentstack/basequery.py +0 -0
  19. {contentstack-2.6.1 → contentstack-2.7.0}/contentstack/controller.py +0 -0
  20. {contentstack-2.6.1 → contentstack-2.7.0}/contentstack/deep_merge_lp.py +0 -0
  21. {contentstack-2.6.1 → contentstack-2.7.0}/contentstack/endpoint.py +0 -0
  22. {contentstack-2.6.1 → contentstack-2.7.0}/contentstack/entryqueryable.py +0 -0
  23. {contentstack-2.6.1 → contentstack-2.7.0}/contentstack/error_messages.py +0 -0
  24. {contentstack-2.6.1 → contentstack-2.7.0}/contentstack/globalfields.py +0 -0
  25. {contentstack-2.6.1 → contentstack-2.7.0}/contentstack/https_connection.py +0 -0
  26. {contentstack-2.6.1 → contentstack-2.7.0}/contentstack/image_transform.py +0 -0
  27. {contentstack-2.6.1 → contentstack-2.7.0}/contentstack/query.py +0 -0
  28. {contentstack-2.6.1 → contentstack-2.7.0}/contentstack/region_refresh.py +0 -0
  29. {contentstack-2.6.1 → contentstack-2.7.0}/contentstack/stack.py +0 -0
  30. {contentstack-2.6.1 → contentstack-2.7.0}/contentstack/taxonomy.py +0 -0
  31. {contentstack-2.6.1 → contentstack-2.7.0}/contentstack/utility.py +0 -0
  32. {contentstack-2.6.1 → contentstack-2.7.0}/setup.cfg +0 -0
  33. {contentstack-2.6.1 → contentstack-2.7.0}/setup.py +0 -0
  34. {contentstack-2.6.1 → contentstack-2.7.0}/tests/test_assets.py +0 -0
  35. {contentstack-2.6.1 → contentstack-2.7.0}/tests/test_basequery.py +0 -0
  36. {contentstack-2.6.1 → contentstack-2.7.0}/tests/test_early_fetch.py +0 -0
  37. {contentstack-2.6.1 → contentstack-2.7.0}/tests/test_early_find.py +0 -0
  38. {contentstack-2.6.1 → contentstack-2.7.0}/tests/test_endpoint.py +0 -0
  39. {contentstack-2.6.1 → contentstack-2.7.0}/tests/test_global_fields.py +0 -0
  40. {contentstack-2.6.1 → contentstack-2.7.0}/tests/test_live_preview.py +0 -0
  41. {contentstack-2.6.1 → contentstack-2.7.0}/tests/test_query.py +0 -0
  42. {contentstack-2.6.1 → contentstack-2.7.0}/tests/test_stack.py +0 -0
  43. {contentstack-2.6.1 → contentstack-2.7.0}/tests/test_taxonomies.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Contentstack
3
- Version: 2.6.1
3
+ Version: 2.7.0
4
4
  Summary: Contentstack is a headless CMS with an API-first approach.
5
5
  Home-page: https://github.com/contentstack/contentstack-python
6
6
  Author: Contentstack
@@ -37,4 +37,5 @@ tests/test_global_fields.py
37
37
  tests/test_live_preview.py
38
38
  tests/test_query.py
39
39
  tests/test_stack.py
40
- tests/test_taxonomies.py
40
+ tests/test_taxonomies.py
41
+ tests/test_variants.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Contentstack
3
- Version: 2.6.1
3
+ Version: 2.7.0
4
4
  Summary: Contentstack is a headless CMS with an API-first approach.
5
5
  Home-page: https://github.com/contentstack/contentstack-python
6
6
  Author: Contentstack
@@ -42,7 +42,7 @@ def get_contentstack_endpoint(region='us', service='', omit_https=False):
42
42
  __title__ = 'contentstack-delivery-python'
43
43
  __author__ = 'contentstack'
44
44
  __status__ = 'debug'
45
- __version__ = 'v2.6.1'
45
+ __version__ = 'v2.7.0'
46
46
  __endpoint__ = 'cdn.contentstack.io'
47
47
  __email__ = 'support@contentstack.com'
48
48
  __developer_email__ = 'mobile@contentstack.com'
@@ -119,17 +119,20 @@ class ContentType:
119
119
  result = self.http_instance.get(url)
120
120
  return result
121
121
 
122
- def variants(self, variant_uid: str | list[str], params: dict = None):
122
+ def variants(self, variant_uid: str | list[str], branch: str = None, params: dict = None):
123
123
  """
124
124
  Fetches the variants of the content type
125
- :param variant_uid: {str} -- variant_uid
126
- :return: Entry, so you can chain this call.
125
+ :param variant_uid: {str | list[str]} -- variant UID or list of variant UIDs
126
+ :param branch: {str} -- optional branch name to scope the variant request
127
+ :param params: {dict} -- optional query parameters
128
+ :return: Variants, so you can chain this call.
127
129
  """
128
130
  return Variants(
129
131
  http_instance=self.http_instance,
130
132
  content_type_uid=self.__content_type_uid,
131
133
  entry_uid=None,
132
134
  variant_uid=variant_uid,
135
+ branch=branch,
133
136
  params=params,
134
137
  logger=None
135
138
  )
@@ -267,17 +267,20 @@ class Entry(EntryQueryable):
267
267
  return merged_response # Now correctly returns a dictionary
268
268
  raise ValueError(ErrorMessages.MISSING_LIVE_PREVIEW_KEYS)
269
269
 
270
- def variants(self, variant_uid: str | list[str], params: dict = None):
270
+ def variants(self, variant_uid: str | list[str], branch: str = None, params: dict = None):
271
271
  """
272
272
  Fetches the variants of the entry
273
- :param variant_uid: {str} -- variant_uid
274
- :return: Entry, so you can chain this call.
273
+ :param variant_uid: {str | list[str]} -- variant UID or list of variant UIDs
274
+ :param branch: {str} -- optional branch name to scope the variant request
275
+ :param params: {dict} -- optional query parameters
276
+ :return: Variants, so you can chain this call.
275
277
  """
276
278
  return Variants(
277
279
  http_instance=self.http_instance,
278
280
  content_type_uid=self.content_type_id,
279
281
  entry_uid=self.entry_uid,
280
282
  variant_uid=variant_uid,
283
+ branch=branch,
281
284
  params=params,
282
285
  logger=self.logger
283
286
  )
@@ -20,6 +20,7 @@ class Variants(EntryQueryable):
20
20
  content_type_uid=None,
21
21
  entry_uid=None,
22
22
  variant_uid=None,
23
+ branch=None,
23
24
  params=None,
24
25
  logger=None):
25
26
 
@@ -30,29 +31,47 @@ class Variants(EntryQueryable):
30
31
  self.content_type_id = content_type_uid
31
32
  self.entry_uid = entry_uid
32
33
  self.variant_uid = variant_uid
34
+ self.branch = branch
33
35
  self.logger = logger or logging.getLogger(__name__)
34
36
  self.entry_param = params or {}
35
37
 
38
+ def _prepare_variant_headers(self):
39
+ headers = self.http_instance.headers.copy()
40
+ if isinstance(self.variant_uid, str):
41
+ headers['x-cs-variant-uid'] = self.variant_uid
42
+ elif isinstance(self.variant_uid, list):
43
+ headers['x-cs-variant-uid'] = ','.join(self.variant_uid)
44
+ if self.branch is not None:
45
+ headers['branch'] = self.branch
46
+ return headers
47
+
48
+ def _apply_variant_headers(self, headers):
49
+ self._original_branch = self.http_instance.headers.get('branch')
50
+ self.http_instance.headers.update(headers)
51
+
52
+ def _cleanup_variant_headers(self):
53
+ self.http_instance.headers.pop('x-cs-variant-uid', None)
54
+ if self.branch is not None:
55
+ if self._original_branch is not None:
56
+ self.http_instance.headers['branch'] = self._original_branch
57
+ else:
58
+ self.http_instance.headers.pop('branch', None)
59
+
36
60
  def find(self, params=None):
37
61
  """
38
62
  find the variants of the entry of a particular content type
39
63
  :param self.variant_uid: {str} -- self.variant_uid
40
64
  :return: Entry, so you can chain this call.
41
65
  """
42
- headers = self.http_instance.headers.copy() # Create a local copy of headers
43
- if isinstance(self.variant_uid, str):
44
- headers['x-cs-variant-uid'] = self.variant_uid
45
- elif isinstance(self.variant_uid, list):
46
- headers['x-cs-variant-uid'] = ','.join(self.variant_uid)
47
-
66
+ headers = self._prepare_variant_headers()
48
67
  if params is not None:
49
68
  self.entry_param.update(params)
50
69
  encoded_params = parse.urlencode(self.entry_param)
51
70
  endpoint = self.http_instance.endpoint
52
71
  url = f'{endpoint}/content_types/{self.content_type_id}/entries?{encoded_params}'
53
- self.http_instance.headers.update(headers)
72
+ self._apply_variant_headers(headers)
54
73
  result = self.http_instance.get(url)
55
- self.http_instance.headers.pop('x-cs-variant-uid', None)
74
+ self._cleanup_variant_headers()
56
75
  return result
57
76
 
58
77
  def fetch(self, params=None):
@@ -77,18 +96,13 @@ class Variants(EntryQueryable):
77
96
  if self.entry_uid is None:
78
97
  raise ValueError(ErrorMessages.ENTRY_UID_REQUIRED)
79
98
  else:
80
- headers = self.http_instance.headers.copy() # Create a local copy of headers
81
- if isinstance(self.variant_uid, str):
82
- headers['x-cs-variant-uid'] = self.variant_uid
83
- elif isinstance(self.variant_uid, list):
84
- headers['x-cs-variant-uid'] = ','.join(self.variant_uid)
85
-
99
+ headers = self._prepare_variant_headers()
86
100
  if params is not None:
87
101
  self.entry_param.update(params)
88
102
  encoded_params = parse.urlencode(self.entry_param)
89
103
  endpoint = self.http_instance.endpoint
90
104
  url = f'{endpoint}/content_types/{self.content_type_id}/entries/{self.entry_uid}?{encoded_params}'
91
- self.http_instance.headers.update(headers)
105
+ self._apply_variant_headers(headers)
92
106
  result = self.http_instance.get(url)
93
- self.http_instance.headers.pop('x-cs-variant-uid', None)
107
+ self._cleanup_variant_headers()
94
108
  return result
@@ -9,6 +9,7 @@ ENVIRONMENT = config.ENVIRONMENT
9
9
  HOST = config.HOST
10
10
  FAQ_UID = config.FAQ_UID # Add this in your config.py
11
11
  VARIANT_UID = config.VARIANT_UID
12
+ BRANCH = getattr(config, 'BRANCH', None)
12
13
 
13
14
  class TestEntry(unittest.TestCase):
14
15
 
@@ -318,6 +319,48 @@ class TestEntry(unittest.TestCase):
318
319
  result = entry.fetch()
319
320
  self.assertIn('variants', result['entry']['publish_details'])
320
321
 
322
+ @unittest.skipIf(BRANCH is None, "config.BRANCH is required for branch variant tests")
323
+ def test_41a_entry_variants_with_branch(self):
324
+ """Test single entry variants with branch"""
325
+ content_type = self.stack.content_type('faq')
326
+ result = content_type.entry(FAQ_UID).variants(VARIANT_UID, BRANCH).fetch()
327
+ self.assertIn('variants', result['entry']['publish_details'])
328
+
329
+ @unittest.skipIf(BRANCH is None, "config.BRANCH is required for branch variant tests")
330
+ def test_41b_entry_variants_multiple_uids_with_branch(self):
331
+ """Test single entry variants with multiple UIDs and branch"""
332
+ content_type = self.stack.content_type('faq')
333
+ result = content_type.entry(FAQ_UID).variants([VARIANT_UID], BRANCH).fetch()
334
+ self.assertIn('variants', result['entry']['publish_details'])
335
+
336
+ @unittest.skipIf(BRANCH is None, "config.BRANCH is required for branch variant tests")
337
+ def test_41c_content_type_variants_find_with_branch(self):
338
+ """Test entries query variants with branch"""
339
+ content_type = self.stack.content_type('faq')
340
+ result = content_type.variants(VARIANT_UID, BRANCH).find()
341
+ self.assertIn('variants', result['entries'][0]['publish_details'])
342
+
343
+ @unittest.skipIf(BRANCH is None, "config.BRANCH is required for branch variant tests")
344
+ def test_41d_content_type_variants_multiple_uids_find_with_branch(self):
345
+ """Test entries query variants with multiple UIDs and branch"""
346
+ content_type = self.stack.content_type('faq')
347
+ result = content_type.variants([VARIANT_UID], BRANCH).find()
348
+ self.assertIn('variants', result['entries'][0]['publish_details'])
349
+
350
+ @unittest.skipIf(BRANCH is None, "config.BRANCH is required for branch variant tests")
351
+ def test_41e_content_type_variants_branch_as_keyword_arg(self):
352
+ """Test content_type.variants() with branch passed as keyword argument"""
353
+ content_type = self.stack.content_type('faq')
354
+ result = content_type.variants(VARIANT_UID, branch=BRANCH).find()
355
+ self.assertIn('variants', result['entries'][0]['publish_details'])
356
+
357
+ @unittest.skipIf(BRANCH is None, "config.BRANCH is required for branch variant tests")
358
+ def test_41f_content_type_variants_branch_with_params(self):
359
+ """Test content_type.variants() with branch and extra query params"""
360
+ content_type = self.stack.content_type('faq')
361
+ result = content_type.variants(VARIANT_UID, branch=BRANCH, params={'locale': 'en-us'}).find()
362
+ self.assertIn('variants', result['entries'][0]['publish_details'])
363
+
321
364
  def test_42_entry_environment_removal(self):
322
365
  """Test entry remove_environment method"""
323
366
  entry = (self.stack.content_type('faq')
@@ -0,0 +1,199 @@
1
+ """
2
+ Unit tests for Variants branch support in contentstack.variants
3
+ """
4
+
5
+ import pytest
6
+ from unittest.mock import MagicMock
7
+ from urllib.parse import urlencode
8
+
9
+ from contentstack.variants import Variants
10
+
11
+
12
+ @pytest.fixture
13
+ def mock_http_instance():
14
+ mock = MagicMock()
15
+ mock.endpoint = "https://cdn.contentstack.io/v3"
16
+ mock.headers = {
17
+ "api_key": "api_key",
18
+ "access_token": "delivery_token",
19
+ "environment": "test_env",
20
+ }
21
+ mock.get = MagicMock(return_value={"entries": []})
22
+ return mock
23
+
24
+
25
+ @pytest.fixture
26
+ def variants(mock_http_instance):
27
+ return Variants(
28
+ http_instance=mock_http_instance,
29
+ content_type_uid="faq",
30
+ entry_uid="entry_uid",
31
+ variant_uid="variant_uid",
32
+ )
33
+
34
+
35
+ def _capture_headers_on_get(mock_http_instance):
36
+ captured = {}
37
+
38
+ def _get(url):
39
+ captured["headers"] = mock_http_instance.headers.copy()
40
+ return {"entries": []}
41
+
42
+ mock_http_instance.get.side_effect = _get
43
+ return captured
44
+
45
+
46
+ class TestVariantsBranch:
47
+ def test_fetch_sets_variant_and_branch_headers(self, mock_http_instance):
48
+ captured = _capture_headers_on_get(mock_http_instance)
49
+ variants = Variants(
50
+ http_instance=mock_http_instance,
51
+ content_type_uid="faq",
52
+ entry_uid="entry_uid",
53
+ variant_uid="variant_uid",
54
+ branch="dev_branch",
55
+ )
56
+ variants.fetch()
57
+
58
+ assert captured["headers"]["x-cs-variant-uid"] == "variant_uid"
59
+ assert captured["headers"]["branch"] == "dev_branch"
60
+
61
+ def test_fetch_multiple_variant_uids_with_branch(self, mock_http_instance):
62
+ captured = _capture_headers_on_get(mock_http_instance)
63
+ variants = Variants(
64
+ http_instance=mock_http_instance,
65
+ content_type_uid="faq",
66
+ entry_uid="entry_uid",
67
+ variant_uid=["variant1", "variant2"],
68
+ branch="dev_branch",
69
+ )
70
+ variants.fetch()
71
+
72
+ assert captured["headers"]["x-cs-variant-uid"] == "variant1,variant2"
73
+ assert captured["headers"]["branch"] == "dev_branch"
74
+
75
+ def test_find_sets_variant_and_branch_headers(self, mock_http_instance):
76
+ captured = _capture_headers_on_get(mock_http_instance)
77
+ variants = Variants(
78
+ http_instance=mock_http_instance,
79
+ content_type_uid="faq",
80
+ entry_uid=None,
81
+ variant_uid="variant_uid",
82
+ branch="dev_branch",
83
+ )
84
+ variants.find()
85
+
86
+ assert captured["headers"]["x-cs-variant-uid"] == "variant_uid"
87
+ assert captured["headers"]["branch"] == "dev_branch"
88
+
89
+ def test_fetch_restores_stack_branch_after_request(self, mock_http_instance):
90
+ mock_http_instance.headers["branch"] = "main"
91
+ variants = Variants(
92
+ http_instance=mock_http_instance,
93
+ content_type_uid="faq",
94
+ entry_uid="entry_uid",
95
+ variant_uid="variant_uid",
96
+ branch="dev_branch",
97
+ )
98
+ variants.fetch()
99
+
100
+ assert "x-cs-variant-uid" not in mock_http_instance.headers
101
+ assert mock_http_instance.headers["branch"] == "main"
102
+
103
+ def test_fetch_removes_branch_when_stack_had_none(self, mock_http_instance):
104
+ variants = Variants(
105
+ http_instance=mock_http_instance,
106
+ content_type_uid="faq",
107
+ entry_uid="entry_uid",
108
+ variant_uid="variant_uid",
109
+ branch="dev_branch",
110
+ )
111
+ variants.fetch()
112
+
113
+ assert "branch" not in mock_http_instance.headers
114
+ assert "x-cs-variant-uid" not in mock_http_instance.headers
115
+
116
+ def test_fetch_without_branch_uses_stack_branch(self, mock_http_instance):
117
+ mock_http_instance.headers["branch"] = "main"
118
+ variants = Variants(
119
+ http_instance=mock_http_instance,
120
+ content_type_uid="faq",
121
+ entry_uid="entry_uid",
122
+ variant_uid="variant_uid",
123
+ )
124
+ variants.fetch()
125
+
126
+ assert mock_http_instance.headers["branch"] == "main"
127
+ assert "x-cs-variant-uid" not in mock_http_instance.headers
128
+
129
+ def test_fetch_cleans_up_variant_header_only(self, variants, mock_http_instance):
130
+ variants.fetch()
131
+
132
+ assert "x-cs-variant-uid" not in mock_http_instance.headers
133
+ assert mock_http_instance.headers["environment"] == "test_env"
134
+
135
+ def test_fetch_builds_expected_url(self, variants, mock_http_instance):
136
+ variants.fetch()
137
+ expected_url = (
138
+ "https://cdn.contentstack.io/v3/content_types/faq/entries/entry_uid?"
139
+ )
140
+ mock_http_instance.get.assert_called_once()
141
+ assert mock_http_instance.get.call_args[0][0].startswith(expected_url)
142
+
143
+ def test_find_builds_expected_url(self, mock_http_instance):
144
+ variants = Variants(
145
+ http_instance=mock_http_instance,
146
+ content_type_uid="faq",
147
+ entry_uid=None,
148
+ variant_uid="variant_uid",
149
+ branch="dev_branch",
150
+ )
151
+ variants.find(params={"locale": "en-us"})
152
+ expected_params = urlencode({"locale": "en-us"})
153
+ expected_url = (
154
+ f"https://cdn.contentstack.io/v3/content_types/faq/entries?{expected_params}"
155
+ )
156
+ mock_http_instance.get.assert_called_once_with(expected_url)
157
+
158
+ def test_entry_variants_passes_branch(self, mock_http_instance):
159
+ from contentstack.entry import Entry
160
+
161
+ entry = Entry(
162
+ http_instance=mock_http_instance,
163
+ content_type_uid="faq",
164
+ entry_uid="entry_uid",
165
+ )
166
+ result = entry.variants("variant_uid", "dev_branch")
167
+ assert isinstance(result, Variants)
168
+ assert result.branch == "dev_branch"
169
+ assert result.variant_uid == "variant_uid"
170
+
171
+ def test_content_type_variants_passes_branch(self, mock_http_instance):
172
+ from contentstack.contenttype import ContentType
173
+
174
+ content_type = ContentType(mock_http_instance, "faq")
175
+ result = content_type.variants(["variant1", "variant2"], "dev_branch")
176
+ assert isinstance(result, Variants)
177
+ assert result.branch == "dev_branch"
178
+ assert result.variant_uid == ["variant1", "variant2"]
179
+
180
+ def test_variants_backward_compatible_params_kwarg(self, mock_http_instance):
181
+ from contentstack.entry import Entry
182
+
183
+ entry = Entry(
184
+ http_instance=mock_http_instance,
185
+ content_type_uid="faq",
186
+ entry_uid="entry_uid",
187
+ )
188
+ result = entry.variants("variant_uid", params={"locale": "en-us"})
189
+ assert result.branch is None
190
+ assert result.entry_param == {"locale": "en-us"}
191
+
192
+ def test_content_type_variants_fetch_raises_without_entry_uid(self, mock_http_instance):
193
+ from contentstack.contenttype import ContentType
194
+ import pytest
195
+
196
+ content_type = ContentType(mock_http_instance, "faq")
197
+ variants = content_type.variants("variant_uid")
198
+ with pytest.raises(ValueError):
199
+ variants.fetch()
File without changes
File without changes
File without changes
File without changes