Skip to content

Commit d50832d

Browse files
authored
Add reuse entries option (contentful#32)
* Add reuse entries option * Make resource cache locale aware
1 parent 6b4bfaa commit d50832d

File tree

11 files changed

+140
-44
lines changed

11 files changed

+140
-44
lines changed

CHANGELOG.md

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

33
## Unreleased
4+
### Added
5+
* Added support to reuse entries. This is a performance improvement, which is disabled by default due to backwards compatibility. All users are highly encouraged to enable it and test it in their applications. Inspired by @rezonant in [contentful/contentful.rb#164]((https://github.com/contentful/contentful.rb/pull/164).
46

57
## v1.8.0
68
### Added

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ test-all:
5656
coverage:
5757
coverage run --source contentful setup.py test
5858
coverage report -m
59+
flake8 contentful
5960

6061
watch:
6162
fswatch -d -e contentful/__pycache__ -e tests/__pycache__ contentful tests | xargs -n1 make coverage

README.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ Client Configuration Options
172172

173173
``content_type_cache``: (optional) Boolean determining wether to store a Cache of the Content Types in order to properly coerce Entry fields, defaults to True.
174174

175+
``reuse_entries``: (optional) Boolean, when True, reuse hydrated Entry and Asset objects within the same request when possible. Can result in a large speed increase and better handles cyclical object graphs. This can be a good alternative to max_include_resolution_depth if your content model contains (or can contain) circular references.
176+
175177
``proxy_host``: (optional) URL for Proxy, defaults to None.
176178

177179
``proxy_port``: (optional) Port for Proxy, defaults to None.
@@ -184,7 +186,7 @@ Client Configuration Options
184186

185187
``max_rate_limit_wait``: (optional) Timeout (in seconds) for waiting for retry after RateLimitError, defaults to 60.
186188

187-
``max_include_resolution_depth``: (optional) Maximum include resolution level for Resources, defaults to 20 (max include level * 2).
189+
``max_include_resolution_depth``: (optional) Maximum include resolution level for Resources, defaults to 20 (max include level * 2). Note that when `reuse_entries` is enabled, the max include resolution depth only affects deep chains of unique objects (ie, not simple circular references).
188190

189191
``application_name``: (optional) User application name, defaults to None.
190192

_docs/index.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ Client Configuration Options
169169

170170
``content_type_cache``: (optional) Boolean determining wether to store a Cache of the Content Types in order to properly coerce Entry fields, defaults to True.
171171

172+
``reuse_entries``: (optional) Boolean, when True, reuse hydrated Entry and Asset objects within the same request when possible. Can result in a large speed increase and better handles cyclical object graphs. This can be a good alternative to max_include_resolution_depth if your content model contains (or can contain) circular references.
173+
172174
``proxy_host``: (optional) URL for Proxy, defaults to None.
173175

174176
``proxy_port``: (optional) Port for Proxy, defaults to None.
@@ -181,7 +183,7 @@ Client Configuration Options
181183

182184
``max_rate_limit_wait``: (optional) Timeout (in seconds) for waiting for retry after RateLimitError, defaults to 60.
183185

184-
``max_include_resolution_depth``: (optional) Maximum include resolution level for Resources, defaults to 20 (max include level * 2).
186+
``max_include_resolution_depth``: (optional) Maximum include resolution level for Resources, defaults to 20 (max include level * 2).Note that when `reuse_entries` is enabled, the max include resolution depth only affects deep chains of unique objects (ie, not simple circular references).
185187

186188
``application_name``: (optional) User application name, defaults to None.
187189

contentful/client.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import requests
22
import platform
33
from re import sub
4-
from .utils import ConfigurationException, NotSupportedException
4+
from .utils import ConfigurationException
55
from .utils import retry_request, string_class
66
from .errors import get_error, RateLimitExceededError, EntryNotFoundError
77
from .resource_builder import ResourceBuilder
@@ -49,6 +49,9 @@ class Client(object):
4949
:param content_type_cache: (optional) Boolean determining wether to
5050
store a Cache of the Content Types in order to properly coerce
5151
Entry fields, defaults to True.
52+
:param reuse_entries: (optional) Boolean determining wether to reuse
53+
hydrated Entry and Asset objects within the same request when possible.
54+
Defaults to False
5255
:param proxy_host: (optional) URL for Proxy, defaults to None.
5356
:param proxy_port: (optional) Port for Proxy, defaults to None.
5457
:param proxy_username: (optional) Username for Proxy, defaults to None.
@@ -89,6 +92,7 @@ def __init__(
8992
gzip_encoded=True,
9093
raise_errors=True,
9194
content_type_cache=True,
95+
reuse_entries=False,
9296
proxy_host=None,
9397
proxy_port=None,
9498
proxy_username=None,
@@ -112,6 +116,7 @@ def __init__(
112116
self.gzip_encoded = gzip_encoded
113117
self.raise_errors = raise_errors
114118
self.content_type_cache = content_type_cache
119+
self.reuse_entries = reuse_entries
115120
self.proxy_host = proxy_host
116121
self.proxy_port = proxy_port
117122
self.proxy_username = proxy_username
@@ -566,7 +571,8 @@ def _get(self, url, query=None):
566571
self.default_locale,
567572
localized,
568573
response.json(),
569-
max_depth=self.max_include_resolution_depth
574+
max_depth=self.max_include_resolution_depth,
575+
reuse_entries=self.reuse_entries
570576
).build()
571577

572578
def _has_proxy(self):

contentful/entry.py

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@ class Entry(FieldsResource):
2121
API Reference: https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/entries
2222
"""
2323

24-
def _coerce(self, field_id, value, localized, includes, errors):
24+
def _coerce(self, field_id, value, localized, includes, errors, resources=None):
2525
if is_link(value) and not unresolvable(value, errors):
2626
return self._build_nested_resource(
2727
value,
2828
localized,
29-
includes
29+
includes,
30+
resources=resources
3031
)
3132
elif is_link_array(value):
3233
items = []
@@ -37,7 +38,8 @@ def _coerce(self, field_id, value, localized, includes, errors):
3738
self._build_nested_resource(
3839
link,
3940
localized,
40-
includes
41+
includes,
42+
resources=resources
4143
)
4244
)
4345

@@ -56,33 +58,48 @@ def _coerce(self, field_id, value, localized, includes, errors):
5658
value,
5759
localized,
5860
includes,
59-
errors
61+
errors,
62+
resources
6063
)
6164

62-
def _build_nested_resource(self, value, localized, includes):
65+
def _build_nested_resource(self, value, localized, includes, resources=None):
6366
# Maximum include Depth is 10 in the API, but we raise it to 20 (default),
6467
# in case one of the included items has a reference in an upper level,
6568
# so we can keep the include chain for that object as well
66-
# Any included object after the 20th level of depth will be just a Link
67-
if self._depth < self._max_depth:
68-
resource = resource_for_link(value, includes)
69+
# Any included object after the 20th level of depth will be just a Link.
70+
# When using reuse_entries, this is not the case if the entry was previously
71+
# cached.
6972

73+
resource = resource_for_link(
74+
value,
75+
includes,
76+
resources=resources,
77+
locale=self.sys.get('locale', '*')
78+
)
79+
80+
if isinstance(resource, FieldsResource): # Resource comes from instance cache
81+
return resource
82+
83+
if self._depth < self._max_depth:
7084
if resource is not None:
7185
return self._resolve_include(
7286
resource,
7387
localized,
74-
includes
88+
includes,
89+
resources=resources
7590
)
7691

7792
return self._build_link(value)
7893

79-
def _resolve_include(self, resource, localized, includes):
94+
def _resolve_include(self, resource, localized, includes, resources=None):
8095
from .resource_builder import ResourceBuilder
8196
return ResourceBuilder(
8297
self.default_locale,
8398
localized,
8499
resource,
85100
includes,
101+
reuse_entries=bool(resources),
102+
resources=resources,
86103
depth=self._depth + 1,
87104
max_depth=self._max_depth
88105
).build()

contentful/resource.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def __init__(
3232
includes=None,
3333
errors=None,
3434
localized=False,
35+
resources=None,
3536
depth=0,
3637
max_depth=20):
3738
self.raw = item
@@ -40,6 +41,14 @@ def __init__(
4041
self._max_depth = max_depth
4142
self.sys = self._hydrate_sys(item)
4243

44+
if resources is not None and 'sys' in item:
45+
cache_key = "{0}:{1}:{2}".format(
46+
item['sys']['type'],
47+
item['sys']['id'],
48+
item['sys'].get('locale', '*')
49+
)
50+
resources[cache_key] = self
51+
4352
def _hydrate_sys(self, item):
4453
sys = {}
4554
for k, v in item.get('sys', {}).items():
@@ -81,18 +90,20 @@ def __init__(
8190
includes=None,
8291
errors=None,
8392
localized=False,
93+
resources=None,
8494
**kwargs):
8595
super(FieldsResource, self).__init__(
8696
item,
8797
includes=includes,
8898
errors=errors,
8999
localized=localized,
100+
resources=resources,
90101
**kwargs
91102
)
92103

93-
self._fields = self._hydrate_fields(item, localized, includes, errors)
104+
self._fields = self._hydrate_fields(item, localized, includes, errors, resources=resources)
94105

95-
def _hydrate_fields(self, item, localized, includes, errors):
106+
def _hydrate_fields(self, item, localized, includes, errors, resources=None):
96107
if 'fields' not in item:
97108
return {}
98109

@@ -105,12 +116,12 @@ def _hydrate_fields(self, item, localized, includes, errors):
105116
locale = self._locale()
106117
fields = {locale: {}}
107118
if localized:
108-
self._hydrate_localized_entry(fields, item, includes, errors)
119+
self._hydrate_localized_entry(fields, item, includes, errors, resources)
109120
else:
110-
self._hydrate_non_localized_entry(fields, item, includes, errors)
121+
self._hydrate_non_localized_entry(fields, item, includes, errors, resources)
111122
return fields
112123

113-
def _hydrate_localized_entry(self, fields, item, includes, errors):
124+
def _hydrate_localized_entry(self, fields, item, includes, errors, resources=None):
114125
for k, locales in item['fields'].items():
115126
for locale, v in locales.items():
116127
if locale not in fields:
@@ -120,20 +131,22 @@ def _hydrate_localized_entry(self, fields, item, includes, errors):
120131
v,
121132
True,
122133
includes,
123-
errors
134+
errors,
135+
resources=resources
124136
)
125137

126-
def _hydrate_non_localized_entry(self, fields, item, includes, errors):
138+
def _hydrate_non_localized_entry(self, fields, item, includes, errors, resources=None):
127139
for k, v in item['fields'].items():
128140
fields[self._locale()][snake_case(k)] = self._coerce(
129141
snake_case(k),
130142
v,
131143
False,
132144
includes,
133-
errors
145+
errors,
146+
resources=resources
134147
)
135148

136-
def _coerce(self, field_id, value, localized, includes, errors):
149+
def _coerce(self, field_id, value, localized, includes, errors, resources=None):
137150
return value
138151

139152
def fields(self, locale=None):

contentful/resource_builder.py

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
# Classes imported here are meant to be used via globals() on build
2-
from .array import Array # noqa: F401
3-
from .entry import Entry # noqa: F401
4-
from .asset import Asset # noqa: F401
5-
from .space import Space # noqa: F401
6-
from .content_type import ContentType # noqa: F401
7-
from .deleted_asset import DeletedAsset # noqa: F401
8-
from .deleted_entry import DeletedEntry # noqa: F401
9-
from .locale import Locale # noqa: F401
1+
from .array import Array
2+
from .entry import Entry
3+
from .asset import Asset
4+
from .space import Space
5+
from .content_type import ContentType
6+
from .deleted_asset import DeletedAsset
7+
from .deleted_entry import DeletedEntry
8+
from .locale import Locale
109
from .sync_page import SyncPage
1110
from .utils import unresolvable
1211

@@ -31,15 +30,22 @@ def __init__(
3130
localized,
3231
json,
3332
includes_for_single=None,
33+
reuse_entries=False,
34+
resources=None,
3435
depth=0,
3536
max_depth=20):
3637
self.default_locale = default_locale
3738
self.localized = localized
3839
self.json = json
3940
self.includes_for_single = includes_for_single
41+
self.reuse_entries = reuse_entries
4042
self.depth = depth
4143
self.max_depth = max_depth
4244

45+
if resources is None:
46+
resources = {} if self.reuse_entries else None
47+
self.resources = resources
48+
4349
def build(self):
4450
"""Creates the objects from the JSON response"""
4551

@@ -78,26 +84,41 @@ def _build_item(self, item, includes=None, errors=None):
7884
if errors is None:
7985
errors = []
8086

81-
buildables = [
82-
'Entry',
83-
'Asset',
84-
'ContentType',
85-
'Space',
86-
'DeletedEntry',
87-
'DeletedAsset',
88-
'Locale'
89-
]
87+
buildables = {
88+
'Entry': Entry,
89+
'Asset': Asset,
90+
'ContentType': ContentType,
91+
'Space': Space,
92+
'DeletedEntry': DeletedEntry,
93+
'DeletedAsset': DeletedAsset,
94+
'Locale': Locale
95+
}
96+
97+
resource = self._resource_from_cache(item) if self.reuse_entries else None
98+
if resource is not None:
99+
return resource
100+
90101
if item['sys']['type'] in buildables:
91-
return globals()[item['sys']['type']](
102+
return buildables[item['sys']['type']](
92103
item,
93104
default_locale=self.default_locale,
94105
localized=self.localized,
95106
includes=includes,
96107
errors=errors,
108+
resources=self.resources,
97109
depth=self.depth,
98110
max_depth=self.max_depth
99111
)
100112

113+
def _resource_from_cache(self, item):
114+
cache_key = "{0}:{1}:{2}".format(
115+
item['sys']['type'],
116+
item['sys']['id'],
117+
item['sys'].get('locale', '*')
118+
)
119+
if self.resources and cache_key in self.resources:
120+
return self.resources[cache_key]
121+
101122
def _includes(self):
102123
includes = list(self.json['items'])
103124
for e in ['Entry', 'Asset']:

contentful/utils.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,18 @@ def unresolvable(item, errors):
115115
return False
116116

117117

118-
def resource_for_link(link, includes):
118+
def resource_for_link(link, includes, resources=None, locale=None):
119119
"""Returns the resource that matches the link"""
120120

121+
if resources is not None:
122+
cache_key = "{0}:{1}:{2}".format(
123+
link['sys']['linkType'],
124+
link['sys']['id'],
125+
locale
126+
)
127+
if cache_key in resources:
128+
return resources[cache_key]
129+
121130
for i in includes:
122131
if (i['sys']['id'] == link['sys']['id'] and
123132
i['sys']['type'] == link['sys']['linkType']):
@@ -165,5 +174,6 @@ def wrapper(url, query=None):
165174
)
166175
log.debug(retry_message)
167176
time.sleep(reset_time * uniform(1.0, 1.2))
168-
raise exception
177+
if exception is not None:
178+
raise exception
169179
return wrapper

0 commit comments

Comments
 (0)