Fix json_schema_to_type crashes on keywords, boolean schemas, empty enums, and name collisions (#3818)

* Fix crash bugs in json_schema_to_type

- Handle boolean schemas (True/False) at the public entry point
- Append trailing underscore to Python keyword property names (PEP 8)
- Return Any for empty enum values instead of crashing Pydantic
- Deduplicate field names after sanitization to prevent collisions
  (e.g. "foo-bar" and "foo_bar" both sanitizing to "foo_bar")

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Move local imports to module level in test_json_schema_type

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
This commit is contained in:
Bill Easton 2026-04-11 10:29:26 -05:00 committed by GitHub
commit 4b59e0d94b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 99 additions and 3 deletions

View file

@ -36,6 +36,7 @@ from __future__ import annotations
import hashlib
import json
import keyword
import re
from collections.abc import Callable, Mapping
from copy import deepcopy
@ -140,13 +141,14 @@ class JSONSchema(TypedDict):
def json_schema_to_type(
schema: Mapping[str, Any],
schema: Mapping[str, Any] | bool,
name: str | None = None,
) -> type:
"""Convert JSON schema to appropriate Python type with validation.
Args:
schema: A JSON Schema dictionary defining the type structure and validation rules
schema: A JSON Schema dictionary defining the type structure and validation rules.
Boolean schemas are also accepted (``True`` = any type, ``False`` = unsatisfiable).
name: Optional name for object schemas. Only allowed when schema type is "object".
If not provided for objects, name will be inferred from schema's "title"
property or default to "Root".
@ -197,6 +199,12 @@ def json_schema_to_type(
name: NameType
```
"""
# Boolean schemas (JSON Schema 2020-12 §4.3.2; also valid since draft-06)
if schema is True:
return Any
if schema is False:
return _UnsatisfiableType # type: ignore[return-value] # ty:ignore[invalid-return-type]
# Normalise YAML-parsed types (datetime/date → str, non-str keys → str)
# so that downstream json.dumps/hashing and default values work correctly.
schema = _normalize_yaml_types(schema)
@ -301,6 +309,11 @@ def _create_numeric_type(
def _create_enum(name: str, values: list[Any]) -> type:
"""Create enum type from list of values."""
if not values:
# Empty enum means no value is valid (same semantics as ``false``
# schema). Return the unsatisfiable type instead of ``Literal[()]``
# which triggers an AssertionError in Pydantic.
return _UnsatisfiableType # type: ignore[return-value] # ty:ignore[invalid-return-type]
# Always return Literal for enum fields to preserve the literal nature
return Literal[tuple(values)] # type: ignore[return-value] # ty:ignore[invalid-type-form]
@ -466,6 +479,9 @@ def _sanitize_name(name: str) -> str:
# Step 5: only strip trailing underscores if they weren't in the original name
if not original_name.endswith("_"):
cleaned = cleaned.rstrip("_")
# Step 6: if result is a Python keyword, append an underscore (PEP 8 convention)
if keyword.iskeyword(cleaned):
cleaned = f"{cleaned}_"
return cleaned
@ -597,8 +613,17 @@ def _create_dataclass(
required = schema.get("required", [])
fields: list[tuple[Any, ...]] = []
used_field_names: set[str] = set()
for prop_name, prop_schema in properties.items():
field_name = _sanitize_name(prop_name)
# Deduplicate: if sanitized names collide (e.g. "foo-bar" and
# "foo_bar" both become "foo_bar"), append a numeric suffix.
base = field_name
counter = 2
while field_name in used_field_names:
field_name = f"{base}_{counter}"
counter += 1
used_field_names.add(field_name)
# Boolean schemas (JSON Schema draft-06+): resolve type directly,
# then use an empty dict for .get() calls below.

View file

@ -1,8 +1,9 @@
"""Core JSON schema type conversion tests."""
import dataclasses
from dataclasses import Field
from enum import Enum
from typing import Literal
from typing import Any, Literal
import pytest
from pydantic import TypeAdapter, ValidationError
@ -239,3 +240,73 @@ class TestConstrainedTypes:
assert TypeAdapter(type_).validate_python("y") == "y"
with pytest.raises(ValidationError):
TypeAdapter(type_).validate_python("z")
class TestCrashPrevention:
"""Schemas that previously caused crashes should now be handled gracefully."""
def test_boolean_schema_true(self):
"""Boolean schema True should return Any (JSON Schema draft-06+)."""
assert json_schema_to_type(True) is Any
def test_boolean_schema_false(self):
"""Boolean schema False should return an unsatisfiable type."""
result = json_schema_to_type(False)
with pytest.raises(ValidationError):
TypeAdapter(result).validate_python("anything")
def test_python_keyword_property_names(self):
"""Properties named after Python keywords should not crash."""
schema = {
"type": "object",
"properties": {
"class": {"type": "string"},
"return": {"type": "integer"},
"import": {"type": "boolean"},
},
"required": ["class"],
}
T = json_schema_to_type(schema)
ta = TypeAdapter(T)
result = ta.validate_python({"class": "A", "return": 1, "import": True})
assert result.class_ == "A" # ty:ignore[unresolved-attribute]
def test_empty_enum(self):
"""Empty enum means no value is valid — should reject like a false schema."""
schema = {
"type": "object",
"properties": {"status": {"enum": []}},
"required": ["status"],
}
T = json_schema_to_type(schema)
ta = TypeAdapter(T)
with pytest.raises(ValidationError):
ta.validate_python({"status": "anything"})
def test_sanitized_name_collision(self):
"""Properties that collide after sanitization get deduplicated."""
schema = {
"type": "object",
"properties": {
"foo-bar": {"type": "string"},
"foo_bar": {"type": "string"},
},
}
T = json_schema_to_type(schema)
field_names = [f.name for f in dataclasses.fields(T)]
assert len(field_names) == 2
assert len(set(field_names)) == 2
def test_empty_property_name(self):
"""Empty and whitespace-only property names should not crash."""
schema = {
"type": "object",
"properties": {
"": {"type": "string"},
" ": {"type": "integer"},
},
}
T = json_schema_to_type(schema)
field_names = [f.name for f in dataclasses.fields(T)]
assert len(field_names) == 2
assert len(set(field_names)) == 2