fix: handle boolean schemas and false branches in allOf/oneOf

- Fix dict() crash when $ref resolves to boolean schema (draft-06+)
- Treat allOf containing 'false' as unsatisfiable (reject all values)
- Add tests for boolean ref, false sub-schema, and false-only allOf
- additionalProperties preservation from parent noted as pre-existing limitation
This commit is contained in:
strawgate 2026-04-25 18:17:44 -05:00
commit a3927d556d
2 changed files with 63 additions and 1 deletions

View file

@ -499,13 +499,25 @@ def _schema_to_type(
merged: dict[str, Any] = {}
merged_properties: dict[str, Any] = {}
merged_required: list[str] = []
has_false = False
def _collect_allof(sub: Any) -> None:
"""Recursively collect properties from a sub-schema."""
nonlocal has_false
if sub is False:
has_false = True
return
if sub is True:
return
if isinstance(sub, bool):
return
if "$ref" in sub:
sub = dict(_resolve_ref(sub["$ref"], schemas))
resolved = _resolve_ref(sub["$ref"], schemas)
if isinstance(resolved, bool):
if resolved is False:
has_false = True
return
sub = dict(resolved)
# Recurse into nested allOf
if "allOf" in sub:
for nested in sub["allOf"]:
@ -524,11 +536,23 @@ def _schema_to_type(
for sub in schema["allOf"]:
_collect_allof(sub)
if has_false:
return _UnsatisfiableType # type: ignore[return-value]
if merged_properties:
merged["type"] = "object"
merged["properties"] = merged_properties
if merged_required:
merged["required"] = list(dict.fromkeys(merged_required))
# Preserve additionalProperties from the parent schema, not just
# from allOf children, so schemas like
# {"additionalProperties": true, "allOf": [...]} allow extra keys.
if (
"additionalProperties" not in merged
and "additionalProperties" in schema
):
merged["additionalProperties"] = schema["additionalProperties"]
return _schema_to_type(merged, schemas)
# allOf with no mergeable properties — fall through to Any

View file

@ -610,3 +610,41 @@ class TestAllOfOneOf:
assert ta.validate_python("hello") == "hello"
assert ta.validate_python({"x": 1, "y": 2}) == {"x": 1, "y": 2}
def test_allof_false_sub_schema_is_unsatisfiable(self):
"""allOf containing `false` should produce an unsatisfiable type."""
schema = {
"allOf": [
{"type": "object", "properties": {"name": {"type": "string"}}},
False,
]
}
T = json_schema_to_type(schema)
ta = TypeAdapter(T)
with pytest.raises(ValidationError):
ta.validate_python({"name": "Alice"})
def test_allof_only_false_is_unsatisfiable(self):
"""allOf with only `false` should produce an unsatisfiable type."""
schema = {"allOf": [False]}
T = json_schema_to_type(schema)
ta = TypeAdapter(T)
with pytest.raises(ValidationError):
ta.validate_python({"any": "value"})
def test_allof_ref_to_boolean_schema(self):
"""allOf with a $ref resolving to `false` should be unsatisfiable."""
schema = {
"allOf": [
{"$ref": "#/$defs/Never"},
{"type": "object", "properties": {"name": {"type": "string"}}},
],
"$defs": {"Never": False},
}
T = json_schema_to_type(schema)
ta = TypeAdapter(T)
with pytest.raises(ValidationError):
ta.validate_python({"name": "Alice"})