Merge lp:~leonardr/lazr.restful/benji-updated-size-link into lp:lazr.restful

Proposed by Leonard Richardson
Status: Merged
Approved by: Graham Binns
Approved revision: 160
Merge reported by: Leonard Richardson
Merged at revision: not available
Proposed branch: lp:~leonardr/lazr.restful/benji-updated-size-link
Merge into: lp:lazr.restful
Diff against target: 1233 lines (+451/-244)
21 files modified
setup.py (+1/-1)
src/lazr/restful/NEWS.txt (+9/-0)
src/lazr/restful/_operation.py (+28/-2)
src/lazr/restful/_resource.py (+48/-18)
src/lazr/restful/declarations.py (+19/-12)
src/lazr/restful/docs/webservice-declarations.txt (+62/-15)
src/lazr/restful/docs/webservice.txt (+1/-0)
src/lazr/restful/example/base/root.py (+1/-0)
src/lazr/restful/example/base/tests/collection.txt (+17/-8)
src/lazr/restful/example/base/tests/wadl.txt (+6/-5)
src/lazr/restful/example/multiversion/tests/wadl.txt (+29/-0)
src/lazr/restful/interfaces/_rest.py (+7/-0)
src/lazr/restful/simple.py (+2/-1)
src/lazr/restful/tales.py (+7/-1)
src/lazr/restful/templates/wadl-root.pt (+31/-8)
src/lazr/restful/testing/webservice.py (+3/-0)
src/lazr/restful/tests/test_utils.py (+23/-1)
src/lazr/restful/tests/test_webservice.py (+78/-2)
src/lazr/restful/utils.py (+12/-0)
src/lazr/restful/version.txt (+1/-1)
versions.cfg (+66/-169)
To merge this branch: bzr merge lp:~leonardr/lazr.restful/benji-updated-size-link
Reviewer Review Type Date Requested Status
Graham Binns (community) code Approve
Review via email: mp+32187@code.launchpad.net

Description of the change

This branch makes lazr.restful send a 'total_size_link' in lieu of a 'total_size', to spare the underlying data model the pain of calculating the total size of a collection all the time.

The branch has already been reviewed (https://code.edge.launchpad.net/~benji/lazr.restful/size-link/+merge/31971). I'm asking for a review of the changes benji and I made in response to the review. A diff of just these changes is here: http://paste.ubuntu.com/475892/

Summary of changes:

1. Rather than passing total_size_only as an argument into batch(), create a new method get_total_size and call it when the total size is desired, instead of calling batch().

2. When generating the WADL, use a new TALES method to determine whether or not total_size_link is active, rather than putting that boolean in the ZPT namespace. I had to rename namespace['context'] to namespace['service'] to get this to work, because there's code in the ZPT template that assigns local variables to 'context', clobbering namespace['context'].

3. Removed a no-longer-necessary named operation and field from webservice-declarations.txt. (This operation was introduced earlier in the branch, at a time when you had to explicitly tag a named operation to make use of total_size_only. Now, all named operations implicitly have total_size_only.)

To post a comment you must log in.
Revision history for this message
Graham Binns (gmb) wrote :

The changes in http://paste.ubuntu.com/475892/ look good; r=me.

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'setup.py'
2--- setup.py 2010-03-04 14:27:58 +0000
3+++ setup.py 2010-08-10 12:24:46 +0000
4@@ -57,7 +57,7 @@
5 'docutils',
6 'epydoc', # used by wadl generation
7 'grokcore.component==1.6',
8- 'lazr.batchnavigator',
9+ 'lazr.batchnavigator>=1.2.0-dev',
10 'lazr.delegates',
11 'lazr.enum',
12 'lazr.lifecycle',
13
14=== modified file 'src/lazr/restful/NEWS.txt'
15--- src/lazr/restful/NEWS.txt 2010-08-05 14:01:44 +0000
16+++ src/lazr/restful/NEWS.txt 2010-08-10 12:24:46 +0000
17@@ -2,6 +2,15 @@
18 NEWS for lazr.restful
19 =====================
20
21+0.11.0 (unreleased)
22+===================
23+
24+Added an optimization to total_size so that it is fetched via a link when
25+possible. The new configuration option first_version_with_total_size_link
26+specifies what version should be the first to expose the behavior. The default
27+is for it to be enabled for all versions so set this option to preserve the
28+earlier behavior for previously released web services.
29+
30 0.10.0 (2010-08-05)
31 ===================
32
33
34=== modified file 'src/lazr/restful/_operation.py'
35--- src/lazr/restful/_operation.py 2010-04-20 18:26:49 +0000
36+++ src/lazr/restful/_operation.py 2010-08-10 12:24:46 +0000
37@@ -44,6 +44,23 @@
38 def __init__(self, context, request):
39 self.context = context
40 self.request = request
41+ self.total_size_only = False
42+
43+ def total_size_link(self, navigator):
44+ """Return a link to the total size of a collection."""
45+ if getattr(self, 'include_total_size', True):
46+ # This is a named operation that includes the total size
47+ # inline rather than with a link.
48+ return None
49+ if not IResourceGETOperation.providedBy(self):
50+ # Only GET operations can have their total size split out into
51+ # a link, because only GET operations are safe.
52+ return None
53+ base = str(self.request.URL)
54+ query = navigator.getCleanQueryString()
55+ if query != '':
56+ query += '&'
57+ return base + '?' + query + "ws.show=total_size"
58
59 def __call__(self):
60 values, errors = self.validate()
61@@ -80,13 +97,22 @@
62 # this object served to the client.
63 return result
64
65+ # The similar patterns in the two branches below suggest some deeper
66+ # symmetry that should be extracted.
67 if queryMultiAdapter((result, self.request), ICollection):
68 # If the result is a web service collection, serve only one
69 # batch of the collection.
70 collection = getMultiAdapter((result, self.request), ICollection)
71- result = CollectionResource(collection, self.request).batch() + '}'
72+ resource = CollectionResource(collection, self.request)
73+ if self.total_size_only:
74+ result = resource.get_total_size(collection)
75+ else:
76+ result = resource.batch() + '}'
77 elif self.should_batch(result):
78- result = self.batch(result, self.request) + '}'
79+ if self.total_size_only:
80+ result = self.get_total_size(result)
81+ else:
82+ result = self.batch(result, self.request) + '}'
83 else:
84 # Serialize the result to JSON. Any embedded entries will be
85 # automatically serialized.
86
87=== modified file 'src/lazr/restful/_resource.py'
88--- src/lazr/restful/_resource.py 2010-08-04 18:25:21 +0000
89+++ src/lazr/restful/_resource.py 2010-08-10 12:24:46 +0000
90@@ -110,6 +110,7 @@
91 status_reasons[code] = reason
92 init_status_codes()
93
94+
95 def decode_value(value):
96 """Return a unicode value curresponding to `value`."""
97 if isinstance(value, unicode):
98@@ -589,27 +590,45 @@
99
100 """A mixin for resources that need to batch lists of entries."""
101
102- def __init__(self, context, request):
103- """A basic constructor."""
104- # Like all mixin classes, this class is designed to be used
105- # with multiple inheritance. That requires defining __init__
106- # to call the next constructor in the chain, which means using
107- # super() even though this class itself has no superclass.
108- super(BatchingResourceMixin, self).__init__(context, request)
109+ # TODO: determine real need for __init__ and super() call
110+
111+ def total_size_link(self, navigator):
112+ """Return the URL to fetch to find out the collection's total size.
113+
114+ If this is None, the total size will be included inline.
115+
116+ :param navigator: A BatchNavigator object for the current batch.
117+ """
118+ return None
119+
120+ def get_total_size(self, entries):
121+ """Get the number of items in entries.
122+
123+ :return: a JSON string representing the number of objects in the list
124+ """
125+ if not hasattr(entries, '__len__'):
126+ entries = IFiniteSequence(entries)
127+
128+ return simplejson.dumps(len(entries))
129+
130
131 def batch(self, entries, request):
132 """Prepare a batch from a (possibly huge) list of entries.
133
134- :return: A JSON string representing a hash:
135+ :return: a JSON string representing a hash:
136+
137 'entries' contains a list of EntryResource objects for the
138 entries that actually made it into this batch
139 'total_size' contains the total size of the list.
140+ 'total_size_link' contains a link to the total size of the list.
141 'next_url', if present, contains a URL to get the next batch
142 in the list.
143 'prev_url', if present, contains a URL to get the previous batch
144 in the list.
145 'start' contains the starting index of this batch
146
147+ Only one of 'total_size' or 'total_size_link' will be present.
148+
149 Note that the JSON string will be missing its final curly
150 brace. This is in case the caller wants to add some additional
151 keys to the JSON hash. It's the caller's responsibility to add
152@@ -620,11 +639,12 @@
153 navigator = WebServiceBatchNavigator(entries, request)
154
155 view_permission = getUtility(IWebServiceConfiguration).view_permission
156- resources = [EntryResource(entry, request)
157- for entry in navigator.batch
158- if checkPermission(view_permission, entry)]
159- batch = { 'total_size' : navigator.batch.listlength,
160- 'start' : navigator.batch.start }
161+ batch = { 'start' : navigator.batch.start }
162+ total_size_link = self.total_size_link(navigator)
163+ if total_size_link is None:
164+ batch['total_size'] = navigator.batch.listlength
165+ else:
166+ batch['total_size_link'] = total_size_link
167 if navigator.batch.start < 0:
168 batch['start'] = None
169 next_url = navigator.nextBatchURL()
170@@ -637,6 +657,9 @@
171
172 # String together a bunch of entry representations, possibly
173 # obtained from a representation cache.
174+ resources = [EntryResource(entry, request)
175+ for entry in navigator.batch
176+ if checkPermission(view_permission, entry)]
177 entry_strings = [
178 resource._representation(HTTPResource.JSON_TYPE)
179 for resource in resources]
180@@ -674,6 +697,10 @@
181 except ComponentLookupError:
182 self.request.response.setStatus(400)
183 return "No such operation: " + operation_name
184+
185+ show = self.request.form.get('ws.show')
186+ if show == 'total_size':
187+ operation.total_size_only = True
188 return operation()
189
190 def handleCustomPOST(self, operation_name):
191@@ -1664,14 +1691,17 @@
192 self.request.response.setHeader('Content-type', self.JSON_TYPE)
193 return result
194
195- def batch(self, entries=None):
196+ def batch(self, entries=None, request=None):
197 """Return a JSON representation of a batch of entries.
198
199 :param entries: (Optional) A precomputed list of entries to batch.
200+ :param request: (Optional) The current request.
201 """
202 if entries is None:
203 entries = self.collection.find()
204- result = super(CollectionResource, self).batch(entries, self.request)
205+ if request is None:
206+ request = self.request
207+ result = super(CollectionResource, self).batch(entries, request)
208 result += (
209 ', "resource_type_link" : ' + simplejson.dumps(self.type_url)
210 + '}')
211@@ -1862,7 +1892,7 @@
212 # by the entry classes.
213 collection_classes.append(registration.factory)
214 namespace = self.WADL_TEMPLATE.pt_getContext()
215- namespace['context'] = self
216+ namespace['service'] = self
217 namespace['request'] = self.request
218 namespace['entries'] = entry_classes
219 namespace['collections'] = collection_classes
220@@ -2061,13 +2091,13 @@
221 def singular_type(self):
222 """Return the singular name for this object type."""
223 interface = self.entry_interface
224- return interface.queryTaggedValue(LAZR_WEBSERVICE_NAME)['singular']
225+ return interface.getTaggedValue(LAZR_WEBSERVICE_NAME)['singular']
226
227 @property
228 def plural_type(self):
229 """Return the plural name for this object type."""
230 interface = self.entry_interface
231- return interface.queryTaggedValue(LAZR_WEBSERVICE_NAME)['plural']
232+ return interface.getTaggedValue(LAZR_WEBSERVICE_NAME)['plural']
233
234 @property
235 def type_link(self):
236
237=== modified file 'src/lazr/restful/declarations.py'
238--- src/lazr/restful/declarations.py 2010-08-04 18:25:21 +0000
239+++ src/lazr/restful/declarations.py 2010-08-10 12:24:46 +0000
240@@ -63,7 +63,7 @@
241 from lazr.restful.security import protect_schema
242 from lazr.restful.utils import (
243 camelcase_to_underscore_separated, get_current_web_service_request,
244- make_identifier_safe, VersionedDict)
245+ make_identifier_safe, VersionedDict, is_total_size_link_active)
246
247 LAZR_WEBSERVICE_EXPORTED = '%s.exported' % LAZR_WEBSERVICE_NS
248 LAZR_WEBSERVICE_MUTATORS = '%s.exported.mutators' % LAZR_WEBSERVICE_NS
249@@ -712,10 +712,9 @@
250 class operation_returns_collection_of(_method_annotator):
251 """Specify that the exported operation returns a collection.
252
253- The decorator takes a single argument: an interface that's been
254- exported as an entry.
255+ The decorator takes one required argument, "schema", an interface that's
256+ been exported as an entry.
257 """
258-
259 def __init__(self, schema):
260 _check_called_from_interface_def('%s()' % self.__class__.__name__)
261 if not IInterface.providedBy(schema):
262@@ -1209,23 +1208,22 @@
263 version = getUtility(IWebServiceConfiguration).active_versions[0]
264
265 bases = (BaseResourceOperationAdapter, )
266- if tag['type'] == 'read_operation':
267+ operation_type = tag['type']
268+ if operation_type == 'read_operation':
269 prefix = 'GET'
270 provides = IResourceGETOperation
271- elif tag['type'] in ('factory', 'write_operation'):
272+ elif operation_type in ('factory', 'write_operation'):
273 provides = IResourcePOSTOperation
274 prefix = 'POST'
275- if tag['type'] == 'factory':
276+ if operation_type == 'factory':
277 bases = (BaseFactoryResourceOperationAdapter,)
278- elif tag['type'] == 'destructor':
279+ elif operation_type == 'destructor':
280 provides = IResourceDELETEOperation
281 prefix = 'DELETE'
282 else:
283- raise AssertionError('Unknown method export type: %s' % tag['type'])
284+ raise AssertionError('Unknown method export type: %s' % operation_type)
285
286 return_type = tag['return_type']
287- if return_type is None:
288- return_type = None
289
290 name = _versioned_class_name(
291 '%s_%s_%s' % (prefix, method.interface.__name__, tag['as']),
292@@ -1238,7 +1236,16 @@
293 '_method_name': method.__name__,
294 '__doc__': method.__doc__}
295
296- if tag['type'] == 'write_operation':
297+ if isinstance(return_type, CollectionField):
298+ # If the version we're being asked for is equal to or later than the
299+ # version in which we started exposing total_size_link and this is a
300+ # read operation, then include it, otherwise include total_size.
301+ config = getUtility(IWebServiceConfiguration)
302+ class_dict['include_total_size'] = not (
303+ is_total_size_link_active(version, config) and
304+ operation_type == 'read_operation')
305+
306+ if operation_type == 'write_operation':
307 class_dict['send_modification_event'] = True
308 factory = type(name, bases, class_dict)
309 classImplements(factory, provides)
310
311=== modified file 'src/lazr/restful/docs/webservice-declarations.txt'
312--- src/lazr/restful/docs/webservice-declarations.txt 2010-08-02 20:10:33 +0000
313+++ src/lazr/restful/docs/webservice-declarations.txt 2010-08-10 12:24:46 +0000
314@@ -32,7 +32,7 @@
315 field, but not the inventory_number field.
316
317 >>> from zope.interface import Interface
318- >>> from zope.schema import TextLine, Float, List
319+ >>> from zope.schema import Text, TextLine, Float, List
320 >>> from lazr.restful.declarations import (
321 ... export_as_webservice_entry, exported)
322 >>> class IBook(Interface):
323@@ -213,7 +213,7 @@
324 TypeError: export_as_webservice_collection() is missing a method
325 tagged with @collection_default_content.
326
327-As it is an error, to mark more than one methods:
328+As it is an error, to mark more than one method:
329
330 >>> class TwoDefaultContent(Interface):
331 ... export_as_webservice_collection(IDummyInterface)
332@@ -330,8 +330,8 @@
333 ... text=copy_field(IBook['title'], title=u'Text to search for.'))
334 ... @operation_returns_collection_of(IBook)
335 ... @export_read_operation()
336- ... def searchBooks(text):
337- ... """Return list of books containing 'text'."""
338+ ... def searchBookTitles(text):
339+ ... """Return list of books whose titles contain 'text'."""
340 ...
341 ... @operation_parameters(
342 ... text=copy_field(IBook['title'], title=u'Text to search for.'))
343@@ -393,11 +393,11 @@
344 return_type: <lazr.restful._operation.ObjectLink object...>
345 type: 'factory'
346
347-We did specify the return type for the 'searchBooks' method: it
348+We did specify the return type for the 'searchBookTitles' method: it
349 returns a collection.
350
351- >>> print_export_tag(IBookSetOnSteroids['searchBooks'])
352- as: 'searchBooks'
353+ >>> print_export_tag(IBookSetOnSteroids['searchBookTitles'])
354+ as: 'searchBookTitles'
355 call_with: {}
356 params: {'text': <...TextLine...>}
357 return_type: <lazr.restful.fields.CollectionField object...>
358@@ -920,7 +920,8 @@
359 >>> class Book(object):
360 ... """Simple IBook implementation."""
361 ... implements(IBook)
362- ... def __init__(self, author, title, base_price, inventory_number):
363+ ... def __init__(self, author, title, base_price,
364+ ... inventory_number):
365 ... self.author = author
366 ... self.title = title
367 ... self.base_price = base_price
368@@ -937,6 +938,7 @@
369 >>> class MyWebServiceConfiguration(TestWebServiceConfiguration):
370 ... active_versions = ["beta", "1.0", "2.0", "3.0"]
371 ... last_version_with_mutator_named_operations = "1.0"
372+ ... first_version_with_total_size_link = "2.0"
373 ... code_revision = "1.0b"
374 ... default_batch_size = 50
375 >>> provideUtility(MyWebServiceConfiguration(), IWebServiceConfiguration)
376@@ -1087,7 +1089,7 @@
377 ... generate_operation_adapter)
378
379 >>> read_method_adapter_factory = generate_operation_adapter(
380- ... IBookSetOnSteroids['searchBooks'])
381+ ... IBookSetOnSteroids['searchBookTitles'])
382 >>> IResourceGETOperation.implementedBy(read_method_adapter_factory)
383 True
384
385@@ -1099,14 +1101,14 @@
386
387 >>> from lazr.restful import ResourceOperation
388 >>> read_method_adapter_factory.__name__
389- 'GET_IBookSetOnSteroids_searchBooks_beta'
390+ 'GET_IBookSetOnSteroids_searchBookTitles_beta'
391 >>> issubclass(read_method_adapter_factory, ResourceOperation)
392 True
393
394 The adapter's docstring is taken from the decorated method docstring.
395
396 >>> read_method_adapter_factory.__doc__
397- "Return list of books containing 'text'."
398+ "Return list of books whose titles contain 'text'."
399
400 The adapter's params attribute contains the specification of the
401 parameters accepted by the operation.
402@@ -1126,7 +1128,7 @@
403 ...
404 ... result = None
405 ...
406- ... def searchBooks(self, text):
407+ ... def searchBookTitles(self, text):
408 ... return self.result
409 ...
410 ... def new(self, author, base_price, title):
411@@ -1148,7 +1150,7 @@
412 return value is a dictionary containing a batched list.
413
414 >>> print read_method_adapter.call(text='')
415- {"total_size": 0, "start": null, "entries": []}
416+ {"total_size": 0, "start": 0, "entries": []}
417
418 Methods exported as a write operations generates an adapter providing
419 IResourcePOSTOperation.
420@@ -1649,6 +1651,51 @@
421 AssertionError: 'IMultiVersionCollection' isn't tagged for export
422 to web service version 'NoSuchVersion'.
423
424+total_size_link
425+~~~~~~~~~~~~~~~
426+
427+Collections previously exposed their total size via a `total_size` attribute.
428+However, newer versions of lazr.restful expose a `total_size_link` intead. To
429+facilitate transitioning from one approach to the other the configuration
430+option `first_version_with_total_size_link` has been added to
431+IWebServiceConfiguration.
432+
433+By default the first_version_with_total_size_link is set to the earliest
434+available web service version, but if you have stable versions of your web
435+service you wish to maintain compatability with you can specify the version in
436+which you want the new behavior to take effect in the web service
437+configuration.
438+
439+ >>> from zope.component import getUtility
440+ >>> config = getUtility(IWebServiceConfiguration)
441+ >>> config.last_version_with_mutator_named_operations = '1.0'
442+
443+ >>> from lazr.restful.declarations import (
444+ ... export_read_operation, operation_returns_collection_of,
445+ ... operation_for_version)
446+ >>> class IWithMultiVersionCollection(Interface):
447+ ... export_as_webservice_entry()
448+ ...
449+ ... @operation_for_version('2.0')
450+ ... @operation_for_version('1.0')
451+ ... @operation_returns_collection_of(Interface)
452+ ... @export_read_operation()
453+ ... def method():
454+ ... """A method that returns a collection."""
455+
456+ >>> method = IWithMultiVersionCollection['method']
457+ >>> dummy_data = None # this would be an intance that has the method
458+ >>> v10 = generate_operation_adapter(method, '1.0')(dummy_data, request)
459+ >>> v20 = generate_operation_adapter(method, '2.0')(dummy_data, request)
460+
461+We can see that version 1.0 includes the total size for backward compatability
462+while version 2.0 includes a link to fetch the total size.
463+
464+ >>> v10.include_total_size
465+ True
466+ >>> v20.include_total_size
467+ False
468+
469 Entries
470 -------
471
472@@ -2541,8 +2588,8 @@
473 >>> request_interface = IWebServiceClientRequest
474 >>> adapter_registry.lookup(
475 ... (IBookSetOnSteroids, request_interface),
476- ... IResourceGETOperation, 'searchBooks')
477- <class '...GET_IBookSetOnSteroids_searchBooks_beta'>
478+ ... IResourceGETOperation, 'searchBookTitles')
479+ <class '...GET_IBookSetOnSteroids_searchBookTitles_beta'>
480 >>> adapter_registry.lookup(
481 ... (IBookSetOnSteroids, request_interface),
482 ... IResourcePOSTOperation, 'create_book')
483
484=== modified file 'src/lazr/restful/docs/webservice.txt'
485--- src/lazr/restful/docs/webservice.txt 2010-08-04 18:25:21 +0000
486+++ src/lazr/restful/docs/webservice.txt 2010-08-10 12:24:46 +0000
487@@ -503,6 +503,7 @@
488 ... code_revision = 'test'
489 ... max_batch_size = 100
490 ... directives.publication_class(WebServiceTestPublication)
491+ ... first_version_with_total_size_link = 'devel'
492
493 >>> from grokcore.component.testing import grok_component
494 >>> ignore = grok_component(
495
496=== modified file 'src/lazr/restful/example/base/root.py'
497--- src/lazr/restful/example/base/root.py 2010-06-14 14:09:31 +0000
498+++ src/lazr/restful/example/base/root.py 2010-08-10 12:24:46 +0000
499@@ -398,6 +398,7 @@
500 <p>Don't use this unless you like changing things.</p>"""
501 }
502 last_version_with_mutator_named_operations = None
503+ first_version_with_total_size_link = None
504 use_https = False
505 view_permission = 'lazr.restful.example.base.View'
506
507
508=== modified file 'src/lazr/restful/example/base/tests/collection.txt'
509--- src/lazr/restful/example/base/tests/collection.txt 2009-04-24 15:14:07 +0000
510+++ src/lazr/restful/example/base/tests/collection.txt 2010-08-10 12:24:46 +0000
511@@ -173,14 +173,12 @@
512 ... "/cookbooks?ws.op=find_recipes&%s" % args).jsonBody()
513
514 >>> s_recipes = search_recipes("chicken")
515- >>> s_recipes['total_size']
516- 3
517 >>> sorted(r['instructions'] for r in s_recipes['entries'])
518 [u'Draw, singe, stuff, and truss...', u'You can always judge...']
519
520 >>> veg_recipes = search_recipes("chicken", True)
521- >>> veg_recipes['total_size']
522- 0
523+ >>> veg_recipes['entries']
524+ []
525
526 A custom operation that returns a list of objects is paginated, just
527 like a collection.
528@@ -196,11 +194,21 @@
529 empty list of results:
530
531 >>> empty_collection = search_recipes("nosuchrecipe")
532- >>> empty_collection['total_size']
533- 0
534 >>> [r['instructions'] for r in empty_collection['entries']]
535 []
536
537+When an operation yields a collection of objects, the representation
538+includes a link that yields the total size of the collection.
539+
540+ >>> print s_recipes['total_size_link']
541+ http://.../cookbooks?search=chicken&vegetarian=false&ws.op=find_recipes&ws.show=total_size
542+
543+Sending a GET request to that link yields a JSON representation of the
544+total size.
545+
546+ >>> print webservice.get(s_recipes['total_size_link']).jsonBody()
547+ 3
548+
549 Custom operations may have error handling. In this case, the error
550 handling is in the validate() method of the 'search' field.
551
552@@ -216,8 +224,9 @@
553
554 >>> general_cookbooks = webservice.get(
555 ... "/cookbooks?ws.op=find_for_cuisine&cuisine=General")
556- >>> general_cookbooks.jsonBody()['total_size']
557- 3
558+ >>> print general_cookbooks.jsonBody()['total_size_link']
559+ http://cookbooks.dev/devel/cookbooks?cuisine=General&ws.op=find_for_cuisine&ws.show=total_size
560+
561
562 POST operations
563 ===============
564
565=== modified file 'src/lazr/restful/example/base/tests/wadl.txt'
566--- src/lazr/restful/example/base/tests/wadl.txt 2010-03-10 18:45:04 +0000
567+++ src/lazr/restful/example/base/tests/wadl.txt 2010-08-10 12:24:46 +0000
568@@ -537,8 +537,9 @@
569 'entry_resource_descriptions'.
570
571 >>> entry_resource_descriptions = []
572- >>> entry_resource_types = other_children[first_entry_type_index:-2]
573- >>> hosted_binary_resource_type, simple_binary_type = other_children[-2:]
574+ >>> entry_resource_types = other_children[first_entry_type_index:-3]
575+ >>> (hosted_binary_resource_type, scalar_type, simple_binary_type
576+ ... ) = other_children[-3:]
577 >>> for index in range(0, len(entry_resource_types), 5):
578 ... entry_resource_descriptions.append(
579 ... (tuple(entry_resource_types[index:index + 5])))
580@@ -1155,9 +1156,9 @@
581 All collection representations have the same five <param> tags.
582
583 >>> [param.attrib['name'] for param in collection_rep]
584- ['resource_type_link', 'total_size', 'start', 'next_collection_link',
585- 'prev_collection_link', 'entries', 'entry_links']
586- >>> (type_link, size, start, next, prev, entries,
587+ ['resource_type_link', 'total_size', 'total_size_link', 'start',
588+ 'next_collection_link', 'prev_collection_link', 'entries', 'entry_links']
589+ >>> (type_link, size, size_link, start, next, prev, entries,
590 ... entry_links) = collection_rep
591
592 So what's the difference between a collection of people and a
593
594=== modified file 'src/lazr/restful/example/multiversion/tests/wadl.txt'
595--- src/lazr/restful/example/multiversion/tests/wadl.txt 2010-03-10 20:44:03 +0000
596+++ src/lazr/restful/example/multiversion/tests/wadl.txt 2010-08-10 12:24:46 +0000
597@@ -92,3 +92,32 @@
598 >>> pair_collection = contents['pair_collection']
599 >>> sorted([method.attrib['id'] for method in pair_collection])
600 ['key_value_pairs-get']
601+
602+total_size_link
603+===============
604+
605+The version in which total_size_link is introduced is controlled by the
606+first_version_with_total_size_link attribute of the web service configuration
607+(IWebServiceConfiguration) utility.
608+
609+We'll configure the web service to begin including `total_size_link` values
610+in version 3.0:
611+
612+ >>> from zope.component import getUtility
613+ >>> from lazr.restful.interfaces import IWebServiceConfiguration
614+ >>> config = getUtility(IWebServiceConfiguration)
615+ >>> config.first_version_with_total_size_link = '3.0'
616+
617+Now if we request the WADL for 3.0 it will include a description of
618+total_size_link.
619+
620+ >>> webservice.get('/', media_type='application/vnd.sun.wadl+xml',
621+ ... api_version='3.0').body
622+ '...<wadl:param style="plain" name="total_size_link"...'
623+
624+If we request an earlier version, total_size_link is not described.
625+
626+ >>> wadl = webservice.get('/', media_type='application/vnd.sun.wadl+xml',
627+ ... api_version='2.0').body
628+ >>> 'total_size_link' in wadl
629+ False
630
631=== modified file 'src/lazr/restful/interfaces/_rest.py'
632--- src/lazr/restful/interfaces/_rest.py 2010-06-03 14:42:44 +0000
633+++ src/lazr/restful/interfaces/_rest.py 2010-08-10 12:24:46 +0000
634@@ -507,6 +507,13 @@
635 all subsequent versions, they will not be published as named
636 operations.""")
637
638+ first_version_with_total_size_link = TextLine(
639+ default=None,
640+ description=u"""In earlier versions of lazr.restful collections
641+ included a total_size field, now they include a total_size_link
642+ instead. Setting this value determines in which version the new
643+ behavior takes effect.""")
644+
645 code_revision = TextLine(
646 default=u"",
647 description=u"""A string designating the current revision
648
649=== modified file 'src/lazr/restful/simple.py'
650--- src/lazr/restful/simple.py 2010-06-14 18:47:39 +0000
651+++ src/lazr/restful/simple.py 2010-08-10 12:24:46 +0000
652@@ -477,5 +477,6 @@
653
654
655 BaseWebServiceConfiguration = implement_from_dict(
656- "BaseWebServiceConfiguration", IWebServiceConfiguration, {}, object)
657+ "BaseWebServiceConfiguration", IWebServiceConfiguration,
658+ {'first_version_with_total_size_link': None}, object)
659
660
661=== modified file 'src/lazr/restful/tales.py'
662--- src/lazr/restful/tales.py 2010-03-10 20:44:03 +0000
663+++ src/lazr/restful/tales.py 2010-08-10 12:24:46 +0000
664@@ -34,7 +34,8 @@
665 IResourceOperation, IResourcePOSTOperation, IScopedCollection,
666 ITopLevelEntryLink, IWebServiceClientRequest, IWebServiceConfiguration,
667 IWebServiceVersion, LAZR_WEBSERVICE_NAME)
668-from lazr.restful.utils import get_current_web_service_request
669+from lazr.restful.utils import (get_current_web_service_request,
670+ is_total_size_link_active)
671
672
673 class WadlDocstringLinker(DocstringLinker):
674@@ -234,6 +235,11 @@
675 return config.version_descriptions.get(self.service_version, None)
676
677 @property
678+ def is_total_size_link_active(self):
679+ config = getUtility(IWebServiceConfiguration)
680+ return is_total_size_link_active(self.resource.request.version, config)
681+
682+ @property
683 def top_level_resources(self):
684 """Return a list of dicts describing the top-level resources."""
685 resource_dicts = []
686
687=== modified file 'src/lazr/restful/templates/wadl-root.pt'
688--- src/lazr/restful/templates/wadl-root.pt 2010-03-10 18:50:27 +0000
689+++ src/lazr/restful/templates/wadl-root.pt 2010-08-10 12:24:46 +0000
690@@ -13,21 +13,21 @@
691
692 <wadl:doc xmlns="http://www.w3.org/1999/xhtml"
693 title="About this service"
694- tal:content="structure context/wadl:description">
695+ tal:content="structure service/wadl:description">
696 Version-independent description of the web service.
697 </wadl:doc>
698
699 <wadl:doc xmlns="http://www.w3.org/1999/xhtml"
700- tal:define="version context/wadl:service_version"
701+ tal:define="version service/wadl:service_version"
702 tal:attributes="title string:About version ${version}"
703- tal:content="structure context/wadl:version_description">
704+ tal:content="structure service/wadl:version_description">
705 Description of this version of the web service.
706 </wadl:doc>
707
708 <!--There is one "service root" resource, located (as you'd expect)
709 at the service root. This very document is the WADL
710 representation of the "service root" resource.-->
711- <resources tal:attributes="base context/wadl:url">
712+ <resources tal:attributes="base service/wadl:url">
713 <resource path="" type="#service-root" />
714 </resources>
715
716@@ -46,7 +46,7 @@
717 <!--The JSON representation of a "service root" resource contains a
718 number of links to collection-type resources.-->
719 <representation mediaType="application/json" id="service-root-json">
720- <tal:root_params tal:repeat="param context/wadl:top_level_resources">
721+ <tal:root_params tal:repeat="param service/wadl:top_level_resources">
722 <param style="plain" tal:attributes="name param/name;
723 path param/path">
724 <link tal:attributes="resource_type param/resource/wadl:type_link" />
725@@ -284,15 +284,29 @@
726
727 <representation mediaType="application/json"
728 tal:attributes="id
729- string:${context/wadl_entry:entry_page_representation_id}">
730+ string:${context/wadl_entry:entry_page_representation_id}"
731+ tal:define="is_total_size_link_active
732+ service/wadl:is_total_size_link_active">
733
734 <param style="plain" name="resource_type_link"
735 path="$['resource_type_link']">
736 <link />
737 </param>
738
739+ <tal:comment condition="nothing">
740+ If we are not using total_size_link, continue to signal that
741+ total_size is required as it has been in the past.
742+ </tal:comment>
743+
744 <param style="plain" name="total_size" path="$['total_size']"
745- required="true" />
746+ tal:attributes="
747+ required python:is_total_size_link_active and 'false' or 'true'"/>
748+
749+ <param tal:condition="is_total_size_link_active"
750+ style="plain" name="total_size_link" path="$['total_size_link']"
751+ required="false">
752+ <link resource_type="#ScalarValue" />
753+ </param>
754
755 <param style="plain" name="start" path="$['start']" required="true" />
756
757@@ -321,7 +335,7 @@
758 <!--End representation and resource_type definitions for entry
759 resources. -->
760
761- <!--Finally, describe the 'hosted binary file' type.-->
762+ <!--Finally, describe the 'hosted binary file' type...-->
763 <resource_type id="HostedFile">
764 <method name="GET" id="HostedFile-get">
765 <response>
766@@ -334,6 +348,15 @@
767 <method name="DELETE" id="HostedFile-delete" />
768 </resource_type>
769
770+ <!--...and the simple 'scalar value' type.-->
771+ <resource_type id="ScalarValue">
772+ <method name="GET" id="ScalarValue-get">
773+ <response>
774+ <representation mediaType="application/json" />
775+ </response>
776+ </method>
777+ </resource_type>
778+
779 <!--Define a data type for binary data.-->
780 <xsd:simpleType name="binary">
781 <xsd:list itemType="byte" />
782
783=== modified file 'src/lazr/restful/testing/webservice.py'
784--- src/lazr/restful/testing/webservice.py 2010-03-03 16:40:14 +0000
785+++ src/lazr/restful/testing/webservice.py 2010-08-10 12:24:46 +0000
786@@ -135,6 +135,7 @@
787 self.traversed_objects = []
788 self.query_string_params = {}
789 self.method = 'GET'
790+ self.URL = 'http://api.example.org/'
791
792
793 def getTraversalStack(self):
794@@ -173,6 +174,8 @@
795 def pprint_collection(json_body):
796 """Pretty-print a webservice collection JSON representation."""
797 for key, value in sorted(json_body.items()):
798+ if key == 'total_size_link':
799+ continue
800 if key != 'entries':
801 print '%s: %r' % (key, value)
802 print '---'
803
804=== modified file 'src/lazr/restful/tests/test_utils.py'
805--- src/lazr/restful/tests/test_utils.py 2010-03-03 13:08:12 +0000
806+++ src/lazr/restful/tests/test_utils.py 2010-08-10 12:24:46 +0000
807@@ -10,7 +10,8 @@
808 from zope.security.management import (
809 endInteraction, newInteraction, queryInteraction)
810
811-from lazr.restful.utils import get_current_browser_request
812+from lazr.restful.utils import (get_current_browser_request,
813+ is_total_size_link_active)
814
815
816 class TestUtils(unittest.TestCase):
817@@ -28,6 +29,27 @@
818 self.assertEquals(request, get_current_browser_request())
819 endInteraction()
820
821+ def test_is_total_size_link_active(self):
822+ # Parts of the code want to know if the sizes of collections should be
823+ # reported in an attribute or via a link back to the service. The
824+ # is_total_size_link_active function takes the version of the API in
825+ # question and a web service configuration object and returns a
826+ # boolean that is true if a link should be used, false otherwise.
827+
828+ # Here's the fake web service config we'll be using.
829+ class FakeConfig:
830+ active_versions = ['1.0', '2.0', '3.0']
831+ first_version_with_total_size_link = '2.0'
832+
833+ # First, if the version is lower than the threshold for using links,
834+ # the result is false (i.e., links should not be used).
835+ self.assertEqual(is_total_size_link_active('1.0', FakeConfig), False)
836+
837+ # However, if the requested version is equal to, or higher than the
838+ # threshold, the result is true (i.e., links should be used).
839+ self.assertEqual(is_total_size_link_active('2.0', FakeConfig), True)
840+ self.assertEqual(is_total_size_link_active('3.0', FakeConfig), True)
841+
842 # For the sake of convenience, test_get_current_web_service_request()
843 # and tag_request_with_version_name() are tested in test_webservice.py.
844
845
846=== modified file 'src/lazr/restful/tests/test_webservice.py'
847--- src/lazr/restful/tests/test_webservice.py 2010-03-03 16:33:21 +0000
848+++ src/lazr/restful/tests/test_webservice.py 2010-08-10 12:24:46 +0000
849@@ -9,20 +9,22 @@
850 import unittest
851
852 from zope.component import getGlobalSiteManager, getUtility
853-from zope.interface import implements, Interface
854+from zope.interface import implements, Interface, directlyProvides
855 from zope.publisher.browser import TestRequest
856 from zope.schema import Date, Datetime, TextLine
857 from zope.security.management import (
858 endInteraction, newInteraction, queryInteraction)
859 from zope.traversing.browser.interfaces import IAbsoluteURL
860
861+from lazr.restful import ResourceOperation
862 from lazr.restful.fields import Reference
863 from lazr.restful.interfaces import (
864 ICollection, IEntry, IEntryResource, IResourceGETOperation,
865 IServiceRootResource, IWebServiceConfiguration,
866 IWebServiceClientRequest, IWebServiceVersion)
867 from lazr.restful import EntryResource, ResourceGETOperation
868-from lazr.restful.declarations import exported, export_as_webservice_entry
869+from lazr.restful.declarations import (exported, export_as_webservice_entry,
870+ LAZR_WEBSERVICE_NAME)
871 from lazr.restful.testing.webservice import (
872 create_web_service_request, IGenericCollection, IGenericEntry,
873 WebServiceTestCase, WebServiceTestPublication)
874@@ -30,6 +32,7 @@
875 from lazr.restful.utils import (
876 get_current_browser_request, get_current_web_service_request,
877 tag_request_with_version_name)
878+from lazr.restful._resource import CollectionResource, BatchingResourceMixin
879
880
881 def get_resource_factory(model_interface, resource_interface):
882@@ -350,5 +353,78 @@
883 self.assertEquals("2.0", webservice_request.version)
884 self.assertTrue(marker_20.providedBy(webservice_request))
885
886+
887 def additional_tests():
888 return unittest.TestLoader().loadTestsFromName(__name__)
889+
890+
891+class ITestEntry(IEntry):
892+ """Interface for a test entry."""
893+ export_as_webservice_entry()
894+
895+
896+class TestEntry:
897+ implements(ITestEntry)
898+ def __init__(self, context, request):
899+ pass
900+
901+
902+class BaseBatchingTest:
903+ """A base class which tests BatchingResourceMixin and subclasses."""
904+
905+ testmodule_objects = [HasRestrictedField, IHasRestrictedField]
906+
907+ def setUp(self):
908+ super(BaseBatchingTest, self).setUp()
909+ # Register TestEntry as the IEntry implementation for ITestEntry.
910+ getGlobalSiteManager().registerAdapter(
911+ TestEntry, [ITestEntry, IWebServiceClientRequest], provided=IEntry)
912+ # Is doing this by hand the right way?
913+ ITestEntry.setTaggedValue(
914+ LAZR_WEBSERVICE_NAME,
915+ dict(singular='test_entity', plural='test_entities'))
916+
917+
918+ def make_instance(self, entries, request):
919+ raise NotImplementedError('You have to make your own instances.')
920+
921+ def test_getting_a_batch(self):
922+ entries = [1, 2, 3]
923+ request = create_web_service_request('/devel')
924+ instance = self.make_instance(entries, request)
925+ total_size = instance.get_total_size(entries)
926+ self.assertEquals(total_size, '3')
927+
928+
929+class TestBatchingResourceMixin(BaseBatchingTest, WebServiceTestCase):
930+ """Test that BatchingResourceMixin does batching correctly."""
931+
932+ def make_instance(self, entries, request):
933+ return BatchingResourceMixin()
934+
935+
936+class TestCollectionResourceBatching(BaseBatchingTest, WebServiceTestCase):
937+ """Test that CollectionResource does batching correctly."""
938+
939+ def make_instance(self, entries, request):
940+ class Collection:
941+ implements(ICollection)
942+ entry_schema = ITestEntry
943+
944+ def __init__(self, entries):
945+ self.entries = entries
946+
947+ def find(self):
948+ return self.entries
949+
950+ return CollectionResource(Collection(entries), request)
951+
952+
953+class TestResourceOperationBatching(BaseBatchingTest, WebServiceTestCase):
954+ """Test that ResourceOperation does batching correctly."""
955+
956+ def make_instance(self, entries, request):
957+ # constructor parameters are ignored
958+ return ResourceOperation(None, request)
959+
960+
961
962=== modified file 'src/lazr/restful/utils.py'
963--- src/lazr/restful/utils.py 2010-03-10 20:44:03 +0000
964+++ src/lazr/restful/utils.py 2010-08-10 12:24:46 +0000
965@@ -37,6 +37,18 @@
966 missing = object()
967
968
969+def is_total_size_link_active(version, config):
970+ versions = config.active_versions
971+ total_size_link_version = config.first_version_with_total_size_link
972+ # The version "None" is a special marker for the earliest version.
973+ if total_size_link_version is None:
974+ total_size_link_version = versions[0]
975+ # If the version we're being asked about is equal to or later than the
976+ # version in which we started exposing total_size_link, then we should
977+ # return True, False otherwise.
978+ return versions.index(total_size_link_version) <= versions.index(version)
979+
980+
981 class VersionedDict(object):
982 """A stack of named dictionaries.
983
984
985=== modified file 'src/lazr/restful/version.txt'
986--- src/lazr/restful/version.txt 2010-08-05 14:01:44 +0000
987+++ src/lazr/restful/version.txt 2010-08-10 12:24:46 +0000
988@@ -1,1 +1,1 @@
989-0.10.0
990+0.11.0-dev
991
992=== modified file 'versions.cfg'
993--- versions.cfg 2009-04-15 14:43:40 +0000
994+++ versions.cfg 2010-08-10 12:24:46 +0000
995@@ -1,172 +1,69 @@
996+[buildout]
997+versions = versions
998+allow-picked-versions = false
999+use-dependency-links = false
1000+
1001 [versions]
1002-ClientForm = 0.2.9
1003-RestrictedPython = 3.4.2
1004-ZConfig = 2.5.1
1005-ZODB3 = 3.8.1
1006-docutils = 0.4
1007-jquery.javascript = 1.0.0
1008-jquery.layer = 1.0.0
1009-lxml = 1.3.6
1010-mechanize = 0.1.7b
1011-pytz = 2007k
1012-setuptools = 0.6c9
1013-z3c.coverage = 1.1.2
1014-z3c.csvvocabulary = 1.0.0
1015-z3c.etestbrowser = 1.0.4
1016-z3c.form = 1.9.0
1017-z3c.formdemo = 1.5.3
1018-z3c.formjs = 0.4.0
1019-z3c.formjsdemo = 0.3.1
1020-z3c.formui = 1.4.2
1021-z3c.i18n = 0.1.1
1022-z3c.layer = 0.2.3
1023-z3c.macro = 1.1.0
1024-z3c.macroviewlet = 1.0.0
1025-z3c.menu = 0.2.0
1026-z3c.optionstorage = 1.0.4
1027-z3c.pagelet = 1.0.2
1028-z3c.rml = 0.7.3
1029-z3c.skin.pagelet = 1.0.2
1030-z3c.template = 1.1.0
1031-z3c.testing = 0.2.0
1032-z3c.traverser = 0.2.3
1033-z3c.viewlet = 1.0.0
1034-z3c.viewtemplate = 0.3.2
1035-z3c.zrtresource = 1.0.1
1036-zc.buildout = 1.1.1
1037-zc.catalog = 1.2.0
1038-zc.datetimewidget = 0.5.2
1039-zc.i18n = 0.5.2
1040-zc.recipe.egg = 1.0.0
1041-zc.recipe.filestorage = 1.0.0
1042-zc.recipe.testrunner = 1.0.0
1043-zc.resourcelibrary = 1.0.1
1044-zc.table = 0.6
1045-zc.zope3recipes = 0.6.2
1046-zdaemon = 2.0.2
1047-zodbcode = 3.4.0
1048-zope.annotation = 3.4.1
1049-zope.app.annotation = 3.4.0
1050-zope.app.apidoc = 3.4.3
1051-zope.app.applicationcontrol = 3.4.3
1052-zope.app.appsetup = 3.4.1
1053-zope.app.authentication = 3.4.4
1054-zope.app.basicskin = 3.4.0
1055-zope.app.boston = 3.4.0
1056-zope.app.broken = 3.4.0
1057-zope.app.cache = 3.4.1
1058-zope.app.catalog = 3.5.1
1059-zope.app.component = 3.4.1
1060-zope.app.container = 3.5.6
1061-zope.app.content = 3.4.0
1062-zope.app.dav = 3.4.1
1063-zope.app.debug = 3.4.1
1064-zope.app.debugskin = 3.4.0
1065-zope.app.dependable = 3.4.0
1066-zope.app.dtmlpage = 3.4.1
1067-zope.app.error = 3.5.1
1068-zope.app.exception = 3.4.1
1069-zope.app.externaleditor = 3.4.0
1070-zope.app.file = 3.4.4
1071-zope.app.folder = 3.4.0
1072-zope.app.form = 3.4.1
1073-zope.app.ftp = 3.4.0
1074-zope.app.generations = 3.4.1
1075-zope.app.homefolder = 3.4.0
1076-zope.app.http = 3.4.1
1077-zope.app.i18n = 3.4.4
1078-zope.app.i18nfile = 3.4.1
1079-zope.app.interface = 3.4.0
1080-zope.app.interpreter = 3.4.0
1081-zope.app.intid = 3.4.1
1082-zope.app.keyreference = 3.4.1
1083-zope.app.layers = 3.4.0
1084-zope.app.locales = 3.4.5
1085-zope.app.locking = 3.4.0
1086-zope.app.module = 3.4.0
1087-zope.app.onlinehelp = 3.4.1
1088-zope.app.pagetemplate = 3.4.1
1089-zope.app.pluggableauth = 3.4.0
1090-zope.app.preference = 3.4.1
1091-zope.app.preview = 3.4.0
1092-zope.app.principalannotation = 3.4.0
1093-zope.app.publication = 3.4.3
1094-zope.app.publisher = 3.4.1
1095-zope.app.pythonpage = 3.4.1
1096-zope.app.renderer = 3.4.0
1097-zope.app.rotterdam = 3.4.1
1098-zope.app.schema = 3.4.0
1099-zope.app.security = 3.5.2
1100-zope.app.securitypolicy = 3.4.6
1101-zope.app.server = 3.4.2
1102-zope.app.session = 3.5.1
1103-zope.app.skins = 3.4.0
1104-zope.app.sqlscript = 3.4.1
1105-zope.app.testing = 3.4.3
1106-zope.app.traversing = 3.4.0
1107-zope.app.tree = 3.4.0
1108-zope.app.twisted = 3.4.1
1109-zope.app.undo = 3.4.0
1110-zope.app.wfmc = 0.1.2
1111-zope.app.workflow = 3.4.1
1112-zope.app.wsgi = 3.4.1
1113-zope.app.xmlrpcintrospection = 3.4.0
1114-zope.app.zapi = 3.4.0
1115-zope.app.zcmlfiles = 3.4.3
1116-zope.app.zopeappgenerations = 3.4.0
1117-zope.app.zptpage = 3.4.1
1118-zope.cachedescriptors = 3.4.1
1119-zope.component = 3.4.0
1120-zope.configuration = 3.4.0
1121-zope.contentprovider = 3.4.0
1122-zope.contenttype = 3.4.0
1123-zope.copypastemove = 3.4.0
1124+# Alphabetical, case-SENSITIVE, blank line after this comment
1125+
1126+Jinja2 = 2.5
1127+Pygments = 1.3.1
1128+RestrictedPython = 3.5.1
1129+Sphinx = 1.0.1
1130+ZConfig = 2.7.1
1131+ZODB3 = 3.9.2
1132+docutils = 0.5
1133+epydoc = 3.0.1
1134+grokcore.component = 1.6
1135+lazr.batchnavigator = 1.2.0
1136+lazr.delegates = 1.2.0
1137+lazr.enum = 1.1.2
1138+lazr.lifecycle = 1.0
1139+lazr.uri = 1.0.2
1140+lxml = 2.2.7
1141+martian = 0.11
1142+pytz = 2010h
1143+setuptools = 0.6c11
1144+simplejson = 2.0.9
1145+transaction = 1.0.0
1146+van.testing = 2.0.1
1147+wsgi-intercept = 0.4
1148+wsgiref = 0.1.2
1149+z3c.recipe.sphinxdoc = 0.0.8
1150+z3c.recipe.staticlxml = 0.7.1
1151+z3c.recipe.tag = 0.2.0
1152+zc.buildout = 1.4.3
1153+zc.lockfile = 1.0.0
1154+zc.recipe.cmmi = 1.3.1
1155+zc.recipe.egg = 1.2.3b2
1156+zc.recipe.testrunner = 1.3.0
1157+zdaemon = 2.0.4
1158+zope.annotation = 3.5.0
1159+zope.app.pagetemplate = 3.7.1
1160+zope.browser = 1.2
1161+zope.cachedescriptors = 3.5.0
1162+zope.component = 3.9.3
1163+zope.configuration = 3.6.0
1164+zope.contenttype = 3.5.0
1165+zope.copy = 3.5.0
1166 zope.datetime = 3.4.0
1167-zope.decorator = 3.4.0
1168-zope.deferredimport = 3.4.0
1169-zope.deprecation = 3.4.0
1170-zope.documenttemplate = 3.4.0
1171-zope.dottedname = 3.4.2
1172-zope.dublincore = 3.4.0
1173-zope.error = 3.5.1
1174-zope.event = 3.4.0
1175-zope.exceptions = 3.4.0
1176-zope.file = 0.3.0
1177-zope.filerepresentation = 3.4.0
1178-zope.formlib = 3.4.0
1179-zope.hookable = 3.4.0
1180-zope.html = 1.0.1
1181-zope.i18n = 3.4.0
1182-zope.i18nmessageid = 3.4.3
1183-zope.index = 3.4.1
1184-zope.interface = 3.4.1
1185-zope.lifecycleevent = 3.4.0
1186-zope.location = 3.4.0
1187-zope.mimetype = 0.3.0
1188-zope.minmax = 1.1.0
1189-zope.modulealias = 3.4.0
1190-zope.pagetemplate = 3.4.0
1191-zope.proxy = 3.4.2
1192-zope.publisher = 3.4.6
1193-zope.rdb = 3.4.0
1194-zope.schema = 3.4.0
1195-zope.security = 3.4.1
1196-zope.securitypolicy = 3.4.1
1197-zope.sendmail = 3.4.0
1198-zope.sequencesort = 3.4.0
1199-zope.server = 3.4.3
1200-zope.session = 3.4.1
1201-zope.size = 3.4.0
1202-zope.structuredtext = 3.4.0
1203-zope.tal = 3.4.1
1204+zope.dublincore = 3.5.0
1205+zope.event = 3.4.1
1206+zope.exceptions = 3.5.2
1207+zope.hookable = 3.4.1
1208+zope.i18n = 3.7.1
1209+zope.i18nmessageid = 3.5.0
1210+zope.interface = 3.5.2
1211+zope.lifecycleevent = 3.5.2
1212+zope.location = 3.7.0
1213+zope.pagetemplate = 3.5.0
1214+zope.proxy = 3.5.0
1215+zope.publisher = 3.12.0
1216+zope.schema = 3.5.4
1217+zope.security = 3.7.1
1218+zope.size = 3.4.1
1219+zope.tal = 3.5.1
1220 zope.tales = 3.4.0
1221-zope.testbrowser = 3.4.2
1222-zope.testing = 3.5.6
1223-zope.testrecorder = 0.3.0
1224-zope.thread = 3.4
1225-zope.traversing = 3.6.0
1226-zope.ucol = 1.0.2
1227-zope.viewlet = 3.4.2
1228-zope.wfmc = 3.4.0
1229-zope.xmlpickle = 3.4.0
1230-van.testing = 2.0.1
1231+zope.testing = 3.9.4
1232+zope.testrunner = 4.0.0b5
1233+zope.traversing = 3.8.0

Subscribers

People subscribed via source and target branches