Merge lp:~radix/txaws/parameter-enrichment into lp:txaws
- parameter-enrichment
- Merge into trunk
Status: | Merged |
---|---|
Approved by: | Chad Smith |
Approved revision: | 153 |
Merged at revision: | 141 |
Proposed branch: | lp:~radix/txaws/parameter-enrichment |
Merge into: | lp:txaws |
Diff against target: |
917 lines (+524/-168) 3 files modified
txaws/server/schema.py (+307/-158) txaws/server/tests/test_resource.py (+2/-1) txaws/server/tests/test_schema.py (+215/-9) |
To merge this branch: | bzr merge lp:~radix/txaws/parameter-enrichment |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Chad Smith (community) | Approve | ||
Thomas Herve | Approve | ||
Review via email: mp+103592@code.launchpad.net |
Commit message
Description of the change
This is one part of schema enrichment. It adds List and Structure Parameter types, and significantly refactors the way that paramater parsing and formatting is done.
Thomas Herve (therve) : | # |
- 150. By Christopher Armstrong
-
- fix bundling multiple list / structure arguments
- make default list value []
- give a name to Lists and structures that are automatically converted from the old schema system
- better test coverage for these things
Christopher Armstrong (radix) wrote : | # |
Looks like I broke the second return value from 'extract' -- It's returning a nested structure when it should still be returning the flat structure. I'll fix that.
- 151. By Christopher Armstrong
-
- fix the 'rest' return value from extract so it returns the original format of arguments and not a nested structure.
Christopher Armstrong (radix) wrote : | # |
I've fixed the 'rest' return format.
The only tests in landscape and clouddeck that are still failing are because of names in error messages.
I think this is a pretty tolerable incompatibility, but I'm going to see what I can do to do maintain compatibility here. Please take another look in the meantime (and if another reviewer wants to take a look, that'd be appreciated).
Christopher Armstrong (radix) wrote : | # |
Oh, and to address your comments explicitly:
[1] fixed (name.split(
[2] fixed
[3] I've made [] the default default for List, but I'm not sure if 'default' should be forwarded from the item to the list. After investigating it for a while, the original behavior of default= on a list item (i.e. numbered parameter) seems broken... Given a schema of Unicode("foo.n", optional=True, default=u"foo"), after extracting an empty dict the value of arguments.foo == Arguments({}).
This is a little bit concerning given that we have code that assumes it works (or at least, specifies defaults). Given that the existing behavior is weird, I don't think it's worth putting any effort into it; everyone should specify lists as List(..., optional=True, default=[...]) from now on. Does this seem reasonable?
[4] Wow, that's a seriously dumb one. Fixed.
[5] The reason we don't convert it to a list is in this part of the code we don't know that it's meant to be interpreted as a list. Plus, it's just meant to be an internal data structure used during parsing and formatting, and never exposed to users. (however, it was accidentally being exposed to users as the 'rest' return value from extract(), which I've fixed).
Thanks!
- 152. By Christopher Armstrong
-
add an explicit test for specified defaults of Lists - already works, just
double-checking
Thomas Herve (therve) wrote : | # |
[6]
+ _result.
+ _prefix=path))
You can remove the update call, or remove passing the result value, but I don't think you need to do both.
Looks good to me, +1!
- 153. By Christopher Armstrong
-
we don't need to update the result dict, the function is doing it for us.
Chad Smith (chad.smith) wrote : | # |
+1. It's nice to have the additional Schema classes defined.
[1] Do we want to alert the user as to the List argument format options for list parameters in our error messages? Or, would the following be too clunky?
Instead of the message:
The request must contain the parameter names
The error message could be:
The request must contain the parameter names or names.#.
OR
The request must contain the parameter names or names.N.
Maybe with something like this in coerce() with class attribute kind ="parameter" on Parameter:
- raise MissingParamete
+ if self.kind == "list":
+ raise MissingParamete
+ (self.name, self.name))
+ else:
+ raise MissingParamete
[2] Should probably add a more explicit error message test in test_non_list:
if not isinstance(value, dict):
raise InvalidParamete
+ self.assertEqua
+ self.assertEqua
+ self.assertEqua
+ error.message)
[3] Do we care about schema.extract error conditions if Schema is defined as Unicode(
[4] This branch does cause a couple of incompatibilities with error message tests for Unicode("blah.#") types in Landscape unit tests. You are already working it, but we should make sure Clouddeck & Landscape fix branches are committed at the same time.
- 154. By Christopher Armstrong
-
handle non-lists as list input.
- 155. By Christopher Armstrong
-
include the kind of the parameter in the MissingParameter error
Christopher Armstrong (radix) wrote : | # |
[1] I've changed the error message to specify the kind of the parameter. That way users will know what they're expected to pass. Unfortunately this means a whole lot more unit tests will need to be updated :)
[2] I actually removed that exception, because it was what was preventing the "foo" -> "foo.1" from working. test_non_list is now gone.
[3] This was already the behavior of the schema system, so changing this would result in more incompatibilities. We should basically consider naming fields with dots in them to be deprecated; new schemas should all use List and Structure to be explicit about what child fields mean.
[4] Yep, I'll work on the incompatibility branch for landscape after your naming branch lands, so it minimizes conflicts.
Preview Diff
1 | === modified file 'txaws/server/schema.py' |
2 | --- txaws/server/schema.py 2012-03-27 11:51:09 +0000 |
3 | +++ txaws/server/schema.py 2012-05-02 15:45:23 +0000 |
4 | @@ -21,11 +21,19 @@ |
5 | @param name: The name of the missing parameter. |
6 | """ |
7 | |
8 | - def __init__(self, name): |
9 | + def __init__(self, name, kind=None): |
10 | message = "The request must contain the parameter %s" % name |
11 | + if kind is not None: |
12 | + message += " (%s)" % (kind,) |
13 | super(MissingParameterError, self).__init__(message) |
14 | |
15 | |
16 | +class InconsistentParameterError(SchemaError): |
17 | + def __init__(self, name): |
18 | + message = "Parameter %s is used inconsistently" % (name,) |
19 | + super(InconsistentParameterError, self).__init__(message) |
20 | + |
21 | + |
22 | class InvalidParameterValueError(SchemaError): |
23 | """Raised when the value of a parameter is invalid.""" |
24 | |
25 | @@ -51,6 +59,20 @@ |
26 | super(UnknownParameterError, self).__init__(message) |
27 | |
28 | |
29 | +class UnknownParametersError(Exception): |
30 | + """ |
31 | + Raised when extra unknown fields are passed to L{Structure.parse}. |
32 | + |
33 | + @ivar result: The already coerced result representing the known parameters. |
34 | + @ivar unknown: The unknown parameters. |
35 | + """ |
36 | + def __init__(self, result, unknown): |
37 | + self.result = result |
38 | + self.unknown = unknown |
39 | + message = "The parameters %s are not recognized" % (unknown,) |
40 | + super(UnknownParametersError, self).__init__(message) |
41 | + |
42 | + |
43 | class Parameter(object): |
44 | """A single parameter in an HTTP request. |
45 | |
46 | @@ -67,7 +89,10 @@ |
47 | @param validator: A callable to validate the parameter, returning a bool. |
48 | """ |
49 | |
50 | - def __init__(self, name, optional=False, default=None, |
51 | + supports_multiple = False |
52 | + kind = None |
53 | + |
54 | + def __init__(self, name=None, optional=False, default=None, |
55 | min=None, max=None, allow_none=False, validator=None): |
56 | self.name = name |
57 | self.optional = optional |
58 | @@ -91,7 +116,7 @@ |
59 | value = "" |
60 | if value == "": |
61 | if not self.allow_none: |
62 | - raise MissingParameterError(self.name) |
63 | + raise MissingParameterError(self.name, kind=self.kind) |
64 | return self.default |
65 | try: |
66 | self._check_range(value) |
67 | @@ -182,7 +207,7 @@ |
68 | lower_than_min_template = "Value must be at least %s." |
69 | greater_than_max_template = "Value exceeds maximum of %s." |
70 | |
71 | - def __init__(self, name, optional=False, default=None, |
72 | + def __init__(self, name=None, optional=False, default=None, |
73 | min=0, max=None, allow_none=False, validator=None): |
74 | super(Integer, self).__init__(name, optional, default, min, max, |
75 | allow_none, validator) |
76 | @@ -228,8 +253,10 @@ |
77 | |
78 | kind = "enum" |
79 | |
80 | - def __init__(self, name, mapping, optional=False, default=None): |
81 | + def __init__(self, name=None, mapping=None, optional=False, default=None): |
82 | super(Enum, self).__init__(name, optional=optional, default=default) |
83 | + if mapping is None: |
84 | + raise TypeError("Must provide mapping") |
85 | self.mapping = mapping |
86 | self.reverse = dict((value, key) for key, value in mapping.iteritems()) |
87 | |
88 | @@ -260,18 +287,149 @@ |
89 | return datetime.strftime(utc_value, "%Y-%m-%dT%H:%M:%SZ") |
90 | |
91 | |
92 | +class List(Parameter): |
93 | + """ |
94 | + A homogenous list of instances of a parameterized type. |
95 | + |
96 | + There is a strange behavior that lists can have any starting index and any |
97 | + gaps are ignored. Conventionally they are 1-based, and so indexes proceed |
98 | + like 1, 2, 3... However, any non-negative index can be used and the |
99 | + ordering will be used to determine the true index. So:: |
100 | + |
101 | + {5: 'a', 7: 'b', 9: 'c'} |
102 | + |
103 | + becomes:: |
104 | + |
105 | + ['a', 'b', 'c'] |
106 | + """ |
107 | + |
108 | + kind = "list" |
109 | + supports_multiple = True |
110 | + |
111 | + def __init__(self, name=None, item=None, optional=False, default=None): |
112 | + """ |
113 | + @param item: A L{Parameter} instance which will be used to parse and |
114 | + format the values in the list. |
115 | + """ |
116 | + if item is None: |
117 | + raise TypeError("Must provide item") |
118 | + super(List, self).__init__(name, optional=optional, default=default) |
119 | + self.item = item |
120 | + if default is None: |
121 | + self.default = [] |
122 | + |
123 | + def parse(self, value): |
124 | + """ |
125 | + Convert a dictionary of {relative index: value} to a list of parsed |
126 | + C{value}s. |
127 | + """ |
128 | + indices = [] |
129 | + if not isinstance(value, dict): |
130 | + # We interpret non-list inputs as a list of one element, for |
131 | + # compatibility with certain EC2 APIs. |
132 | + return [self.item.coerce(value)] |
133 | + for index in value.keys(): |
134 | + try: |
135 | + indices.append(int(index)) |
136 | + except ValueError: |
137 | + raise UnknownParameterError(index) |
138 | + result = [None] * len(value) |
139 | + for index_index, index in enumerate(sorted(indices)): |
140 | + v = value[str(index)] |
141 | + if index < 0: |
142 | + raise UnknownParameterError(index) |
143 | + result[index_index] = self.item.coerce(v) |
144 | + return result |
145 | + |
146 | + def format(self, value): |
147 | + """ |
148 | + Convert a list like:: |
149 | + |
150 | + ["a", "b", "c"] |
151 | + |
152 | + to: |
153 | + |
154 | + {"1": "a", "2": "b", "3": "c"} |
155 | + |
156 | + C{value} may also be an L{Arguments} instance, mapping indices to |
157 | + values. Who knows why. |
158 | + """ |
159 | + if isinstance(value, Arguments): |
160 | + return dict((str(i), self.item.format(v)) for i, v in value) |
161 | + return dict((str(i + 1), self.item.format(v)) |
162 | + for i, v in enumerate(value)) |
163 | + |
164 | + |
165 | +class Structure(Parameter): |
166 | + """ |
167 | + A structure with named fields of parameterized types. |
168 | + """ |
169 | + |
170 | + kind = "structure" |
171 | + supports_multiple = True |
172 | + |
173 | + def __init__(self, name=None, fields=None, optional=False, default=None): |
174 | + """ |
175 | + @param fields: A mapping of field name to field L{Parameter} instance. |
176 | + """ |
177 | + if fields is None: |
178 | + raise TypeError("Must provide fields") |
179 | + super(Structure, self).__init__(name, optional=optional, |
180 | + default=default) |
181 | + self.fields = fields |
182 | + |
183 | + def parse(self, value): |
184 | + """ |
185 | + Convert a dictionary of raw values to a dictionary of processed values. |
186 | + """ |
187 | + result = {} |
188 | + rest = {} |
189 | + for k, v in value.iteritems(): |
190 | + if k in self.fields: |
191 | + if (isinstance(v, dict) |
192 | + and not self.fields[k].supports_multiple): |
193 | + if len(v) == 1: |
194 | + # We support "foo.1" as "foo" as long as there is only |
195 | + # one "foo.#" parameter provided.... -_- |
196 | + v = v.values()[0] |
197 | + else: |
198 | + raise InvalidParameterCombinationError(k) |
199 | + result[k] = self.fields[k].coerce(v) |
200 | + else: |
201 | + rest[k] = v |
202 | + for k, v in self.fields.iteritems(): |
203 | + if k not in result: |
204 | + result[k] = v.coerce(None) |
205 | + if rest: |
206 | + raise UnknownParametersError(result, rest) |
207 | + return result |
208 | + |
209 | + def format(self, value): |
210 | + """ |
211 | + Convert a dictionary of processed values to a dictionary of raw values. |
212 | + """ |
213 | + if not isinstance(value, Arguments): |
214 | + value = value.iteritems() |
215 | + return dict((k, self.fields[k].format(v)) for k, v in value) |
216 | + |
217 | + |
218 | class Arguments(object): |
219 | """Arguments parsed from a request.""" |
220 | |
221 | def __init__(self, tree): |
222 | """Initialize a new L{Arguments} instance. |
223 | |
224 | - @param tree: The C{dict}-based structure of the L{Argument}instance |
225 | + @param tree: The C{dict}-based structure of the L{Argument} instance |
226 | to create. |
227 | """ |
228 | for key, value in tree.iteritems(): |
229 | self.__dict__[key] = self._wrap(value) |
230 | |
231 | + def __str__(self): |
232 | + return "Arguments(%s)" % (self.__dict__,) |
233 | + |
234 | + __repr__ = __str__ |
235 | + |
236 | def __iter__(self): |
237 | """Returns an iterator yielding C{(name, value)} tuples.""" |
238 | return self.__dict__.iteritems() |
239 | @@ -288,7 +446,7 @@ |
240 | """Wrap the given L{tree} with L{Arguments} as necessary. |
241 | |
242 | @param tree: A {dict}, containing L{dict}s and/or leaf values, nested |
243 | - arbitrarily deep. |
244 | + arbitrarily deep. |
245 | """ |
246 | if isinstance(value, dict): |
247 | if any(isinstance(name, int) for name in value.keys()): |
248 | @@ -299,6 +457,8 @@ |
249 | return [self._wrap(value) for (name, value) in items] |
250 | else: |
251 | return Arguments(value) |
252 | + elif isinstance(value, list): |
253 | + return [self._wrap(x) for x in value] |
254 | else: |
255 | return value |
256 | |
257 | @@ -308,7 +468,7 @@ |
258 | The schema that the arguments of an HTTP request must be compliant with. |
259 | """ |
260 | |
261 | - def __init__(self, *parameters): |
262 | + def __init__(self, *_parameters, **kwargs): |
263 | """Initialize a new L{Schema} instance. |
264 | |
265 | Any number of L{Parameter} instances can be passed. The parameter path |
266 | @@ -323,60 +483,34 @@ |
267 | |
268 | A more complex example:: |
269 | |
270 | - schema = Schema(Unicode('Name.#')) |
271 | + schema = Schema(List('Names', item=Unicode())) |
272 | |
273 | - means that the result of L{Schema.extract} would have a C{Name} |
274 | + means that the result of L{Schema.extract} would have a C{Names} |
275 | attribute, which would itself contain a list of names. Similarly, |
276 | - L{Schema.bundle} would look for a C{Name} attribute. |
277 | + L{Schema.bundle} would look for a C{Names} attribute. |
278 | """ |
279 | - self._parameters = dict( |
280 | - (self._get_template(parameter.name), parameter) |
281 | - for parameter in parameters) |
282 | + if 'parameters' in kwargs: |
283 | + if len(_parameters) > 0: |
284 | + raise TypeError("parameters= must only be passed " |
285 | + "without positional arguments") |
286 | + self._parameters = kwargs['parameters'] |
287 | + else: |
288 | + self._parameters = self._convert_old_schema(_parameters) |
289 | |
290 | def extract(self, params): |
291 | """Extract parameters from a raw C{dict} according to this schema. |
292 | |
293 | @param params: The raw parameters to parse. |
294 | - @return: An L{Arguments} object holding the extracted arguments. |
295 | - |
296 | - @raises UnknownParameterError: If C{params} contains keys that this |
297 | - schema doesn't know about. |
298 | + @return: A tuple of an L{Arguments} object holding the extracted |
299 | + arguments and any unparsed arguments. |
300 | """ |
301 | - tree = {} |
302 | - rest = {} |
303 | - |
304 | - # Extract from the given arguments and parse according to the |
305 | - # corresponding parameters. |
306 | - for name, value in params.iteritems(): |
307 | - template = self._get_template(name) |
308 | - parameter = self._parameters.get(template) |
309 | - |
310 | - if template.endswith(".#") and parameter is None: |
311 | - # If we were unable to find a direct match for a template that |
312 | - # allows multiple values. Let's attempt to find it without the |
313 | - # multiple value marker which Amazon allows. For example if the |
314 | - # template is 'PublicIp', then a single key 'PublicIp.1' is |
315 | - # allowed. |
316 | - parameter = self._parameters.get(template[:-2]) |
317 | - if parameter is not None: |
318 | - name = name[:-2] |
319 | - |
320 | - # At this point, we have a template that doesn't have the .# |
321 | - # marker to indicate multiple values. We don't allow multiple |
322 | - # "single" values for the same element. |
323 | - if name in tree.keys(): |
324 | - raise InvalidParameterCombinationError(name) |
325 | - |
326 | - if parameter is None: |
327 | - rest[name] = value |
328 | - else: |
329 | - self._set_value(tree, name, parameter.coerce(value)) |
330 | - |
331 | - # Ensure that the tree arguments are consistent with constraints |
332 | - # defined in the schema. |
333 | - for template, parameter in self._parameters.iteritems(): |
334 | - self._ensure_tree(tree, parameter, *template.split(".")) |
335 | - |
336 | + structure = Structure(fields=self._parameters) |
337 | + try: |
338 | + tree = structure.coerce(self._convert_flat_to_nest(params)) |
339 | + rest = {} |
340 | + except UnknownParametersError, error: |
341 | + tree = error.result |
342 | + rest = self._convert_nest_to_flat(error.unknown) |
343 | return Arguments(tree), rest |
344 | |
345 | def bundle(self, *arguments, **extra): |
346 | @@ -390,117 +524,85 @@ |
347 | params = {} |
348 | |
349 | for argument in arguments: |
350 | - self._flatten(params, argument) |
351 | - self._flatten(params, extra) |
352 | + params.update(argument) |
353 | |
354 | + params.update(extra) |
355 | + result = {} |
356 | for name, value in params.iteritems(): |
357 | - parameter = self._parameters.get(self._get_template(name)) |
358 | + if value is None: |
359 | + continue |
360 | + segments = name.split('.') |
361 | + first = segments[0] |
362 | + parameter = self._parameters.get(first) |
363 | if parameter is None: |
364 | raise RuntimeError("Parameter '%s' not in schema" % name) |
365 | else: |
366 | if value is None: |
367 | - params[name] = "" |
368 | - else: |
369 | - params[name] = parameter.format(value) |
370 | - |
371 | - return params |
372 | - |
373 | - def _get_template(self, key): |
374 | - """Return the canonical template for a given parameter key. |
375 | - |
376 | - For example:: |
377 | - |
378 | - 'Child.1.Name.2' |
379 | - |
380 | - becomes:: |
381 | - |
382 | - 'Child.#.Name.#' |
383 | - |
384 | - """ |
385 | - parts = key.split(".") |
386 | - for index, part in enumerate(parts[1::2]): |
387 | - parts[index * 2 + 1] = "#" |
388 | - return ".".join(parts) |
389 | - |
390 | - def _set_value(self, tree, path, value): |
391 | - """Set C{value} at C{path} in the given C{tree}. |
392 | - |
393 | - For example:: |
394 | - |
395 | - tree = {} |
396 | - _set_value(tree, 'foo.1.bar.2', True) |
397 | - |
398 | - results in C{tree} becoming:: |
399 | - |
400 | - {'foo': {1: {'bar': {2: True}}}} |
401 | - |
402 | - @param tree: A L{dict}. |
403 | - @param path: A L{str}. |
404 | - @param value: The value to set. Can be anything. |
405 | - """ |
406 | - nodes = [] |
407 | - for index, node in enumerate(path.split(".")): |
408 | - if index % 2: |
409 | - # Nodes with odd indexes must be non-negative integers |
410 | - try: |
411 | - node = int(node) |
412 | - except ValueError: |
413 | - raise UnknownParameterError(path) |
414 | - if node < 0: |
415 | - raise UnknownParameterError(path) |
416 | - nodes.append(node) |
417 | - for node in nodes[:-1]: |
418 | - tree = tree.setdefault(node, {}) |
419 | - tree[nodes[-1]] = value |
420 | - |
421 | - def _ensure_tree(self, tree, parameter, node, *nodes): |
422 | - """Check that C{node} exists in C{tree} and is followed by C{nodes}. |
423 | - |
424 | - C{node} and C{nodes} should correspond to a template path (i.e. where |
425 | - there are no absolute indexes, but C{#} instead). |
426 | - """ |
427 | - if node == "#": |
428 | - if len(nodes) == 0: |
429 | - if len(tree.keys()) == 0 and not parameter.optional: |
430 | - raise MissingParameterError(parameter.name) |
431 | - else: |
432 | - for subtree in tree.itervalues(): |
433 | - self._ensure_tree(subtree, parameter, *nodes) |
434 | - else: |
435 | - if len(nodes) == 0: |
436 | - if node not in tree.keys(): |
437 | - # No value for this parameter is present, if it's not |
438 | - # optional nor allow_none is set, the call below will |
439 | - # raise a MissingParameterError |
440 | - tree[node] = parameter.coerce(None) |
441 | - else: |
442 | - if node not in tree.keys(): |
443 | - tree[node] = {} |
444 | - self._ensure_tree(tree[node], parameter, *nodes) |
445 | - |
446 | - def _flatten(self, params, tree, path=""): |
447 | - """ |
448 | - For every element in L{tree}, set C{path} to C{value} in the given |
449 | - L{params} dictionary. |
450 | - |
451 | - @param params: A L{dict} which will be populated. |
452 | - @param tree: A structure made up of L{Argument}s, L{list}s, L{dict}s |
453 | - and leaf values. |
454 | - """ |
455 | - if isinstance(tree, Arguments): |
456 | - for name, value in tree: |
457 | - self._flatten(params, value, "%s.%s" % (path, name)) |
458 | - elif isinstance(tree, dict): |
459 | - for name, value in tree.iteritems(): |
460 | - self._flatten(params, value, "%s.%s" % (path, name)) |
461 | - elif isinstance(tree, list): |
462 | - for index, value in enumerate(tree): |
463 | - self._flatten(params, value, "%s.%d" % (path, index + 1)) |
464 | - elif tree is not None: |
465 | - params[path.lstrip(".")] = tree |
466 | - else: |
467 | - # None is discarded. |
468 | - pass |
469 | + result[name] = "" |
470 | + else: |
471 | + result[name] = parameter.format(value) |
472 | + |
473 | + return self._convert_nest_to_flat(result) |
474 | + |
475 | + def _convert_flat_to_nest(self, params): |
476 | + """ |
477 | + Convert a structure in the form of:: |
478 | + |
479 | + {'foo.1.bar': 'value', |
480 | + 'foo.2.baz': 'value'} |
481 | + |
482 | + to:: |
483 | + |
484 | + {'foo': {'1': {'bar': 'value'}, |
485 | + '2': {'baz': 'value'}}} |
486 | + |
487 | + This is intended for use both during parsing of HTTP arguments like |
488 | + 'foo.1.bar=value' and when dealing with schema declarations that look |
489 | + like 'foo.n.bar'. |
490 | + |
491 | + This is the inverse of L{_convert_nest_to_flat}. |
492 | + """ |
493 | + result = {} |
494 | + for k, v in params.iteritems(): |
495 | + last = result |
496 | + segments = k.split('.') |
497 | + for index, item in enumerate(segments): |
498 | + if index == len(segments) - 1: |
499 | + newd = v |
500 | + else: |
501 | + newd = {} |
502 | + if not isinstance(last, dict): |
503 | + raise InconsistentParameterError(k) |
504 | + if type(last.get(item)) is dict and type(newd) is not dict: |
505 | + raise InconsistentParameterError(k) |
506 | + last = last.setdefault(item, newd) |
507 | + return result |
508 | + |
509 | + def _convert_nest_to_flat(self, params, _result=None, _prefix=None): |
510 | + """ |
511 | + Convert a data structure that looks like:: |
512 | + |
513 | + {"foo": {"bar": "baz", "shimmy": "sham"}} |
514 | + |
515 | + to:: |
516 | + |
517 | + {"foo.bar": "baz", |
518 | + "foo.shimmy": "sham"} |
519 | + |
520 | + This is the inverse of L{_convert_flat_to_nest}. |
521 | + """ |
522 | + if _result is None: |
523 | + _result = {} |
524 | + for k, v in params.iteritems(): |
525 | + if _prefix is None: |
526 | + path = k |
527 | + else: |
528 | + path = _prefix + '.' + k |
529 | + if isinstance(v, dict): |
530 | + self._convert_nest_to_flat(v, _result=_result, _prefix=path) |
531 | + else: |
532 | + _result[path] = v |
533 | + return _result |
534 | |
535 | def extend(self, *schema_items): |
536 | """ |
537 | @@ -513,3 +615,50 @@ |
538 | else: |
539 | raise TypeError("Illegal argument %s" % item) |
540 | return Schema(*parameters) |
541 | + |
542 | + def _convert_old_schema(self, parameters): |
543 | + """ |
544 | + Convert an ugly old schema, using dotted names, to the hot new schema, |
545 | + using List and Structure. |
546 | + |
547 | + The old schema assumes that every other dot implies an array. So a list |
548 | + of two parameters, |
549 | + |
550 | + [Integer("foo.bar.baz.quux"), Integer("foo.bar.shimmy")] |
551 | + |
552 | + becomes:: |
553 | + |
554 | + {"foo": List( |
555 | + item=Structure( |
556 | + fields={"baz": List(item=Integer()), |
557 | + "shimmy": Integer()}))} |
558 | + |
559 | + By design, the old schema syntax ignored the names "bar" and "quux". |
560 | + """ |
561 | + crap = {} |
562 | + for parameter in parameters: |
563 | + crap[parameter.name] = parameter |
564 | + nest = self._convert_flat_to_nest(crap) |
565 | + return self._inner_convert_old_schema(nest, 0).fields |
566 | + |
567 | + def _inner_convert_old_schema(self, mapping, depth): |
568 | + """ |
569 | + Internal recursion helper for L{_convert_old_schema}. |
570 | + """ |
571 | + if not isinstance(mapping, dict): |
572 | + return mapping |
573 | + if depth % 2 == 0: |
574 | + fields = {} |
575 | + for k, v in mapping.iteritems(): |
576 | + fields[k] = self._inner_convert_old_schema(v, depth + 1) |
577 | + return Structure("anonymous_structure", fields=fields) |
578 | + else: |
579 | + if not isinstance(mapping, dict): |
580 | + raise TypeError("mapping %r must be a dict" % (mapping,)) |
581 | + if not len(mapping) == 1: |
582 | + raise ValueError("Multiple different index names specified: %r" |
583 | + % (mapping.keys(),)) |
584 | + item = mapping.values()[0] |
585 | + item = self._inner_convert_old_schema(item, depth + 1) |
586 | + name = item.name.split('.', 1)[0] |
587 | + return List(name=name, item=item, optional=item.optional) |
588 | |
589 | === modified file 'txaws/server/tests/test_resource.py' |
590 | --- txaws/server/tests/test_resource.py 2012-01-27 02:10:24 +0000 |
591 | +++ txaws/server/tests/test_resource.py 2012-05-02 15:45:23 +0000 |
592 | @@ -344,7 +344,8 @@ |
593 | errors = self.flushLoggedErrors() |
594 | self.assertEquals(0, len(errors)) |
595 | self.assertEqual("MissingParameter - The request must contain " |
596 | - "the parameter Action", request.response) |
597 | + "the parameter Action (unicode)", |
598 | + request.response) |
599 | self.assertEqual(400, request.code) |
600 | |
601 | return self.api.handle(request).addCallback(check) |
602 | |
603 | === modified file 'txaws/server/tests/test_schema.py' |
604 | --- txaws/server/tests/test_schema.py 2012-03-27 12:01:45 +0000 |
605 | +++ txaws/server/tests/test_schema.py 2012-05-02 15:45:23 +0000 |
606 | @@ -8,7 +8,9 @@ |
607 | |
608 | from txaws.server.exception import APIError |
609 | from txaws.server.schema import ( |
610 | - Arguments, Bool, Date, Enum, Integer, Parameter, RawStr, Schema, Unicode) |
611 | + Arguments, Bool, Date, Enum, Integer, Parameter, RawStr, Schema, Unicode, |
612 | + List, Structure, |
613 | + InconsistentParameterError, InvalidParameterValueError) |
614 | |
615 | |
616 | class ArgumentsTestCase(TestCase): |
617 | @@ -77,10 +79,12 @@ |
618 | required but not present in the request. |
619 | """ |
620 | parameter = Parameter("Test") |
621 | + parameter.kind = "testy kind" |
622 | error = self.assertRaises(APIError, parameter.coerce, None) |
623 | self.assertEqual(400, error.status) |
624 | self.assertEqual("MissingParameter", error.code) |
625 | - self.assertEqual("The request must contain the parameter Test", |
626 | + self.assertEqual("The request must contain the parameter Test " |
627 | + "(testy kind)", |
628 | error.message) |
629 | |
630 | def test_coerce_with_default(self): |
631 | @@ -381,6 +385,11 @@ |
632 | _, rest = schema.extract({"name": "value"}) |
633 | self.assertEqual(rest, {"name": "value"}) |
634 | |
635 | + def test_extract_with_nested_rest(self): |
636 | + schema = Schema() |
637 | + _, rest = schema.extract({"foo.1.bar": "hey", "foo.2.baz": "there"}) |
638 | + self.assertEqual({"foo.1.bar": "hey", "foo.2.baz": "there"}, rest) |
639 | + |
640 | def test_extract_with_many_arguments(self): |
641 | """L{Schema.extract} can handle multiple parameters.""" |
642 | schema = Schema(Unicode("name"), Integer("count")) |
643 | @@ -395,6 +404,26 @@ |
644 | self.assertEqual(u"value", arguments.name) |
645 | self.assertEqual(None, arguments.count) |
646 | |
647 | + def test_extract_with_optional_default(self): |
648 | + """ |
649 | + The value of C{default} on a parameter is used as the value when it is |
650 | + not provided as an argument and the parameter is C{optional}. |
651 | + """ |
652 | + schema = Schema(Unicode("name"), |
653 | + Integer("count", optional=True, default=5)) |
654 | + arguments, _ = schema.extract({"name": "value"}) |
655 | + self.assertEqual(u"value", arguments.name) |
656 | + self.assertEqual(5, arguments.count) |
657 | + |
658 | + def test_extract_structure_with_optional(self): |
659 | + """L{Schema.extract} can handle optional parameters.""" |
660 | + schema = Schema( |
661 | + Structure( |
662 | + "struct", |
663 | + fields={"name": Unicode(optional=True, default="radix")})) |
664 | + arguments, _ = schema.extract({"struct": {}}) |
665 | + self.assertEqual(u"radix", arguments.struct.name) |
666 | + |
667 | def test_extract_with_numbered(self): |
668 | """ |
669 | L{Schema.extract} can handle parameters with numbered values. |
670 | @@ -404,12 +433,23 @@ |
671 | self.assertEqual("Joe", arguments.name[0]) |
672 | self.assertEqual("Tom", arguments.name[1]) |
673 | |
674 | + def test_extract_with_goofy_numbered(self): |
675 | + """ |
676 | + L{Schema.extract} only uses the relative values of indices to determine |
677 | + the index in the resultant list. |
678 | + """ |
679 | + schema = Schema(Unicode("name.n")) |
680 | + arguments, _ = schema.extract({"name.5": "Joe", "name.10": "Tom"}) |
681 | + self.assertEqual("Joe", arguments.name[0]) |
682 | + self.assertEqual("Tom", arguments.name[1]) |
683 | + |
684 | def test_extract_with_single_numbered(self): |
685 | """ |
686 | - L{Schema.extract} can handle a single parameter with a numbered value. |
687 | + L{Schema.extract} can handle an un-numbered argument passed in to a |
688 | + numbered parameter. |
689 | """ |
690 | schema = Schema(Unicode("name.n")) |
691 | - arguments, _ = schema.extract({"name.0": "Joe"}) |
692 | + arguments, _ = schema.extract({"name": "Joe"}) |
693 | self.assertEqual("Joe", arguments.name[0]) |
694 | |
695 | def test_extract_complex(self): |
696 | @@ -458,8 +498,8 @@ |
697 | given without an index. |
698 | """ |
699 | schema = Schema(Unicode("name.n")) |
700 | - _, rest = schema.extract({"name": "foo", "name.1": "bar"}) |
701 | - self.assertEqual(rest, {"name": "foo"}) |
702 | + self.assertRaises(InconsistentParameterError, |
703 | + schema.extract, {"name": "foo", "name.1": "bar"}) |
704 | |
705 | def test_extract_with_non_numbered_template(self): |
706 | """ |
707 | @@ -480,7 +520,7 @@ |
708 | error = self.assertRaises(APIError, schema.extract, params) |
709 | self.assertEqual(400, error.status) |
710 | self.assertEqual("UnknownParameter", error.code) |
711 | - self.assertEqual("The parameter name.one is not recognized", |
712 | + self.assertEqual("The parameter one is not recognized", |
713 | error.message) |
714 | |
715 | def test_extract_with_negative_index(self): |
716 | @@ -493,7 +533,7 @@ |
717 | error = self.assertRaises(APIError, schema.extract, params) |
718 | self.assertEqual(400, error.status) |
719 | self.assertEqual("UnknownParameter", error.code) |
720 | - self.assertEqual("The parameter name.-1 is not recognized", |
721 | + self.assertEqual("The parameter -1 is not recognized", |
722 | error.message) |
723 | |
724 | def test_bundle(self): |
725 | @@ -513,6 +553,16 @@ |
726 | params = schema.bundle(name=["foo", "bar"]) |
727 | self.assertEqual({"name.1": "foo", "name.2": "bar"}, params) |
728 | |
729 | + def test_bundle_with_two_numbered(self): |
730 | + """ |
731 | + L{Schema.bundle} can bundle multiple numbered lists. |
732 | + """ |
733 | + schema = Schema(Unicode("names.n"), Unicode("things.n")) |
734 | + params = schema.bundle(names=["foo", "bar"], things=["baz", "quux"]) |
735 | + self.assertEqual({"names.1": "foo", "names.2": "bar", |
736 | + "things.1": "baz", "things.2": "quux"}, |
737 | + params) |
738 | + |
739 | def test_bundle_with_none(self): |
740 | """L{None} values are discarded in L{Schema.bundle}.""" |
741 | schema = Schema(Unicode("name.n", optional=True)) |
742 | @@ -524,7 +574,7 @@ |
743 | L{Schema.bundle} correctly handles an empty numbered arguments list. |
744 | """ |
745 | schema = Schema(Unicode("name.n")) |
746 | - params = schema.bundle(names=[]) |
747 | + params = schema.bundle(name=[]) |
748 | self.assertEqual({}, params) |
749 | |
750 | def test_bundle_with_numbered_not_supplied(self): |
751 | @@ -544,6 +594,41 @@ |
752 | self.assertEqual({"name.1": "Foo", "name.2": "Bar", "count": "123"}, |
753 | params) |
754 | |
755 | + def test_bundle_with_structure(self): |
756 | + """L{Schema.bundle} can bundle L{Structure}s.""" |
757 | + schema = Schema( |
758 | + parameters={ |
759 | + "struct": Structure(fields={"field1": Unicode(), |
760 | + "field2": Integer()})}) |
761 | + params = schema.bundle(struct={"field1": "hi", "field2": 59}) |
762 | + self.assertEqual({"struct.field1": "hi", "struct.field2": "59"}, |
763 | + params) |
764 | + |
765 | + def test_bundle_with_list(self): |
766 | + """L{Schema.bundle} can bundle L{List}s.""" |
767 | + schema = Schema(parameters={"things": List(item=Unicode())}) |
768 | + params = schema.bundle(things=["foo", "bar"]) |
769 | + self.assertEqual({"things.1": "foo", "things.2": "bar"}, params) |
770 | + |
771 | + def test_bundle_with_structure_with_arguments(self): |
772 | + """ |
773 | + L{Schema.bundle} can bundle L{Structure}s (specified as L{Arguments}). |
774 | + """ |
775 | + schema = Schema( |
776 | + parameters={ |
777 | + "struct": Structure(fields={"field1": Unicode(), |
778 | + "field2": Integer()})}) |
779 | + params = schema.bundle(struct=Arguments({"field1": "hi", |
780 | + "field2": 59})) |
781 | + self.assertEqual({"struct.field1": "hi", "struct.field2": "59"}, |
782 | + params) |
783 | + |
784 | + def test_bundle_with_list_with_arguments(self): |
785 | + """L{Schema.bundle} can bundle L{List}s (specified as L{Arguments}).""" |
786 | + schema = Schema(parameters={"things": List(item=Unicode())}) |
787 | + params = schema.bundle(things=Arguments({1: "foo", 2: "bar"})) |
788 | + self.assertEqual({"things.1": "foo", "things.2": "bar"}, params) |
789 | + |
790 | def test_bundle_with_arguments(self): |
791 | """L{Schema.bundle} can bundle L{Arguments} too.""" |
792 | schema = Schema(Unicode("name.n"), Integer("count")) |
793 | @@ -590,3 +675,124 @@ |
794 | self.assertEqual(u"value", arguments.name) |
795 | self.assertEqual("testing", arguments.computer) |
796 | self.assertEqual(5, arguments.count) |
797 | + |
798 | + def test_list(self): |
799 | + """L{List}s can be extracted.""" |
800 | + schema = Schema(List("foo", Integer())) |
801 | + arguments, _ = schema.extract({"foo.1": "1", "foo.2": "2"}) |
802 | + self.assertEqual([1, 2], arguments.foo) |
803 | + |
804 | + def test_optional_list(self): |
805 | + """ |
806 | + The default value of an optional L{List} is C{[]}. |
807 | + """ |
808 | + schema = Schema(List("names", Unicode(), optional=True)) |
809 | + arguments, _ = schema.extract({}) |
810 | + self.assertEqual([], arguments.names) |
811 | + |
812 | + def test_default_list(self): |
813 | + """ |
814 | + The default of a L{List} can be specified as a list. |
815 | + """ |
816 | + schema = Schema(List("names", Unicode(), optional=True, default=[u"foo", u"bar"])) |
817 | + arguments, _ = schema.extract({}) |
818 | + self.assertEqual([u"foo", u"bar"], arguments.names) |
819 | + |
820 | + def test_list_of_list(self): |
821 | + """L{List}s can be nested.""" |
822 | + schema = Schema(List("foo", List(item=Unicode()))) |
823 | + arguments, _ = schema.extract( |
824 | + {"foo.1.1": "first-first", "foo.1.2": "first-second", |
825 | + "foo.2.1": "second-first", "foo.2.2": "second-second"}) |
826 | + self.assertEqual([["first-first", "first-second"], |
827 | + ["second-first", "second-second"]], |
828 | + arguments.foo) |
829 | + |
830 | + def test_structure(self): |
831 | + """ |
832 | + L{Schema}s with L{Structure} parameters can have arguments extracted. |
833 | + """ |
834 | + schema = Schema(Structure("foo", {"a": Integer(), "b": Integer()})) |
835 | + arguments, _ = schema.extract({"foo.a": "1", "foo.b": "2"}) |
836 | + self.assertEqual(1, arguments.foo.a) |
837 | + self.assertEqual(2, arguments.foo.b) |
838 | + |
839 | + def test_structure_of_structures(self): |
840 | + """L{Structure}s can be nested.""" |
841 | + sub_struct = Structure(fields={"a": Unicode(), "b": Unicode()}) |
842 | + schema = Schema(Structure("foo", fields={"a": sub_struct, |
843 | + "b": sub_struct})) |
844 | + arguments, _ = schema.extract({"foo.a.a": "a-a", "foo.a.b": "a-b", |
845 | + "foo.b.a": "b-a", "foo.b.b": "b-b"}) |
846 | + self.assertEqual("a-a", arguments.foo.a.a) |
847 | + self.assertEqual("a-b", arguments.foo.a.b) |
848 | + self.assertEqual("b-a", arguments.foo.b.a) |
849 | + self.assertEqual("b-b", arguments.foo.b.b) |
850 | + |
851 | + def test_list_of_structures(self): |
852 | + """L{List}s of L{Structure}s are extracted properly.""" |
853 | + schema = Schema( |
854 | + List("foo", Structure(fields={"a": Integer(), "b": Integer()}))) |
855 | + arguments, _ = schema.extract({"foo.1.a": "1", "foo.1.b": "2", |
856 | + "foo.2.a": "3", "foo.2.b": "4"}) |
857 | + self.assertEqual(1, arguments.foo[0]['a']) |
858 | + self.assertEqual(2, arguments.foo[0]['b']) |
859 | + self.assertEqual(3, arguments.foo[1]['a']) |
860 | + self.assertEqual(4, arguments.foo[1]['b']) |
861 | + |
862 | + def test_structure_of_list(self): |
863 | + """L{Structure}s of L{List}s are extracted properly.""" |
864 | + schema = Schema(Structure("foo", fields={"l": List(item=Integer())})) |
865 | + arguments, _ = schema.extract({"foo.l.1": "1", "foo.l.2": "2"}) |
866 | + self.assertEqual([1, 2], arguments.foo.l) |
867 | + |
868 | + def test_new_parameters(self): |
869 | + """ |
870 | + L{Schema} accepts a C{parameters} parameter to specify parameters in a |
871 | + {name: field} format. |
872 | + """ |
873 | + schema = Schema( |
874 | + parameters={"foo": Structure( |
875 | + fields={"l": List(item=Integer())})}) |
876 | + arguments, _ = schema.extract({"foo.l.1": "1", "foo.l.2": "2"}) |
877 | + self.assertEqual([1, 2], arguments.foo.l) |
878 | + |
879 | + def test_schema_conversion_list_name(self): |
880 | + """ |
881 | + Backwards-compatibility conversion maintains the name of lists. |
882 | + """ |
883 | + schema = Schema(Unicode("foos.N")) |
884 | + self.assertEqual("foos", schema._parameters["foos"].name) |
885 | + |
886 | + def test_schema_conversion_structure_name(self): |
887 | + """ |
888 | + Backwards-compatibility conversion maintains the names of fields in |
889 | + structures. |
890 | + """ |
891 | + schema = Schema(Unicode("foos.N.field"), |
892 | + Unicode("foos.N.field2")) |
893 | + self.assertEqual("anonymous_structure", |
894 | + schema._parameters["foos"].item.name) |
895 | + self.assertEqual("foos.N.field", |
896 | + schema._parameters["foos"].item.fields["field"].name) |
897 | + self.assertEqual("foos.N.field2", |
898 | + schema._parameters["foos"].item.fields["field2"].name) |
899 | + |
900 | + def test_schema_conversion_optional_list(self): |
901 | + """ |
902 | + Backwards-compatibility conversions maintains optional-ness of lists. |
903 | + """ |
904 | + schema = Schema(Unicode("foos.N", optional=True)) |
905 | + arguments, _ = schema.extract({}) |
906 | + self.assertEqual([], arguments.foos) |
907 | + |
908 | + def test_schema_conversion_optional_structure_field(self): |
909 | + """ |
910 | + Backwards-compatibility conversion maintains optional-ness of structure |
911 | + fields. |
912 | + """ |
913 | + schema = Schema(Unicode("foos.N.field"), |
914 | + Unicode("foos.N.field2", optional=True, default=u"hi")) |
915 | + arguments, _ = schema.extract({"foos.0.field": u"existent"}) |
916 | + self.assertEqual(u"existent", arguments.foos[0].field) |
917 | + self.assertEqual(u"hi", arguments.foos[0].field2) |
[1] In _secret_ convert_ old_schema, you should pass item.name as name parameter to List, to keep compatibility.
[2] You also need to forward the optional parameter.
[3] Still in the sample, it seems you should forward the default value, and use [] otherwise as default.
[4] In _convert_ nest_to_ flat, you return as soon as you get a dict: if you have more params, they get ignore. You should probably call _result.update instead.
[5]
+ {'foo.1.bar': 'value',
+ 'foo.2.baz': 'value'}
+
+ to::
+
+ {'foo': {'1': {'bar': 'value'},
+ '2': {'baz': 'value'}}}
I think we talked about that, but what's the reason we don't convert this to:
{'foo': [{'bar': 'value'}, {'baz': 'value'}]} ?
FWIW, I discovered most of this running Landscape/ Clouddeck. We can deal with some small incompatibilities, but it'd be nice to narrow them down. Thanks!