Fix boolean property schemas in JSON Schema parsing (#3785)

This commit is contained in:
Jeremiah Lowin 2026-04-07 17:55:51 -04:00 committed by GitHub
commit e5b96343d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 108 additions and 4 deletions

View file

@ -53,6 +53,7 @@ from typing import (
from pydantic import (
AnyUrl,
BaseModel,
BeforeValidator,
ConfigDict,
EmailStr,
Field,
@ -65,6 +66,15 @@ from typing_extensions import NotRequired, TypedDict
__all__ = ["JSONSchema", "json_schema_to_type"]
def _reject_all(v: Any) -> Any:
"""Validator that rejects every value, implementing JSON Schema `false`."""
raise ValueError("No value is valid against a false schema")
# JSON Schema `false` means no value is valid. This type rejects everything
# during Pydantic validation.
_UnsatisfiableType = Annotated[Any, BeforeValidator(_reject_all)]
FORMAT_TYPES: dict[str, Any] = {
"date-time": datetime,
"email": EmailStr,
@ -313,10 +323,18 @@ def _get_from_type_handler(
def _schema_to_type(
schema: Mapping[str, Any],
schema: Mapping[str, Any] | bool,
schemas: Mapping[str, Any],
) -> type | ForwardRef:
"""Convert schema to appropriate Python type."""
# Boolean schemas are valid in JSON Schema draft-06+:
# true means "any value is valid" (equivalent to {}),
# false means "no value is valid" (unsatisfiable).
if schema is True:
return Any
if schema is False:
return _UnsatisfiableType # type: ignore[return-value] # ty:ignore[invalid-return-type]
if not schema:
return object
@ -475,7 +493,13 @@ def _create_pydantic_model(
defaults = {}
for prop_name, prop_schema in properties.items():
field_type = _schema_to_type(prop_schema, schemas or {})
# Boolean schemas (JSON Schema draft-06+): resolve type directly,
# then use an empty dict for .get() calls below.
if isinstance(prop_schema, bool):
field_type = _schema_to_type(prop_schema, schemas or {})
prop_schema = {}
else:
field_type = _schema_to_type(prop_schema, schemas or {})
# Handle defaults
default_value = prop_schema.get("default", MISSING)
@ -540,8 +564,13 @@ def _create_dataclass(
for prop_name, prop_schema in properties.items():
field_name = _sanitize_name(prop_name)
# Check for self-reference in property
if prop_schema.get("$ref") == "#":
# Boolean schemas (JSON Schema draft-06+): resolve type directly,
# then use an empty dict for .get() calls below.
if isinstance(prop_schema, bool):
field_type = _schema_to_type(prop_schema, schemas or {})
prop_schema = {}
elif prop_schema.get("$ref") == "#":
# Check for self-reference in property
field_type = ForwardRef(sanitized_name)
else:
field_type = _schema_to_type(prop_schema, schemas or {})
@ -623,6 +652,10 @@ def _merge_defaults(
# For each property in the schema
for prop_name, prop_schema in schema.get("properties", {}).items():
# Normalize boolean schemas (JSON Schema draft-06+)
if isinstance(prop_schema, bool):
continue
# If property is missing, apply defaults in priority order
if prop_name not in result:
if parent_default and prop_name in parent_default:

View file

@ -111,6 +111,77 @@ class TestSimpleTypes:
validator.validate_python(False)
class TestBooleanSchemas:
"""JSON Schema draft-06+ allows true/false as property schemas."""
def test_true_property_schema_accepts_any_value(self):
"""A property with schema `true` should accept any value."""
schema = {
"type": "object",
"properties": {"name": {"type": "string"}, "anything": True},
"required": ["name", "anything"],
}
result = json_schema_to_type(schema)
validator = TypeAdapter(result)
obj = validator.validate_python({"name": "test", "anything": 42})
assert obj.name == "test" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
assert obj.anything == 42 # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
def test_false_property_schema_rejects_values(self):
"""A property with schema `false` should reject any provided value."""
schema = {
"type": "object",
"properties": {"name": {"type": "string"}, "never": False},
"required": ["name"],
}
result = json_schema_to_type(schema)
validator = TypeAdapter(result)
obj = validator.validate_python({"name": "test"})
assert obj.name == "test" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
with pytest.raises(ValidationError):
validator.validate_python({"name": "test", "never": "anything"})
def test_boolean_schema_in_object_with_additional_properties(self):
"""Boolean property schemas work alongside additionalProperties=True."""
schema = {
"type": "object",
"properties": {
"known": {"type": "string"},
"flexible": True,
},
"required": ["known"],
"additionalProperties": True,
}
result = json_schema_to_type(schema)
validator = TypeAdapter(result)
obj = validator.validate_python(
{"known": "hello", "flexible": [1, 2, 3], "extra": "field"}
)
assert obj.known == "hello" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
assert obj.flexible == [1, 2, 3] # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
def test_issue_3783_boolean_property_schemas(self):
"""Regression test for GitHub issue #3783."""
schema = {
"type": "object",
"properties": {
"ts": {"type": "integer"},
"level": True,
"app": True,
"tag": {"type": ["array", "null"], "items": {"type": "string"}},
},
"required": ["ts"],
"additionalProperties": True,
}
result = json_schema_to_type(schema)
validator = TypeAdapter(result)
obj = validator.validate_python({"ts": 123, "level": "info", "app": "myapp"})
assert obj.ts == 123 # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
assert obj.level == "info" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
assert obj.app == "myapp" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
class TestConstrainedTypes:
def test_constant(self):
validator = TypeAdapter(Literal["x"])