Skip to content

Commit

Permalink
Use a validator that corresponds to the input schema draft version
Browse files Browse the repository at this point in the history
  • Loading branch information
Stranger6667 committed Sep 9, 2020
1 parent 181e7c7 commit 220481a
Show file tree
Hide file tree
Showing 4 changed files with 107 additions and 40 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
31 changes: 21 additions & 10 deletions src/hypothesis_jsonschema/_canonicalise.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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


Expand Down
99 changes: 69 additions & 30 deletions src/hypothesis_jsonschema/_from_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
TRUTHY,
TYPE_STRINGS,
HypothesisRefResolutionError,
JSONSchemaValidator,
JSONType,
Schema,
canonicalish,
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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}"
Expand All @@ -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()
Expand All @@ -160,32 +164,37 @@ 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)
)
if "anyOf" in 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"
Expand All @@ -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"]
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
15 changes: 15 additions & 0 deletions tests/test_canonicalise.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 220481a

Please sign in to comment.