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
=== modified file 'src/lazr/restful/NEWS.txt'
--- src/lazr/restful/NEWS.txt 2010-04-07 15:01:33 +0000
+++ src/lazr/restful/NEWS.txt 2010-04-14 15:29:18 +0000
@@ -2,8 +2,8 @@
2NEWS for lazr.restful2NEWS for lazr.restful
3=====================3=====================
44
5Development50.9.25 (2010-04-14)
6===========6===================
77
8Special note: This version introduces a new configuration element,8Special note: This version introduces a new configuration element,
9'caching_policy'. This element starts out simple but may become more9'caching_policy'. This element starts out simple but may become more
@@ -12,7 +12,12 @@
1212
13Service root resources are now client-side cacheable for an amount of13Service root resources are now client-side cacheable for an amount of
14time that depends on the server configuration and the version of the14time that depends on the server configuration and the version of the
15web service requested.15web service requested. To get the full benefit, clients will need to
16upgrade to lazr.restfulclient 0.9.14.
17
18When a PATCH or PUT request changes multiple fields at once, the
19changes are applied in a deterministic order designed to minimize
20possible conflicts.
1621
170.9.24 (2010-03-17)220.9.24 (2010-03-17)
18====================23====================
1924
=== 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 15:29:18 +0000
@@ -76,6 +76,7 @@
76from zope.traversing.browser.interfaces import IAbsoluteURL76from zope.traversing.browser.interfaces import IAbsoluteURL
7777
78from lazr.batchnavigator import BatchNavigator78from lazr.batchnavigator import BatchNavigator
79from lazr.delegates import Passthrough
79from lazr.enum import BaseItem80from lazr.enum import BaseItem
80from lazr.lifecycle.event import ObjectModifiedEvent81from lazr.lifecycle.event import ObjectModifiedEvent
81from lazr.lifecycle.snapshot import Snapshot82from lazr.lifecycle.snapshot import Snapshot
@@ -956,7 +957,7 @@
956 propagated to the client.957 propagated to the client.
957 """958 """
958 changeset = copy.copy(changeset)959 changeset = copy.copy(changeset)
959 validated_changeset = {}960 validated_changeset = []
960 errors = []961 errors = []
961962
962 # The self link and resource type link aren't part of the963 # The self link and resource type link aren't part of the
@@ -983,12 +984,7 @@
983984
984 # For every field in the schema, see if there's a corresponding985 # For every field in the schema, see if there's a corresponding
985 # field in the changeset.986 # field in the changeset.
986 # Get the fields ordered by name so that we always evaluate them in987 for name, field in get_entry_fields_in_write_order(self.entry):
987 # the same order. This is needed to predict errors when testing.
988 for name, field in getFieldsInOrder(self.entry.schema):
989 if name.startswith('_'):
990 # This field is not part of the web service interface.
991 continue
992 field = field.bind(self.entry.context)988 field = field.bind(self.entry.context)
993 marshaller = getMultiAdapter((field, self.request),989 marshaller = getMultiAdapter((field, self.request),
994 IFieldMarshaller)990 IFieldMarshaller)
@@ -1114,7 +1110,7 @@
1114 error = "Validation error"1110 error = "Validation error"
1115 errors.append("%s: %s" % (repr_name, error))1111 errors.append("%s: %s" % (repr_name, error))
1116 continue1112 continue
1117 validated_changeset[field] = (name, value)1113 validated_changeset.append((field, value))
1118 # If there are any fields left in the changeset, they're1114 # If there are any fields left in the changeset, they're
1119 # fields that don't correspond to some field in the1115 # fields that don't correspond to some field in the
1120 # schema. They're all errors.1116 # schema. They're all errors.
@@ -1134,8 +1130,10 @@
11341130
1135 # Store the entry's current URL so we can see if it changes.1131 # Store the entry's current URL so we can see if it changes.
1136 original_url = absoluteURL(self.entry.context, self.request)1132 original_url = absoluteURL(self.entry.context, self.request)
1137 # Make the changes.1133
1138 for field, (name, value) in validated_changeset.items():1134 # Make the changes, in the same order obtained from
1135 # get_entry_fields_in_write_order.
1136 for field, value in validated_changeset:
1139 field.set(self.entry, value)1137 field.set(self.entry, value)
1140 # The representation has changed, and etags will need to be1138 # The representation has changed, and etags will need to be
1141 # recalculated.1139 # recalculated.
@@ -1145,7 +1143,7 @@
1145 event = ObjectModifiedEvent(1143 event = ObjectModifiedEvent(
1146 object=self.entry.context,1144 object=self.entry.context,
1147 object_before_modification=entry_before_modification,1145 object_before_modification=entry_before_modification,
1148 edited_fields=validated_changeset.keys())1146 edited_fields=[field for field, value in validated_changeset])
1149 notify(event)1147 notify(event)
11501148
1151 # The changeset contained new values for some of this object's1149 # The changeset contained new values for some of this object's
@@ -1430,8 +1428,10 @@
14301428
1431 # Make sure the representation includes values for all1429 # Make sure the representation includes values for all
1432 # writable attributes.1430 # writable attributes.
1433 # Get the fields ordered by name so that we always evaluate them in1431 #
1434 # the same order. This is needed to predict errors when testing.1432 # Get the fields ordered by schema order so that we always
1433 # evaluate them in the same order. This is needed to predict
1434 # errors when testing.
1435 for name, field in getFieldsInOrder(self.entry.schema):1435 for name, field in getFieldsInOrder(self.entry.schema):
1436 if not self.isModifiableField(field, True):1436 if not self.isModifiableField(field, True):
1437 continue1437 continue
@@ -1988,3 +1988,36 @@
1988 """The URL to the description of the object's full representation."""1988 """The URL to the description of the object's full representation."""
1989 return "%s#%s-full" % (1989 return "%s#%s-full" % (
1990 self._service_root_url(), self.singular_type)1990 self._service_root_url(), self.singular_type)
1991
1992
1993def get_entry_fields_in_write_order(entry):
1994 """Return the entry's fields in the order they should be written to.
1995
1996 The ordering is intended to 1) be deterministic for a given schema
1997 and 2) minimize the chance of conflicts. Fields that are just
1998 fields come before fields (believed to be) controlled by
1999 mutators. Within each group, fields are returned in the order they
2000 appear in the schema.
2001
2002 :param entry: An object that provides IEntry.
2003 :return: A list of 2-tuples (field name, field object)
2004 """
2005 non_mutator_fields = []
2006 mutator_fields = []
2007 field_implementations = entry.__class__.__dict__
2008 for name, field in getFieldsInOrder(entry.schema):
2009 if name.startswith('_'):
2010 # This field is not part of the web service interface.
2011 continue
2012
2013 add_to = non_mutator_fields
2014 # If this field is secretly a subclass of lazr.delegates
2015 # Passthrough (but not a direct instance of Passthrough), put
2016 # it at the end -- it's probably controlled by a mutator.
2017 implementation = field_implementations[name]
2018 if (issubclass(implementation.__class__, Passthrough)
2019 and not implementation.__class__ is Passthrough):
2020 add_to = mutator_fields
2021 add_to.append((name, field))
2022 return non_mutator_fields + mutator_fields
2023
19912024
=== modified file 'src/lazr/restful/docs/webservice-declarations.txt'
--- src/lazr/restful/docs/webservice-declarations.txt 2010-03-03 13:38:05 +0000
+++ src/lazr/restful/docs/webservice-declarations.txt 2010-04-14 15:29:18 +0000
@@ -5,7 +5,6 @@
5with some decorators. From this tagging the web service API will be5with some decorators. From this tagging the web service API will be
6created automatically.6created automatically.
77
8========================
9Exporting the data model8Exporting the data model
10========================9========================
1110
@@ -770,13 +769,11 @@
770 return_type: None769 return_type: None
771 type: 'write_operation'770 type: 'write_operation'
772771
773=========================
774Generating the webservice772Generating the webservice
775=========================773=========================
776774
777
778Entry775Entry
779=====776-----
780777
781The webservice can be generated from tagged interfaces. For every778The webservice can be generated from tagged interfaces. For every
782version in the web service, generate_entry_interfaces() will create a779version in the web service, generate_entry_interfaces() will create a
@@ -969,9 +966,8 @@
969 ...966 ...
970 TypeError: 'IBookSet' isn't exported as an entry.967 TypeError: 'IBookSet' isn't exported as an entry.
971968
972
973Collection969Collection
974==========970----------
975971
976An ICollection adapter for content interface tagged as being exported as972An ICollection adapter for content interface tagged as being exported as
977collections on the webservice can be generated by using the973collections on the webservice can be generated by using the
@@ -1046,7 +1042,7 @@
1046 TypeError: 'IBook' isn't exported as a collection.1042 TypeError: 'IBook' isn't exported as a collection.
10471043
1048Methods1044Methods
1049=======1045-------
10501046
1051IResourceOperation adapters can be generated for exported methods by1047IResourceOperation adapters can be generated for exported methods by
1052using the generate_operation_adapter() function. Using it on a method1048using the generate_operation_adapter() function. Using it on a method
@@ -1259,10 +1255,8 @@
1259 >>> print destructor_method_adapter_factory.__name__1255 >>> print destructor_method_adapter_factory.__name__
1260 DELETE_IBookOnSteroids_destroy_beta1256 DELETE_IBookOnSteroids_destroy_beta
12611257
1262
1263
1264Destructor1258Destructor
1265==========1259----------
12661260
1267A method can be designated as a destructor for the entry. Here, the1261A method can be designated as a destructor for the entry. Here, the
1268destroy() method is designated as the destructor for IHasText.1262destroy() method is designated as the destructor for IHasText.
@@ -1322,9 +1316,8 @@
1322 TypeError: An entry can only have one destructor method for1316 TypeError: An entry can only have one destructor method for
1323 version (earliest version); destroy and destroy2 make two.1317 version (earliest version); destroy and destroy2 make two.
13241318
1325
1326Mutators1319Mutators
1327========1320--------
13281321
1329A method can be designated as a mutator for some field. Here, the1322A method can be designated as a mutator for some field. Here, the
1330set_text() method is designated as the mutator for the 'text' field.1323set_text() method is designated as the mutator for the 'text' field.
@@ -1471,7 +1464,7 @@
1471 (earliest version); set_value_2 makes two.1464 (earliest version); set_value_2 makes two.
14721465
1473Caching1466Caching
1474=======1467-------
14751468
1476It is possible to cache a server response in the browser cache using1469It is possible to cache a server response in the browser cache using
1477the @cache_for decorator:1470the @cache_for decorator:
14781471
=== modified file 'src/lazr/restful/docs/webservice.txt'
--- src/lazr/restful/docs/webservice.txt 2010-02-11 17:57:16 +0000
+++ src/lazr/restful/docs/webservice.txt 2010-04-14 15:29:18 +0000
@@ -1,3 +1,4 @@
1********************
1RESTful Web Services2RESTful Web Services
2********************3********************
34
@@ -6,7 +7,6 @@
6a model for managing recipes, and then publishing the model objects as7a model for managing recipes, and then publishing the model objects as
7resources through a web service.8resources through a web service.
89
9=====================
10Example model objects10Example model objects
11=====================11=====================
1212
@@ -473,7 +473,6 @@
473 >>> setSecurityPolicy(SimpleSecurityPolicy)473 >>> setSecurityPolicy(SimpleSecurityPolicy)
474 <class ...>474 <class ...>
475475
476=========================================
477Web Service Infrastructure Initialization476Web Service Infrastructure Initialization
478=========================================477=========================================
479478
@@ -532,7 +531,6 @@
532 ... register_versioned_request_utility(cls, version)531 ... register_versioned_request_utility(cls, version)
533532
534533
535======================
536Defining the resources534Defining the resources
537======================535======================
538536
@@ -624,7 +622,6 @@
624 ... [IChoice, IWebServiceClientRequest, AuthorVocabulary],622 ... [IChoice, IWebServiceClientRequest, AuthorVocabulary],
625 ... IFieldMarshaller)623 ... IFieldMarshaller)
626624
627==========================
628Implementing the resources625Implementing the resources
629==========================626==========================
630627
@@ -806,7 +803,67 @@
806 >>> scoped_collection.entry_schema803 >>> scoped_collection.entry_schema
807 <InterfaceClass __builtin__.IRecipeEntry>804 <InterfaceClass __builtin__.IRecipeEntry>
808805
809=================806Field ordering
807--------------
808
809When an entry's fields are modified, it's important that the
810modifications happen in a deterministic order, to minimize (or at
811least make deterministic) bad interactions between fields. The helper
812function get_entry_fields_in_write_order() handles this.
813
814Ordinarily, fields are written to in the same order they are found in
815the underlying schema.
816
817 >>> author_entry = getMultiAdapter((A1, request), IEntry)
818 >>> from lazr.restful._resource import get_entry_fields_in_write_order
819 >>> def print_fields_in_write_order(entry):
820 ... for name, field in get_entry_fields_in_write_order(entry):
821 ... print name
822
823 >>> print_fields_in_write_order(author_entry)
824 name
825 favorite_recipe
826 popularity
827
828The one exception is if a field is wrapped in a subclass of the
829Passthrough class defined by the lazr.delegates library. Classes
830generated through lazr.restful's annotations use a Passthrough
831subclass to control a field that triggers complex logic when its value
832changes. To minimize the risk of bad interactions, all the simple
833fields are changed before any of the complex fields.
834
835Here's a simple subclass of Passthrough.
836
837 >>> from lazr.delegates import Passthrough
838 >>> class MyPassthrough(Passthrough):
839 ... pass
840
841When we replace 'favorite_recipe' with an instance of this subclass,
842that field shows up at the end of the list of fields.
843
844 >>> old_favorite_recipe = AuthorEntry.favorite_recipe
845 >>> AuthorEntry.favorite_recipe = MyPassthrough('favorite_recipe', A1)
846 >>> print_fields_in_write_order(author_entry)
847 name
848 popularity
849 favorite_recipe
850
851When we replace 'name' with a Passthrough subclass, it also shows up
852at the end--but it still shows up before 'favorite_recipe', because it
853comes before 'favorite_recipe' in the schema.
854
855 >>> old_name = AuthorEntry.name
856 >>> AuthorEntry.name = MyPassthrough('name', A1)
857 >>> print_fields_in_write_order(author_entry)
858 popularity
859 name
860 favorite_recipe
861
862Cleanup to restore the old AuthorEntry implementation:
863
864 >>> AuthorEntry.favorite_recipe = old_favorite_recipe
865 >>> AuthorEntry.name = old_name
866
810Custom operations867Custom operations
811=================868=================
812869
@@ -923,7 +980,6 @@
923 ... RecipeDeleteOperation, name="")980 ... RecipeDeleteOperation, name="")
924981
925982
926================
927Resource objects983Resource objects
928================984================
929985
@@ -944,7 +1000,6 @@
944Similarly, you can implement ``RecipeEntry`` to the ``IEntry`` interface, and1000Similarly, you can implement ``RecipeEntry`` to the ``IEntry`` interface, and
945expose it through the web as an ``EntryResource``.1001expose it through the web as an ``EntryResource``.
9461002
947=========================
948The Service Root Resource1003The Service Root Resource
949=========================1004=========================
9501005
@@ -1046,7 +1101,6 @@
1046 AssertionError: There must be one (and only one) adapter1101 AssertionError: There must be one (and only one) adapter
1047 from DishCollection to ICollection.1102 from DishCollection to ICollection.
10481103
1049====================
1050Collection resources1104Collection resources
1051====================1105====================
10521106
@@ -1234,7 +1288,6 @@
1234 u'Nouvelle Brazilian'1288 u'Nouvelle Brazilian'
12351289
12361290
1237===============
1238Entry resources1291Entry resources
1239===============1292===============
12401293
@@ -1381,7 +1434,6 @@
1381 u'Draw, singe, stuff, and truss...',1434 u'Draw, singe, stuff, and truss...',
1382 u'You can always judge...']1435 u'You can always judge...']
13831436
1384=============================
1385Named operation return values1437Named operation return values
1386=============================1438=============================
13871439
@@ -1514,7 +1566,6 @@
1514 ...1566 ...
1515 TypeError: Could not serialize object [<object object...>] to JSON.1567 TypeError: Could not serialize object [<object object...>] to JSON.
15161568
1517=====
1518ETags1569ETags
1519=====1570=====
15201571
@@ -1580,7 +1631,6 @@
1580 >>> etag_after_readonly_change == etag_original1631 >>> etag_after_readonly_change == etag_original
1581 False1632 False
15821633
1583===================
1584Resource Visibility1634Resource Visibility
1585===================1635===================
15861636
@@ -1684,7 +1734,6 @@
1684 ...1734 ...
1685 Unauthorized: (<Recipe object...>, 'dish', ...)1735 Unauthorized: (<Recipe object...>, 'dish', ...)
16861736
1687=====================
1688Stored file resources1737Stored file resources
1689=====================1738=====================
16901739
@@ -1753,7 +1802,6 @@
1753 >>> print C2.cover1802 >>> print C2.cover
1754 None1803 None
17551804
1756===============
1757Field resources1805Field resources
1758===============1806===============
17591807
@@ -1764,7 +1812,6 @@
1764 >>> print field_resource()1812 >>> print field_resource()
1765 "The Joy of Cooking"1813 "The Joy of Cooking"
17661814
1767==================================
1768Requesting non available resources1815Requesting non available resources
1769==================================1816==================================
17701817
@@ -1793,7 +1840,6 @@
1793 ...1840 ...
1794 NotFound: ... name: u'comments/10'1841 NotFound: ... name: u'comments/10'
17951842
1796====================
1797Manipulating entries1843Manipulating entries
1798====================1844====================
17991845
@@ -1965,7 +2011,6 @@
1965 NotFound: ... name: u'recipes/Foies de voilaille en aspic'2011 NotFound: ... name: u'recipes/Foies de voilaille en aspic'
19662012
19672013
1968=================
1969Within a template2014Within a template
1970=================2015=================
19712016
19722017
=== modified file 'src/lazr/restful/version.txt'
--- src/lazr/restful/version.txt 2010-03-15 19:44:45 +0000
+++ src/lazr/restful/version.txt 2010-04-14 15:29:18 +0000
@@ -1,1 +1,1 @@
10.9.2410.9.25

Subscribers

People subscribed via source and target branches