Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# CHANGELOG

## _v2.6.0_

### **Date: 18-May-2026**

- Added optional branch support for entry variants on `Entry.variants()` and `ContentType.variants()`.

## _v2.5.1_

### **Date: 15-April-2026**
Expand Down
2 changes: 1 addition & 1 deletion contentstack/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
__title__ = 'contentstack-delivery-python'
__author__ = 'contentstack'
__status__ = 'debug'
__version__ = 'v2.5.1'
__version__ = 'v2.6.0'
__endpoint__ = 'cdn.contentstack.io'
__email__ = 'support@contentstack.com'
__developer_email__ = 'mobile@contentstack.com'
Expand Down
9 changes: 6 additions & 3 deletions contentstack/contenttype.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,17 +118,20 @@ def find(self, params=None):
result = self.http_instance.get(url)
return result

def variants(self, variant_uid: str | list[str], params: dict = None):
def variants(self, variant_uid: str | list[str], branch: str = None, params: dict = None):
"""
Fetches the variants of the content type
:param variant_uid: {str} -- variant_uid
:return: Entry, so you can chain this call.
:param variant_uid: {str | list[str]} -- variant UID or list of variant UIDs
:param branch: {str} -- optional branch name to scope the variant request
:param params: {dict} -- optional query parameters
:return: Variants, so you can chain this call.
"""
return Variants(
http_instance=self.http_instance,
content_type_uid=self.__content_type_uid,
entry_uid=None,
variant_uid=variant_uid,
branch=branch,
params=params,
logger=None
)
9 changes: 6 additions & 3 deletions contentstack/entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,17 +266,20 @@ def _merged_response(self):
return merged_response # Now correctly returns a dictionary
raise ValueError(ErrorMessages.MISSING_LIVE_PREVIEW_KEYS)

def variants(self, variant_uid: str | list[str], params: dict = None):
def variants(self, variant_uid: str | list[str], branch: str = None, params: dict = None):
"""
Fetches the variants of the entry
:param variant_uid: {str} -- variant_uid
:return: Entry, so you can chain this call.
:param variant_uid: {str | list[str]} -- variant UID or list of variant UIDs
:param branch: {str} -- optional branch name to scope the variant request
:param params: {dict} -- optional query parameters
:return: Variants, so you can chain this call.
"""
return Variants(
http_instance=self.http_instance,
content_type_uid=self.content_type_id,
entry_uid=self.entry_uid,
variant_uid=variant_uid,
branch=branch,
params=params,
logger=self.logger
)
Expand Down
46 changes: 30 additions & 16 deletions contentstack/variants.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def __init__(self,
content_type_uid=None,
entry_uid=None,
variant_uid=None,
branch=None,
params=None,
logger=None):

Expand All @@ -30,29 +31,47 @@ def __init__(self,
self.content_type_id = content_type_uid
self.entry_uid = entry_uid
self.variant_uid = variant_uid
self.branch = branch
self.logger = logger or logging.getLogger(__name__)
self.entry_param = params or {}

def _prepare_variant_headers(self):
headers = self.http_instance.headers.copy()
if isinstance(self.variant_uid, str):
headers['x-cs-variant-uid'] = self.variant_uid
elif isinstance(self.variant_uid, list):
headers['x-cs-variant-uid'] = ','.join(self.variant_uid)
if self.branch is not None:
headers['branch'] = self.branch
return headers

def _apply_variant_headers(self, headers):
self._original_branch = self.http_instance.headers.get('branch')
self.http_instance.headers.update(headers)

def _cleanup_variant_headers(self):
self.http_instance.headers.pop('x-cs-variant-uid', None)
if self.branch is not None:
if self._original_branch is not None:
self.http_instance.headers['branch'] = self._original_branch
else:
self.http_instance.headers.pop('branch', None)

def find(self, params=None):
"""
find the variants of the entry of a particular content type
:param self.variant_uid: {str} -- self.variant_uid
:return: Entry, so you can chain this call.
"""
headers = self.http_instance.headers.copy() # Create a local copy of headers
if isinstance(self.variant_uid, str):
headers['x-cs-variant-uid'] = self.variant_uid
elif isinstance(self.variant_uid, list):
headers['x-cs-variant-uid'] = ','.join(self.variant_uid)

headers = self._prepare_variant_headers()
if params is not None:
self.entry_param.update(params)
encoded_params = parse.urlencode(self.entry_param)
endpoint = self.http_instance.endpoint
url = f'{endpoint}/content_types/{self.content_type_id}/entries?{encoded_params}'
self.http_instance.headers.update(headers)
self._apply_variant_headers(headers)
result = self.http_instance.get(url)
self.http_instance.headers.pop('x-cs-variant-uid', None)
self._cleanup_variant_headers()
return result

def fetch(self, params=None):
Expand All @@ -77,18 +96,13 @@ def fetch(self, params=None):
if self.entry_uid is None:
raise ValueError(ErrorMessages.ENTRY_UID_REQUIRED)
else:
headers = self.http_instance.headers.copy() # Create a local copy of headers
if isinstance(self.variant_uid, str):
headers['x-cs-variant-uid'] = self.variant_uid
elif isinstance(self.variant_uid, list):
headers['x-cs-variant-uid'] = ','.join(self.variant_uid)

headers = self._prepare_variant_headers()
if params is not None:
self.entry_param.update(params)
encoded_params = parse.urlencode(self.entry_param)
endpoint = self.http_instance.endpoint
url = f'{endpoint}/content_types/{self.content_type_id}/entries/{self.entry_uid}?{encoded_params}'
self.http_instance.headers.update(headers)
self._apply_variant_headers(headers)
result = self.http_instance.get(url)
self.http_instance.headers.pop('x-cs-variant-uid', None)
self._cleanup_variant_headers()
return result
29 changes: 29 additions & 0 deletions tests/test_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
HOST = config.HOST
FAQ_UID = config.FAQ_UID # Add this in your config.py
VARIANT_UID = config.VARIANT_UID
BRANCH = getattr(config, 'BRANCH', None)

class TestEntry(unittest.TestCase):

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

@unittest.skipIf(BRANCH is None, "config.BRANCH is required for branch variant tests")
def test_41a_entry_variants_with_branch(self):
"""Test single entry variants with branch"""
content_type = self.stack.content_type('faq')
result = content_type.entry(FAQ_UID).variants(VARIANT_UID, BRANCH).fetch()
self.assertIn('variants', result['entry']['publish_details'])

@unittest.skipIf(BRANCH is None, "config.BRANCH is required for branch variant tests")
def test_41b_entry_variants_multiple_uids_with_branch(self):
"""Test single entry variants with multiple UIDs and branch"""
content_type = self.stack.content_type('faq')
result = content_type.entry(FAQ_UID).variants([VARIANT_UID], BRANCH).fetch()
self.assertIn('variants', result['entry']['publish_details'])

@unittest.skipIf(BRANCH is None, "config.BRANCH is required for branch variant tests")
def test_41c_content_type_variants_find_with_branch(self):
"""Test entries query variants with branch"""
content_type = self.stack.content_type('faq')
result = content_type.variants(VARIANT_UID, BRANCH).find()
self.assertIn('variants', result['entries'][0]['publish_details'])

@unittest.skipIf(BRANCH is None, "config.BRANCH is required for branch variant tests")
def test_41d_content_type_variants_multiple_uids_find_with_branch(self):
"""Test entries query variants with multiple UIDs and branch"""
content_type = self.stack.content_type('faq')
result = content_type.variants([VARIANT_UID], BRANCH).find()
self.assertIn('variants', result['entries'][0]['publish_details'])

def test_42_entry_environment_removal(self):
"""Test entry remove_environment method"""
entry = (self.stack.content_type('faq')
Expand Down
190 changes: 190 additions & 0 deletions tests/test_variants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
"""
Unit tests for Variants branch support in contentstack.variants
"""

import pytest
from unittest.mock import MagicMock
from urllib.parse import urlencode

from contentstack.variants import Variants


@pytest.fixture
def mock_http_instance():
mock = MagicMock()
mock.endpoint = "https://cdn.contentstack.io/v3"
mock.headers = {
"api_key": "api_key",
"access_token": "delivery_token",
"environment": "test_env",
}
mock.get = MagicMock(return_value={"entries": []})
return mock


@pytest.fixture
def variants(mock_http_instance):
return Variants(
http_instance=mock_http_instance,
content_type_uid="faq",
entry_uid="entry_uid",
variant_uid="variant_uid",
)


def _capture_headers_on_get(mock_http_instance):
captured = {}

def _get(url):
captured["headers"] = mock_http_instance.headers.copy()
return {"entries": []}

mock_http_instance.get.side_effect = _get
return captured


class TestVariantsBranch:
def test_fetch_sets_variant_and_branch_headers(self, mock_http_instance):
captured = _capture_headers_on_get(mock_http_instance)
variants = Variants(
http_instance=mock_http_instance,
content_type_uid="faq",
entry_uid="entry_uid",
variant_uid="variant_uid",
branch="dev_branch",
)
variants.fetch()

assert captured["headers"]["x-cs-variant-uid"] == "variant_uid"
assert captured["headers"]["branch"] == "dev_branch"

def test_fetch_multiple_variant_uids_with_branch(self, mock_http_instance):
captured = _capture_headers_on_get(mock_http_instance)
variants = Variants(
http_instance=mock_http_instance,
content_type_uid="faq",
entry_uid="entry_uid",
variant_uid=["variant1", "variant2"],
branch="dev_branch",
)
variants.fetch()

assert captured["headers"]["x-cs-variant-uid"] == "variant1,variant2"
assert captured["headers"]["branch"] == "dev_branch"

def test_find_sets_variant_and_branch_headers(self, mock_http_instance):
captured = _capture_headers_on_get(mock_http_instance)
variants = Variants(
http_instance=mock_http_instance,
content_type_uid="faq",
entry_uid=None,
variant_uid="variant_uid",
branch="dev_branch",
)
variants.find()

assert captured["headers"]["x-cs-variant-uid"] == "variant_uid"
assert captured["headers"]["branch"] == "dev_branch"

def test_fetch_restores_stack_branch_after_request(self, mock_http_instance):
mock_http_instance.headers["branch"] = "main"
variants = Variants(
http_instance=mock_http_instance,
content_type_uid="faq",
entry_uid="entry_uid",
variant_uid="variant_uid",
branch="dev_branch",
)
variants.fetch()

assert "x-cs-variant-uid" not in mock_http_instance.headers
assert mock_http_instance.headers["branch"] == "main"

def test_fetch_removes_branch_when_stack_had_none(self, mock_http_instance):
variants = Variants(
http_instance=mock_http_instance,
content_type_uid="faq",
entry_uid="entry_uid",
variant_uid="variant_uid",
branch="dev_branch",
)
variants.fetch()

assert "branch" not in mock_http_instance.headers
assert "x-cs-variant-uid" not in mock_http_instance.headers

def test_fetch_without_branch_uses_stack_branch(self, mock_http_instance):
mock_http_instance.headers["branch"] = "main"
variants = Variants(
http_instance=mock_http_instance,
content_type_uid="faq",
entry_uid="entry_uid",
variant_uid="variant_uid",
)
variants.fetch()

assert mock_http_instance.headers["branch"] == "main"
assert "x-cs-variant-uid" not in mock_http_instance.headers

def test_fetch_cleans_up_variant_header_only(self, variants, mock_http_instance):
variants.fetch()

assert "x-cs-variant-uid" not in mock_http_instance.headers
assert mock_http_instance.headers["environment"] == "test_env"

def test_fetch_builds_expected_url(self, variants, mock_http_instance):
variants.fetch()
expected_url = (
"https://cdn.contentstack.io/v3/content_types/faq/entries/entry_uid?"
)
mock_http_instance.get.assert_called_once()
assert mock_http_instance.get.call_args[0][0].startswith(expected_url)

def test_find_builds_expected_url(self, mock_http_instance):
variants = Variants(
http_instance=mock_http_instance,
content_type_uid="faq",
entry_uid=None,
variant_uid="variant_uid",
branch="dev_branch",
)
variants.find(params={"locale": "en-us"})
expected_params = urlencode({"locale": "en-us"})
expected_url = (
f"https://cdn.contentstack.io/v3/content_types/faq/entries?{expected_params}"
)
mock_http_instance.get.assert_called_once_with(expected_url)

def test_entry_variants_passes_branch(self, mock_http_instance):
from contentstack.entry import Entry

entry = Entry(
http_instance=mock_http_instance,
content_type_uid="faq",
entry_uid="entry_uid",
)
result = entry.variants("variant_uid", "dev_branch")
assert isinstance(result, Variants)
assert result.branch == "dev_branch"
assert result.variant_uid == "variant_uid"

def test_content_type_variants_passes_branch(self, mock_http_instance):
from contentstack.contenttype import ContentType

content_type = ContentType(mock_http_instance, "faq")
result = content_type.variants(["variant1", "variant2"], "dev_branch")
assert isinstance(result, Variants)
assert result.branch == "dev_branch"
assert result.variant_uid == ["variant1", "variant2"]

def test_variants_backward_compatible_params_kwarg(self, mock_http_instance):
from contentstack.entry import Entry

entry = Entry(
http_instance=mock_http_instance,
content_type_uid="faq",
entry_uid="entry_uid",
)
result = entry.variants("variant_uid", params={"locale": "en-us"})
assert result.branch is None
assert result.entry_param == {"locale": "en-us"}
Loading