Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# CHANGELOG

## _v2.2.0_

### **Date: 14-July-2025**

- Variants Support Added.

## _v2.1.1_

### **Date: 07-July-2025**

- Fixed sanity testcases and removed hardcoded secrets.

## _v2.1.0_

### **Date: 02-June-2025**
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.1.0'
__version__ = 'v2.2.0'
__endpoint__ = 'cdn.contentstack.io'
__email__ = '[email protected]'
__developer_email__ = '[email protected]'
Expand Down
16 changes: 16 additions & 0 deletions contentstack/contenttype.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from contentstack.entry import Entry
from contentstack.query import Query
from contentstack.variants import Variants

class ContentType:
"""
Expand Down Expand Up @@ -118,3 +119,18 @@ def find(self, params=None):
url = f'{endpoint}/content_types?{encoded_params}'
result = self.http_instance.get(url)
return result

def variants(self, variant_uid: str | list[str], params: dict = None):
"""
Fetches the variants of the content type
:param variant_uid: {str} -- variant_uid
:return: Entry, 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,
params=params,
logger=None
)
17 changes: 17 additions & 0 deletions contentstack/entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from contentstack.deep_merge_lp import DeepMergeMixin
from contentstack.entryqueryable import EntryQueryable
from contentstack.variants import Variants

class Entry(EntryQueryable):
"""
Expand Down Expand Up @@ -222,6 +223,22 @@ def _merged_response(self):
merged_response = DeepMergeMixin(entry_response, lp_entry).to_dict() # Convert to dictionary
return merged_response # Now correctly returns a dictionary
raise ValueError("Missing required keys in live_preview data")

def variants(self, variant_uid: str | list[str], params: dict = None):
"""
Fetches the variants of the entry
:param variant_uid: {str} -- variant_uid
:return: Entry, 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,
params=params,
logger=self.logger
)




Expand Down
93 changes: 93 additions & 0 deletions contentstack/variants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import logging
from urllib import parse

from contentstack.entryqueryable import EntryQueryable

class Variants(EntryQueryable):
"""
An entry is the actual piece of content that you want to publish.
Entries can be created for one of the available content types.

Entry works with
version={version_number}
environment={environment_name}
locale={locale_code}
"""

def __init__(self,
http_instance=None,
content_type_uid=None,
entry_uid=None,
variant_uid=None,
params=None,
logger=None):

super().__init__()
EntryQueryable.__init__(self)
self.entry_param = {}
self.http_instance = http_instance
self.content_type_id = content_type_uid
self.entry_uid = entry_uid
self.variant_uid = variant_uid
self.logger = logger or logging.getLogger(__name__)
self.entry_param = params or {}

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)

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)
result = self.http_instance.get(url)
self.http_instance.headers.pop('x-cs-variant-uid', None)
return result

def fetch(self, params=None):
"""
This method is useful to fetch variant entries of a particular content type and entries of the of the stack.
:return:dict -- contentType response
------------------------------
Example:

>>> import contentstack
>>> stack = contentstack.Stack('api_key', 'delivery_token', 'environment')
>>> content_type = stack.content_type('content_type_uid')
>>> some_dict = {'abc':'something'}
>>> response = content_type.fetch(some_dict)
------------------------------
"""
"""
Fetches the variants of the entry
:param self.variant_uid: {str} -- self.variant_uid
:return: Entry, so you can chain this call.
"""
if self.entry_uid is None:
raise ValueError("entry_uid is 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)

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)
result = self.http_instance.get(url)
self.http_instance.headers.pop('x-cs-variant-uid', None)
return result
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ twython==3.9.1
setuptools==80.3.1
contentstack-utils==1.3.0
python-dateutil==2.8.2
requests==2.32.3
requests==2.32.4
coverage==7.6.0
tox==4.5.1
virtualenv==20.26.6
Expand Down Expand Up @@ -58,6 +58,6 @@ zipp==3.20.1
distlib~=0.3.8
cachetools~=5.4.0
tomlkit~=0.13.2
urllib3==2.2.3
urllib3==2.5.0
exceptiongroup~=1.2.2
iniconfig~=2.0.0
82 changes: 42 additions & 40 deletions tests/test_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
import config
import contentstack

_UID = 'blt53ca1231625bdde4'
API_KEY = config.APIKEY
DELIVERY_TOKEN = config.DELIVERYTOKEN
ENVIRONMENT = config.ENVIRONMENT
HOST = config.HOST

FAQ_UID = config.FAQ_UID # Add this in your config.py
VARIANT_UID = config.VARIANT_UID

class TestEntry(unittest.TestCase):

Expand All @@ -19,69 +19,54 @@ def test_run_initial_query(self):
query = self.stack.content_type('faq').query()
result = query.find()
if result is not None:
self._UID = result['entries'][0]['uid']
print(f'the uid is: {_UID}')
self.faq_uid = result['entries'][0]['uid']
print(f'the uid is: {self.faq_uid}')

def test_entry_by_UID(self):
global _UID
entry = self.stack.content_type('faq').entry(_UID)
entry = self.stack.content_type('faq').entry(FAQ_UID)
result = entry.fetch()
if result is not None:
_UID = result['entry']['uid']
self.assertEqual(_UID, result['entry']['uid'])
self.assertEqual(FAQ_UID, result['entry']['uid'])

def test_03_entry_environment(self):
global _UID
entry = self.stack.content_type('faq').entry(
_UID).environment('test')
entry = self.stack.content_type('faq').entry(FAQ_UID).environment('test')
self.assertEqual("test", entry.http_instance.headers['environment'])

def test_04_entry_locale(self):
global _UID
entry = self.stack.content_type('faq').entry(_UID).locale('en-ei')
entry = self.stack.content_type('faq').entry(FAQ_UID).locale('en-ei')
entry.fetch()
self.assertEqual('en-ei', entry.entry_param['locale'])

def test_05_entry_version(self):
global _UID
entry = self.stack.content_type('faq').entry(_UID).version(3)
entry = self.stack.content_type('faq').entry(FAQ_UID).version(3)
entry.fetch()
self.assertEqual(3, entry.entry_param['version'])

def test_06_entry_params(self):
global _UID
entry = self.stack.content_type('faq').entry(
_UID).param('param_key', 'param_value')
entry = self.stack.content_type('faq').entry(FAQ_UID).param('param_key', 'param_value')
entry.fetch()
self.assertEqual('param_value', entry.entry_param['param_key'])

def test_07_entry_base_only(self):
global _UID
entry = self.stack.content_type(
'faq').entry(_UID).only('field_UID')
entry = self.stack.content_type('faq').entry(FAQ_UID).only('field_UID')
entry.fetch()
self.assertEqual({'environment': 'development',
'only[BASE][]': 'field_UID'}, entry.entry_param)

def test_08_entry_base_excepts(self):
global _UID
entry = self.stack.content_type('faq').entry(
_UID).excepts('field_UID')
entry = self.stack.content_type('faq').entry(FAQ_UID).excepts('field_UID')
entry.fetch()
self.assertEqual({'environment': 'development',
'except[BASE][]': 'field_UID'}, entry.entry_param)

def test_10_entry_base_include_reference_only(self):
global _UID
entry = self.stack.content_type('faq').entry(_UID).only('field1')
entry = self.stack.content_type('faq').entry(FAQ_UID).only('field1')
entry.fetch()
self.assertEqual({'environment': 'development', 'only[BASE][]': 'field1'},
entry.entry_param)

def test_11_entry_base_include_reference_excepts(self):
global _UID
entry = self.stack.content_type(
'faq').entry(_UID).excepts('field1')
entry = self.stack.content_type('faq').entry(FAQ_UID).excepts('field1')
entry.fetch()
self.assertEqual({'environment': 'development', 'except[BASE][]': 'field1'},
entry.entry_param)
Expand All @@ -95,15 +80,13 @@ def test_12_entry_include_reference_github_issue(self):
response = _entry.fetch()

def test_13_entry_support_include_fallback_unit_test(self):
global _UID
entry = self.stack.content_type('faq').entry(
_UID).include_fallback()
entry = self.stack.content_type('faq').entry(FAQ_UID).include_fallback()
self.assertEqual(
True, entry.entry_param.__contains__('include_fallback'))

def test_14_entry_queryable_only(self):
try:
entry = self.stack.content_type('faq').entry(_UID).only(4)
entry = self.stack.content_type('faq').entry(FAQ_UID).only(4)
result = entry.fetch()
self.assertEqual(None, result['uid'])
except KeyError as e:
Expand All @@ -112,28 +95,25 @@ def test_14_entry_queryable_only(self):

def test_entry_queryable_excepts(self):
try:
entry = self.stack.content_type('faq').entry(_UID).excepts(4)
entry = self.stack.content_type('faq').entry(FAQ_UID).excepts(4)
result = entry.fetch()
self.assertEqual(None, result['uid'])
except KeyError as e:
if hasattr(e, 'message'):
self.assertEqual("Invalid field_UID provided", e.args[0])

def test_16_entry_queryable_include_content_type(self):
entry = self.stack.content_type('faq').entry(
_UID).include_content_type()
entry = self.stack.content_type('faq').entry(FAQ_UID).include_content_type()
self.assertEqual({'include_content_type': 'true', 'include_global_field_schema': 'true'},
entry.entry_queryable_param)

def test_reference_content_type_uid(self):
entry = self.stack.content_type('faq').entry(
_UID).include_reference_content_type_uid()
entry = self.stack.content_type('faq').entry(FAQ_UID).include_reference_content_type_uid()
self.assertEqual({'include_reference_content_type_uid': 'true'},
entry.entry_queryable_param)

def test_19_entry_queryable_add_param(self):
entry = self.stack.content_type('faq').entry(
_UID).add_param('cms', 'contentstack')
entry = self.stack.content_type('faq').entry(FAQ_UID).add_param('cms', 'contentstack')
self.assertEqual({'cms': 'contentstack'}, entry.entry_queryable_param)

def test_20_entry_include_fallback(self):
Expand All @@ -154,6 +134,28 @@ def test_22_entry_include_metadata(self):
content_type = self.stack.content_type('faq')
entry = content_type.entry("878783238783").include_metadata()
self.assertEqual({'include_metadata': 'true'}, entry.entry_queryable_param)

def test_23_content_type_variants(self):
content_type = self.stack.content_type('faq')
entry = content_type.variants(VARIANT_UID).find()
self.assertIn('variants', entry['entries'][0]['publish_details'])

def test_24_entry_variants(self):
content_type = self.stack.content_type('faq')
entry = content_type.entry(FAQ_UID).variants(VARIANT_UID).fetch()
self.assertIn('variants', entry['entry']['publish_details'])

def test_25_content_type_variants_with_has_hash_variant(self):
content_type = self.stack.content_type('faq')
entry = content_type.variants([VARIANT_UID]).find()
self.assertIn('variants', entry['entries'][0]['publish_details'])

def test_25_content_type_entry_variants_with_has_hash_variant(self):
content_type = self.stack.content_type('faq').entry(FAQ_UID)
entry = content_type.variants([VARIANT_UID]).fetch()
self.assertIn('variants', entry['entry']['publish_details'])




if __name__ == '__main__':
Expand Down
6 changes: 3 additions & 3 deletions tests/test_live_preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
import contentstack
from contentstack.deep_merge_lp import DeepMergeMixin

management_token = 'cs8743874323343u9'
entry_uid = 'blt8743874323343u9'
preview_token = 'abcdefgh1234567890'
management_token = config.MANAGEMENT_TOKEN
entry_uid = config.LIVE_PREVIEW_ENTRY_UID
preview_token = config.PREVIEW_TOKEN

_lp_query = {
'live_preview': '#0#0#0#0#0#0#0#0#0#',
Expand Down
Loading
Loading