Merge lp:~radix/txaws/parameter-enrichment into lp:txaws

Proposed by Christopher Armstrong
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
Reviewer Review Type Date Requested Status
Chad Smith (community) Approve
Thomas Herve Approve
Review via email: mp+103592@code.launchpad.net

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.

To post a comment you must log in.
Revision history for this message
Thomas Herve (therve) wrote :

[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!

review: Approve
Revision history for this message
Thomas Herve (therve) :
review: Needs Fixing
lp:~radix/txaws/parameter-enrichment updated
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

Revision history for this message
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.

lp:~radix/txaws/parameter-enrichment updated
151. By Christopher Armstrong

- fix the 'rest' return value from extract so it returns the original format of arguments and not a nested structure.

Revision history for this message
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).

Revision history for this message
Christopher Armstrong (radix) wrote :

Oh, and to address your comments explicitly:

[1] fixed (name.split('.',1)[0], actually)
[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!

lp:~radix/txaws/parameter-enrichment updated
152. By Christopher Armstrong

add an explicit test for specified defaults of Lists - already works, just
double-checking

Revision history for this message
Thomas Herve (therve) wrote :

[6]
+ _result.update(self._convert_nest_to_flat(v, _result=_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!

review: Approve
lp:~radix/txaws/parameter-enrichment updated
153. By Christopher Armstrong

we don't need to update the result dict, the function is doing it for us.

Revision history for this message
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 MissingParameterError(self.name)
+ if self.kind == "list":
+ raise MissingParameterError("%s or %s.#" %
+ (self.name, self.name))
+ else:
+ raise MissingParameterError(self.name)

[2] Should probably add a more explicit error message test in test_non_list:

        if not isinstance(value, dict):
            raise InvalidParameterValueError("%r should be a dict." % (value,))
+ self.assertEqual(400, error.status)
+ self.assertEqual("InvalidParameterValue", error.code)
+ self.assertEqual("'foo' should be a dict.",
+ error.message)

[3] Do we care about schema.extract error conditions if Schema is defined as Unicode("names.blah") and "names.10" and "names.20" are specified? Right now, this doesn't raise errors and schema extract gives us arguments.name[0] and arguments.name[1]. Should Unicode schema definition/declaration be constrained a bit more to prevent misuse in initial Schema definition? It seems we'll accept anything delimited by a single "." which we then interpret as a list.

[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.

review: Approve
lp:~radix/txaws/parameter-enrichment updated
154. By Christopher Armstrong

handle non-lists as list input.

155. By Christopher Armstrong

include the kind of the parameter in the MissingParameter error

Revision history for this message
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

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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)

Subscribers

People subscribed via source and target branches