Merge lp:~cjwatson/lazr.restful/range-factory into lp:lazr.restful

Proposed by Colin Watson
Status: Needs review
Proposed branch: lp:~cjwatson/lazr.restful/range-factory
Merge into: lp:lazr.restful
Diff against target: 415 lines (+146/-16)
9 files modified
setup.py (+1/-1)
src/lazr/restful/_operation.py (+10/-1)
src/lazr/restful/_resource.py (+16/-3)
src/lazr/restful/declarations.py (+11/-5)
src/lazr/restful/docs/webservice-declarations.txt (+47/-2)
src/lazr/restful/docs/webservice.txt (+51/-3)
src/lazr/restful/fields.py (+2/-1)
src/lazr/restful/interfaces/_fields.py (+5/-0)
src/lazr/restful/interfaces/_rest.py (+3/-0)
To merge this branch: bzr merge lp:~cjwatson/lazr.restful/range-factory
Reviewer Review Type Date Requested Status
LAZR Developers Pending
Review via email: mp+355966@code.launchpad.net

Commit message

Allow declaring a custom range factory for use with returned collections.

Description of the change

Integrating this into Launchpad's StormRangeFactory may still require a bit more care, and I'd like to have at least one use case there that definitely works before landing this, but I might as well get this up for review in the meantime.

To post a comment you must log in.

Unmerged revisions

212. By Colin Watson

Get ScopedCollection.range_factory_class from the corresponding field.

211. By Colin Watson

Merge trunk.

210. By Colin Watson

Merge trunk.

209. By Colin Watson

Allow declaring a custom range factory for use with returned collections.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'setup.py'
--- setup.py 2018-02-21 14:46:13 +0000
+++ setup.py 2018-10-02 06:38:17 +0000
@@ -56,7 +56,7 @@
56 'docutils>=0.3.7',56 'docutils>=0.3.7',
57 'epydoc', # used by wadl generation57 'epydoc', # used by wadl generation
58 'grokcore.component==1.6',58 'grokcore.component==1.6',
59 'lazr.batchnavigator>=1.2.0-dev',59 'lazr.batchnavigator>=1.2.3',
60 'lazr.delegates>=2.0.3',60 'lazr.delegates>=2.0.3',
61 'lazr.enum',61 'lazr.enum',
62 'lazr.lifecycle',62 'lazr.lifecycle',
6363
=== modified file 'src/lazr/restful/_operation.py'
--- src/lazr/restful/_operation.py 2018-04-23 12:42:00 +0000
+++ src/lazr/restful/_operation.py 2018-10-02 06:38:17 +0000
@@ -118,7 +118,9 @@
118 if self.total_size_only:118 if self.total_size_only:
119 result = self.get_total_size(result)119 result = self.get_total_size(result)
120 else:120 else:
121 result = self.batch(result, self.request) + '}'121 result = self.batch(
122 result, self.request,
123 range_factory=self.get_range_factory(result)) + '}'
122 else:124 else:
123 # Serialize the result to JSON. Any embedded entries will be125 # Serialize the result to JSON. Any embedded entries will be
124 # automatically serialized.126 # automatically serialized.
@@ -163,6 +165,13 @@
163 # Any other objects (eg. Entries) are not batched.165 # Any other objects (eg. Entries) are not batched.
164 return False166 return False
165167
168 def get_range_factory(self, result):
169 """The custom range factory to use for this response, if any."""
170 if (ICollectionField.providedBy(self.return_type) and
171 self.return_type.range_factory_class is not None):
172 return self.return_type.range_factory_class(result)
173 return None
174
166 def validate(self):175 def validate(self):
167 """Validate incoming arguments against the operation schema.176 """Validate incoming arguments against the operation schema.
168177
169178
=== modified file 'src/lazr/restful/_resource.py'
--- src/lazr/restful/_resource.py 2018-10-01 08:55:53 +0000
+++ src/lazr/restful/_resource.py 2018-10-02 06:38:17 +0000
@@ -611,7 +611,7 @@
611 return simplejson.dumps(len(entries))611 return simplejson.dumps(len(entries))
612612
613613
614 def batch(self, entries, request):614 def batch(self, entries, request, range_factory=None):
615 """Prepare a batch from a (possibly huge) list of entries.615 """Prepare a batch from a (possibly huge) list of entries.
616616
617 :return: a JSON string representing a hash:617 :return: a JSON string representing a hash:
@@ -635,7 +635,8 @@
635 """635 """
636 if not hasattr(entries, '__len__'):636 if not hasattr(entries, '__len__'):
637 entries = IFiniteSequence(entries)637 entries = IFiniteSequence(entries)
638 navigator = WebServiceBatchNavigator(entries, request)638 navigator = WebServiceBatchNavigator(
639 entries, request, range_factory=range_factory)
639640
640 view_permission = getUtility(IWebServiceConfiguration).view_permission641 view_permission = getUtility(IWebServiceConfiguration).view_permission
641 batch = { 'start' : navigator.batch.start }642 batch = { 'start' : navigator.batch.start }
@@ -1817,7 +1818,12 @@
1817 entries = self.collection.find()1818 entries = self.collection.find()
1818 if request is None:1819 if request is None:
1819 request = self.request1820 request = self.request
1820 result = super(CollectionResource, self).batch(entries, request)1821 if self.collection.range_factory_class is None:
1822 range_factory = None
1823 else:
1824 range_factory = self.collection.range_factory_class(entries)
1825 result = super(CollectionResource, self).batch(
1826 entries, request, range_factory=range_factory)
1821 result += (1827 result += (
1822 ', "resource_type_link" : ' + simplejson.dumps(self.type_url))1828 ', "resource_type_link" : ' + simplejson.dumps(self.type_url))
1823 return result1829 return result
@@ -2089,6 +2095,8 @@
2089class Collection:2095class Collection:
2090 """A collection of entries."""2096 """A collection of entries."""
20912097
2098 range_factory_class = None
2099
2092 def __init__(self, context, request):2100 def __init__(self, context, request):
2093 """Associate the entry with some database model object."""2101 """Associate the entry with some database model object."""
2094 self.context = context2102 self.context = context
@@ -2124,6 +2132,11 @@
2124 return getGlobalSiteManager().adapters.lookup(2132 return getGlobalSiteManager().adapters.lookup(
2125 (model_schema, request_interface), IEntry).schema2133 (model_schema, request_interface), IEntry).schema
21262134
2135 @property
2136 def range_factory_class(self):
2137 """See `ICollection`."""
2138 return self.relationship.range_factory_class
2139
2127 def find(self):2140 def find(self):
2128 """See `ICollection`."""2141 """See `ICollection`."""
2129 return self.collection2142 return self.collection
21302143
=== modified file 'src/lazr/restful/declarations.py'
--- src/lazr/restful/declarations.py 2018-09-28 15:46:34 +0000
+++ src/lazr/restful/declarations.py 2018-10-02 06:38:17 +0000
@@ -354,7 +354,7 @@
354 return field354 return field
355355
356356
357def export_as_webservice_collection(entry_schema):357def export_as_webservice_collection(entry_schema, range_factory_class=None):
358 """Mark the interface as exported on the web service as a collection.358 """Mark the interface as exported on the web service as a collection.
359359
360 :raises TypeError: if the interface doesn't have a method decorated with360 :raises TypeError: if the interface doesn't have a method decorated with
@@ -369,7 +369,8 @@
369 # check it.369 # check it.
370 tags = _get_interface_tags()370 tags = _get_interface_tags()
371 tags[LAZR_WEBSERVICE_EXPORTED] = dict(371 tags[LAZR_WEBSERVICE_EXPORTED] = dict(
372 type=COLLECTION_TYPE, collection_entry_schema=entry_schema)372 type=COLLECTION_TYPE, collection_entry_schema=entry_schema,
373 collection_range_factory_class=range_factory_class)
373374
374 def mark_collection(interface):375 def mark_collection(interface):
375 """Class advisor that tags the interface once it is created."""376 """Class advisor that tags the interface once it is created."""
@@ -846,15 +847,18 @@
846 """Specify that the exported operation returns a collection.847 """Specify that the exported operation returns a collection.
847848
848 The decorator takes one required argument, "schema", an interface that's849 The decorator takes one required argument, "schema", an interface that's
849 been exported as an entry.850 been exported as an entry, and one optional argument,
851 "range_factory_class", which specifies a custom class implementing
852 IRangeFactory to be used to adapt results for this collection.
850 """853 """
851 def __init__(self, schema):854 def __init__(self, schema, range_factory_class=None):
852 _check_called_from_interface_def('%s()' % self.__class__.__name__)855 _check_called_from_interface_def('%s()' % self.__class__.__name__)
853 if not IInterface.providedBy(schema):856 if not IInterface.providedBy(schema):
854 raise TypeError('Collection value type %s does not provide '857 raise TypeError('Collection value type %s does not provide '
855 'IInterface.' % schema)858 'IInterface.' % schema)
856 self.return_type = CollectionField(859 self.return_type = CollectionField(
857 value_type=Reference(schema=schema))860 value_type=Reference(schema=schema),
861 range_factory_class=range_factory_class)
858862
859 def annotate_method(self, method, annotations):863 def annotate_method(self, method, annotations):
860 annotations['return_type'] = self.return_type864 annotations['return_type'] = self.return_type
@@ -1355,8 +1359,10 @@
1355 "version '%s'." % (interface.__name__, version))1359 "version '%s'." % (interface.__name__, version))
1356 method_name, params = default_content_by_version[version]1360 method_name, params = default_content_by_version[version]
1357 entry_schema = tag['collection_entry_schema']1361 entry_schema = tag['collection_entry_schema']
1362 range_factory_class = tag['collection_range_factory_class']
1358 class_dict = {1363 class_dict = {
1359 'entry_schema' : CollectionEntrySchema(entry_schema),1364 'entry_schema' : CollectionEntrySchema(entry_schema),
1365 'range_factory_class': range_factory_class,
1360 'method_name': method_name,1366 'method_name': method_name,
1361 'params': params,1367 'params': params,
1362 '__doc__': interface.__doc__,1368 '__doc__': interface.__doc__,
13631369
=== modified file 'src/lazr/restful/docs/webservice-declarations.txt'
--- src/lazr/restful/docs/webservice-declarations.txt 2018-09-28 15:41:05 +0000
+++ src/lazr/restful/docs/webservice-declarations.txt 2018-10-02 06:38:17 +0000
@@ -215,6 +215,7 @@
215 >>> print_export_tag(IBookSet)215 >>> print_export_tag(IBookSet)
216 collection_default_content: {None: ('getAllBooks', {})}216 collection_default_content: {None: ('getAllBooks', {})}
217 collection_entry_schema: <InterfaceClass __builtin__.IBook>217 collection_entry_schema: <InterfaceClass __builtin__.IBook>
218 collection_range_factory_class: None
218 type: 'collection'219 type: 'collection'
219220
220 >>> print_export_tag(ICheckedOutBookSet)221 >>> print_export_tag(ICheckedOutBookSet)
@@ -222,6 +223,7 @@
222 {'title': '',223 {'title': '',
223 'user': <class '...REQUEST_USER'>})}224 'user': <class '...REQUEST_USER'>})}
224 collection_entry_schema: <InterfaceClass __builtin__.IBook>225 collection_entry_schema: <InterfaceClass __builtin__.IBook>
226 collection_range_factory_class: None
225 type: 'collection'227 type: 'collection'
226228
227The entry schema for a collection must be provided and must be an229The entry schema for a collection must be provided and must be an
@@ -231,7 +233,7 @@
231 ... export_as_webservice_collection()233 ... export_as_webservice_collection()
232 Traceback (most recent call last):234 Traceback (most recent call last):
233 ...235 ...
234 TypeError: export_as_webservice_collection() takes exactly 1236 TypeError: export_as_webservice_collection() takes at least 1
235 argument (0 given)237 argument (0 given)
236238
237 >>> class InvalidEntrySchema(Interface):239 >>> class InvalidEntrySchema(Interface):
@@ -355,12 +357,25 @@
355a single entry; @operation_returns_collection_of is used when the357a single entry; @operation_returns_collection_of is used when the
356operation returns a collection of entries.358operation returns a collection of entries.
357359
360 >>> from lazr.batchnavigator import ListRangeFactory
358 >>> from lazr.restful.declarations import (361 >>> from lazr.restful.declarations import (
359 ... export_operation_as, export_factory_operation,362 ... export_operation_as, export_factory_operation,
360 ... export_read_operation, operation_parameters,363 ... export_read_operation, operation_parameters,
361 ... operation_returns_entry, operation_returns_collection_of,364 ... operation_returns_entry, operation_returns_collection_of,
362 ... rename_parameters_as)365 ... rename_parameters_as)
363 >>> from lazr.restful.interface import copy_field366 >>> from lazr.restful.interface import copy_field
367
368 >>> class RecordingRangeFactory(ListRangeFactory):
369 ... calls = []
370 ...
371 ... def getEndpointMemos(self, batch):
372 ... self.calls.append('getEndpointMemos')
373 ... return ListRangeFactory.getEndpointMemos(self, batch)
374 ...
375 ... def getSlice(self, size, memo, forwards):
376 ... self.calls.append(('getSlice', size, memo, forwards))
377 ... return ListRangeFactory.getSlice(self, size, memo, forwards)
378
364 >>> class IBookSetOnSteroids(IBookSet):379 >>> class IBookSetOnSteroids(IBookSet):
365 ... """IBookSet supporting some methods."""380 ... """IBookSet supporting some methods."""
366 ... export_as_webservice_collection(IBook)381 ... export_as_webservice_collection(IBook)
@@ -375,6 +390,17 @@
375 ...390 ...
376 ... @operation_parameters(391 ... @operation_parameters(
377 ... text=copy_field(IBook['title'], title=u'Text to search for.'))392 ... text=copy_field(IBook['title'], title=u'Text to search for.'))
393 ... @operation_returns_collection_of(
394 ... IBook, range_factory_class=RecordingRangeFactory)
395 ... @export_read_operation()
396 ... def searchBookTitlesCustomRange(text):
397 ... """Return list of books whose titles contain 'text'.
398 ...
399 ... This method uses a custom range factory.
400 ... """
401 ...
402 ... @operation_parameters(
403 ... text=copy_field(IBook['title'], title=u'Text to search for.'))
378 ... @operation_returns_entry(IBook)404 ... @operation_returns_entry(IBook)
379 ... @export_read_operation()405 ... @export_read_operation()
380 ... def bestMatch(text):406 ... def bestMatch(text):
@@ -442,6 +468,22 @@
442 params: {'text': <...TextLine...>}468 params: {'text': <...TextLine...>}
443 return_type: <lazr.restful.fields.CollectionField object...>469 return_type: <lazr.restful.fields.CollectionField object...>
444 type: 'read_operation'470 type: 'read_operation'
471 >>> print IBookSetOnSteroids['searchBookTitles'].getTaggedValue(
472 ... 'lazr.restful.exported')['return_type'].range_factory_class
473 None
474
475The 'searchBookTitlesCustomRange' method is just the same, except that it
476uses a custom range factory.
477
478 >>> print_export_tag(IBookSetOnSteroids['searchBookTitlesCustomRange'])
479 as: 'searchBookTitlesCustomRange'
480 call_with: {}
481 params: {'text': <...TextLine...>}
482 return_type: <lazr.restful.fields.CollectionField object...>
483 type: 'read_operation'
484 >>> print IBookSetOnSteroids['searchBookTitlesCustomRange'].getTaggedValue(
485 ... 'lazr.restful.exported')['return_type'].range_factory_class
486 <class 'RecordingRangeFactory'>
445487
446The 'bestMatch' method returns an entry.488The 'bestMatch' method returns an entry.
447489
@@ -1187,6 +1229,9 @@
1187 ... def searchBookTitles(self, text):1229 ... def searchBookTitles(self, text):
1188 ... return self.result1230 ... return self.result
1189 ...1231 ...
1232 ... def searchBookTitlesCustomRange(self, text):
1233 ... return self.result
1234 ...
1190 ... def new(self, author, base_price, title):1235 ... def new(self, author, base_price, title):
1191 ... return Book(author, title, base_price, "unknown")1236 ... return Book(author, title, base_price, "unknown")
11921237
@@ -2584,7 +2629,7 @@
25842629
2585 >>> print debug_proxy(ProxyFactory(collection_adapter))2630 >>> print debug_proxy(ProxyFactory(collection_adapter))
2586 zope.security._proxy._Proxy (using zope.security.checker.Checker)2631 zope.security._proxy._Proxy (using zope.security.checker.Checker)
2587 public: entry_schema, find2632 public: entry_schema, find, range_factory_class
25882633
2589 >>> print debug_proxy(ProxyFactory(read_method_adapter))2634 >>> print debug_proxy(ProxyFactory(read_method_adapter))
2590 zope.security._proxy._Proxy (using zope.security.checker.Checker)2635 zope.security._proxy._Proxy (using zope.security.checker.Checker)
25912636
=== modified file 'src/lazr/restful/docs/webservice.txt'
--- src/lazr/restful/docs/webservice.txt 2018-10-01 08:55:53 +0000
+++ src/lazr/restful/docs/webservice.txt 2018-10-02 06:38:17 +0000
@@ -609,13 +609,27 @@
609 ... singular="recipe", plural="recipes",609 ... singular="recipe", plural="recipes",
610 ... publish_web_link=True))610 ... publish_web_link=True))
611611
612 >>> from lazr.batchnavigator import ListRangeFactory
613 >>> class RecordingRangeFactory(ListRangeFactory):
614 ... calls = []
615 ...
616 ... def getEndpointMemos(self, batch):
617 ... self.calls.append('getEndpointMemos')
618 ... return ListRangeFactory.getEndpointMemos(self, batch)
619 ...
620 ... def getSlice(self, size, memo, forwards):
621 ... self.calls.append(('getSlice', size, memo, forwards))
622 ... return ListRangeFactory.getSlice(self, size, memo, forwards)
623
612 >>> from lazr.restful.fields import ReferenceChoice624 >>> from lazr.restful.fields import ReferenceChoice
613 >>> class ICookbookEntry(IEntry):625 >>> class ICookbookEntry(IEntry):
614 ... name = TextLine(title=u"Name", required=True)626 ... name = TextLine(title=u"Name", required=True)
615 ... cuisine = TextLine(title=u"Cuisine", required=False, default=None)627 ... cuisine = TextLine(title=u"Cuisine", required=False, default=None)
616 ... author = ReferenceChoice(628 ... author = ReferenceChoice(
617 ... schema=IAuthor, vocabulary=AuthorVocabulary())629 ... schema=IAuthor, vocabulary=AuthorVocabulary())
618 ... recipes = CollectionField(value_type=Reference(schema=IRecipe))630 ... recipes = CollectionField(
631 ... value_type=Reference(schema=IRecipe),
632 ... range_factory_class=RecordingRangeFactory)
619 ... comments = CollectionField(value_type=Reference(schema=IComment))633 ... comments = CollectionField(value_type=Reference(schema=IComment))
620 ... cover = Bytes(0, 5000, title=u"An image of the cookbook's cover.")634 ... cover = Bytes(0, 5000, title=u"An image of the cookbook's cover.")
621 ... taggedValue(635 ... taggedValue(
@@ -785,10 +799,12 @@
785 ... """799 ... """
786 ...800 ...
787 ... entry_schema = ICookbookEntry801 ... entry_schema = ICookbookEntry
802 ... range_factory_class = RecordingRangeFactory
788 ...803 ...
789 ... def find(self):804 ... def find(self):
790 ... """Find all the cookbooks."""805 ... """Find all the cookbooks."""
791 ... return self.context.getAll()806 ... return self.context.getAll()
807
792 >>> sm.registerAdapter(CookbookCollection,808 >>> sm.registerAdapter(CookbookCollection,
793 ... (ICookbookSet, IWebServiceClientRequest),809 ... (ICookbookSet, IWebServiceClientRequest),
794 ... provided=ICollection)810 ... provided=ICollection)
@@ -1190,6 +1206,8 @@
1190But if we ask for a page size of two, we can see how pagination1206But if we ask for a page size of two, we can see how pagination
1191works. Here's page one, with two cookbooks on it.1207works. Here's page one, with two cookbooks on it.
11921208
1209 >>> RecordingRangeFactory.calls = []
1210
1193 >>> request = create_web_service_request(1211 >>> request = create_web_service_request(
1194 ... '/beta/cookbooks', environ={'QUERY_STRING' : 'ws.size=2'})1212 ... '/beta/cookbooks', environ={'QUERY_STRING' : 'ws.size=2'})
1195 >>> collection = request.traverse(app)1213 >>> collection = request.traverse(app)
@@ -1222,6 +1240,36 @@
1222 >>> len(representation['entries'])1240 >>> len(representation['entries'])
1223 11241 1
12241242
1243Serving this collection uses the custom range factory.
1244
1245 >>> 'getEndpointMemos' in RecordingRangeFactory.calls
1246 True
1247
1248Similarly, serving a scoped collection uses the custom range factory, if one
1249is defined.
1250
1251 >>> RecordingRangeFactory.calls = []
1252
1253 >>> request = create_web_service_request(
1254 ... '/beta/cookbooks/The Joy of Cooking/recipes',
1255 ... environ={'QUERY_STRING': 'ws.size=1'})
1256 >>> collection = request.traverse(app)
1257 >>> representation = load_json(collection())
1258
1259 >>> sorted(representation.keys())
1260 [u'entries', u'next_collection_link', u'resource_type_link',
1261 u'start', u'total_size']
1262 >>> representation['next_collection_link']
1263 u'http://api.cookbooks.dev/beta/cookbooks/The%20Joy%20of%20Cooking/recipes?ws.size=1&memo=1&ws.start=1'
1264 >>> len(representation['entries'])
1265 1
1266 >>> representation['total_size']
1267 2
1268
1269 >>> 'getEndpointMemos' in RecordingRangeFactory.calls
1270 True
1271
1272
1225Custom operations1273Custom operations
1226=================1274=================
12271275
12281276
=== modified file 'src/lazr/restful/fields.py'
--- src/lazr/restful/fields.py 2016-02-17 01:07:21 +0000
+++ src/lazr/restful/fields.py 2018-10-02 06:38:17 +0000
@@ -25,7 +25,7 @@
25 # has a _type of list, and we don't want to have to implement list25 # has a _type of list, and we don't want to have to implement list
26 # semantics for this class.26 # semantics for this class.
2727
28 def __init__(self, *args, **kwargs):28 def __init__(self, range_factory_class=None, *args, **kwargs):
29 """A generic collection field.29 """A generic collection field.
3030
31 The readonly property defaults to True since these fields are usually31 The readonly property defaults to True since these fields are usually
@@ -34,6 +34,7 @@
34 """34 """
35 kwargs.setdefault('readonly', True)35 kwargs.setdefault('readonly', True)
36 super(CollectionField, self).__init__(*args, **kwargs)36 super(CollectionField, self).__init__(*args, **kwargs)
37 self.range_factory_class = range_factory_class
3738
3839
39@implementer(IReference)40@implementer(IReference)
4041
=== modified file 'src/lazr/restful/interfaces/_fields.py'
--- src/lazr/restful/interfaces/_fields.py 2011-01-19 22:29:32 +0000
+++ src/lazr/restful/interfaces/_fields.py 2018-10-02 06:38:17 +0000
@@ -34,6 +34,11 @@
34 All iterables satisfy this collection field.34 All iterables satisfy this collection field.
35 """35 """
3636
37 range_factory_class = Attribute(
38 "A class implementing IRangeFactory to be used to adapt results "
39 "for this collection field, or None.")
40
41
37class IReference(IObject):42class IReference(IObject):
38 """A reference to an object providing a particular schema.43 """A reference to an object providing a particular schema.
3944
4045
=== modified file 'src/lazr/restful/interfaces/_rest.py'
--- src/lazr/restful/interfaces/_rest.py 2011-03-31 01:13:59 +0000
+++ src/lazr/restful/interfaces/_rest.py 2018-10-02 06:38:17 +0000
@@ -224,6 +224,9 @@
224 """A collection, driven by an ICollectionResource."""224 """A collection, driven by an ICollectionResource."""
225225
226 entry_schema = Attribute("The schema for this collection's entries.")226 entry_schema = Attribute("The schema for this collection's entries.")
227 range_factory_class = Attribute(
228 "A class implementing IRangeFactory to be used to adapt results "
229 "for this collection, or None.")
227230
228 def find():231 def find():
229 """Retrieve all entries in the collection under the given scope.232 """Retrieve all entries in the collection under the given scope.

Subscribers

People subscribed via source and target branches