Merge lp:~leonardr/lazr.restful/multiversion-mutators into lp:lazr.restful

Proposed by Leonard Richardson
Status: Merged
Merged at revision: not available
Proposed branch: lp:~leonardr/lazr.restful/multiversion-mutators
Merge into: lp:lazr.restful
Diff against target: 303 lines (+192/-20)
2 files modified
src/lazr/restful/declarations.py (+70/-11)
src/lazr/restful/docs/webservice-declarations.txt (+122/-9)
To merge this branch: bzr merge lp:~leonardr/lazr.restful/multiversion-mutators
Reviewer Review Type Date Requested Status
Paul Hummer (community) code Approve
Review via email: mp+18702@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote :

This diff makes mutators version-aware. It lets you define different mutators for different versions, and it prevents you from defining more than one mutator for any particular version.

The complication here is that the @mutator_for annotation is attached to the mutator method, but it modifies a field. Before multi-version, @mutator_for just grabbed the field's annotation dictionary and modified it. But now, "modifying the field's annotation dictionary" means modifying the way the field is published *in the most recent version*. We need to modify the way the field is published in a *specific* version. But we have no guarantee that the field already has an existing set of annotations for that version, and if it's not in the stack already we have no idea whether that version comes before or after other versions.

Here's an example. (I don't have a test like this because it doesn't add any code coverage, and I don't think it's necessary to write a test to explain the internal implementation of something, but it's the best way to explain the problem.) Let's say I publish an entry like this:

    field = exported(TextLine(), ('foo', dict(exported_as='foofield'),
                                 ('bar', dict(exported_as='barfield'))

    @mutator_for(field)
    @export_write_operation()
    @operation_for_version('baz')
    def mutator(value):
        ...

When I process the 'mutator' method I need to modify the annotations on the 'field' field. That field's LAZR_WEBSERVICE_EXPORTED annotation stack looks the same as it did in the exported() call:

[('foo', dict(exported_as='foofield'),
 ('bar', dict(exported_as='barfield')]

Where does 'baz' go? There is *no way* to know. The decision has to be deferred until generate_entry_interfaces, when we have an ordered list of the versions. So this 'baz' thing can't go into LAZR_WEBSERVICE_EXPORTED at all. That's why I created a separate annotation dictionary just for mutators, LAZR_WEBSERVICE_MUTATORS. This is a regular dictionary (not a VersionedDict) that maps versions to (mutator, mutator_annotations) 2-tuples. When we encounter @mutator_for, the stuff that was going to go into LAZR_WEBSERVICE_EXPORTED gets put into here instead. Later on, in generate_entry_interfaces and generate_entry_adapters, when we need to do per-version error checking or generate a per-version Property, we pull the version-indexed value out of LAZR_WEBSERVICE_MUTATORS.

Revision history for this message
Paul Hummer (rockstar) wrote :

Leonard, thanks for making the pagetests so detailed. It makes reviewing easier, since I hop back and forth between test and code when I have questions. I'm also glad to see the tests that were XXX missing from a previous branch are in this one.

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/lazr/restful/declarations.py'
2--- src/lazr/restful/declarations.py 2010-02-04 22:23:54 +0000
3+++ src/lazr/restful/declarations.py 2010-02-05 15:08:15 +0000
4@@ -8,6 +8,7 @@
5 'ENTRY_TYPE',
6 'FIELD_TYPE',
7 'LAZR_WEBSERVICE_EXPORTED',
8+ 'LAZR_WEBSERVICE_MUTATORS',
9 'OPERATION_TYPES',
10 'REQUEST_USER',
11 'cache_for',
12@@ -63,6 +64,7 @@
13 make_identifier_safe, VersionedDict)
14
15 LAZR_WEBSERVICE_EXPORTED = '%s.exported' % LAZR_WEBSERVICE_NS
16+LAZR_WEBSERVICE_MUTATORS = '%s.exported.mutators' % LAZR_WEBSERVICE_NS
17 COLLECTION_TYPE = 'collection'
18 ENTRY_TYPE = 'entry'
19 FIELD_TYPE = 'field'
20@@ -220,6 +222,13 @@
21
22 # Now we can annotate the field object with the VersionedDict.
23 field.setTaggedValue(LAZR_WEBSERVICE_EXPORTED, annotation_stack)
24+
25+ # We track the field's mutator information separately because it's
26+ # defined in the named operations, not in the fields. The last
27+ # thing we want to do is try to insert a foreign value into an
28+ # already create annotation stack.
29+ field.setTaggedValue(LAZR_WEBSERVICE_MUTATORS, {})
30+
31 return field
32
33
34@@ -531,13 +540,19 @@
35 "non-fixed argument. %s takes %d." %
36 (method.__name__, len(free_params)))
37
38- field_annotations = self.field.queryTaggedValue(
39- LAZR_WEBSERVICE_EXPORTED)
40- if 'mutated_by' in field_annotations:
41- raise TypeError("A field can only have one mutator method; "
42- "%s makes two." % method.__name__ )
43- field_annotations['mutated_by'] = method
44- field_annotations['mutated_by_annotations'] = annotations
45+ # We need to keep mutator annotations in a separate dictionary
46+ # from the field's main annotations because we're not
47+ # processing the field. We're processing a named operation,
48+ # and we have no idea where the named operation's current
49+ # version fits into the field's annotations.
50+ version, method_annotations = annotations.stack[-1]
51+ mutator_annotations = self.field.queryTaggedValue(
52+ LAZR_WEBSERVICE_MUTATORS)
53+ if version in mutator_annotations:
54+ raise TypeError(
55+ "A field can only have one mutator method for version %s; "
56+ "%s makes two." % (_version_name(version), method.__name__ ))
57+ mutator_annotations[version] = (method, dict(method_annotations))
58
59
60 def _free_parameters(method, annotations):
61@@ -879,8 +894,15 @@
62 tags = tags[0]
63 if tags.get('exported') is False:
64 continue
65- readonly = (field.readonly
66- and tags.get('mutated_by', None) is None)
67+ mutator_annotations = field.queryTaggedValue(
68+ LAZR_WEBSERVICE_MUTATORS)
69+ error_prefix = (
70+ 'Duplicate mutator for interface "%s", field "%s":' % (
71+ interface.__name__, field.__name__))
72+ mutated_by, mutated_by_annotations = _get_by_version(
73+ mutator_annotations, version, versions[0],
74+ error_prefix, (None, {}))
75+ readonly = (field.readonly and mutated_by is None)
76 attrs[tags['as']] = copy_field(
77 field, __name__=tags['as'], readonly=readonly)
78 class_name = _versioned_class_name(
79@@ -919,6 +941,7 @@
80 adapters_by_version = {}
81 for name, field in getFields(content_interface).items():
82 tag_stack = field.queryTaggedValue(LAZR_WEBSERVICE_EXPORTED)
83+ mutator_annotations = field.queryTaggedValue(LAZR_WEBSERVICE_MUTATORS)
84 if tag_stack is None:
85 continue
86 for tags_version, tags in tag_stack.stack:
87@@ -931,11 +954,16 @@
88 # this version's adapter class dictionary.
89 if tags.get('exported') is False:
90 continue
91- mutator = tags.get('mutated_by', None)
92+ error_prefix = (
93+ 'Duplicate mutator for interface "%s", field "%s":' % (
94+ content_interface.__name__, field.__name__))
95+ mutator, annotations = _get_by_version(
96+ mutator_annotations, tags_version, webservice_interfaces[0][0],
97+ error_prefix, (None, {}))
98+
99 if mutator is None:
100 property = Passthrough(name, 'context')
101 else:
102- annotations = tags['mutated_by_annotations']
103 property = PropertyWithMutator(
104 name, 'context', mutator, annotations)
105 adapter_dict[tags['as']] = property
106@@ -1291,3 +1319,34 @@
107 version = "__Earliest"
108 name = "%s_%s" % (base_name, version.encode('utf8'))
109 return make_identifier_safe(name)
110+
111+
112+def _get_by_version(dictionary, version, earliest_version,
113+ error_prefix, default=None):
114+ """Pull from a dictionary using `version` as the key.
115+
116+ Under normal circumstances, this will be an ordinary lookup. But
117+ some values for the earliest version are stored under None
118+ (because the name of the earliest version wasn't known at the
119+ time). This helper function knows to look again under None if
120+ a lookup for the earliest version fails.
121+
122+ It also knows to raise an exception signaling a conflict if a
123+ value is present under the earliest version *and* under None.
124+
125+ :param dictionary: The dictionary to use for the lookup.
126+ :param version: The dictionary key.
127+ :param earliest_version: The version that might also be filed under None.
128+ :param default: A default value to use if the lookup fails.
129+ """
130+ value = dictionary.get(version, None)
131+ if version == earliest_version:
132+ earliest_value = dictionary.get(None, None)
133+ if value is not None and earliest_value is not None:
134+ raise ValueError(
135+ error_prefix + " Both implicit and explicit definitions found "
136+ "for earliest version %s." % version)
137+ value = value or earliest_value
138+ if value is None:
139+ value = default
140+ return value
141
142=== modified file 'src/lazr/restful/docs/webservice-declarations.txt'
143--- src/lazr/restful/docs/webservice-declarations.txt 2010-02-04 21:22:50 +0000
144+++ src/lazr/restful/docs/webservice-declarations.txt 2010-02-05 15:08:15 +0000
145@@ -1465,8 +1465,8 @@
146 ... pass
147 Traceback (most recent call last):
148 ...
149- TypeError: A field can only have one mutator method; set_value_2
150- makes two.
151+ TypeError: A field can only have one mutator method for version
152+ (earliest version); set_value_2 makes two.
153
154 Caching
155 =======
156@@ -1622,6 +1622,11 @@
157 Entries
158 -------
159
160+The singular and plural name of an entry never changes between
161+versions, because the names are a property of the original
162+interface. But the published fields can change or be renamed from
163+version to version.
164+
165 Here's a data model interface defining four fields which are published
166 in some versions and not others, and which may have different names in
167 different versions.
168@@ -1803,11 +1808,9 @@
169 AttributeError: 'MultiVersionEntryEntry_3_0Adapter' object has no
170 attribute 'new_in_10'
171
172-
173-[XXX leonardr I'll add these tests in my next branch; this branch is
174-already way too large.
175-
176- The singular and plural name of an entry will not change between versions.
177+[XXX leonardr These tests need to be done in pagetests, since they
178+ test the behavior of the webservice rather than whether certain
179+ combinations of annotations are valid.
180
181 If a field has a mutator in version n, later versions will inherit the
182 same mutator.
183@@ -2245,8 +2248,118 @@
184 >>> attrs['return_type']
185 <lazr.restful.fields.CollectionField object...>
186
187-[XXX leonardr mutator_for modifies the field, not the method, so it
188-won't work until I add multiversion support for fields.]
189+Mutator operations
190+******************
191+
192+Different versions can define different mutator methods for the same field.
193+
194+ >>> class IDifferentMutators(Interface):
195+ ... export_as_webservice_entry()
196+ ...
197+ ... field = exported(TextLine(readonly=True))
198+ ...
199+ ... @mutator_for(field)
200+ ... @export_write_operation()
201+ ... @operation_for_version('beta')
202+ ... @operation_parameters(new_value=TextLine())
203+ ... def set_value(new_value):
204+ ... pass
205+ ...
206+ ... @mutator_for(field)
207+ ... @export_write_operation()
208+ ... @operation_for_version('1.0')
209+ ... @operation_parameters(new_value=TextLine())
210+ ... def set_value_2(new_value):
211+ ... pass
212+
213+ >>> ignored = generate_entry_interfaces(
214+ ... IDifferentMutators, 'beta', '1.0')
215+
216+But you can't define two mutators for the same field in the same version.
217+
218+ >>> class IDuplicateMutator(Interface):
219+ ... export_as_webservice_entry()
220+ ...
221+ ... field = exported(TextLine(readonly=True))
222+ ...
223+ ... @mutator_for(field)
224+ ... @export_write_operation()
225+ ... @operation_for_version('1.0')
226+ ... @operation_parameters(new_value=TextLine())
227+ ... def set_value(new_value):
228+ ... pass
229+ ...
230+ ... @mutator_for(field)
231+ ... @export_write_operation()
232+ ... @operation_for_version('1.0')
233+ ... @operation_parameters(new_value=TextLine())
234+ ... def set_value_2(new_value):
235+ ... pass
236+ Traceback (most recent call last):
237+ ...
238+ TypeError: A field can only have one mutator method for version
239+ 1.0; set_value_2 makes two.
240+
241+Here's a case that's a little trickier. You'll also get an error if
242+you implicitly define a mutator for the earliest version (without
243+giving its name), and then define another one explicitly (giving the
244+name of the earliest version.)
245+
246+ >>> class IImplicitAndExplicitMutator(Interface):
247+ ... export_as_webservice_entry()
248+ ...
249+ ... field = exported(TextLine(readonly=True))
250+ ...
251+ ... @mutator_for(field)
252+ ... @export_write_operation()
253+ ... @operation_for_version('beta')
254+ ... @operation_parameters(new_value=TextLine())
255+ ... def set_value_2(new_value):
256+ ... pass
257+ ...
258+ ... @mutator_for(field)
259+ ... @export_write_operation()
260+ ... @operation_parameters(new_value=TextLine())
261+ ... def set_value_2(new_value):
262+ ... pass
263+
264+ >>> generate_entry_interfaces(
265+ ... IImplicitAndExplicitMutator, 'beta', '1.0')
266+ Traceback (most recent call last):
267+ ...
268+ ValueError: Duplicate mutator for interface
269+ "IImplicitAndExplicitMutator", field "field": Both implicit and
270+ explicit definitions found for earliest version beta.
271+
272+This error isn't detected until you try to generate the entry
273+interfaces, because until that point lazr.restful doesn't know that
274+'beta' is the earliest version. If the earliest version was 'alpha',
275+the IImplicitAndExplicitMutator class would be valid.
276+
277+(Again, to test this hypothesis, we need to re-define the class,
278+because the generate_entry_interfaces call modified the original
279+class's annotations in place.)
280+
281+ >>> class IImplicitAndExplicitMutator(Interface):
282+ ... export_as_webservice_entry()
283+ ...
284+ ... field = exported(TextLine(readonly=True))
285+ ...
286+ ... @mutator_for(field)
287+ ... @export_write_operation()
288+ ... @operation_for_version('beta')
289+ ... @operation_parameters(new_value=TextLine())
290+ ... def set_value_2(new_value):
291+ ... pass
292+ ...
293+ ... @mutator_for(field)
294+ ... @export_write_operation()
295+ ... @operation_parameters(new_value=TextLine())
296+ ... def set_value_2(new_value):
297+ ... pass
298+
299+ >>> ignored = generate_entry_interfaces(
300+ ... IImplicitAndExplicitMutator, 'alpha', 'beta', '1.0')
301
302 Destructor operations
303 *********************

Subscribers

People subscribed via source and target branches