Skip to content

Commit 6da6c5b

Browse files
Merge pull request #97 from contentstack/staging
Livepreview 2.0 and Timeline support
2 parents 2b2bd12 + d78e978 commit 6da6c5b

11 files changed

+183
-71
lines changed

CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# CHANGELOG
22

3+
## _v1.11.0_
4+
5+
### **Date: 17-MARCH-2025**
6+
7+
- Added Livepreview 2.0 and Timeline support
38

49
## _v1.10.0_
510

LICENSE

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2012 - 2024 Contentstack. All rights reserved.
3+
Copyright (c) 2012 - 2025 Contentstack. All rights reserved.
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ Read through to understand how to use the Sync API with Contentstack Python SDK.
152152

153153
### The MIT License (MIT)
154154

155-
Copyright © 2012-2023 [Contentstack](https://www.contentstack.com/). All Rights Reserved
155+
Copyright © 2012-2025 [Contentstack](https://www.contentstack.com/). All Rights Reserved
156156

157157
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
158158
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the

contentstack/__init__.py

+1-1
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__ = 'v1.10.0'
25+
__version__ = 'v1.11.0'
2626
__endpoint__ = 'cdn.contentstack.io'
2727
__email__ = '[email protected]'
2828
__developer_email__ = '[email protected]'

contentstack/deep_merge_lp.py

+20-5
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,36 @@
11
class DeepMergeMixin:
22

33
def __init__(self, entry_response, lp_response):
4+
if not isinstance(entry_response, list) or not isinstance(lp_response, list):
5+
raise TypeError("Both entry_response and lp_response must be lists of dictionaries")
6+
47
self.entry_response = entry_response
58
self.lp_response = lp_response
9+
self.merged_response = self._merge_entries(entry_response, lp_response)
10+
11+
def _merge_entries(self, entry_list, lp_list):
12+
"""Merge each LP entry into the corresponding entry response based on UID"""
13+
merged_entries = {entry["uid"]: entry.copy() for entry in entry_list} # Convert to dict for easy lookup
614

7-
for lp_obj in self.lp_response:
15+
for lp_obj in lp_list:
816
uid = lp_obj.get("uid")
9-
matching_objs = [entry_obj for entry_obj in entry_response if entry_obj.get("uid") == uid]
10-
if matching_objs:
11-
for matching_obj in matching_objs:
12-
self._deep_merge(lp_obj, matching_obj)
17+
if uid in merged_entries:
18+
self._deep_merge(lp_obj, merged_entries[uid])
19+
else:
20+
merged_entries[uid] = lp_obj # If LP object does not exist in entry_response, add it
21+
22+
return list(merged_entries.values()) # Convert back to a list
1323

1424
def _deep_merge(self, source, destination):
25+
if not isinstance(destination, dict) or not isinstance(source, dict):
26+
return source # Return source if it's not a dict
1527
for key, value in source.items():
1628
if isinstance(value, dict):
1729
node = destination.setdefault(key, {})
1830
self._deep_merge(value, node)
1931
else:
2032
destination[key] = value
2133
return destination
34+
35+
def to_dict(self):
36+
return self.merged_response

contentstack/entry.py

+25-6
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,10 @@ def _impl_live_preview(self):
195195
if lv is not None and lv['enable'] and 'content_type_uid' in lv and lv[
196196
'content_type_uid'] == self.content_type_id:
197197
url = lv['url']
198-
self.http_instance.headers['authorization'] = lv['management_token']
198+
if lv.get('management_token'):
199+
self.http_instance.headers['authorization'] = lv['management_token']
200+
else:
201+
self.http_instance.headers['preview_token'] = lv['preview_token']
199202
lp_resp = self.http_instance.get(url)
200203
if lp_resp is not None and not 'error_code' in lp_resp:
201204
self.http_instance.live_preview['lp_response'] = lp_resp
@@ -204,8 +207,24 @@ def _impl_live_preview(self):
204207

205208
def _merged_response(self):
206209
if 'entry_response' in self.http_instance.live_preview and 'lp_response' in self.http_instance.live_preview:
207-
entry_response = self.http_instance.live_preview['entry_response']['entry']
208-
lp_response = self.http_instance.live_preview['lp_response']
209-
merged_response = DeepMergeMixin(entry_response, lp_response)
210-
return merged_response.entry_response
211-
pass
210+
entry_response = self.http_instance.live_preview['entry_response']
211+
# Ensure lp_entry exists
212+
if 'entry' in self.http_instance.live_preview.get('lp_response', {}):
213+
lp_entry = self.http_instance.live_preview['lp_response']['entry']
214+
else:
215+
lp_entry = {}
216+
if not isinstance(entry_response, list):
217+
entry_response = [entry_response]
218+
if not isinstance(lp_entry, list):
219+
lp_entry = [lp_entry] # Wrap in a list if it's a dict
220+
if not all(isinstance(item, dict) for item in entry_response):
221+
raise TypeError(f"entry_response must be a list of dictionaries. Got: {entry_response}")
222+
if not all(isinstance(item, dict) for item in lp_entry):
223+
raise TypeError(f"lp_entry must be a list of dictionaries. Got: {lp_entry}")
224+
merged_response = DeepMergeMixin(entry_response, lp_entry).to_dict() # Convert to dictionary
225+
return merged_response # Now correctly returns a dictionary
226+
raise ValueError("Missing required keys in live_preview data")
227+
228+
229+
230+

contentstack/query.py

+33-12
Original file line numberDiff line numberDiff line change
@@ -321,31 +321,52 @@ def __execute_network_call(self):
321321
self.query_params["query"] = json.dumps(self.parameters)
322322
if 'environment' in self.http_instance.headers:
323323
self.query_params['environment'] = self.http_instance.headers['environment']
324+
324325
encoded_string = parse.urlencode(self.query_params, doseq=True)
325326
url = f'{self.base_url}?{encoded_string}'
326327
self._impl_live_preview()
327328
response = self.http_instance.get(url)
328-
if self.http_instance.live_preview is not None and not 'errors' in response:
329-
self.http_instance.live_preview['entry_response'] = response['entries']
329+
# Ensure response is converted to dictionary
330+
if isinstance(response, str):
331+
try:
332+
response = json.loads(response) # Convert JSON string to dictionary
333+
except json.JSONDecodeError as e:
334+
print(f"JSON decode error: {e}")
335+
return {"error": "Invalid JSON response"} # Return an error dictionary
336+
337+
if self.http_instance.live_preview is not None and 'errors' not in response:
338+
if 'entries' in response:
339+
self.http_instance.live_preview['entry_response'] = response['entries'][0] # Get first entry
340+
else:
341+
print(f"Error: 'entries' key missing in response: {response}")
342+
return {"error": "'entries' key missing in response"}
330343
return self._merged_response()
331344
return response
332345

333346
def _impl_live_preview(self):
334347
lv = self.http_instance.live_preview
335-
if lv is not None and lv['enable'] and 'content_type_uid' in lv and lv[
336-
'content_type_uid'] == self.content_type_uid:
348+
if lv is not None and lv.get('enable') and lv.get('content_type_uid') == self.content_type_uid:
337349
url = lv['url']
338-
self.http_instance.headers['authorization'] = lv['management_token']
350+
if lv.get('management_token'):
351+
self.http_instance.headers['authorization'] = lv['management_token']
352+
else:
353+
self.http_instance.headers['preview_token'] = lv['preview_token']
339354
lp_resp = self.http_instance.get(url)
340-
if lp_resp is not None and not 'error_code' in lp_resp:
341-
self.http_instance.live_preview['lp_response'] = lp_resp
355+
356+
if lp_resp and 'error_code' not in lp_resp:
357+
if 'entry' in lp_resp:
358+
self.http_instance.live_preview['lp_response'] = {'entry': lp_resp['entry']} # Extract entry
359+
else:
360+
print(f"Warning: Missing 'entry' key in lp_response: {lp_resp}")
342361
return None
343362
return None
344363

345364
def _merged_response(self):
346-
if 'entry_response' in self.http_instance.live_preview and 'lp_response' in self.http_instance.live_preview:
347-
entry_response = self.http_instance.live_preview['entry_response']['entries']
348-
lp_response = self.http_instance.live_preview['lp_response']
365+
live_preview = self.http_instance.live_preview
366+
if 'entry_response' in live_preview and 'lp_response' in live_preview:
367+
entry_response = live_preview['entry_response']
368+
lp_response = live_preview['lp_response']
349369
merged_response = DeepMergeMixin(entry_response, lp_response)
350-
return merged_response.entry_response
351-
pass
370+
return merged_response # Return the merged dictionary
371+
372+
raise ValueError("Missing required keys in live_preview data")

contentstack/stack.py

+48-35
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def __init__(self, api_key: str, delivery_token: str, environment: str,
4141
total=5, backoff_factor=0, status_forcelist=[408, 429]),
4242
live_preview=None,
4343
branch=None,
44-
early_access = None,
44+
early_access = None,
4545
):
4646
"""
4747
# Class that wraps the credentials of the authenticated user. Think of
@@ -96,6 +96,15 @@ def __init__(self, api_key: str, delivery_token: str, environment: str,
9696
self.live_preview = live_preview
9797
self.early_access = early_access
9898
self._validate_stack()
99+
self._setup_headers()
100+
self._setup_live_preview()
101+
self.http_instance = HTTPSConnection(
102+
endpoint=self.endpoint,
103+
headers=self.headers,
104+
timeout=self.timeout,
105+
retry_strategy=self.retry_strategy,
106+
live_preview=self.live_preview
107+
)
99108

100109
def _validate_stack(self):
101110
if self.api_key is None or self.api_key == '':
@@ -123,6 +132,7 @@ def _validate_stack(self):
123132
self.host = f'{self.region.value}-{DEFAULT_HOST}'
124133
self.endpoint = f'https://{self.host}/{self.version}'
125134

135+
def _setup_headers(self):
126136
self.headers = {
127137
'api_key': self.api_key,
128138
'access_token': self.delivery_token,
@@ -131,18 +141,10 @@ def _validate_stack(self):
131141
if self.early_access is not None:
132142
early_access_str = ', '.join(self.early_access)
133143
self.headers['x-header-ea'] = early_access_str
134-
144+
135145
if self.branch is not None:
136146
self.headers['branch'] = self.branch
137-
138-
self.http_instance = HTTPSConnection(
139-
endpoint=self.endpoint,
140-
headers=self.headers,
141-
timeout=self.timeout,
142-
retry_strategy=self.retry_strategy,
143-
live_preview=self.live_preview
144-
)
145-
147+
146148
@property
147149
def get_api_key(self):
148150
"""
@@ -323,8 +325,7 @@ def __sync_request(self):
323325
base_url = f'{self.http_instance.endpoint}/stacks/sync'
324326
self.sync_param['environment'] = self.http_instance.headers['environment']
325327
query = parse.urlencode(self.sync_param)
326-
url = f'{base_url}?{query}'
327-
return self.http_instance.get(url)
328+
return self.http_instance.get(f'{base_url}?{query}')
328329

329330
def image_transform(self, image_url, **kwargs):
330331
"""
@@ -341,6 +342,15 @@ def image_transform(self, image_url, **kwargs):
341342
raise PermissionError(
342343
'image_url required for the image_transformation')
343344
return ImageTransform(self.http_instance, image_url, **kwargs)
345+
346+
def _setup_live_preview(self):
347+
if self.live_preview and self.live_preview.get("enable"):
348+
region_prefix = "" if self.region.value == "us" else f"{self.region.value}-"
349+
self.live_preview["host"] = f"{region_prefix}rest-preview.contentstack.com"
350+
351+
if self.live_preview.get("preview_token"):
352+
self.headers["preview_token"] = self.live_preview["preview_token"]
353+
344354

345355
def live_preview_query(self, **kwargs):
346356
"""
@@ -361,28 +371,31 @@ def live_preview_query(self, **kwargs):
361371
'authorization': 'management_token'
362372
)
363373
"""
364-
365-
if self.live_preview is not None and self.live_preview['enable'] and 'live_preview_query' in kwargs:
366-
self.live_preview.update(**kwargs['live_preview_query'])
367-
query = kwargs['live_preview_query']
368-
if query is not None:
369-
self.live_preview['live_preview'] = query['live_preview']
370-
else:
371-
self.live_preview['live_preview'] = 'init'
372-
if 'content_type_uid' in self.live_preview and self.live_preview['content_type_uid'] is not None:
373-
self.live_preview['content_type_uid'] = query['content_type_uid']
374-
if 'entry_uid' in self.live_preview and self.live_preview['entry_uid'] is not None:
375-
self.live_preview['entry_uid'] = query['entry_uid']
376-
self._cal_url()
374+
if self.live_preview and self.live_preview.get("enable") and "live_preview_query" in kwargs:
375+
query = kwargs["live_preview_query"]
376+
if isinstance(query, dict):
377+
self.live_preview.update(query)
378+
self.live_preview["live_preview"] = query.get("live_preview", "init")
379+
if "content_type_uid" in query:
380+
self.live_preview["content_type_uid"] = query["content_type_uid"]
381+
if "entry_uid" in query:
382+
self.live_preview["entry_uid"] = query["entry_uid"]
383+
384+
for key in ["release_id", "preview_timestamp"]:
385+
if key in query:
386+
self.http_instance.headers[key] = query[key]
387+
else:
388+
self.http_instance.headers.pop(key, None)
389+
390+
self._cal_url()
377391
return self
378392

379393
def _cal_url(self):
380-
host = self.live_preview['host']
381-
ct = self.live_preview['content_type_uid']
382-
url = f'https://{host}/v3/content_types/{ct}/entries'
383-
if 'entry_uid' in self.live_preview:
384-
uid = self.live_preview['entry_uid']
385-
lv = self.live_preview['live_preview']
386-
url = f'{url}/{uid}?live_preview={lv}'
387-
self.live_preview['url'] = url
388-
pass
394+
host = self.live_preview.get("host", DEFAULT_HOST)
395+
content_type = self.live_preview.get("content_type_uid", "default_content_type")
396+
url = f"https://{host}/v3/content_types/{content_type}/entries"
397+
entry_uid = self.live_preview.get("entry_uid")
398+
live_preview = self.live_preview.get("live_preview", "init")
399+
if entry_uid:
400+
url = f"{url}/{entry_uid}?live_preview={live_preview}"
401+
self.live_preview["url"] = url

requirements.txt

+5-5
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@ python-dateutil==2.8.2
55
requests==2.32.2
66
coverage==7.2.6
77
tox==4.5.1
8-
virtualenv==20.23.0
8+
virtualenv==20.26.6
99
Sphinx==7.0.1
1010
sphinxcontrib-websupport==1.2.4
1111
pip==23.3.1
1212
build==0.10.0
13-
wheel==0.40.0
14-
lxml==4.9.2
13+
wheel==0.38.0
14+
lxml==5.3.1
1515
utils~=1.0.1
1616
keyring==23.13.1
1717
docutils==0.20.1
@@ -33,7 +33,7 @@ pytz==2023.3
3333
Babel==2.12.1
3434
pep517==0.13.0
3535
tomli~=2.0.1
36-
Werkzeug==3.0.3
36+
Werkzeug==3.0.6
3737
Flask~=2.3.2
3838
click~=8.1.3
3939
MarkupSafe==2.1.2
@@ -44,7 +44,7 @@ pkginfo==1.9.6
4444
pylint==3.1.0
4545
astroid==3.1.0
4646
mccabe==0.7.0
47-
platformdirs==3.5.1
47+
platformdirs==3.9.1
4848
imagesize==1.4.1
4949
snowballstemmer~=2.2.0
5050
Pygments~=2.15.1

tests/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,19 @@
1212
from .test_entry import TestEntry
1313
from .test_query import TestQuery
1414
from .test_stack import TestStack
15+
from .test_live_preview import TestLivePreviewConfig
1516

1617

1718
def all_tests():
1819
test_module_stack = TestLoader().loadTestsFromTestCase(TestStack)
1920
test_module_asset = TestLoader().loadTestsFromTestCase(TestAsset)
2021
test_module_entry = TestLoader().loadTestsFromTestCase(TestEntry)
2122
test_module_query = TestLoader().loadTestsFromTestCase(TestQuery)
23+
test_module_live_preview = TestLoader().loadTestsFromTestCase(TestLivePreviewConfig)
2224
TestSuite([
2325
test_module_stack,
2426
test_module_asset,
2527
test_module_entry,
2628
test_module_query,
29+
test_module_live_preview
2730
])

0 commit comments

Comments
 (0)