Merge lp:~zyga/linaro-python-json/schema-and-object-tracing into lp:linaro-python-json
- schema-and-object-tracing
- Merge into trunk
Status: | Merged |
---|---|
Approved by: | James Westby |
Approved revision: | 107 |
Merged at revision: | 105 |
Proposed branch: | lp:~zyga/linaro-python-json/schema-and-object-tracing |
Merge into: | lp:linaro-python-json |
Diff against target: |
884 lines (+410/-111) 2 files modified
linaro_json/schema.py (+251/-75) linaro_json/tests/test_schema.py (+159/-36) |
To merge this branch: | bzr merge lp:~zyga/linaro-python-json/schema-and-object-tracing |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
James Westby (community) | Approve | ||
Review via email: mp+40723@code.launchpad.net |
Commit message
Description of the change
This branch contains all the code necessary to make use of linaro-python-json in launch-control to validate our dashboard bundle schema.
There are two separate changes:
1) The big change is error reporting. Previously there was a rather verbose dunp of JSON and a murky message what failed to validate (previously in this branch, in launch-control it was even worse). This has been changed to have a very short and concrete message of what the failure is (most of the time it's a constant string, rarely parametrized by expected data type). To allow the user or software to look up the offending piece of data and schema there are two new properties on ValidationError: object_expr and schema_expr. Each one gives a JavaScript expression that if evaluated against the data or schema objects yields the part that failed to validate. It can be thought as extremely simple xpath query.
All existing unit tests are _extended_ (not changed) so that they inspect the ValidationError for the new_message, object_expr and schema_expr and compare them against expected good values. Reading the unit tests also gives a good overview of how the error reporting messages look like and that it's actually helpful and easy to find the relevant fragment of data.
2) The minor change that I just lumped together is minimal support for "format" schema. In particular the "data-time" format is now supported allowing us to validate data that should express JSON date time objects. The code is very small and comes with unit tests for both good and bad cases. More information about this feature is here: http://
- 107. By Zygmunt Krynicki
-
Remove needless newline
James Westby (james-w) : | # |
Preview Diff
1 | === modified file 'linaro_json/schema.py' |
2 | --- linaro_json/schema.py 2010-11-09 14:35:30 +0000 |
3 | +++ linaro_json/schema.py 2010-11-12 14:13:07 +0000 |
4 | @@ -5,6 +5,7 @@ |
5 | |
6 | See: json-schema.org for details |
7 | """ |
8 | +import datetime |
9 | import decimal |
10 | import itertools |
11 | import re |
12 | @@ -23,9 +24,33 @@ |
13 | |
14 | class ValidationError(ValueError): |
15 | """ |
16 | - A bug in the validated object prevents the program from working |
17 | + A bug in the validated object prevents the program from working. |
18 | + |
19 | + The error instance has several interesting properties: |
20 | + :message: old and verbose message that contains less helpful |
21 | + message and lots of JSON data (deprecated). |
22 | + :new_message: short and concise message about the problem |
23 | + :object_expr: a JavaScript expression that evaluates to the |
24 | + object that failed to validate. The expression always starts |
25 | + with a root object called 'object'. |
26 | + :schema_expr: a JavaScript expression that evaluats to the |
27 | + schema that was checked at the time validation failed. The |
28 | + expression always starts with a root object called 'schema'. |
29 | """ |
30 | |
31 | + def __init__(self, message, new_message=None, |
32 | + object_expr=None, schema_expr=None): |
33 | + self.message = message |
34 | + self.new_message = new_message |
35 | + self.object_expr = object_expr |
36 | + self.schema_expr = schema_expr |
37 | + |
38 | + def __cmp__(self, other): |
39 | + return cmp(self.message, other.message) or cmp(self.new_message, self.other_messge) |
40 | + |
41 | + def __repr__(self): |
42 | + return self.message |
43 | + |
44 | |
45 | class Schema(object): |
46 | """ |
47 | @@ -391,7 +416,28 @@ |
48 | } |
49 | |
50 | def __init__(self): |
51 | - self._obj_stack = [] |
52 | + self._schema_stack = [] |
53 | + self._object_stack = [] |
54 | + |
55 | + def _push_object(self, obj, path): |
56 | + self._object_stack.append((obj, path)) |
57 | + |
58 | + def _pop_object(self): |
59 | + self._object_stack.pop() |
60 | + |
61 | + def _push_schema(self, schema, path): |
62 | + self._schema_stack.append((schema, path)) |
63 | + |
64 | + def _pop_schema(self): |
65 | + self._schema_stack.pop() |
66 | + |
67 | + @property |
68 | + def _object(self): |
69 | + return self._object_stack[-1][0] |
70 | + |
71 | + @property |
72 | + def _schema(self): |
73 | + return self._schema_stack[-1][0] |
74 | |
75 | @classmethod |
76 | def validate(cls, schema, obj): |
77 | @@ -407,33 +453,91 @@ |
78 | raise ValueError( |
79 | "schema value {0!r} is not a Schema" |
80 | " object".format(schema)) |
81 | - cls()._validate_no_push(schema, obj) |
82 | + self = cls() |
83 | + self.validate_toplevel(schema, obj) |
84 | return True |
85 | |
86 | - def _validate(self, schema, obj): |
87 | - self._obj_stack.append(obj) |
88 | - try: |
89 | - self._validate_no_push(schema, obj) |
90 | - finally: |
91 | - self._obj_stack.pop() |
92 | - |
93 | - def _validate_no_push(self, schema, obj): |
94 | - self._validate_type(schema, obj) |
95 | - self._validate_requires(schema, obj) |
96 | + def _get_object_expression(self): |
97 | + return "".join(map(lambda x: x[1], self._object_stack)) |
98 | + |
99 | + def _get_schema_expression(self): |
100 | + return "".join(map(lambda x: x[1], self._schema_stack)) |
101 | + |
102 | + def validate_toplevel(self, schema, obj): |
103 | + self._object_stack = [] |
104 | + self._schema_stack = [] |
105 | + self._push_schema(schema, "schema") |
106 | + self._push_object(obj, "object") |
107 | + self._validate() |
108 | + self._pop_schema() |
109 | + self._pop_object() |
110 | + |
111 | + def _validate(self): |
112 | + obj = self._object |
113 | + self._validate_type() |
114 | + self._validate_requires() |
115 | if isinstance(obj, dict): |
116 | - self._obj_stack.append(obj) |
117 | - self._validate_properties(schema, obj) |
118 | - self._validate_additional_properties(schema, obj) |
119 | - self._obj_stack.pop() |
120 | + self._validate_properties() |
121 | + self._validate_additional_properties() |
122 | elif isinstance(obj, list): |
123 | - self._obj_stack.append(obj) |
124 | - self._validate_items(schema, obj) |
125 | - self._obj_stack.pop() |
126 | + self._validate_items() |
127 | else: |
128 | - self._validate_enum(schema, obj) |
129 | - self._report_unsupported(schema) |
130 | - |
131 | - def _report_unsupported(self, schema): |
132 | + self._validate_enum() |
133 | + self._validate_format() |
134 | + self._report_unsupported() |
135 | + |
136 | + def _report_error(self, legacy_message, new_message=None, |
137 | + object_suffix=None, schema_suffix=None): |
138 | + """ |
139 | + Report an error during validation. |
140 | + |
141 | + There are two error messages. The legacy message is used for |
142 | + backwards compatibility and usually contains the object |
143 | + (possibly very large) that failed to validate. The new message |
144 | + is much better as it contains just a short message on what went |
145 | + wrong. User code can inspect object_expr and schema_expr to see |
146 | + which part of the object failed to validate against which part |
147 | + of the schema. |
148 | + |
149 | + The schema_suffix, if provided, is appended to the schema_expr. |
150 | + This is quite handy to specify the bit that the validator looked |
151 | + at (such as the type or optional flag, etc). object_suffix |
152 | + serves the same purpose but is used for object expressions |
153 | + instead. |
154 | + """ |
155 | + object_expr = self._get_object_expression() |
156 | + if object_suffix: |
157 | + object_expr += object_suffix |
158 | + schema_expr = self._get_schema_expression() |
159 | + if schema_suffix: |
160 | + schema_expr += schema_suffix |
161 | + raise ValidationError(legacy_message, new_message, |
162 | + object_expr, schema_expr) |
163 | + |
164 | + def _push_property_schema(self, prop): |
165 | + """ |
166 | + Construct a sub-schema from the value of the specified attribute |
167 | + of the current schema. |
168 | + """ |
169 | + schema = Schema(self._schema.properties[prop]) |
170 | + self._push_schema(schema, ".properties." + prop) |
171 | + |
172 | + def _push_additional_property_schema(self): |
173 | + schema = Schema(self._schema.additionalProperties) |
174 | + self._push_schema(schema, ".additionalProperties") |
175 | + |
176 | + def _push_array_schema(self): |
177 | + schema = Schema(self._schema.items) |
178 | + self._push_schema(schema, ".items") |
179 | + |
180 | + def _push_array_item_object(self, index): |
181 | + self._push_object(self._object[index], "[%d]" % index) |
182 | + |
183 | + def _push_property_object(self, prop): |
184 | + self._push_object(self._object[prop], "." + prop) |
185 | + |
186 | + def _report_unsupported(self): |
187 | + schema = self._schema |
188 | if schema.minimum is not None: |
189 | raise NotImplementedError("minimum is not supported") |
190 | if schema.maximum is not None: |
191 | @@ -450,8 +554,6 @@ |
192 | raise NotImplementedError("minLength is not supported") |
193 | if schema.maxLength is not None: |
194 | raise NotImplementedError("maxLength is not supported") |
195 | - if schema.format is not None: |
196 | - raise NotImplementedError("format is not supported") |
197 | if schema.contentEncoding is not None: |
198 | raise NotImplementedError("contentEncoding is not supported") |
199 | if schema.divisibleBy != 1: |
200 | @@ -459,7 +561,9 @@ |
201 | if schema.disallow is not None: |
202 | raise NotImplementedError("disallow is not supported") |
203 | |
204 | - def _validate_type(self, schema, obj): |
205 | + def _validate_type(self): |
206 | + obj = self._object |
207 | + schema = self._schema |
208 | for json_type in schema.type: |
209 | if json_type == "any": |
210 | return |
211 | @@ -468,14 +572,18 @@ |
212 | # way to test for isinstance(something, bool) that would |
213 | # not catch isinstance(1, bool) :/ |
214 | if obj is not True and obj is not False: |
215 | - raise ValidationError( |
216 | + self._report_error( |
217 | "{obj!r} does not match type {type!r}".format( |
218 | - obj=obj, type=json_type)) |
219 | + obj=obj, type=json_type), |
220 | + "Object has incorrect type (expected boolean)", |
221 | + schema_suffix=".type") |
222 | break |
223 | elif isinstance(json_type, dict): |
224 | # Nested type check. This is pretty odd case. Here we |
225 | # don't change our object stack (it's the same object). |
226 | - self._validate_no_push(Schema(json_type), obj) |
227 | + self._push_schema(Schema(json_type), ".type") |
228 | + self._validate() |
229 | + self._pop_schema() |
230 | break |
231 | else: |
232 | # Simple type check |
233 | @@ -483,106 +591,172 @@ |
234 | # First one that matches, wins |
235 | break |
236 | else: |
237 | - raise ValidationError( |
238 | + self._report_error( |
239 | "{obj!r} does not match type {type!r}".format( |
240 | - obj=obj, type=json_type)) |
241 | - |
242 | - def _validate_properties(self, schema, obj): |
243 | + obj=obj, type=json_type), |
244 | + "Object has incorrect type (expected {type})".format( |
245 | + type=json_type), |
246 | + schema_suffix=".type") |
247 | + |
248 | + def _validate_format(self): |
249 | + fmt = self._schema.format |
250 | + obj = self._object |
251 | + if fmt is None: |
252 | + return |
253 | + if fmt == 'date-time': |
254 | + try: |
255 | + DATE_TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ" |
256 | + datetime.datetime.strptime(obj, DATE_TIME_FORMAT) |
257 | + except ValueError: |
258 | + self._report_error( |
259 | + "{obj!r} is not a string representing JSON date-time".format( |
260 | + obj=obj), |
261 | + "Object is not a string representing JSON date-time", |
262 | + schema_suffix=".format") |
263 | + else: |
264 | + raise NotImplementedError("format {0!r} is not supported".format(format)) |
265 | + |
266 | + def _validate_properties(self): |
267 | + obj = self._object |
268 | + schema = self._schema |
269 | assert isinstance(obj, dict) |
270 | - for prop, prop_schema_data in schema.properties.iteritems(): |
271 | - prop_schema = Schema(prop_schema_data) |
272 | + for prop in schema.properties.iterkeys(): |
273 | + self._push_property_schema(prop) |
274 | if prop in obj: |
275 | - self._validate(prop_schema, obj[prop]) |
276 | + self._push_property_object(prop) |
277 | + self._validate() |
278 | + self._pop_object() |
279 | else: |
280 | - if not prop_schema.optional: |
281 | - raise ValidationError( |
282 | - "{obj!r} does not have property" |
283 | - " {prop!r}".format( obj=obj, prop=prop)) |
284 | + if not self._schema.optional: |
285 | + self._report_error( |
286 | + "{obj!r} does not have property {prop!r}".format( |
287 | + obj=obj, prop=prop), |
288 | + "Object lacks property {prop!r}".format( |
289 | + prop=prop), |
290 | + schema_suffix=".optional") |
291 | + self._pop_schema() |
292 | |
293 | - def _validate_additional_properties(self, schema, obj): |
294 | + def _validate_additional_properties(self): |
295 | + obj = self._object |
296 | assert isinstance(obj, dict) |
297 | - if schema.additionalProperties is False: |
298 | + if self._schema.additionalProperties is False: |
299 | # Additional properties are disallowed |
300 | # Report exception for each unknown property |
301 | for prop in obj.iterkeys(): |
302 | - if prop not in schema.properties: |
303 | - raise ValidationError( |
304 | + if prop not in self._schema.properties: |
305 | + self._report_error( |
306 | "{obj!r} has unknown property {prop!r} and" |
307 | " additionalProperties is false".format( |
308 | - obj=obj, prop=prop)) |
309 | + obj=obj, prop=prop), |
310 | + "Object has unknown property {prop!r} but" |
311 | + " additional properties are disallowed".format( |
312 | + prop=prop), |
313 | + schema_suffix=".additionalProperties") |
314 | else: |
315 | - additional_schema = Schema(schema.additionalProperties) |
316 | # Check each property against this object |
317 | - for prop_value in obj.itervalues(): |
318 | - self._validate(additional_schema, prop_value) |
319 | + self._push_additional_property_schema() |
320 | + for prop in obj.iterkeys(): |
321 | + self._push_property_object(prop) |
322 | + self._validate() |
323 | + self._pop_object() |
324 | + self._pop_schema() |
325 | |
326 | - def _validate_enum(self, schema, obj): |
327 | + def _validate_enum(self): |
328 | + obj = self._object |
329 | + schema = self._schema |
330 | if schema.enum is not None: |
331 | for allowed_value in schema.enum: |
332 | if obj == allowed_value: |
333 | break |
334 | else: |
335 | - raise ValidationError( |
336 | + self._report_error( |
337 | "{obj!r} does not match any value in enumeration" |
338 | - " {enum!r}".format(obj=obj, enum=schema.enum)) |
339 | + " {enum!r}".format(obj=obj, enum=schema.enum), |
340 | + "Object does not match any value in enumeration", |
341 | + schema_suffix=".enum") |
342 | |
343 | - def _validate_items(self, schema, obj): |
344 | + def _validate_items(self): |
345 | + obj = self._object |
346 | + schema = self._schema |
347 | assert isinstance(obj, list) |
348 | items_schema_json = schema.items |
349 | if items_schema_json == {}: |
350 | # default value, don't do anything |
351 | return |
352 | if isinstance(items_schema_json, dict): |
353 | - items_schema = Schema(items_schema_json) |
354 | - for item in obj: |
355 | - self._validate(items_schema, item) |
356 | + self._push_array_schema() |
357 | + for index, item in enumerate(obj): |
358 | + self._push_array_item_object(index) |
359 | + self._validate() |
360 | + self._pop_object() |
361 | + self._pop_schema() |
362 | elif isinstance(items_schema_json, list): |
363 | if len(obj) < len(items_schema_json): |
364 | - # If our array is shorter than the schema then |
365 | + # If our data array is shorter than the schema then |
366 | # validation fails. Longer arrays are okay (during this |
367 | # step) as they are validated based on |
368 | # additionalProperties schema |
369 | - raise ValidationError( |
370 | + self._report_error( |
371 | "{obj!r} is shorter than array schema {schema!r}". |
372 | - format(obj=obj, schema=items_schema_json)) |
373 | + format(obj=obj, schema=items_schema_json), |
374 | + "Object array is shorter than schema array", |
375 | + schema_suffix=".items") |
376 | if len(obj) != len(items_schema_json) and schema.additionalProperties is False: |
377 | # If our array is not exactly the same size as the |
378 | # schema and additional properties are disallowed then |
379 | # validation fails |
380 | - raise ValidationError( |
381 | + self._report_error( |
382 | "{obj!r} is not of the same length as array schema" |
383 | " {schema!r} and additionalProperties is" |
384 | - " false".format(obj=obj, schema=items_schema_json)) |
385 | + " false".format(obj=obj, schema=items_schema_json), |
386 | + "Object array is not of the same length as schema array", |
387 | + schema_suffix=".items") |
388 | # Validate each array element using schema for the |
389 | # corresponding array index, fill missing values (since |
390 | # there may be more items in our array than in the schema) |
391 | # with additionalProperties which by now is not False |
392 | - for item, item_schema_json in itertools.izip_longest( |
393 | - obj, items_schema_json, fillvalue=schema.additionalProperties): |
394 | + for index, (item, item_schema_json) in enumerate( |
395 | + itertools.izip_longest( |
396 | + obj, items_schema_json, |
397 | + fillvalue=schema.additionalProperties)): |
398 | item_schema = Schema(item_schema_json) |
399 | - self._validate(item_schema, item) |
400 | + if index < len(items_schema_json): |
401 | + self._push_schema(item_schema, "items[%d]" % index) |
402 | + else: |
403 | + self._push_schema(item_schema, ".additionalProperties") |
404 | + self._push_array_item_object(index) |
405 | + self._validate() |
406 | + self._pop_schema() |
407 | + self._pop_object() |
408 | |
409 | - def _validate_requires(self, schema, obj): |
410 | + def _validate_requires(self): |
411 | + obj = self._object |
412 | + schema = self._schema |
413 | requires_json = schema.requires |
414 | if requires_json == {}: |
415 | # default value, don't do anything |
416 | return |
417 | # Find our enclosing object in the object stack |
418 | - if len(self._obj_stack) < 2: |
419 | - raise ValidationError( |
420 | + if len(self._object_stack) < 2: |
421 | + self._report_error( |
422 | "{obj!r} requires that enclosing object matches" |
423 | " schema {schema!r} but there is no enclosing" |
424 | - " object".format(obj=obj, schema=requires_json)) |
425 | + " object".format(obj=obj, schema=requires_json), |
426 | + "Object has no enclosing object that matches schema", |
427 | + schema_suffix=".requires") |
428 | # Note: Parent object can be None, (e.g. a null property) |
429 | - parent_obj = self._obj_stack[-2] |
430 | + parent_obj = self._object_stack[-2][0] |
431 | if isinstance(requires_json, basestring): |
432 | # This is a simple property test |
433 | - if (not isinstance(parent_obj, dict) |
434 | + if (not isinstance(parent_obj, dict) |
435 | or requires_json not in parent_obj): |
436 | - raise ValidationError( |
437 | + self._report_error( |
438 | "{obj!r} requires presence of property {requires!r}" |
439 | " in the same object".format( |
440 | - obj=obj, requires=requires_json)) |
441 | + obj=obj, requires=requires_json), |
442 | + "Enclosing object does not have property" |
443 | + " {prop!r}".format(prop=requires_json), |
444 | + schema_suffix=".requires") |
445 | elif isinstance(requires_json, dict): |
446 | # Requires designates a whole schema, the enclosing object |
447 | # must match against that schema. |
448 | @@ -593,9 +767,11 @@ |
449 | # instantiate a new validator with a subset of our current |
450 | # history here. |
451 | sub_validator = Validator() |
452 | - sub_validator._obj_stack = self._obj_stack[:-2] |
453 | - sub_validator._validate_no_push( |
454 | - Schema(requires_json), parent_obj) |
455 | + sub_validator._object_stack = self._object_stack[:-1] |
456 | + sub_validator._schema_stack = self._schema_stack[:] |
457 | + sub_validator._push_schema( |
458 | + Schema(requires_json), ".requires") |
459 | + sub_validator._validate() |
460 | |
461 | |
462 | def validate(schema_text, data_text): |
463 | |
464 | === modified file 'linaro_json/tests/test_schema.py' |
465 | --- linaro_json/tests/test_schema.py 2010-11-09 14:35:41 +0000 |
466 | +++ linaro_json/tests/test_schema.py 2010-11-12 14:13:07 +0000 |
467 | @@ -757,39 +757,52 @@ |
468 | 'schema': '{"type": "string"}', |
469 | 'data': 'null', |
470 | 'raises': ValidationError( |
471 | - "None does not match type 'string'"), |
472 | + "None does not match type 'string'", |
473 | + "Object has incorrect type (expected string)"), |
474 | + 'object_expr': 'object', |
475 | + 'schema_expr': 'schema.type', |
476 | }), |
477 | ("type_string_got_integer", { |
478 | 'schema': '{"type": "string"}', |
479 | 'data': '5', |
480 | 'raises': ValidationError( |
481 | - "5 does not match type 'string'"), |
482 | + "5 does not match type 'string'", |
483 | + "Object has incorrect type (expected string)"), |
484 | + 'object_expr': 'object', |
485 | + 'schema_expr': 'schema.type', |
486 | }), |
487 | ("type_number_got_integer", { |
488 | 'schema': '{"type": "number"}', |
489 | - 'data': '1' |
490 | + 'data': '1', |
491 | }), |
492 | ("type_number_number_float", { |
493 | 'schema': '{"type": "number"}', |
494 | - 'data': '1.1' |
495 | + 'data': '1.1', |
496 | }), |
497 | ("type_number_got_null", { |
498 | 'schema': '{"type": "number"}', |
499 | 'data': 'null', |
500 | 'raises': ValidationError( |
501 | - "None does not match type 'number'"), |
502 | + "None does not match type 'number'", |
503 | + "Object has incorrect type (expected number)"), |
504 | + 'object_expr': 'object', |
505 | + 'schema_expr': 'schema.type', |
506 | }), |
507 | ("type_number_got_string", { |
508 | 'schema': '{"type": "number"}', |
509 | 'data': '"foobar"', |
510 | 'raises': ValidationError( |
511 | "'foobar' does not match type 'number'"), |
512 | + 'object_expr': 'object', |
513 | + 'schema_expr': 'schema.type', |
514 | }), |
515 | ("type_number_got_string_that_looks_like_number", { |
516 | 'schema': '{"type": "number"}', |
517 | 'data': '"3"', |
518 | 'raises': ValidationError( |
519 | "'3' does not match type 'number'"), |
520 | + 'object_expr': 'object', |
521 | + 'schema_expr': 'schema.type', |
522 | }), |
523 | ("type_integer_got_integer_one", { |
524 | 'schema': '{"type": "integer"}', |
525 | @@ -803,13 +816,19 @@ |
526 | 'schema': '{"type": "integer"}', |
527 | 'data': '1.1', |
528 | 'raises': ValidationError( |
529 | - "1.1000000000000001 does not match type 'integer'") |
530 | + "1.1000000000000001 does not match type 'integer'", |
531 | + "Object has incorrect type (expected integer)"), |
532 | + 'object_expr': 'object', |
533 | + 'schema_expr': 'schema.type', |
534 | }), |
535 | ("type_integer_got_null", { |
536 | 'schema': '{"type": "integer"}', |
537 | 'data': 'null', |
538 | 'raises': ValidationError( |
539 | - "None does not match type 'integer'") |
540 | + "None does not match type 'integer'", |
541 | + "Object has incorrect type (expected integer)"), |
542 | + 'object_expr': 'object', |
543 | + 'schema_expr': 'schema.type', |
544 | }), |
545 | ("type_boolean_got_true", { |
546 | 'schema': '{"type": "boolean"}', |
547 | @@ -823,25 +842,37 @@ |
548 | 'schema': '{"type": "boolean"}', |
549 | 'data': 'null', |
550 | 'raises': ValidationError( |
551 | - "None does not match type 'boolean'"), |
552 | + "None does not match type 'boolean'", |
553 | + "Object has incorrect type (expected boolean)"), |
554 | + 'object_expr': 'object', |
555 | + 'schema_expr': 'schema.type', |
556 | }), |
557 | ("type_boolean_got_empty_string", { |
558 | 'schema': '{"type": "boolean"}', |
559 | 'data': '""', |
560 | 'raises': ValidationError( |
561 | - "'' does not match type 'boolean'"), |
562 | + "'' does not match type 'boolean'", |
563 | + "Object has incorrect type (expected boolean)"), |
564 | + 'object_expr': 'object', |
565 | + 'schema_expr': 'schema.type', |
566 | }), |
567 | ("type_boolean_got_empty_list", { |
568 | 'schema': '{"type": "boolean"}', |
569 | 'data': '[]', |
570 | 'raises': ValidationError( |
571 | - "[] does not match type 'boolean'"), |
572 | + "[] does not match type 'boolean'", |
573 | + "Object has incorrect type (expected boolean)"), |
574 | + 'object_expr': 'object', |
575 | + 'schema_expr': 'schema.type', |
576 | }), |
577 | ("type_boolean_got_empty_object", { |
578 | 'schema': '{"type": "boolean"}', |
579 | 'data': '{}', |
580 | 'raises': ValidationError( |
581 | - "{} does not match type 'boolean'"), |
582 | + "{} does not match type 'boolean'", |
583 | + "Object has incorrect type (expected boolean)"), |
584 | + 'object_expr': 'object', |
585 | + 'schema_expr': 'schema.type', |
586 | }), |
587 | ("type_object_got_object", { |
588 | 'schema': '{"type": "object"}', |
589 | @@ -851,13 +882,19 @@ |
590 | 'schema': '{"type": "object"}', |
591 | 'data': '1', |
592 | 'raises': ValidationError( |
593 | - "1 does not match type 'object'") |
594 | + "1 does not match type 'object'", |
595 | + "Object has incorrect type (expected object)"), |
596 | + 'object_expr': 'object', |
597 | + 'schema_expr': 'schema.type', |
598 | }), |
599 | ("type_object_got_null", { |
600 | 'schema': '{"type": "object"}', |
601 | 'data': 'null', |
602 | 'raises': ValidationError( |
603 | - "None does not match type 'object'") |
604 | + "None does not match type 'object'", |
605 | + "Object has incorrect type (expected object)"), |
606 | + 'object_expr': 'object', |
607 | + 'schema_expr': 'schema.type', |
608 | }), |
609 | ("type_array_got_array", { |
610 | 'schema': '{"type": "array"}', |
611 | @@ -867,13 +904,19 @@ |
612 | 'schema': '{"type": "array"}', |
613 | 'data': 'null', |
614 | 'raises': ValidationError( |
615 | - "None does not match type 'array'") |
616 | + "None does not match type 'array'", |
617 | + "Object has incorrect type (expected array)"), |
618 | + 'object_expr': 'object', |
619 | + 'schema_expr': 'schema.type', |
620 | }), |
621 | ("type_array_got_integer", { |
622 | 'schema': '{"type": "array"}', |
623 | 'data': '1', |
624 | 'raises': ValidationError( |
625 | - "1 does not match type 'array'") |
626 | + "1 does not match type 'array'", |
627 | + "Object has incorrect type (expected array)"), |
628 | + 'object_expr': 'object', |
629 | + 'schema_expr': 'schema.type', |
630 | }), |
631 | ("type_null_got_null", { |
632 | 'schema': '{"type": "null"}', |
633 | @@ -883,25 +926,37 @@ |
634 | 'schema': '{"type": "null"}', |
635 | 'data': '""', |
636 | 'raises': ValidationError( |
637 | - "'' does not match type 'null'") |
638 | + "'' does not match type 'null'", |
639 | + "Object has incorrect type (expected null)"), |
640 | + 'object_expr': 'object', |
641 | + 'schema_expr': 'schema.type', |
642 | }), |
643 | ("type_null_got_zero", { |
644 | 'schema': '{"type": "null"}', |
645 | 'data': '0', |
646 | 'raises': ValidationError( |
647 | - "0 does not match type 'null'") |
648 | + "0 does not match type 'null'", |
649 | + "Object has incorrect type (expected null)"), |
650 | + 'object_expr': 'object', |
651 | + 'schema_expr': 'schema.type', |
652 | }), |
653 | ("type_null_got_empty_list", { |
654 | 'schema': '{"type": "null"}', |
655 | 'data': '[]', |
656 | 'raises': ValidationError( |
657 | - "[] does not match type 'null'") |
658 | + "[] does not match type 'null'", |
659 | + "Object has incorrect type (expected null)"), |
660 | + 'object_expr': 'object', |
661 | + 'schema_expr': 'schema.type', |
662 | }), |
663 | ("type_null_got_empty_object", { |
664 | 'schema': '{"type": "null"}', |
665 | 'data': '{}', |
666 | 'raises': ValidationError( |
667 | - "{} does not match type 'null'") |
668 | + "{} does not match type 'null'", |
669 | + "Object has incorrect type (expected null)"), |
670 | + 'object_expr': 'object', |
671 | + 'schema_expr': 'schema.type', |
672 | }), |
673 | ("type_any_got_null", { |
674 | 'schema': '{"type": "any"}', |
675 | @@ -966,7 +1021,10 @@ |
676 | }""", |
677 | 'data': '{"foo": "foobar"}', |
678 | 'raises': ValidationError( |
679 | - "'foobar' does not match type 'number'") |
680 | + "'foobar' does not match type 'number'", |
681 | + "Object has incorrect type (expected number)"), |
682 | + 'object_expr': 'object.foo', |
683 | + 'schema_expr': 'schema.properties.foo.type', |
684 | }), |
685 | ("property_check_ignores_missing_optional_properties", { |
686 | 'schema': """ |
687 | @@ -994,7 +1052,10 @@ |
688 | }""", |
689 | 'data': '{"foo": null}', |
690 | 'raises': ValidationError( |
691 | - "None does not match type 'number'") |
692 | + "None does not match type 'number'", |
693 | + "Object has incorrect type (expected number)"), |
694 | + 'object_expr': 'object.foo', |
695 | + 'schema_expr': 'schema.properties.foo.type', |
696 | }), |
697 | ("property_check_reports_missing_non_optional_properties", { |
698 | 'schema': """ |
699 | @@ -1009,7 +1070,10 @@ |
700 | }""", |
701 | 'data': '{}', |
702 | 'raises': ValidationError( |
703 | - "{} does not have property 'foo'") |
704 | + "{} does not have property 'foo'", |
705 | + "Object lacks property 'foo'"), |
706 | + 'object_expr': 'object', |
707 | + 'schema_expr': 'schema.properties.foo.optional', |
708 | }), |
709 | ("property_check_reports_unknown_properties_when_additionalProperties_is_false", { |
710 | 'schema': """ |
711 | @@ -1020,7 +1084,11 @@ |
712 | 'data': '{"foo": 5}', |
713 | 'raises': ValidationError( |
714 | "{'foo': 5} has unknown property 'foo' and" |
715 | - " additionalProperties is false") |
716 | + " additionalProperties is false", |
717 | + "Object has unknown property 'foo' but additional " |
718 | + "properties are disallowed"), |
719 | + 'object_expr': 'object', |
720 | + 'schema_expr': 'schema.additionalProperties', |
721 | }), |
722 | ("property_check_ignores_normal_properties_when_additionalProperties_is_false", { |
723 | 'schema': """ |
724 | @@ -1053,7 +1121,10 @@ |
725 | }""", |
726 | 'data': '{"foo": "aaa", "bar": 5}', |
727 | 'raises': ValidationError( |
728 | - "5 does not match type 'string'") |
729 | + "5 does not match type 'string'", |
730 | + "Object has incorrect type (expected string)"), |
731 | + 'object_expr': 'object.bar', |
732 | + 'schema_expr': 'schema.additionalProperties.type', |
733 | }), |
734 | ("enum_check_does_nothing_by_default", { |
735 | 'schema': '{}', |
736 | @@ -1067,7 +1138,10 @@ |
737 | 'schema': '{"enum": [1, 2, 3]}', |
738 | 'data': '5', |
739 | 'raises': ValidationError( |
740 | - '5 does not match any value in enumeration [1, 2, 3]') |
741 | + '5 does not match any value in enumeration [1, 2, 3]', |
742 | + "Object does not match any value in enumeration"), |
743 | + 'object_expr': 'object', |
744 | + 'schema_expr': 'schema.enum', |
745 | }), |
746 | ("items_check_does_nothing_for_non_arrays", { |
747 | 'schema': '{"items": {"type": "string"}}', |
748 | @@ -1081,7 +1155,10 @@ |
749 | 'schema': '{"items": {"type": "string"}}', |
750 | 'data': '["foo", null, "froz"]', |
751 | 'raises': ValidationError( |
752 | - "None does not match type 'string'") |
753 | + "None does not match type 'string'", |
754 | + "Object has incorrect type (expected string)"), |
755 | + 'object_expr': 'object[1]', |
756 | + 'schema_expr': 'schema.items.type', |
757 | }), |
758 | ("items_with_array_schema_applies_to_corresponding_items", { |
759 | 'schema': """ |
760 | @@ -1104,7 +1181,10 @@ |
761 | 'data': '["foo"]', |
762 | 'raises': ValidationError( |
763 | "['foo'] is shorter than array schema [{'type':" |
764 | - " 'string'}, {'type': 'boolean'}]") |
765 | + " 'string'}, {'type': 'boolean'}]", |
766 | + "Object array is shorter than schema array"), |
767 | + 'object_expr': 'object', |
768 | + 'schema_expr': 'schema.items', |
769 | }), |
770 | ("items_with_array_schema_and_additionalProperties_of_false_checks_for_too_much_data", { |
771 | 'schema': """ |
772 | @@ -1119,7 +1199,10 @@ |
773 | 'raises': ValidationError( |
774 | "['foo', False, 5] is not of the same length as array" |
775 | " schema [{'type': 'string'}, {'type': 'boolean'}] and" |
776 | - " additionalProperties is false") |
777 | + " additionalProperties is false", |
778 | + "Object array is not of the same length as schema array"), |
779 | + 'object_expr': 'object', |
780 | + 'schema_expr': 'schema.items', |
781 | }), |
782 | ("items_with_array_schema_and_additionalProperties", { |
783 | 'schema': """ |
784 | @@ -1147,7 +1230,10 @@ |
785 | }""", |
786 | 'data': '["foo", false, 5, 7.9, null]', |
787 | 'raises': ValidationError( |
788 | - "None does not match type 'number'") |
789 | + "None does not match type 'number'", |
790 | + "Object has incorrect type (expected number)"), |
791 | + 'object_expr': 'object[4]', |
792 | + 'schema_expr': 'schema.additionalProperties.type', |
793 | }), |
794 | ("requires_with_simple_property_name_does_nothing_when_parent_property_is_not_used", { |
795 | 'schema': """ |
796 | @@ -1182,7 +1268,10 @@ |
797 | 'data': '{"bar": null}', |
798 | 'raises': ValidationError( |
799 | "None requires presence of property 'foo' in the same" |
800 | - " object") |
801 | + " object", |
802 | + "Enclosing object does not have property 'foo'"), |
803 | + 'object_expr': 'object.bar', |
804 | + 'schema_expr': 'schema.properties.bar.requires', |
805 | }), |
806 | ("requires_with_simple_property_name_can_report_problems_while_nested", { |
807 | 'schema': """ |
808 | @@ -1206,7 +1295,10 @@ |
809 | 'data': '{"nested": {"bar": null}}', |
810 | 'raises': ValidationError( |
811 | "None requires presence of property 'foo' in the same" |
812 | - " object") |
813 | + " object", |
814 | + "Enclosing object does not have property 'foo'"), |
815 | + 'object_expr': 'object.nested.bar', |
816 | + 'schema_expr': 'schema.properties.nested.properties.bar.requires', |
817 | }), |
818 | ("requires_with_simple_property_name_works_when_condition_satisfied", { |
819 | 'schema': """ |
820 | @@ -1268,7 +1360,10 @@ |
821 | """, |
822 | 'data': '{"bar": null}', |
823 | 'raises': ValidationError( |
824 | - "{'bar': None} does not have property 'foo'") |
825 | + "{'bar': None} does not have property 'foo'", |
826 | + "Object lacks property 'foo'"), |
827 | + 'object_expr': 'object', |
828 | + 'schema_expr': 'schema.properties.bar.requires.properties.foo.optional', |
829 | }), |
830 | ("requires_with_schema_can_report_subtle_problems", { |
831 | # In this test presence of "bar" requires that "foo" is |
832 | @@ -1294,9 +1389,24 @@ |
833 | """, |
834 | 'data': '{"bar": null, "foo": "not a number"}', |
835 | 'raises': ValidationError( |
836 | - "'not a number' does not match type 'number'") |
837 | - }), |
838 | - |
839 | + "'not a number' does not match type 'number'", |
840 | + "Object has incorrect type (expected number)"), |
841 | + 'object_expr': 'object.foo', |
842 | + 'schema_expr': 'schema.properties.bar.requires.properties.foo.type' |
843 | + }), |
844 | + ("format_date_time_works", { |
845 | + 'schema': '{"format": "date-time"}', |
846 | + 'data': '"2010-11-12T14:38:55Z"', |
847 | + }), |
848 | + ("format_date_time_finds_problems", { |
849 | + 'schema': '{"format": "date-time"}', |
850 | + 'data': '"broken"', |
851 | + 'raises': ValidationError( |
852 | + "'broken' is not a string representing JSON date-time", |
853 | + "Object is not a string representing JSON date-time"), |
854 | + 'object_expr': 'object', |
855 | + 'schema_expr': 'schema.format' |
856 | + }), |
857 | ] |
858 | |
859 | def test_validate(self): |
860 | @@ -1306,10 +1416,23 @@ |
861 | try: |
862 | validate(self.schema, self.data) |
863 | except type(self.raises) as ex: |
864 | - self.assertEqual(str(ex), str(self.raises)) |
865 | + self.assertEqual(ex.message, self.raises.message) |
866 | + if self.raises.new_message is not None: |
867 | + self.assertEqual(ex.new_message, self.raises.new_message) |
868 | + self.assertEqual(ex.object_expr, self.object_expr) |
869 | + self.assertEqual(ex.schema_expr, self.schema_expr) |
870 | except Exception as ex: |
871 | self.fail("Raised exception {0!r} instead of {1!r}".format( |
872 | ex, self.raises)) |
873 | else: |
874 | self.assertEqual( |
875 | True, validate(self.schema, self.data)) |
876 | + |
877 | + def __str__(self): |
878 | + """ |
879 | + Override TestCase to report the scenario name. |
880 | + |
881 | + TODO: Replace this with TestCaseWithScenarios in subsequent pipe |
882 | + """ |
883 | + return self.id() |
884 | + |