feat(jambo): Add oneOf parser (#5)

* Add support for `oneOf` type parsing with validation and example cases

* Improve `oneOf` type parsing: refine validators, add discriminator support, and expand test coverage

* Add hashable and non-hashable value support to `ConstTypeParser` with expanded test cases

* Refine `field_props` check in `_type_parser` for cleaner default handling

* Update `StringTypeParser` to refine `format` handling and enrich `json_schema_extra`

* Remove outdated `oneOf` examples from docs, expand test cases and provide refined examples with discriminator support
This commit is contained in:
Thomas
2025-07-07 15:49:58 +02:00
committed by Vitor Hideyoshi
parent 81a5fffef0
commit 9797fb35d9
9 changed files with 774 additions and 14 deletions

View File

@@ -9,6 +9,7 @@ from .float_type_parser import FloatTypeParser
from .int_type_parser import IntTypeParser
from .null_type_parser import NullTypeParser
from .object_type_parser import ObjectTypeParser
from .oneof_type_parser import OneOfTypeParser
from .ref_type_parser import RefTypeParser
from .string_type_parser import StringTypeParser
@@ -25,6 +26,7 @@ __all__ = [
"IntTypeParser",
"NullTypeParser",
"ObjectTypeParser",
"OneOfTypeParser",
"StringTypeParser",
"RefTypeParser",
]
]

View File

@@ -124,3 +124,26 @@ class GenericTypeParser(ABC, Generic[T]):
return False
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 pydantic import AfterValidator
from typing_extensions import Annotated, Any, Unpack
from typing_extensions import Annotated, Any, Literal, Unpack
class ConstTypeParser(GenericTypeParser):
@@ -33,11 +33,19 @@ class ConstTypeParser(GenericTypeParser):
return const_type, parsed_properties
def _build_const_type(self, const_value):
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
# Try to use Literal for hashable types (required for discriminated unions)
# Fall back to validator approach for non-hashable types
try:
# Test if the value is hashable (can be used in Literal)
hash(const_value)
return Literal[const_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

@@ -0,0 +1,69 @@
from jambo.parser._type_parser import GenericTypeParser
from jambo.types.type_parser_options import TypeParserOptions
from pydantic import Field, BeforeValidator, TypeAdapter, ValidationError
from typing_extensions import Annotated, Union, Unpack, Any
class OneOfTypeParser(GenericTypeParser):
mapped_type = Union
json_schema_type = "oneOf"
def from_properties_impl(
self, name, properties, **kwargs: Unpack[TypeParserOptions]
):
if "oneOf" not in properties:
raise ValueError(f"Invalid JSON Schema: {properties}")
if not isinstance(properties["oneOf"], list):
raise ValueError(f"Invalid JSON Schema: {properties['oneOf']}")
mapped_properties = self.mappings_properties_builder(properties, **kwargs)
sub_properties = properties["oneOf"]
sub_types = [
GenericTypeParser.type_from_properties(name, subProperty, **kwargs)
for subProperty in sub_properties
]
if not kwargs.get("required", False):
mapped_properties["default"] = mapped_properties.get("default")
field_types = [
Annotated[t, Field(**v)] if self._has_meaningful_constraints(v) else t
for t, v in sub_types
]
union_type = Union[(*field_types,)]
discriminator = properties.get("discriminator")
if discriminator and isinstance(discriminator, dict):
property_name = discriminator.get("propertyName")
if property_name:
validated_type = Annotated[union_type, Field(discriminator=property_name)]
return validated_type, mapped_properties
def validate_one_of(value: Any) -> Any:
matched_count = 0
validation_errors = []
for field_type in field_types:
try:
adapter = TypeAdapter(field_type)
adapter.validate_python(value)
matched_count += 1
except ValidationError as e:
validation_errors.append(str(e))
continue
if matched_count == 0:
raise ValueError(f"Value does not match any of the oneOf schemas")
elif matched_count > 1:
raise ValueError(f"Value matches multiple oneOf schemas, exactly one expected")
return value
validated_type = Annotated[union_type, BeforeValidator(validate_one_of)]
return validated_type, mapped_properties

View File

@@ -16,7 +16,6 @@ class StringTypeParser(GenericTypeParser):
"maxLength": "max_length",
"minLength": "min_length",
"pattern": "pattern",
"format": "format",
}
format_type_mapping = {
@@ -53,4 +52,8 @@ class StringTypeParser(GenericTypeParser):
if format_type in self.format_pattern_mapping:
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