Skip to content

Commit 456f627

Browse files
Added Brancges support in entry variants
1 parent 9d196f6 commit 456f627

7 files changed

Lines changed: 268 additions & 23 deletions

File tree

CHANGELOG.md

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

3+
## _v2.6.0_
4+
5+
### **Date: 18-May-2026**
6+
7+
- Added optional branch support for entry variants on `Entry.variants()` and `ContentType.variants()`.
8+
39
## _v2.5.1_
410

511
### **Date: 15-April-2026**

contentstack/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
__title__ = 'contentstack-delivery-python'
2323
__author__ = 'contentstack'
2424
__status__ = 'debug'
25-
__version__ = 'v2.5.1'
25+
__version__ = 'v2.6.0'
2626
__endpoint__ = 'cdn.contentstack.io'
2727
__email__ = 'support@contentstack.com'
2828
__developer_email__ = 'mobile@contentstack.com'

contentstack/contenttype.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,17 +118,20 @@ def find(self, params=None):
118118
result = self.http_instance.get(url)
119119
return result
120120

121-
def variants(self, variant_uid: str | list[str], params: dict = None):
121+
def variants(self, variant_uid: str | list[str], branch: str = None, params: dict = None):
122122
"""
123123
Fetches the variants of the content type
124-
:param variant_uid: {str} -- variant_uid
125-
:return: Entry, so you can chain this call.
124+
:param variant_uid: {str | list[str]} -- variant UID or list of variant UIDs
125+
:param branch: {str} -- optional branch name to scope the variant request
126+
:param params: {dict} -- optional query parameters
127+
:return: Variants, so you can chain this call.
126128
"""
127129
return Variants(
128130
http_instance=self.http_instance,
129131
content_type_uid=self.__content_type_uid,
130132
entry_uid=None,
131133
variant_uid=variant_uid,
134+
branch=branch,
132135
params=params,
133136
logger=None
134137
)

contentstack/entry.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -266,17 +266,20 @@ def _merged_response(self):
266266
return merged_response # Now correctly returns a dictionary
267267
raise ValueError(ErrorMessages.MISSING_LIVE_PREVIEW_KEYS)
268268

269-
def variants(self, variant_uid: str | list[str], params: dict = None):
269+
def variants(self, variant_uid: str | list[str], branch: str = None, params: dict = None):
270270
"""
271271
Fetches the variants of the entry
272-
:param variant_uid: {str} -- variant_uid
273-
:return: Entry, so you can chain this call.
272+
:param variant_uid: {str | list[str]} -- variant UID or list of variant UIDs
273+
:param branch: {str} -- optional branch name to scope the variant request
274+
:param params: {dict} -- optional query parameters
275+
:return: Variants, so you can chain this call.
274276
"""
275277
return Variants(
276278
http_instance=self.http_instance,
277279
content_type_uid=self.content_type_id,
278280
entry_uid=self.entry_uid,
279281
variant_uid=variant_uid,
282+
branch=branch,
280283
params=params,
281284
logger=self.logger
282285
)

contentstack/variants.py

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def __init__(self,
2020
content_type_uid=None,
2121
entry_uid=None,
2222
variant_uid=None,
23+
branch=None,
2324
params=None,
2425
logger=None):
2526

@@ -30,29 +31,47 @@ def __init__(self,
3031
self.content_type_id = content_type_uid
3132
self.entry_uid = entry_uid
3233
self.variant_uid = variant_uid
34+
self.branch = branch
3335
self.logger = logger or logging.getLogger(__name__)
3436
self.entry_param = params or {}
3537

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+
3660
def find(self, params=None):
3761
"""
3862
find the variants of the entry of a particular content type
3963
:param self.variant_uid: {str} -- self.variant_uid
4064
:return: Entry, so you can chain this call.
4165
"""
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()
4867
if params is not None:
4968
self.entry_param.update(params)
5069
encoded_params = parse.urlencode(self.entry_param)
5170
endpoint = self.http_instance.endpoint
5271
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)
5473
result = self.http_instance.get(url)
55-
self.http_instance.headers.pop('x-cs-variant-uid', None)
74+
self._cleanup_variant_headers()
5675
return result
5776

5877
def fetch(self, params=None):
@@ -77,18 +96,13 @@ def fetch(self, params=None):
7796
if self.entry_uid is None:
7897
raise ValueError(ErrorMessages.ENTRY_UID_REQUIRED)
7998
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()
86100
if params is not None:
87101
self.entry_param.update(params)
88102
encoded_params = parse.urlencode(self.entry_param)
89103
endpoint = self.http_instance.endpoint
90104
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)
92106
result = self.http_instance.get(url)
93-
self.http_instance.headers.pop('x-cs-variant-uid', None)
107+
self._cleanup_variant_headers()
94108
return result

tests/test_entry.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
HOST = config.HOST
1010
FAQ_UID = config.FAQ_UID # Add this in your config.py
1111
VARIANT_UID = config.VARIANT_UID
12+
BRANCH = getattr(config, 'BRANCH', None)
1213

1314
class TestEntry(unittest.TestCase):
1415

@@ -318,6 +319,34 @@ def test_41_entry_variants_multiple_uids(self):
318319
result = entry.fetch()
319320
self.assertIn('variants', result['entry']['publish_details'])
320321

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+
321350
def test_42_entry_environment_removal(self):
322351
"""Test entry remove_environment method"""
323352
entry = (self.stack.content_type('faq')

tests/test_variants.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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"}

0 commit comments

Comments
 (0)