mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-26 23:44:17 +02:00
Fix allOf/oneOf edge cases and add comprehensive tests
Bugs fixed:
- additionalProperties: {} now treated as true (per JSON Schema spec)
- Sibling properties override allOf children (local > inherited)
- additionalProperties: false wins when allOf children conflict
- anyOf guard added to typeless-properties shortcircuit
New test coverage:
- Empty additionalProperties schema (with and without properties)
- Sibling property precedence over allOf children
- additionalProperties intersection in allOf
- oneOf with null branch
- properties + anyOf not shortcircuited
- allOf with only required (no properties)
- oneOf with empty additionalProperties dict
- allOf with sibling additionalProperties: true
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
087d4b3b84
commit
025ed3594f
2 changed files with 160 additions and 7 deletions
|
|
@ -403,6 +403,10 @@ def _object_schema_to_type(
|
|||
"""
|
||||
has_properties = bool(schema.get("properties"))
|
||||
additional_props = schema.get("additionalProperties")
|
||||
# Per JSON Schema, an empty schema {} means "accept anything" —
|
||||
# equivalent to additionalProperties: true.
|
||||
if isinstance(additional_props, dict) and not additional_props:
|
||||
additional_props = True
|
||||
class_name = name if name is not None else schema.get("title")
|
||||
|
||||
if not has_properties and additional_props:
|
||||
|
|
@ -458,6 +462,7 @@ def _schema_to_type(
|
|||
and "properties" in schema
|
||||
and "allOf" not in schema
|
||||
and "oneOf" not in schema
|
||||
and "anyOf" not in schema
|
||||
):
|
||||
return _create_dataclass(schema, schema.get("title", "<unknown>"), schemas)
|
||||
|
||||
|
|
@ -529,19 +534,25 @@ def _schema_to_type(
|
|||
_collect_allof(nested)
|
||||
merged_properties.update(sub.get("properties", {}))
|
||||
merged_required.extend(sub.get("required", []))
|
||||
for key in ("title", "description", "additionalProperties"):
|
||||
for key in ("title", "description"):
|
||||
if key in sub and key not in merged:
|
||||
merged[key] = sub[key]
|
||||
# Intersect additionalProperties: false is most restrictive and wins.
|
||||
if "additionalProperties" in sub:
|
||||
existing = merged.get("additionalProperties")
|
||||
if existing is None:
|
||||
merged["additionalProperties"] = sub["additionalProperties"]
|
||||
elif sub["additionalProperties"] is False:
|
||||
merged["additionalProperties"] = False
|
||||
|
||||
# Include sibling properties/required from the schema itself,
|
||||
# not just from allOf children — covers schemas where top-level
|
||||
# properties coexist with an allOf list of inherited properties.
|
||||
merged_properties.update(schema.get("properties", {}))
|
||||
merged_required.extend(schema.get("required", []))
|
||||
|
||||
# Collect from allOf children first, then overlay sibling
|
||||
# properties so local definitions take precedence over inherited.
|
||||
for sub in schema["allOf"]:
|
||||
_collect_allof(sub)
|
||||
|
||||
merged_properties.update(schema.get("properties", {}))
|
||||
merged_required.extend(schema.get("required", []))
|
||||
|
||||
if has_false:
|
||||
return _UnsatisfiableType # type: ignore[return-value]
|
||||
|
||||
|
|
|
|||
|
|
@ -649,3 +649,145 @@ class TestAllOfOneOf:
|
|||
|
||||
with pytest.raises(ValidationError):
|
||||
ta.validate_python({"name": "Alice"})
|
||||
|
||||
def test_additional_properties_empty_schema_is_allow_any(self):
|
||||
"""additionalProperties: {} is equivalent to additionalProperties: true per spec."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"additionalProperties": {},
|
||||
}
|
||||
T = json_schema_to_type(schema)
|
||||
ta = TypeAdapter(T)
|
||||
# Should accept any values (empty schema = true = allow everything)
|
||||
assert ta.validate_python({"x": 1, "y": "two"}) == {"x": 1, "y": "two"}
|
||||
|
||||
def test_additional_properties_empty_schema_with_properties(self):
|
||||
"""properties + additionalProperties: {} should produce Pydantic model (extra=allow)."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"additionalProperties": {},
|
||||
}
|
||||
T = json_schema_to_type(schema)
|
||||
assert issubclass(T, BaseModel)
|
||||
ta = TypeAdapter(T)
|
||||
result = ta.validate_python({"name": "Alice", "extra_key": 42})
|
||||
assert result.name == "Alice"
|
||||
|
||||
def test_sibling_properties_override_allof(self):
|
||||
"""Sibling properties should take precedence over allOf children."""
|
||||
schema = {
|
||||
"properties": {"name": {"type": "integer"}},
|
||||
"allOf": [
|
||||
{"type": "object", "properties": {"name": {"type": "string"}}},
|
||||
],
|
||||
}
|
||||
T = json_schema_to_type(schema)
|
||||
ta = TypeAdapter(T)
|
||||
# Sibling says name is int, allOf child says str — sibling wins
|
||||
result = ta.validate_python({"name": 42})
|
||||
assert result.name == 42
|
||||
|
||||
def test_allof_additional_properties_false_wins(self):
|
||||
"""When allOf children conflict on additionalProperties, false should win."""
|
||||
schema = {
|
||||
"allOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"a": {"type": "string"}},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"b": {"type": "integer"}},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
T = json_schema_to_type(schema)
|
||||
ta = TypeAdapter(T)
|
||||
result = ta.validate_python({"a": "hello", "b": 1})
|
||||
assert result.a == "hello"
|
||||
assert result.b == 1
|
||||
|
||||
def test_oneof_with_null_branch(self):
|
||||
"""oneOf with null branch should produce Optional type."""
|
||||
schema = {
|
||||
"oneOf": [
|
||||
{"type": "string"},
|
||||
{"type": "null"},
|
||||
]
|
||||
}
|
||||
T = json_schema_to_type(schema)
|
||||
ta = TypeAdapter(T)
|
||||
assert ta.validate_python("hello") == "hello"
|
||||
assert ta.validate_python(None) is None
|
||||
|
||||
def test_properties_with_anyof_not_shortcircuited(self):
|
||||
"""Schema with properties + anyOf should NOT shortcircuit to dataclass."""
|
||||
schema = {
|
||||
"properties": {"base": {"type": "string"}},
|
||||
"anyOf": [
|
||||
{"properties": {"variant_a": {"type": "integer"}}},
|
||||
{"properties": {"variant_b": {"type": "boolean"}}},
|
||||
],
|
||||
}
|
||||
T = json_schema_to_type(schema)
|
||||
ta = TypeAdapter(T)
|
||||
result = ta.validate_python({"base": "val"})
|
||||
assert result is not None
|
||||
|
||||
def test_allof_with_only_required_no_properties(self):
|
||||
"""allOf child with only required (no properties) merges correctly."""
|
||||
schema = {
|
||||
"allOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"age": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
{"required": ["name"]},
|
||||
]
|
||||
}
|
||||
T = json_schema_to_type(schema)
|
||||
ta = TypeAdapter(T)
|
||||
result = ta.validate_python({"name": "Alice"})
|
||||
assert result.name == "Alice"
|
||||
with pytest.raises(ValidationError):
|
||||
ta.validate_python({"age": 30})
|
||||
|
||||
def test_oneof_with_empty_additional_properties_dict(self):
|
||||
"""oneOf branch with additionalProperties: {} should produce dict[str, Any]."""
|
||||
schema = {
|
||||
"oneOf": [
|
||||
{"type": "string"},
|
||||
{"type": "object", "additionalProperties": {}},
|
||||
]
|
||||
}
|
||||
T = json_schema_to_type(schema)
|
||||
ta = TypeAdapter(T)
|
||||
assert ta.validate_python("hello") == "hello"
|
||||
assert ta.validate_python({"x": 1}) == {"x": 1}
|
||||
|
||||
def test_allof_with_sibling_additional_properties_true(self):
|
||||
"""properties + additionalProperties: true + allOf should preserve extra keys."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
schema = {
|
||||
"additionalProperties": True,
|
||||
"allOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
T = json_schema_to_type(schema)
|
||||
assert issubclass(T, BaseModel)
|
||||
ta = TypeAdapter(T)
|
||||
result = ta.validate_python({"name": "Alice", "extra": "kept"})
|
||||
assert result.name == "Alice"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue