Skip to content

Commit afc292b

Browse files
committed
Better {not: {anyOf: ...}} handling
We now flatten nexted anyOf chains, and look into anyOf subschemas with an not-subschema.
1 parent 4a6e20b commit afc292b

File tree

3 files changed

+44
-21
lines changed

3 files changed

+44
-21
lines changed

src/hypothesis_jsonschema/_canonicalise.py

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -449,26 +449,33 @@ def canonicalish(schema: JSONType) -> Dict[str, Any]:
449449
# Canonicalise "not" subschemas
450450
if "not" in schema:
451451
not_ = schema.pop("not")
452-
if not_ == TRUTHY or not_ == schema:
453-
# If everything is rejected, discard all other (irrelevant) keys
454-
# TODO: more sensitive detection of cases where the not-clause
455-
# excludes everything in the schema.
456-
return FALSEY
457-
type_keys = {k: set(v.split()) for k, v in TYPE_SPECIFIC_KEYS}
458-
type_constraints = {"type"}
459-
for v in type_keys.values():
460-
type_constraints |= v
461-
if set(not_).issubset(type_constraints):
462-
not_["type"] = get_type(not_)
463-
for t in set(type_).intersection(not_["type"]):
464-
if not type_keys.get(t, set()).intersection(not_):
465-
type_.remove(t)
466-
if t not in ("integer", "number"):
467-
not_["type"].remove(t)
468-
not_ = canonicalish(not_)
469-
if not_ != FALSEY:
470-
# If the "not" key rejects nothing, discard it
471-
schema["not"] = not_
452+
453+
negated = []
454+
to_negate = not_["anyOf"] if set(not_) == {"anyOf"} else [not_]
455+
for not_ in to_negate:
456+
type_keys = {k: set(v.split()) for k, v in TYPE_SPECIFIC_KEYS}
457+
type_constraints = {"type"}
458+
for v in type_keys.values():
459+
type_constraints |= v
460+
if set(not_).issubset(type_constraints):
461+
not_["type"] = get_type(not_)
462+
for t in set(type_).intersection(not_["type"]):
463+
if not type_keys.get(t, set()).intersection(not_):
464+
type_.remove(t)
465+
if t not in ("integer", "number"):
466+
not_["type"].remove(t)
467+
not_ = canonicalish(not_)
468+
469+
m = merged([not_, {**schema, "type": type_}])
470+
if m is not None:
471+
not_ = m
472+
if not_ != FALSEY:
473+
negated.append(not_)
474+
if len(negated) > 1:
475+
schema["not"] = {"anyOf": negated}
476+
elif negated:
477+
schema["not"] = negated[0]
478+
472479
assert isinstance(type_, list), type_
473480
if not type_:
474481
assert type_ == []
@@ -490,6 +497,9 @@ def canonicalish(schema: JSONType) -> Dict[str, Any]:
490497
if TRUTHY in schema.get("anyOf", ()):
491498
schema.pop("anyOf", None)
492499
if "anyOf" in schema:
500+
for i, s in enumerate(list(schema["anyOf"])):
501+
if set(s) == {"anyOf"}:
502+
schema["anyOf"][i : i + 1] = s["anyOf"]
493503
schema["anyOf"] = sorted(schema["anyOf"], key=encode_canonical_json)
494504
schema["anyOf"] = [s for s in schema["anyOf"] if s != FALSEY]
495505
if not schema["anyOf"]:

tests/test_canonicalise.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ def test_canonicalises_to_equivalent_fixpoint(schema_strategy, data):
101101
"multipleOf": 3,
102102
},
103103
{"not": {"type": ["integer", "number"]}, "type": "number"},
104+
{"not": {"anyOf": [{"type": "integer"}, {"type": "number"}]}, "type": "number"},
105+
{"not": {"enum": [1, 2, 3]}, "const": 2},
104106
{"oneOf": []},
105107
{"oneOf": [{}, {}]},
106108
{"oneOf": [True, False, {}]},
@@ -137,6 +139,7 @@ def test_canonicalises_to_empty(schema):
137139
[
138140
({"type": get_type({})}, {}),
139141
({"required": []}, {}),
142+
({"type": "integer", "not": {"type": "string"}}, {"type": "integer"}),
140143
(
141144
{"type": "array", "items": [True, False, True]},
142145
{"type": "array", "items": [{}], "maxItems": 1},
@@ -193,6 +196,16 @@ def test_canonicalises_to_empty(schema):
193196
{"minItems": 1, "type": "array"},
194197
),
195198
({"anyOf": [{}, {"type": "null"}]}, {}),
199+
(
200+
{
201+
"anyOf": [
202+
{"type": "string"},
203+
{"anyOf": [{"type": "number"}, {"type": "array"}]},
204+
]
205+
},
206+
# TODO: collapse into {"type": ["string", "number", "array"]}
207+
{"anyOf": [{"type": "array"}, {"type": "number"}, {"type": "string"}]},
208+
),
196209
({"uniqueItems": False}, {}),
197210
(
198211
{

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ addopts =
5959
--cov-fail-under=100
6060

6161
[flake8]
62-
ignore = D1,E501,W503,S101,S310
62+
ignore = D1,E203,E501,W503,S101,S310
6363
exclude = .*/,__pycache__
6464

6565
[mypy]

0 commit comments

Comments
 (0)