diff --git a/CHANGELOG.md b/CHANGELOG.md index 47b3f54..74c3ad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +- Use a validator that corresponds to the input schema draft version (#66) + #### 0.17.4 - 2020-08-26 - fixed string schemas with different `format` keywords (#63) diff --git a/src/hypothesis_jsonschema/_canonicalise.py b/src/hypothesis_jsonschema/_canonicalise.py index 08e3050..a3b36a4 100644 --- a/src/hypothesis_jsonschema/_canonicalise.py +++ b/src/hypothesis_jsonschema/_canonicalise.py @@ -29,6 +29,12 @@ # (and writing a few steps by hand is a DoS attack on the AST walker in Pytest) JSONType = Union[None, bool, float, str, list, Dict[str, Any]] Schema = Dict[str, JSONType] +JSONSchemaValidator = Union[ + jsonschema.validators.Draft3Validator, + jsonschema.validators.Draft4Validator, + jsonschema.validators.Draft6Validator, + jsonschema.validators.Draft7Validator, +] # Canonical type strings, in order. TYPE_STRINGS = ("null", "boolean", "integer", "number", "string", "array", "object") @@ -66,16 +72,21 @@ def next_down(val: float) -> float: return out +def _get_validator_class(schema: Schema) -> JSONSchemaValidator: + try: + validator = jsonschema.validators.validator_for(schema) + validator.check_schema(schema) + except jsonschema.exceptions.SchemaError: + validator = jsonschema.Draft4Validator + validator.check_schema(schema) + return validator + + def make_validator( - schema: Schema, -) -> Union[ - jsonschema.validators.Draft3Validator, - jsonschema.validators.Draft4Validator, - jsonschema.validators.Draft6Validator, - jsonschema.validators.Draft7Validator, -]: - validator_cls = jsonschema.validators.validator_for(schema) - return validator_cls(schema) + schema: Schema +) -> JSONSchemaValidator: + validator = _get_validator_class(schema) + return validator(schema) class CanonicalisingJsonEncoder(json.JSONEncoder): @@ -888,7 +899,7 @@ def merged(schemas: List[Any]) -> Optional[Schema]: if out == FALSEY: return FALSEY assert isinstance(out, dict) - jsonschema.validators.validator_for(out).check_schema(out) + _get_validator_class(out) return out diff --git a/src/hypothesis_jsonschema/_from_schema.py b/src/hypothesis_jsonschema/_from_schema.py index 5584104..2b2da41 100644 --- a/src/hypothesis_jsonschema/_from_schema.py +++ b/src/hypothesis_jsonschema/_from_schema.py @@ -18,6 +18,7 @@ TRUTHY, TYPE_STRINGS, HypothesisRefResolutionError, + JSONSchemaValidator, JSONType, Schema, canonicalish, @@ -43,7 +44,9 @@ def merged_as_strategies( - schemas: List[Schema], custom_formats: Optional[Dict[str, st.SearchStrategy[str]]] + schemas: List[Schema], + custom_formats: Optional[Dict[str, st.SearchStrategy[str]]], + metaschema_validator: JSONSchemaValidator, ) -> st.SearchStrategy[JSONType]: assert schemas, "internal error: must pass at least one schema to merge" if len(schemas) == 1: @@ -57,9 +60,9 @@ def merged_as_strategies( ): if combined.issuperset(group): continue - s = merged([inputs[g] for g in group]) + s = merged([inputs[g] for g in group], metaschema_validator) if s is not None and s != FALSEY: - validators = [make_validator(s) for s in schemas] + validators = [make_validator(s, metaschema_validator) for s in schemas] strats.append( from_schema(s, custom_formats=custom_formats).filter( lambda obj: all(v.is_valid(obj) for v in validators) @@ -114,8 +117,9 @@ def __from_schema( *, custom_formats: Dict[str, st.SearchStrategy[str]] = None, ) -> st.SearchStrategy[JSONType]: + metaschema_validator = jsonschema.validators.validator_for(schema) try: - schema = resolve_all_refs(schema) + schema = resolve_all_refs(schema, metaschema_validator=metaschema_validator) except RecursionError: raise HypothesisRefResolutionError( f"Could not resolve recursive references in schema={schema!r}" @@ -142,7 +146,7 @@ def __from_schema( } custom_formats[_FORMATS_TOKEN] = None # type: ignore - schema = canonicalish(schema) + schema = canonicalish(schema, metaschema_validator) # Boolean objects are special schemata; False rejects all and True accepts all. if schema == FALSEY: return st.nothing() @@ -160,7 +164,7 @@ def __from_schema( if "not" in schema: not_ = schema.pop("not") assert isinstance(not_, dict) - validator = make_validator(not_).is_valid + validator = make_validator(not_, metaschema_validator).is_valid return from_schema(schema, custom_formats=custom_formats).filter( lambda v: not validator(v) ) @@ -168,24 +172,29 @@ def __from_schema( tmp = schema.copy() ao = tmp.pop("anyOf") assert isinstance(ao, list) - return st.one_of([merged_as_strategies([tmp, s], custom_formats) for s in ao]) + return st.one_of( + [ + merged_as_strategies([tmp, s], custom_formats, metaschema_validator) + for s in ao + ] + ) if "allOf" in schema: tmp = schema.copy() ao = tmp.pop("allOf") assert isinstance(ao, list) - return merged_as_strategies([tmp] + ao, custom_formats) + return merged_as_strategies([tmp] + ao, custom_formats, metaschema_validator) if "oneOf" in schema: tmp = schema.copy() oo = tmp.pop("oneOf") assert isinstance(oo, list) - schemas = [merged([tmp, s]) for s in oo] + schemas = [merged([tmp, s], metaschema_validator) for s in oo] return st.one_of( [ from_schema(s, custom_formats=custom_formats) for s in schemas if s is not None ] - ).filter(make_validator(schema).is_valid) + ).filter(make_validator(schema, metaschema_validator).is_valid) # Simple special cases if "enum" in schema: assert schema["enum"], "Canonicalises to non-empty list or FALSEY" @@ -196,18 +205,21 @@ def __from_schema( map_: Dict[str, Callable[[Schema], st.SearchStrategy[JSONType]]] = { "null": lambda _: st.none(), "boolean": lambda _: st.booleans(), - "number": number_schema, - "integer": integer_schema, + "number": partial(number_schema, metaschema_validator), + "integer": partial(integer_schema, metaschema_validator), "string": partial(string_schema, custom_formats), - "array": partial(array_schema, custom_formats), - "object": partial(object_schema, custom_formats), + "array": partial(array_schema, custom_formats, metaschema_validator), + "object": partial(object_schema, custom_formats, metaschema_validator), } assert set(map_) == set(TYPE_STRINGS) return st.one_of([map_[t](schema) for t in get_type(schema)]) def _numeric_with_multiplier( - min_value: Optional[float], max_value: Optional[float], schema: Schema + min_value: Optional[float], + max_value: Optional[float], + schema: Schema, + metaschema_validator: JSONSchemaValidator, ) -> st.SearchStrategy[float]: """Handle numeric schemata containing the multipleOf key.""" multiple_of = schema["multipleOf"] @@ -225,23 +237,31 @@ def _numeric_with_multiplier( return ( st.integers(min_value, max_value) .map(lambda x: x * multiple_of) - .filter(make_validator(schema).is_valid) + .filter(make_validator(schema, metaschema_validator).is_valid) ) -def integer_schema(schema: dict) -> st.SearchStrategy[float]: +def integer_schema( + metaschema_validator: JSONSchemaValidator, schema: dict +) -> st.SearchStrategy[float]: """Handle integer schemata.""" min_value, max_value = get_integer_bounds(schema) if "multipleOf" in schema: - return _numeric_with_multiplier(min_value, max_value, schema) + return _numeric_with_multiplier( + min_value, max_value, schema, metaschema_validator + ) return st.integers(min_value, max_value) -def number_schema(schema: dict) -> st.SearchStrategy[float]: +def number_schema( + metaschema_validator: JSONSchemaValidator, schema: dict +) -> st.SearchStrategy[float]: """Handle numeric schemata.""" min_value, max_value, exclude_min, exclude_max = get_number_bounds(schema) if "multipleOf" in schema: - return _numeric_with_multiplier(min_value, max_value, schema) + return _numeric_with_multiplier( + min_value, max_value, schema, metaschema_validator + ) return st.floats( min_value=min_value, max_value=max_value, @@ -423,7 +443,9 @@ def string_schema( def array_schema( - custom_formats: Dict[str, st.SearchStrategy[str]], schema: dict + custom_formats: Dict[str, st.SearchStrategy[str]], + metaschema_validator: JSONSchemaValidator, + schema: dict, ) -> st.SearchStrategy[List[JSONType]]: """Handle schemata for arrays.""" _from_schema_ = partial(from_schema, custom_formats=custom_formats) @@ -444,10 +466,14 @@ def array_schema( # allowed to do so. We'll skip the None (unmergable / no contains) cases # below, and let Hypothesis ignore the FALSEY cases for us. if "contains" in schema: - for i, mrgd in enumerate(merged([schema["contains"], s]) for s in items): + for i, mrgd in enumerate( + merged([schema["contains"], s], metaschema_validator) for s in items + ): if mrgd is not None: items_strats[i] |= _from_schema_(mrgd) - contains_additional = merged([schema["contains"], additional_items]) + contains_additional = merged( + [schema["contains"], additional_items], metaschema_validator + ) if contains_additional is not None: additional_items_strat |= _from_schema_(contains_additional) @@ -484,12 +510,17 @@ def not_seen(elem: JSONType) -> bool: items_strat = _from_schema_(items) if "contains" in schema: contains_strat = _from_schema_(schema["contains"]) - if merged([items, schema["contains"]]) != schema["contains"]: + if ( + merged([items, schema["contains"]], metaschema_validator) + != schema["contains"] + ): # We only need this filter if we couldn't merge items in when # canonicalising. Note that for list-items, above, we just skip # the mixed generation in this case (because they tend to be # heterogeneous) and hope it works out anyway. - contains_strat = contains_strat.filter(make_validator(items).is_valid) + contains_strat = contains_strat.filter( + make_validator(items, metaschema_validator).is_valid + ) items_strat |= contains_strat strat = st.lists( @@ -500,12 +531,14 @@ def not_seen(elem: JSONType) -> bool: ) if "contains" not in schema: return strat - contains = make_validator(schema["contains"]).is_valid + contains = make_validator(schema["contains"], metaschema_validator).is_valid return strat.filter(lambda val: any(contains(x) for x in val)) def object_schema( - custom_formats: Dict[str, st.SearchStrategy[str]], schema: dict + custom_formats: Dict[str, st.SearchStrategy[str]], + metaschema_validator: JSONSchemaValidator, + schema: dict, ) -> st.SearchStrategy[Dict[str, JSONType]]: """Handle a manageable subset of possible schemata for objects.""" required = schema.get("required", []) # required keys @@ -540,7 +573,7 @@ def object_schema( st.one_of([st.from_regex(p) for p in sorted(patterns)]), ) all_names_strategy = st.one_of([s for s in name_strats if not s.is_empty]).filter( - make_validator(names).is_valid + make_validator(names, metaschema_validator).is_valid ) @st.composite # type: ignore @@ -583,12 +616,18 @@ def from_object_schema(draw: Any) -> Any: pattern_schemas.insert(0, properties[key]) if pattern_schemas: - out[key] = draw(merged_as_strategies(pattern_schemas, custom_formats)) + out[key] = draw( + merged_as_strategies( + pattern_schemas, custom_formats, metaschema_validator + ) + ) else: out[key] = draw(from_schema(additional, custom_formats=custom_formats)) for k, v in dep_schemas.items(): - if k in out and not make_validator(v).is_valid(out): + if k in out and not make_validator(v, metaschema_validator).is_valid( + out + ): out.pop(key) elements.reject() diff --git a/tests/test_canonicalise.py b/tests/test_canonicalise.py index a79bcc4..0cfecf5 100644 --- a/tests/test_canonicalise.py +++ b/tests/test_canonicalise.py @@ -521,6 +521,21 @@ def test_canonicalise_is_only_valid_for_schemas(): canonicalish("not a schema") +def test_validators_use_proper_draft(): + # See GH-66 + schema = { + "$schema": "http://json-schema.org/draft-04/schema#", + "not": { + "allOf": [ + {"exclusiveMinimum": True, "minimum": 0}, + {"exclusiveMaximum": True, "maximum": 10}, + ] + }, + } + cc = canonicalish(schema) + jsonschema.validators.validator_for(cc).check_schema(cc) + + # Expose fuzz targets in a form that FuzzBuzz can understand (no dotted names) fuzz_canonical_json_encoding = test_canonical_json_encoding.hypothesis.fuzz_one_input fuzz_merge_semantics = test_merge_semantics.hypothesis.fuzz_one_input