[FEATURE] Implements OneOf #37

Merged
HideyoshiNakazone merged 7 commits from feature/implements-one-of into main 2025-08-19 23:45:30 +00:00
6 changed files with 100 additions and 173 deletions
Showing only changes of commit cc6f2d42d5 - Show all commits

View File

@@ -124,26 +124,3 @@ class GenericTypeParser(ABC, Generic[T]):
return False return False
return True return True
@staticmethod
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:
return False
# If only default is set and it's None, no meaningful constraints
if field_props == {"default": None}:
return False
# If there are multiple properties or non-None default, that's meaningful
return True

View File

@@ -3,7 +3,7 @@ from jambo.types.json_schema_type import JSONSchemaNativeTypes
from jambo.types.type_parser_options import TypeParserOptions from jambo.types.type_parser_options import TypeParserOptions
from pydantic import AfterValidator from pydantic import AfterValidator
from typing_extensions import Annotated, Any, Literal, Unpack from typing_extensions import Annotated, Any, Unpack
class ConstTypeParser(GenericTypeParser): class ConstTypeParser(GenericTypeParser):
@@ -33,19 +33,11 @@ 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) def _validate_const_value(value: Any) -> Any:
# Fall back to validator approach for non-hashable types if value != const_value:
try: raise ValueError(
# Test if the value is hashable (can be used in Literal) f"Value must be equal to the constant value: {const_value}"
hash(const_value) )
return Literal[const_value] return value
except TypeError:
# Non-hashable type (like list, dict), use validator approach
def _validate_const_value(value: Any) -> Any:
if value != const_value:
raise ValueError(
f"Value must be equal to the constant value: {const_value}"
)
return value
return Annotated[type(const_value), AfterValidator(_validate_const_value)] return Annotated[type(const_value), AfterValidator(_validate_const_value)]

View File

@@ -1,8 +1,8 @@
from jambo.parser._type_parser import GenericTypeParser from jambo.parser._type_parser import GenericTypeParser
from jambo.types.type_parser_options import TypeParserOptions from jambo.types.type_parser_options import TypeParserOptions
from pydantic import Field, BeforeValidator, TypeAdapter, ValidationError from pydantic import BeforeValidator, Field, TypeAdapter, ValidationError
from typing_extensions import Annotated, Union, Unpack, Any from typing_extensions import Annotated, Any, Union, Unpack
class OneOfTypeParser(GenericTypeParser): class OneOfTypeParser(GenericTypeParser):
@@ -11,7 +11,7 @@ class OneOfTypeParser(GenericTypeParser):
json_schema_type = "oneOf" json_schema_type = "oneOf"
def from_properties_impl( def from_properties_impl(
self, name, properties, **kwargs: Unpack[TypeParserOptions] self, name, properties, **kwargs: Unpack[TypeParserOptions]
): ):
if "oneOf" not in properties: if "oneOf" not in properties:
raise ValueError(f"Invalid JSON Schema: {properties}") raise ValueError(f"Invalid JSON Schema: {properties}")
@@ -42,7 +42,9 @@ class OneOfTypeParser(GenericTypeParser):
if discriminator and isinstance(discriminator, dict): if discriminator and isinstance(discriminator, dict):
property_name = discriminator.get("propertyName") property_name = discriminator.get("propertyName")
if property_name: if property_name:
validated_type = Annotated[union_type, Field(discriminator=property_name)] validated_type = Annotated[
union_type, Field(discriminator=property_name)
]
return validated_type, mapped_properties return validated_type, mapped_properties
def validate_one_of(value: Any) -> Any: def validate_one_of(value: Any) -> Any:
@@ -59,11 +61,34 @@ class OneOfTypeParser(GenericTypeParser):
continue continue
if matched_count == 0: if matched_count == 0:
raise ValueError(f"Value does not match any of the oneOf schemas") raise ValueError("Value does not match any of the oneOf schemas")
elif matched_count > 1: elif matched_count > 1:
raise ValueError(f"Value matches multiple oneOf schemas, exactly one expected") raise ValueError(
"Value matches multiple oneOf schemas, exactly one expected"
)
return value return value
validated_type = Annotated[union_type, BeforeValidator(validate_one_of)] validated_type = Annotated[union_type, BeforeValidator(validate_one_of)]
return validated_type, mapped_properties return validated_type, mapped_properties
@staticmethod
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:
return False
# If only default is set and it's None, no meaningful constraints
if field_props == {"default": None}:
return False
# If there are multiple properties or non-None default, that's meaningful
return True

View File

@@ -16,6 +16,7 @@ class StringTypeParser(GenericTypeParser):
"maxLength": "max_length", "maxLength": "max_length",
"minLength": "min_length", "minLength": "min_length",
"pattern": "pattern", "pattern": "pattern",
"format": "format",
} }
format_type_mapping = { format_type_mapping = {
@@ -37,9 +38,7 @@ class StringTypeParser(GenericTypeParser):
def from_properties_impl( def from_properties_impl(
self, name, properties, **kwargs: Unpack[TypeParserOptions] self, name, properties, **kwargs: Unpack[TypeParserOptions]
): ):
mapped_properties = self.mappings_properties_builder( mapped_properties = self.mappings_properties_builder(properties, **kwargs)
properties, **kwargs
)
format_type = properties.get("format") format_type = properties.get("format")
if not format_type: if not format_type:
@@ -52,8 +51,4 @@ class StringTypeParser(GenericTypeParser):
if format_type in self.format_pattern_mapping: if format_type in self.format_pattern_mapping:
mapped_properties["pattern"] = self.format_pattern_mapping[format_type] mapped_properties["pattern"] = self.format_pattern_mapping[format_type]
if "json_schema_extra" not in mapped_properties:
mapped_properties["json_schema_extra"] = {}
mapped_properties["json_schema_extra"]["format"] = format_type
return mapped_type, mapped_properties return mapped_type, mapped_properties

View File

@@ -1,13 +1,12 @@
from jambo.parser import ConstTypeParser from jambo.parser import ConstTypeParser
from typing_extensions import Annotated, Literal, get_args, get_origin from typing_extensions import Annotated, get_args, get_origin
from unittest import TestCase from unittest import TestCase
class TestConstTypeParser(TestCase): class TestConstTypeParser(TestCase):
def test_const_type_parser_hashable_value(self): def test_const_type_parser(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,60 +16,8 @@ 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_args(parsed_type), (expected_const_value,))
self.assertEqual(parsed_properties["default"], expected_const_value)
def test_const_type_parser_non_hashable_value(self):
"""Test const parser with non-hashable values (uses Annotated with validator)"""
parser = ConstTypeParser()
expected_const_value = [1, 2, 3] # Lists are not hashable
properties = {"const": expected_const_value}
parsed_type, parsed_properties = parser.from_properties_impl(
"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(str, get_args(parsed_type))
self.assertEqual(parsed_properties["default"], expected_const_value)
def test_const_type_parser_integer_value(self):
"""Test const parser with integer values (uses Literal)"""
parser = ConstTypeParser()
expected_const_value = 42
properties = {"const": expected_const_value}
parsed_type, parsed_properties = parser.from_properties_impl(
"int_const", properties
)
# Check that we get a Literal type for hashable values
self.assertEqual(get_origin(parsed_type), Literal)
self.assertEqual(get_args(parsed_type), (expected_const_value,))
self.assertEqual(parsed_properties["default"], expected_const_value)
def test_const_type_parser_boolean_value(self):
"""Test const parser with boolean values (uses Literal)"""
parser = ConstTypeParser()
expected_const_value = True
properties = {"const": expected_const_value}
parsed_type, parsed_properties = parser.from_properties_impl(
"bool_const", properties
)
# Check that we get a Literal type for hashable values
self.assertEqual(get_origin(parsed_type), Literal)
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)

View File

@@ -97,7 +97,7 @@ class TestOneOfTypeParser(TestCase):
"email": {"type": "string", "format": "email"} "email": {"type": "string", "format": "email"}
}, },
"required": ["email"], "required": ["email"],
"additionalProperties": False "additionalProperties": False,
}, },
{ {
"type": "object", "type": "object",
@@ -105,8 +105,8 @@ class TestOneOfTypeParser(TestCase):
"phone": {"type": "string", "pattern": "^[0-9-]+$"} "phone": {"type": "string", "pattern": "^[0-9-]+$"}
}, },
"required": ["phone"], "required": ["phone"],
"additionalProperties": False "additionalProperties": False,
} },
] ]
}, },
}, },
@@ -135,25 +135,23 @@ class TestOneOfTypeParser(TestCase):
"type": "object", "type": "object",
"properties": { "properties": {
"type": {"const": "cat"}, "type": {"const": "cat"},
"meows": {"type": "boolean"} "meows": {"type": "boolean"},
}, },
"required": ["type", "meows"] "required": ["type", "meows"],
}, },
{ {
"type": "object", "type": "object",
"properties": { "properties": {
"type": {"const": "dog"}, "type": {"const": "dog"},
"barks": {"type": "boolean"} "barks": {"type": "boolean"},
}, },
"required": ["type", "barks"] "required": ["type", "barks"],
} },
], ],
"discriminator": { "discriminator": {"propertyName": "type"},
"propertyName": "type"
}
} }
}, },
"required": ["pet"] "required": ["pet"],
} }
Model = SchemaConverter.build(schema) Model = SchemaConverter.build(schema)
@@ -183,29 +181,33 @@ class TestOneOfTypeParser(TestCase):
"type": "object", "type": "object",
"properties": { "properties": {
"vehicle_type": {"const": "car"}, "vehicle_type": {"const": "car"},
"doors": {"type": "integer", "minimum": 2, "maximum": 4} "doors": {
"type": "integer",
"minimum": 2,
"maximum": 4,
},
}, },
"required": ["vehicle_type", "doors"] "required": ["vehicle_type", "doors"],
}, },
{ {
"type": "object", "type": "object",
"properties": { "properties": {
"vehicle_type": {"const": "motorcycle"}, "vehicle_type": {"const": "motorcycle"},
"engine_size": {"type": "number", "minimum": 125} "engine_size": {"type": "number", "minimum": 125},
}, },
"required": ["vehicle_type", "engine_size"] "required": ["vehicle_type", "engine_size"],
} },
], ],
"discriminator": { "discriminator": {
"propertyName": "vehicle_type", "propertyName": "vehicle_type",
"mapping": { "mapping": {
"car": "#/properties/vehicle/oneOf/0", "car": "#/properties/vehicle/oneOf/0",
"motorcycle": "#/properties/vehicle/oneOf/1" "motorcycle": "#/properties/vehicle/oneOf/1",
} },
} },
} }
}, },
"required": ["vehicle"] "required": ["vehicle"],
} }
Model = SchemaConverter.build(schema) Model = SchemaConverter.build(schema)
@@ -229,25 +231,23 @@ class TestOneOfTypeParser(TestCase):
"type": "object", "type": "object",
"properties": { "properties": {
"type": {"const": "circle"}, "type": {"const": "circle"},
"radius": {"type": "number", "minimum": 0} "radius": {"type": "number", "minimum": 0},
}, },
"required": ["type", "radius"] "required": ["type", "radius"],
}, },
{ {
"type": "object", "type": "object",
"properties": { "properties": {
"type": {"const": "square"}, "type": {"const": "square"},
"side": {"type": "number", "minimum": 0} "side": {"type": "number", "minimum": 0},
}, },
"required": ["type", "side"] "required": ["type", "side"],
} },
], ],
"discriminator": { "discriminator": {"propertyName": "type"},
"propertyName": "type"
}
} }
}, },
"required": ["shape"] "required": ["shape"],
} }
Model = SchemaConverter.build(schema) Model = SchemaConverter.build(schema)
@@ -283,9 +283,7 @@ class TestOneOfTypeParser(TestCase):
"title": "Test", "title": "Test",
"type": "object", "type": "object",
"properties": { "properties": {
"value": { "value": {"oneOf": None},
"oneOf": None
},
}, },
} }
@@ -302,7 +300,7 @@ class TestOneOfTypeParser(TestCase):
{"type": "string"}, {"type": "string"},
{"type": "integer"}, {"type": "integer"},
], ],
"default": "test" "default": "test",
}, },
}, },
} }
@@ -321,7 +319,7 @@ class TestOneOfTypeParser(TestCase):
{"type": "string", "minLength": 5}, {"type": "string", "minLength": 5},
{"type": "integer", "minimum": 10}, {"type": "integer", "minimum": 10},
], ],
"default": "hi" "default": "hi",
}, },
}, },
} }
@@ -340,20 +338,20 @@ class TestOneOfTypeParser(TestCase):
"type": "object", "type": "object",
"properties": { "properties": {
"type": {"const": "a"}, "type": {"const": "a"},
"value": {"type": "string"} "value": {"type": "string"},
} },
}, },
{ {
"type": "object", "type": "object",
"properties": { "properties": {
"type": {"const": "b"}, "type": {"const": "b"},
"value": {"type": "integer"} "value": {"type": "integer"},
} },
} },
], ],
"discriminator": {} # discriminator without propertyName "discriminator": {}, # discriminator without propertyName
} }
} },
} }
Model = SchemaConverter.build(schema) Model = SchemaConverter.build(schema)
@@ -383,23 +381,18 @@ class TestOneOfTypeParser(TestCase):
"properties": { "properties": {
"value": { "value": {
"oneOf": [ "oneOf": [
{ {"type": "object", "properties": {"data": {"type": "string"}}},
"type": "object",
"properties": {
"data": {"type": "string"}
}
},
{ {
"type": "object", "type": "object",
"properties": { "properties": {
"data": {"type": "string"}, "data": {"type": "string"},
"optional": {"type": "string"} "optional": {"type": "string"},
} },
} },
], ],
"discriminator": {} # discriminator without propertyName "discriminator": {}, # discriminator without propertyName
} }
} },
} }
Model = SchemaConverter.build(schema) Model = SchemaConverter.build(schema)
@@ -419,11 +412,11 @@ class TestOneOfTypeParser(TestCase):
"value": { "value": {
"oneOf": [ "oneOf": [
{"type": "string", "maxLength": 6}, {"type": "string", "maxLength": 6},
{"type": "string", "minLength": 4} {"type": "string", "minLength": 4},
] ]
} }
}, },
"required": ["value"] "required": ["value"],
} }
Model = SchemaConverter.build(schema) Model = SchemaConverter.build(schema)
@@ -453,26 +446,24 @@ class TestOneOfTypeParser(TestCase):
"type": "object", "type": "object",
"properties": { "properties": {
"type": {"const": "circle"}, "type": {"const": "circle"},
"radius": {"type": "number", "minimum": 0} "radius": {"type": "number", "minimum": 0},
}, },
"required": ["type", "radius"] "required": ["type", "radius"],
}, },
{ {
"type": "object", "type": "object",
"properties": { "properties": {
"type": {"const": "rectangle"}, "type": {"const": "rectangle"},
"width": {"type": "number", "minimum": 0}, "width": {"type": "number", "minimum": 0},
"height": {"type": "number", "minimum": 0} "height": {"type": "number", "minimum": 0},
}, },
"required": ["type", "width", "height"] "required": ["type", "width", "height"],
} },
], ],
"discriminator": { "discriminator": {"propertyName": "type"},
"propertyName": "type"
}
} }
}, },
"required": ["shape"] "required": ["shape"],
} }
Model = SchemaConverter.build(schema) Model = SchemaConverter.build(schema)