Merge lp:~leonardr/lazr.restful/561521 into lp:lazr.restful

Proposed by Leonard Richardson
Status: Merged
Approved by: Edwin Grubbs
Approved revision: 130
Merged at revision: not available
Proposed branch: lp:~leonardr/lazr.restful/561521
Merge into: lp:lazr.restful
Diff against target: 436 lines (+123/-47)
5 files modified
src/lazr/restful/NEWS.txt (+8/-3)
src/lazr/restful/_resource.py (+46/-13)
src/lazr/restful/docs/webservice-declarations.txt (+6/-13)
src/lazr/restful/docs/webservice.txt (+62/-17)
src/lazr/restful/version.txt (+1/-1)
To merge this branch: bzr merge lp:~leonardr/lazr.restful/561521
Reviewer Review Type Date Requested Status
Edwin Grubbs (community) Approve
Review via email: mp+23404@code.launchpad.net

Description of the change

This branch fixes bug 561521, in which a Launchpad test was found to fail on some Lucid installs because modifications dictated by PATCH requests happened in a nondeterministic order. This branch enforces a 1) deterministic order that's 2) less likely to cause problems.

The new order is less likely to cause problems because all of an entry's fields that are handled by mutator methods are saved until last. For testability purposes, I defined a helper function get_entry_fields_in_write_order(). (It's not in __all__, but it's only used in _resource.py and in the test, so I don't think it needs to be.) To make the order deterministic, I changed validated_changeset from a dict to a list of tuples.

I also did some drive-by cleanup of test headings.

I have one slight misgiving about the get_entry_fields_in_write_order(). To determine whether an entry's field is handled by a mutator method it checks for a subclass of lazr.delegates "Passthrough" class. Checking isinstance(Passthrough) doesn't work because every field of a generated adapter is a Passthrough.

You could argue that I'm really looking for an instance of PropertyWithMutator, the Passthrough subclass defined in declarations.py. This is less general, such that if someone made their own PWM-like Passthrough subclass, get_entry_fields_in_write_order() wouldn't pick it up. But people should really be using the autogenerated entry adapters, which always use PWM. I could go either way on this. I could also go either way on whether the helper method should be in _resource.py or declarations.py. (If it's in declarations.py, I'll have to do some fancy importing in _resource.py to avoid an import loop.)

To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote :

Oh, I should mention that in addition to testing the unit tests, I tested this branch in conjunction with Launchpad to make sure it made the test failure go away. (The test that failed was webservice/xx-distribution.txt)

Revision history for this message
Edwin Grubbs (edwin-grubbs) wrote :

Hi Leonard,

This branch looks good. I just have one comment below.

merge-conditional

-Edwin

>=== modified file 'src/lazr/restful/_resource.py'
>--- src/lazr/restful/_resource.py 2010-04-13 15:35:24 +0000
>+++ src/lazr/restful/_resource.py 2010-04-14 17:30:37 +0000
>@@ -1988,3 +1988,36 @@
> """The URL to the description of the object's full representation."""
> return "%s#%s-full" % (
> self._service_root_url(), self.singular_type)
>+
>+
>+def get_entry_fields_in_write_order(entry):
>+ """Return the entry's fields in the order they should be written to.
>+
>+ The ordering is intended to 1) be deterministic for a given schema
>+ and 2) minimize the chance of conflicts. Fields that are just
>+ fields come before fields (believed to be) controlled by
>+ mutators. Within each group, fields are returned in the order they
>+ appear in the schema.
>+
>+ :param entry: An object that provides IEntry.
>+ :return: A list of 2-tuples (field name, field object)
>+ """
>+ non_mutator_fields = []
>+ mutator_fields = []
>+ field_implementations = entry.__class__.__dict__
>+ for name, field in getFieldsInOrder(entry.schema):
>+ if name.startswith('_'):
>+ # This field is not part of the web service interface.
>+ continue
>+
>+ add_to = non_mutator_fields
>+ # If this field is secretly a subclass of lazr.delegates
>+ # Passthrough (but not a direct instance of Passthrough), put
>+ # it at the end -- it's probably controlled by a mutator.
>+ implementation = field_implementations[name]
>+ if (issubclass(implementation.__class__, Passthrough)
>+ and not implementation.__class__ is Passthrough):
>+ add_to = mutator_fields
>+ add_to.append((name, field))
>+ return non_mutator_fields + mutator_fields

Setting the add_to variable makes it a little harder to follow the
logic. I would prefer getting rid of it and using an if/else with
mutator_fields.append() and non_mutator_fields.append() instead.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/lazr/restful/NEWS.txt'
2--- src/lazr/restful/NEWS.txt 2010-04-07 15:01:33 +0000
3+++ src/lazr/restful/NEWS.txt 2010-04-14 15:29:18 +0000
4@@ -2,8 +2,8 @@
5 NEWS for lazr.restful
6 =====================
7
8-Development
9-===========
10+0.9.25 (2010-04-14)
11+===================
12
13 Special note: This version introduces a new configuration element,
14 'caching_policy'. This element starts out simple but may become more
15@@ -12,7 +12,12 @@
16
17 Service root resources are now client-side cacheable for an amount of
18 time that depends on the server configuration and the version of the
19-web service requested.
20+web service requested. To get the full benefit, clients will need to
21+upgrade to lazr.restfulclient 0.9.14.
22+
23+When a PATCH or PUT request changes multiple fields at once, the
24+changes are applied in a deterministic order designed to minimize
25+possible conflicts.
26
27 0.9.24 (2010-03-17)
28 ====================
29
30=== modified file 'src/lazr/restful/_resource.py'
31--- src/lazr/restful/_resource.py 2010-04-13 15:35:24 +0000
32+++ src/lazr/restful/_resource.py 2010-04-14 15:29:18 +0000
33@@ -76,6 +76,7 @@
34 from zope.traversing.browser.interfaces import IAbsoluteURL
35
36 from lazr.batchnavigator import BatchNavigator
37+from lazr.delegates import Passthrough
38 from lazr.enum import BaseItem
39 from lazr.lifecycle.event import ObjectModifiedEvent
40 from lazr.lifecycle.snapshot import Snapshot
41@@ -956,7 +957,7 @@
42 propagated to the client.
43 """
44 changeset = copy.copy(changeset)
45- validated_changeset = {}
46+ validated_changeset = []
47 errors = []
48
49 # The self link and resource type link aren't part of the
50@@ -983,12 +984,7 @@
51
52 # For every field in the schema, see if there's a corresponding
53 # field in the changeset.
54- # Get the fields ordered by name so that we always evaluate them in
55- # the same order. This is needed to predict errors when testing.
56- for name, field in getFieldsInOrder(self.entry.schema):
57- if name.startswith('_'):
58- # This field is not part of the web service interface.
59- continue
60+ for name, field in get_entry_fields_in_write_order(self.entry):
61 field = field.bind(self.entry.context)
62 marshaller = getMultiAdapter((field, self.request),
63 IFieldMarshaller)
64@@ -1114,7 +1110,7 @@
65 error = "Validation error"
66 errors.append("%s: %s" % (repr_name, error))
67 continue
68- validated_changeset[field] = (name, value)
69+ validated_changeset.append((field, value))
70 # If there are any fields left in the changeset, they're
71 # fields that don't correspond to some field in the
72 # schema. They're all errors.
73@@ -1134,8 +1130,10 @@
74
75 # Store the entry's current URL so we can see if it changes.
76 original_url = absoluteURL(self.entry.context, self.request)
77- # Make the changes.
78- for field, (name, value) in validated_changeset.items():
79+
80+ # Make the changes, in the same order obtained from
81+ # get_entry_fields_in_write_order.
82+ for field, value in validated_changeset:
83 field.set(self.entry, value)
84 # The representation has changed, and etags will need to be
85 # recalculated.
86@@ -1145,7 +1143,7 @@
87 event = ObjectModifiedEvent(
88 object=self.entry.context,
89 object_before_modification=entry_before_modification,
90- edited_fields=validated_changeset.keys())
91+ edited_fields=[field for field, value in validated_changeset])
92 notify(event)
93
94 # The changeset contained new values for some of this object's
95@@ -1430,8 +1428,10 @@
96
97 # Make sure the representation includes values for all
98 # writable attributes.
99- # Get the fields ordered by name so that we always evaluate them in
100- # the same order. This is needed to predict errors when testing.
101+ #
102+ # Get the fields ordered by schema order so that we always
103+ # evaluate them in the same order. This is needed to predict
104+ # errors when testing.
105 for name, field in getFieldsInOrder(self.entry.schema):
106 if not self.isModifiableField(field, True):
107 continue
108@@ -1988,3 +1988,36 @@
109 """The URL to the description of the object's full representation."""
110 return "%s#%s-full" % (
111 self._service_root_url(), self.singular_type)
112+
113+
114+def get_entry_fields_in_write_order(entry):
115+ """Return the entry's fields in the order they should be written to.
116+
117+ The ordering is intended to 1) be deterministic for a given schema
118+ and 2) minimize the chance of conflicts. Fields that are just
119+ fields come before fields (believed to be) controlled by
120+ mutators. Within each group, fields are returned in the order they
121+ appear in the schema.
122+
123+ :param entry: An object that provides IEntry.
124+ :return: A list of 2-tuples (field name, field object)
125+ """
126+ non_mutator_fields = []
127+ mutator_fields = []
128+ field_implementations = entry.__class__.__dict__
129+ for name, field in getFieldsInOrder(entry.schema):
130+ if name.startswith('_'):
131+ # This field is not part of the web service interface.
132+ continue
133+
134+ add_to = non_mutator_fields
135+ # If this field is secretly a subclass of lazr.delegates
136+ # Passthrough (but not a direct instance of Passthrough), put
137+ # it at the end -- it's probably controlled by a mutator.
138+ implementation = field_implementations[name]
139+ if (issubclass(implementation.__class__, Passthrough)
140+ and not implementation.__class__ is Passthrough):
141+ add_to = mutator_fields
142+ add_to.append((name, field))
143+ return non_mutator_fields + mutator_fields
144+
145
146=== modified file 'src/lazr/restful/docs/webservice-declarations.txt'
147--- src/lazr/restful/docs/webservice-declarations.txt 2010-03-03 13:38:05 +0000
148+++ src/lazr/restful/docs/webservice-declarations.txt 2010-04-14 15:29:18 +0000
149@@ -5,7 +5,6 @@
150 with some decorators. From this tagging the web service API will be
151 created automatically.
152
153-========================
154 Exporting the data model
155 ========================
156
157@@ -770,13 +769,11 @@
158 return_type: None
159 type: 'write_operation'
160
161-=========================
162 Generating the webservice
163 =========================
164
165-
166 Entry
167-=====
168+-----
169
170 The webservice can be generated from tagged interfaces. For every
171 version in the web service, generate_entry_interfaces() will create a
172@@ -969,9 +966,8 @@
173 ...
174 TypeError: 'IBookSet' isn't exported as an entry.
175
176-
177 Collection
178-==========
179+----------
180
181 An ICollection adapter for content interface tagged as being exported as
182 collections on the webservice can be generated by using the
183@@ -1046,7 +1042,7 @@
184 TypeError: 'IBook' isn't exported as a collection.
185
186 Methods
187-=======
188+-------
189
190 IResourceOperation adapters can be generated for exported methods by
191 using the generate_operation_adapter() function. Using it on a method
192@@ -1259,10 +1255,8 @@
193 >>> print destructor_method_adapter_factory.__name__
194 DELETE_IBookOnSteroids_destroy_beta
195
196-
197-
198 Destructor
199-==========
200+----------
201
202 A method can be designated as a destructor for the entry. Here, the
203 destroy() method is designated as the destructor for IHasText.
204@@ -1322,9 +1316,8 @@
205 TypeError: An entry can only have one destructor method for
206 version (earliest version); destroy and destroy2 make two.
207
208-
209 Mutators
210-========
211+--------
212
213 A method can be designated as a mutator for some field. Here, the
214 set_text() method is designated as the mutator for the 'text' field.
215@@ -1471,7 +1464,7 @@
216 (earliest version); set_value_2 makes two.
217
218 Caching
219-=======
220+-------
221
222 It is possible to cache a server response in the browser cache using
223 the @cache_for decorator:
224
225=== modified file 'src/lazr/restful/docs/webservice.txt'
226--- src/lazr/restful/docs/webservice.txt 2010-02-11 17:57:16 +0000
227+++ src/lazr/restful/docs/webservice.txt 2010-04-14 15:29:18 +0000
228@@ -1,3 +1,4 @@
229+********************
230 RESTful Web Services
231 ********************
232
233@@ -6,7 +7,6 @@
234 a model for managing recipes, and then publishing the model objects as
235 resources through a web service.
236
237-=====================
238 Example model objects
239 =====================
240
241@@ -473,7 +473,6 @@
242 >>> setSecurityPolicy(SimpleSecurityPolicy)
243 <class ...>
244
245-=========================================
246 Web Service Infrastructure Initialization
247 =========================================
248
249@@ -532,7 +531,6 @@
250 ... register_versioned_request_utility(cls, version)
251
252
253-======================
254 Defining the resources
255 ======================
256
257@@ -624,7 +622,6 @@
258 ... [IChoice, IWebServiceClientRequest, AuthorVocabulary],
259 ... IFieldMarshaller)
260
261-==========================
262 Implementing the resources
263 ==========================
264
265@@ -806,7 +803,67 @@
266 >>> scoped_collection.entry_schema
267 <InterfaceClass __builtin__.IRecipeEntry>
268
269-=================
270+Field ordering
271+--------------
272+
273+When an entry's fields are modified, it's important that the
274+modifications happen in a deterministic order, to minimize (or at
275+least make deterministic) bad interactions between fields. The helper
276+function get_entry_fields_in_write_order() handles this.
277+
278+Ordinarily, fields are written to in the same order they are found in
279+the underlying schema.
280+
281+ >>> author_entry = getMultiAdapter((A1, request), IEntry)
282+ >>> from lazr.restful._resource import get_entry_fields_in_write_order
283+ >>> def print_fields_in_write_order(entry):
284+ ... for name, field in get_entry_fields_in_write_order(entry):
285+ ... print name
286+
287+ >>> print_fields_in_write_order(author_entry)
288+ name
289+ favorite_recipe
290+ popularity
291+
292+The one exception is if a field is wrapped in a subclass of the
293+Passthrough class defined by the lazr.delegates library. Classes
294+generated through lazr.restful's annotations use a Passthrough
295+subclass to control a field that triggers complex logic when its value
296+changes. To minimize the risk of bad interactions, all the simple
297+fields are changed before any of the complex fields.
298+
299+Here's a simple subclass of Passthrough.
300+
301+ >>> from lazr.delegates import Passthrough
302+ >>> class MyPassthrough(Passthrough):
303+ ... pass
304+
305+When we replace 'favorite_recipe' with an instance of this subclass,
306+that field shows up at the end of the list of fields.
307+
308+ >>> old_favorite_recipe = AuthorEntry.favorite_recipe
309+ >>> AuthorEntry.favorite_recipe = MyPassthrough('favorite_recipe', A1)
310+ >>> print_fields_in_write_order(author_entry)
311+ name
312+ popularity
313+ favorite_recipe
314+
315+When we replace 'name' with a Passthrough subclass, it also shows up
316+at the end--but it still shows up before 'favorite_recipe', because it
317+comes before 'favorite_recipe' in the schema.
318+
319+ >>> old_name = AuthorEntry.name
320+ >>> AuthorEntry.name = MyPassthrough('name', A1)
321+ >>> print_fields_in_write_order(author_entry)
322+ popularity
323+ name
324+ favorite_recipe
325+
326+Cleanup to restore the old AuthorEntry implementation:
327+
328+ >>> AuthorEntry.favorite_recipe = old_favorite_recipe
329+ >>> AuthorEntry.name = old_name
330+
331 Custom operations
332 =================
333
334@@ -923,7 +980,6 @@
335 ... RecipeDeleteOperation, name="")
336
337
338-================
339 Resource objects
340 ================
341
342@@ -944,7 +1000,6 @@
343 Similarly, you can implement ``RecipeEntry`` to the ``IEntry`` interface, and
344 expose it through the web as an ``EntryResource``.
345
346-=========================
347 The Service Root Resource
348 =========================
349
350@@ -1046,7 +1101,6 @@
351 AssertionError: There must be one (and only one) adapter
352 from DishCollection to ICollection.
353
354-====================
355 Collection resources
356 ====================
357
358@@ -1234,7 +1288,6 @@
359 u'Nouvelle Brazilian'
360
361
362-===============
363 Entry resources
364 ===============
365
366@@ -1381,7 +1434,6 @@
367 u'Draw, singe, stuff, and truss...',
368 u'You can always judge...']
369
370-=============================
371 Named operation return values
372 =============================
373
374@@ -1514,7 +1566,6 @@
375 ...
376 TypeError: Could not serialize object [<object object...>] to JSON.
377
378-=====
379 ETags
380 =====
381
382@@ -1580,7 +1631,6 @@
383 >>> etag_after_readonly_change == etag_original
384 False
385
386-===================
387 Resource Visibility
388 ===================
389
390@@ -1684,7 +1734,6 @@
391 ...
392 Unauthorized: (<Recipe object...>, 'dish', ...)
393
394-=====================
395 Stored file resources
396 =====================
397
398@@ -1753,7 +1802,6 @@
399 >>> print C2.cover
400 None
401
402-===============
403 Field resources
404 ===============
405
406@@ -1764,7 +1812,6 @@
407 >>> print field_resource()
408 "The Joy of Cooking"
409
410-==================================
411 Requesting non available resources
412 ==================================
413
414@@ -1793,7 +1840,6 @@
415 ...
416 NotFound: ... name: u'comments/10'
417
418-====================
419 Manipulating entries
420 ====================
421
422@@ -1965,7 +2011,6 @@
423 NotFound: ... name: u'recipes/Foies de voilaille en aspic'
424
425
426-=================
427 Within a template
428 =================
429
430
431=== modified file 'src/lazr/restful/version.txt'
432--- src/lazr/restful/version.txt 2010-03-15 19:44:45 +0000
433+++ src/lazr/restful/version.txt 2010-04-14 15:29:18 +0000
434@@ -1,1 +1,1 @@
435-0.9.24
436+0.9.25

Subscribers

People subscribed via source and target branches