API/schema: make type conversion more strict in terms of undefined fields

This commit is contained in:
Johannes Kirschbauer
2025-08-18 19:29:54 +02:00
parent 1213608f30
commit 287a303484
5 changed files with 356 additions and 16 deletions

View File

@@ -0,0 +1,331 @@
from dataclasses import dataclass, field
from typing import Any, NotRequired, Required
import pytest
from .type_to_jsonschema import JSchemaTypeError, type_to_dict
def test_simple_primitives() -> None:
assert type_to_dict(int) == {
"type": "integer",
}
assert type_to_dict(float) == {
"type": "number",
}
assert type_to_dict(str) == {
"type": "string",
}
assert type_to_dict(bool) == {
"type": "boolean",
}
assert type_to_dict(object) == {
"type": "object",
}
def test_enum_type() -> None:
from enum import Enum
class Color(Enum):
RED = "red"
GREEN = "green"
BLUE = "blue"
assert type_to_dict(Color) == {
"type": "string",
"enum": ["red", "green", "blue"],
}
def test_unsupported_any_types() -> None:
with pytest.raises(JSchemaTypeError) as exc_info:
type_to_dict(Any)
assert "Usage of the Any type is not supported" in str(exc_info.value)
with pytest.raises(JSchemaTypeError) as exc_info:
type_to_dict(list[Any])
assert "Usage of the Any type is not supported" in str(exc_info.value)
# TBD.
# with pytest.raises(JSchemaTypeError) as exc_info:
# type_to_dict(dict[str, Any])
# assert "Usage of the Any type is not supported" in str(exc_info.value)
with pytest.raises(JSchemaTypeError) as exc_info:
type_to_dict(tuple[Any, ...])
assert "Usage of the Any type is not supported" in str(exc_info.value)
with pytest.raises(JSchemaTypeError) as exc_info:
type_to_dict(set[Any])
assert "Usage of the Any type is not supported" in str(exc_info.value)
with pytest.raises(JSchemaTypeError) as exc_info:
type_to_dict(str | Any)
assert "Usage of the Any type is not supported" in str(exc_info.value)
def test_allowed_any_types() -> None:
# Object with arbitrary keys
assert type_to_dict(dict[str, Any]) == {
"type": "object",
"additionalProperties": True,
}
# Union where Any is discarded
assert type_to_dict(str | Any, narrow_unsupported_union_types=True) == {
"type": "string",
}
def test_simple_union_types() -> None:
assert type_to_dict(int | str) == {
"oneOf": [
{"type": "integer"},
{"type": "string"},
]
}
assert type_to_dict(int | str | float) == {
"oneOf": [
{"type": "integer"},
{"type": "string"},
{"type": "number"},
]
}
assert type_to_dict(int | str | None) == {
"oneOf": [
{"type": "integer"},
{"type": "string"},
{"type": "null"},
]
}
def test_complex_union_types() -> None:
@dataclass
class Foo:
foo: str
@dataclass
class Bar:
bar: str
assert type_to_dict(Foo | Bar | None) == {
"oneOf": [
{
"type": "object",
"properties": {
"foo": {"type": "string"},
},
"additionalProperties": False,
"required": ["foo"],
},
{
"type": "object",
"properties": {
"bar": {"type": "string"},
},
"additionalProperties": False,
"required": ["bar"],
},
{"type": "null"},
]
}
def test_dataclasses() -> None:
# @dataclass
# class Example:
# name: str
# value: bool
# assert type_to_dict(Example) == {
# "type": "object",
# "properties": {
# "name": {"type": "string"},
# "value": {"type": "boolean"},
# },
# "additionalProperties": False,
# "required": [
# "name",
# "value",
# ],
# }
@dataclass
class ExampleWithNullable:
name: str
value: int | None
assert type_to_dict(ExampleWithNullable) == {
"type": "object",
"properties": {
"name": {"type": "string"},
"value": {"oneOf": [{"type": "integer"}, {"type": "null"}]},
},
"additionalProperties": False,
"required": [
"name",
"value",
], # value is required because it has no default value
}
@dataclass
class ExampleWithOptional:
name: str
value: int | None = None
assert type_to_dict(ExampleWithOptional) == {
"type": "object",
"properties": {
"name": {"type": "string"},
"value": {"oneOf": [{"type": "integer"}, {"type": "null"}]},
},
"additionalProperties": False,
"required": [
"name"
], # value is optional because it has a default value of None
}
def test_dataclass_with_optional_fields() -> None:
@dataclass
class Example:
value: dict[str, Any] = field(default_factory=dict)
assert type_to_dict(Example) == {
"type": "object",
"properties": {
"value": {
"type": "object",
"additionalProperties": True,
},
},
"additionalProperties": False,
"required": [], # value is optional because it has default factory
}
def test_nested_open_dicts() -> None:
assert type_to_dict(dict[str, dict[str, list[str]]]) == {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {"type": "string"},
},
},
}
def test_type_variables() -> None:
from typing import Generic, TypeVar
T = TypeVar("T")
@dataclass
class Wrapper(Generic[T]):
value: T
assert type_to_dict(Wrapper[int]) == {
"type": "object",
"properties": {
"value": {"type": "integer"},
},
"additionalProperties": False,
"required": ["value"],
}
assert type_to_dict(Wrapper[str]) == {
"type": "object",
"properties": {
"value": {"type": "string"},
},
"additionalProperties": False,
"required": ["value"],
}
def test_type_variable_nested_scopes() -> None:
# Define two type variables with the same name "T" but in different scopes
from typing import Generic, TypeVar
T = TypeVar("T")
@dataclass
class Outer(Generic[T]):
foo: T
@dataclass
class Inner(Generic[T]):
bar: T
assert type_to_dict(Outer[Inner[int]]) == {
"type": "object",
"properties": {
"foo": {
"type": "object",
"properties": {
"bar": {"type": "integer"},
},
"additionalProperties": False,
"required": ["bar"],
},
},
"additionalProperties": False,
"required": ["foo"],
}
def test_total_typed_dict() -> None:
from typing import TypedDict
class ExampleTypedDict(TypedDict):
name: str
value: NotRequired[int]
bar: int | None
assert type_to_dict(ExampleTypedDict) == {
"type": "object",
"properties": {
"name": {"type": "string"},
"value": {"type": "integer"},
"bar": {
"oneOf": [
{
"type": "integer",
},
{
"type": "null",
},
],
},
},
"additionalProperties": False,
# bar is required because it's not explicitly marked as 'NotRequired'
"required": ["bar", "name"],
}
def test_open_typed_dict() -> None:
from typing import TypedDict
class ExampleTypedDict(TypedDict, total=False):
name: Required[str]
value: int
assert type_to_dict(ExampleTypedDict) == {
"type": "object",
"properties": {
"name": {"type": "string"},
"value": {"type": "integer"},
},
"additionalProperties": False,
"required": ["name"],
}

View File

@@ -151,13 +151,13 @@ def type_to_dict(
if f.default is MISSING and f.default_factory is MISSING
}
# Find intersection
intersection = required & required_fields
# TODO: figure out why we needed to do this
# intersection = required_fields & required
return {
"type": "object",
"properties": properties,
"required": list(intersection),
"required": sorted(required_fields),
# Dataclasses can only have the specified properties
"additionalProperties": False,
}
@@ -165,24 +165,26 @@ def type_to_dict(
if is_typed_dict(t):
dict_fields = get_typed_dict_fields(t, scope)
dict_properties: dict = {}
dict_required: list[str] = []
explicit_optional: set[str] = set()
explicit_required: set[str] = set()
for field_name, field_type in dict_fields.items():
# Unwrap special case for "NotRequired" and "Required"
# A field type that only exist for TypedDicts
if (
not is_type_in_union(field_type, type(None))
and get_origin(field_type) is not NotRequired
) or get_origin(field_type) is Required:
dict_required.append(field_name)
if get_origin(field_type) is NotRequired:
explicit_optional.add(field_name)
if get_origin(field_type) is Required:
explicit_required.add(field_name)
dict_properties[field_name] = type_to_dict(
field_type, f"{scope} {t.__name__}.{field_name}", type_map
)
optional = set(dict_fields) - explicit_optional
return {
"type": "object",
"properties": dict_properties,
"required": dict_required if is_total(t) else [],
"required": sorted(optional) if is_total(t) else sorted(explicit_required),
"additionalProperties": False,
}