fix(jambo): Fix allOf, anyOf, null and array type parsing #33

Closed
thommann wants to merge 10 commits from main into main
8 changed files with 8 additions and 56 deletions
Showing only changes of commit 8dbe84fd1c - Show all commits

View File

@@ -29,4 +29,4 @@ __all__ = [
"OneOfTypeParser", "OneOfTypeParser",
"StringTypeParser", "StringTypeParser",
"RefTypeParser", "RefTypeParser",
] ]

View File

@@ -127,23 +127,9 @@ class GenericTypeParser(ABC, Generic[T]):
@staticmethod @staticmethod
def _has_meaningful_constraints(field_props): def _has_meaningful_constraints(field_props):
"""
Check if field properties contain meaningful constraints that require Field wrapping.
Returns False if:
- field_props is None or empty
- field_props only contains {'default': None}
Returns True if:
- field_props contains a non-None default value
- field_props contains other constraint properties (min_length, max_length, pattern, etc.)
"""
if not field_props: if not field_props:
return False return False
# If only default is set and it's None, no meaningful constraints
if field_props == {"default": None}: if field_props == {"default": None}:
return False return False
# If there are multiple properties or non-None default, that's meaningful
return True return True

View File

@@ -35,17 +35,14 @@ class ArrayTypeParser(GenericTypeParser):
mapped_properties = self.mappings_properties_builder(properties, **kwargs) mapped_properties = self.mappings_properties_builder(properties, **kwargs)
# Only set default_factory if the field is not required OR if there's an actual default
if not kwargs.get("required", False) and "default" not in mapped_properties: if not kwargs.get("required", False) and "default" not in mapped_properties:
mapped_properties["default_factory"] = self._build_default_factory( mapped_properties["default_factory"] = self._build_default_factory(
properties.get("default"), wrapper_type properties.get("default"), wrapper_type
) )
elif "default" in properties: elif "default" in properties:
# If there's a default value specified, set the default_factory
mapped_properties["default_factory"] = self._build_default_factory( mapped_properties["default_factory"] = self._build_default_factory(
properties["default"], wrapper_type properties["default"], wrapper_type
) )
# Remove the regular default since we're using default_factory
mapped_properties.pop("default", None) mapped_properties.pop("default", None)
return field_type, mapped_properties return field_type, mapped_properties

View File

@@ -33,14 +33,10 @@ class ConstTypeParser(GenericTypeParser):
return const_type, parsed_properties return const_type, parsed_properties
def _build_const_type(self, const_value): def _build_const_type(self, const_value):
# Try to use Literal for hashable types (required for discriminated unions)
# Fall back to validator approach for non-hashable types
try: try:
# Test if the value is hashable (can be used in Literal)
hash(const_value) hash(const_value)
return Literal[const_value] return Literal[const_value]
except TypeError: except TypeError:
# Non-hashable type (like list, dict), use validator approach
def _validate_const_value(value: Any) -> Any: def _validate_const_value(value: Any) -> Any:
if value != const_value: if value != const_value:
raise ValueError( raise ValueError(
@@ -48,4 +44,4 @@ class ConstTypeParser(GenericTypeParser):
) )
return value return value
HideyoshiNakazone commented 2025-08-19 03:17:19 +00:00 (Migrated from github.com)
Review

This is a good idea and implementation, but would have to be merged in a separate PR.

This is a good idea and implementation, but would have to be merged in a separate PR.
return Annotated[type(const_value), AfterValidator(_validate_const_value)] return Annotated[type(const_value), AfterValidator(_validate_const_value)]

View File

@@ -28,7 +28,7 @@ class StringTypeParser(GenericTypeParser):
"time": time, "time": time,
"date-time": datetime, "date-time": datetime,
"binary": bytes, "binary": bytes,
"file-path": FilePath, # Added file-path format "file-path": FilePath,
} }
format_pattern_mapping = { format_pattern_mapping = {
@@ -57,4 +57,4 @@ class StringTypeParser(GenericTypeParser):
mapped_properties["json_schema_extra"] = {} mapped_properties["json_schema_extra"] = {}
mapped_properties["json_schema_extra"]["format"] = format_type mapped_properties["json_schema_extra"]["format"] = format_type
return mapped_type, mapped_properties return mapped_type, mapped_properties

View File

@@ -99,16 +99,13 @@ class TestArrayTypeParser(TestCase):
parser.from_properties("placeholder", properties) parser.from_properties("placeholder", properties)
def test_array_parser_required_without_default(self): def test_array_parser_required_without_default(self):
"""Regression test: Required array fields without defaults should be required"""
parser = ArrayTypeParser() parser = ArrayTypeParser()
properties = {"items": {"type": "string"}} properties = {"items": {"type": "string"}}
# Test with required=True (should be required)
type_parsing, type_validator = parser.from_properties( type_parsing, type_validator = parser.from_properties(
"test_array", properties, required=True "test_array", properties, required=True
) )
# Should NOT have default_factory when required and no default specified
self.assertNotIn("default_factory", type_validator) self.assertNotIn("default_factory", type_validator)
self.assertNotIn("default", type_validator) self.assertNotIn("default", type_validator)

View File

@@ -7,7 +7,6 @@ from unittest import TestCase
class TestConstTypeParser(TestCase): class TestConstTypeParser(TestCase):
def test_const_type_parser_hashable_value(self): def test_const_type_parser_hashable_value(self):
"""Test const parser with hashable values (uses Literal)"""
parser = ConstTypeParser() parser = ConstTypeParser()
expected_const_value = "United States of America" expected_const_value = "United States of America"
@@ -17,31 +16,27 @@ class TestConstTypeParser(TestCase):
"country", properties "country", properties
) )
# Check that we get a Literal type for hashable values
self.assertEqual(get_origin(parsed_type), Literal) self.assertEqual(get_origin(parsed_type), Literal)
self.assertEqual(get_args(parsed_type), (expected_const_value,)) self.assertEqual(get_args(parsed_type), (expected_const_value,))
self.assertEqual(parsed_properties["default"], expected_const_value) self.assertEqual(parsed_properties["default"], expected_const_value)
def test_const_type_parser_non_hashable_value(self): def test_const_type_parser_non_hashable_value(self):
"""Test const parser with non-hashable values (uses Annotated with validator)"""
parser = ConstTypeParser() parser = ConstTypeParser()
expected_const_value = [1, 2, 3] # Lists are not hashable expected_const_value = [1, 2, 3]
properties = {"const": expected_const_value} properties = {"const": expected_const_value}
parsed_type, parsed_properties = parser.from_properties_impl( parsed_type, parsed_properties = parser.from_properties_impl(
"list_const", properties "list_const", properties
) )
# Check that we get an Annotated type for non-hashable values
self.assertEqual(get_origin(parsed_type), Annotated) self.assertEqual(get_origin(parsed_type), Annotated)
self.assertIn(list, get_args(parsed_type)) self.assertIn(list, get_args(parsed_type))
self.assertEqual(parsed_properties["default"], expected_const_value) self.assertEqual(parsed_properties["default"], expected_const_value)
def test_const_type_parser_integer_value(self): def test_const_type_parser_integer_value(self):
"""Test const parser with integer values (uses Literal)"""
parser = ConstTypeParser() parser = ConstTypeParser()
expected_const_value = 42 expected_const_value = 42
@@ -51,14 +46,12 @@ class TestConstTypeParser(TestCase):
"int_const", properties "int_const", properties
) )
# Check that we get a Literal type for hashable values
self.assertEqual(get_origin(parsed_type), Literal) self.assertEqual(get_origin(parsed_type), Literal)
self.assertEqual(get_args(parsed_type), (expected_const_value,)) self.assertEqual(get_args(parsed_type), (expected_const_value,))
self.assertEqual(parsed_properties["default"], expected_const_value) self.assertEqual(parsed_properties["default"], expected_const_value)
def test_const_type_parser_boolean_value(self): def test_const_type_parser_boolean_value(self):
"""Test const parser with boolean values (uses Literal)"""
parser = ConstTypeParser() parser = ConstTypeParser()
expected_const_value = True expected_const_value = True
@@ -68,7 +61,6 @@ class TestConstTypeParser(TestCase):
"bool_const", properties "bool_const", properties
) )
# Check that we get a Literal type for hashable values
self.assertEqual(get_origin(parsed_type), Literal) self.assertEqual(get_origin(parsed_type), Literal)
self.assertEqual(get_args(parsed_type), (expected_const_value,)) self.assertEqual(get_args(parsed_type), (expected_const_value,))
@@ -99,4 +91,4 @@ class TestConstTypeParser(TestCase):
self.assertIn( self.assertIn(
"Const type invalid_country must have 'const' value of allowed types", "Const type invalid_country must have 'const' value of allowed types",
str(context.exception), str(context.exception),
) )

View File

@@ -351,32 +351,26 @@ class TestOneOfTypeParser(TestCase):
} }
} }
], ],
"discriminator": {} # discriminator without propertyName "discriminator": {}
} }
} }
} }
Model = SchemaConverter.build(schema) Model = SchemaConverter.build(schema)
# Should succeed because input matches exactly one schema (the first one)
# The first schema matches: type="a" matches const("a"), value="test" is a string
# The second schema doesn't match: type="a" does not match const("b")
obj = Model(value={"type": "a", "value": "test", "extra": "invalid"}) obj = Model(value={"type": "a", "value": "test", "extra": "invalid"})
self.assertEqual(obj.value.type, "a") self.assertEqual(obj.value.type, "a")
self.assertEqual(obj.value.value, "test") self.assertEqual(obj.value.value, "test")
# Test with input that matches the second schema
obj2 = Model(value={"type": "b", "value": 42}) obj2 = Model(value={"type": "b", "value": 42})
self.assertEqual(obj2.value.type, "b") self.assertEqual(obj2.value.type, "b")
self.assertEqual(obj2.value.value, 42) self.assertEqual(obj2.value.value, 42)
# Test with input that matches neither schema (should fail)
with self.assertRaises(ValueError) as cm: with self.assertRaises(ValueError) as cm:
Model(value={"type": "c", "value": "test"}) Model(value={"type": "c", "value": "test"})
self.assertIn("does not match any of the oneOf schemas", str(cm.exception)) self.assertIn("does not match any of the oneOf schemas", str(cm.exception))
def test_oneof_multiple_matches_without_discriminator(self): def test_oneof_multiple_matches_without_discriminator(self):
"""Test case where input genuinely matches multiple oneOf schemas"""
schema = { schema = {
"title": "Test", "title": "Test",
"type": "object", "type": "object",
@@ -397,21 +391,18 @@ class TestOneOfTypeParser(TestCase):
} }
} }
], ],
"discriminator": {} # discriminator without propertyName "discriminator": {}
} }
} }
} }
Model = SchemaConverter.build(schema) Model = SchemaConverter.build(schema)
# This input matches both schemas since both accept data as string
# and neither requires specific additional properties
with self.assertRaises(ValueError) as cm: with self.assertRaises(ValueError) as cm:
Model(value={"data": "test"}) Model(value={"data": "test"})
self.assertIn("matches multiple oneOf schemas", str(cm.exception)) self.assertIn("matches multiple oneOf schemas", str(cm.exception))
def test_oneof_overlapping_strings_from_docs(self): def test_oneof_overlapping_strings_from_docs(self):
"""Test the overlapping strings example from documentation"""
schema = { schema = {
"title": "SimpleExample", "title": "SimpleExample",
"type": "object", "type": "object",
@@ -428,21 +419,17 @@ class TestOneOfTypeParser(TestCase):
Model = SchemaConverter.build(schema) Model = SchemaConverter.build(schema)
# Valid: Short string (matches first schema only)
obj1 = Model(value="hi") obj1 = Model(value="hi")
self.assertEqual(obj1.value, "hi") self.assertEqual(obj1.value, "hi")
# Valid: Long string (matches second schema only)
obj2 = Model(value="very long string") obj2 = Model(value="very long string")
self.assertEqual(obj2.value, "very long string") self.assertEqual(obj2.value, "very long string")
# Invalid: Medium string (matches BOTH schemas - violates oneOf)
with self.assertRaises(ValueError) as cm: with self.assertRaises(ValueError) as cm:
Model(value="hello") # 5 chars: matches maxLength=6 AND minLength=4 Model(value="hello") # 5 chars: matches maxLength=6 AND minLength=4
self.assertIn("matches multiple oneOf schemas", str(cm.exception)) self.assertIn("matches multiple oneOf schemas", str(cm.exception))
def test_oneof_shapes_discriminator_from_docs(self): def test_oneof_shapes_discriminator_from_docs(self):
"""Test the shapes discriminator example from documentation"""
schema = { schema = {
"title": "Shape", "title": "Shape",
"type": "object", "type": "object",
@@ -477,17 +464,14 @@ class TestOneOfTypeParser(TestCase):
Model = SchemaConverter.build(schema) Model = SchemaConverter.build(schema)
# Valid: Circle
circle = Model(shape={"type": "circle", "radius": 5.0}) circle = Model(shape={"type": "circle", "radius": 5.0})
self.assertEqual(circle.shape.type, "circle") self.assertEqual(circle.shape.type, "circle")
self.assertEqual(circle.shape.radius, 5.0) self.assertEqual(circle.shape.radius, 5.0)
# Valid: Rectangle
rectangle = Model(shape={"type": "rectangle", "width": 10, "height": 20}) rectangle = Model(shape={"type": "rectangle", "width": 10, "height": 20})
self.assertEqual(rectangle.shape.type, "rectangle") self.assertEqual(rectangle.shape.type, "rectangle")
self.assertEqual(rectangle.shape.width, 10) self.assertEqual(rectangle.shape.width, 10)
self.assertEqual(rectangle.shape.height, 20) self.assertEqual(rectangle.shape.height, 20)
# Invalid: Wrong properties for the type
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
Model(shape={"type": "circle", "width": 10}) Model(shape={"type": "circle", "width": 10})