29 Commits

Author SHA1 Message Date
4055efa5bf Merge pull request #42 from HideyoshiNakazone/improvement/better-string-validations
(improvement): Adds More Type Formats to String Parser
2025-08-20 00:31:46 -03:00
97aed6e9aa (improvement): Adds tests for UUID String Format 2025-08-20 00:30:54 -03:00
d3a2f1e76c (improvement): Adds More Type Formats to String Parser 2025-08-20 00:25:02 -03:00
0a3671974f Merge pull request #41 from HideyoshiNakazone/feature/fixes-docs
(fix): Fixes docs
2025-08-20 00:00:30 -03:00
8761ee5ef6 (fix): Fixes docs 2025-08-20 00:00:03 -03:00
85b5900392 Merge pull request #40 from HideyoshiNakazone/fix/adds-check-for-discriminator-type
(fix): Adds check for discriminator type
2025-08-19 22:31:02 -03:00
7e11c817a7 (fix): Adds check for discriminator type 2025-08-19 22:28:58 -03:00
dc5853c5b2 Merge pull request #39 from HideyoshiNakazone/feature/fixes-readme
(project): Fixes Readme
2025-08-19 20:48:27 -03:00
1e5b686c23 (project): Fixes Readme 2025-08-19 20:47:58 -03:00
bbe4c6979e Merge pull request #37 from HideyoshiNakazone/feature/implements-one-of
[FEATURE] Implements OneOf
2025-08-19 20:45:30 -03:00
c5e70402db (feat): Adds Warning to Docs About Discriminator Keyword 2025-08-19 20:44:16 -03:00
15944549a0 (feat): Adds Aditional Tests 2025-08-19 20:40:49 -03:00
79932bb595 (feature): Removes _has_meaningful_constraints
Removes _has_meaningful_constraints since nowhere in the spec says that a subproperty should have a meaningful value other that its type
2025-08-19 20:29:25 -03:00
86894fa918 (feature): Fix OneOf behavior on invalid discriminator
According to the spec, propertyName is required when using a discriminator. If it is missing, the schema is invalid and should throw.
2025-08-19 20:20:20 -03:00
b386d4954e Merge remote-tracking branch 'origin/main' into feature/implements-one-of 2025-08-19 19:02:43 -03:00
1cab13a4a0 Merge pull request #38 from HideyoshiNakazone/feature/better-const-typing
[FEATURE] Adds Better Const Typing
2025-08-19 19:02:09 -03:00
6dad6e0c68 (feat): Adds Aditional Test for Non-Hashable Const Values 2025-08-19 18:58:33 -03:00
fbbff0bd9e Removes Changes Not Feature Specific 2025-08-19 18:49:45 -03:00
Thomas
9aec7c3e3b 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
2025-08-19 18:44:01 -03:00
cc6f2d42d5 Separates PR for Better Testing and Readability 2025-08-19 18:40:30 -03:00
Thomas
9797fb35d9 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
2025-08-19 18:31:51 -03:00
81a5fffef0 Merge pull request #32 from fredsonnenwald/add-null
Add null type parser
2025-08-18 23:39:11 -03:00
00d88388f8 Fixes Behavior of Pydantic None Type and Adds More Tests 2025-08-18 23:33:16 -03:00
609af7c32b Merge pull request #35 from fredsonnenwald/add-duration
add string duration -> timedelta
2025-08-18 23:05:08 -03:00
c59c1e8768 Merge pull request #36 from HideyoshiNakazone/fix/required-array-field-not-honored
Fix/required array field not honored
2025-08-18 23:00:59 -03:00
7b9464f458 Fixes Array So No DefaultFactory is Created When no Default is Set and Field is Required 2025-08-18 22:53:28 -03:00
617f1aab2b Adds Failing Test Case to Test 2025-08-18 22:27:49 -03:00
Fred Sonnenwald
976708934f add string duration -> timedelta 2025-08-08 12:38:33 +01:00
Fred Sonnenwald
e9d61a1268 Add null type parser 2025-06-30 12:23:47 +01:00
15 changed files with 1018 additions and 33 deletions

View File

@@ -37,6 +37,7 @@ Created to simplifying the process of dynamically generating Pydantic models for
- nested objects
- allOf
- anyOf
- oneOf
- ref
- enum
- const

112
docs/source/usage.oneof.rst Normal file
View File

@@ -0,0 +1,112 @@
OneOf Type
=================
The OneOf type is used to specify that an object must conform to exactly one of the specified schemas. Unlike AnyOf which allows matching multiple schemas, OneOf enforces that the data matches one and only one of the provided schemas.
Examples
-----------------
1. **Overlapping String Example** - A field that accepts strings with overlapping constraints:
.. code-block:: python
from jambo import SchemaConverter
schema = {
"title": "SimpleExample",
"type": "object",
"properties": {
"value": {
"oneOf": [
{"type": "string", "maxLength": 6},
{"type": "string", "minLength": 4}
]
}
},
"required": ["value"]
}
Model = SchemaConverter.build(schema)
# Valid: Short string (matches first schema only)
obj1 = Model(value="hi")
print(obj1.value) # Output: hi
# Valid: Long string (matches second schema only)
obj2 = Model(value="very long string")
print(obj2.value) # Output: very long string
# Invalid: Medium string (matches BOTH schemas - violates oneOf)
try:
obj3 = Model(value="hello") # 5 chars: matches maxLength=6 AND minLength=4
except ValueError as e:
print("Validation fails as expected:", e)
2. **Discriminator Example** - Different shapes with a type field:
.. code-block:: python
from jambo import SchemaConverter
schema = {
"title": "Shape",
"type": "object",
"properties": {
"shape": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "circle"},
"radius": {"type": "number", "minimum": 0}
},
"required": ["type", "radius"]
},
{
"type": "object",
"properties": {
"type": {"const": "rectangle"},
"width": {"type": "number", "minimum": 0},
"height": {"type": "number", "minimum": 0}
},
"required": ["type", "width", "height"]
}
],
"discriminator": {
"propertyName": "type"
}
}
},
"required": ["shape"]
}
Model = SchemaConverter.build(schema)
# Valid: Circle
circle = Model(shape={"type": "circle", "radius": 5.0})
print(circle.shape.type) # Output: circle
# Valid: Rectangle
rectangle = Model(shape={"type": "rectangle", "width": 10, "height": 20})
print(rectangle.shape.type) # Output: rectangle
# Invalid: Wrong properties for the type
try:
invalid = Model(shape={"type": "circle", "width": 10})
except ValueError as e:
print("Validation fails as expected:", e)
.. note::
OneOf ensures exactly one schema matches. The discriminator helps Pydantic efficiently determine which schema to use based on a specific property value.
.. warning::
If your data could match multiple schemas in a oneOf, validation will fail. Ensure schemas are mutually exclusive.
.. warning::
The discriminator feature is not officially in the JSON Schema specification, it was introduced by OpenAPI. Use it with caution and ensure it fits your use case.

View File

@@ -45,5 +45,6 @@ For more complex schemas and types see our documentation on
usage.reference
usage.allof
usage.anyof
usage.oneof
usage.enum
usage.const

View File

@@ -7,7 +7,9 @@ from .const_type_parser import ConstTypeParser
from .enum_type_parser import EnumTypeParser
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
@@ -22,7 +24,9 @@ __all__ = [
"BooleanTypeParser",
"FloatTypeParser",
"IntTypeParser",
"NullTypeParser",
"ObjectTypeParser",
"OneOfTypeParser",
"StringTypeParser",
"RefTypeParser",
]

View File

@@ -35,7 +35,7 @@ class ArrayTypeParser(GenericTypeParser):
mapped_properties = self.mappings_properties_builder(properties, **kwargs)
if "default" not in mapped_properties:
if "default" in properties or not kwargs.get("required", False):
mapped_properties["default_factory"] = self._build_default_factory(
properties.get("default"), wrapper_type
)

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,6 +33,14 @@ class ConstTypeParser(GenericTypeParser):
return const_type, parsed_properties
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:
# 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(

View File

@@ -0,0 +1,18 @@
from jambo.parser._type_parser import GenericTypeParser
from jambo.types.type_parser_options import TypeParserOptions
from typing_extensions import Unpack
class NullTypeParser(GenericTypeParser):
mapped_type = type(None)
json_schema_type = "type:null"
def from_properties_impl(
self, name, properties, **kwargs: Unpack[TypeParserOptions]
):
mapped_properties = self.mappings_properties_builder(properties, **kwargs)
mapped_properties["default"] = None
return self.mapped_type, mapped_properties

View File

@@ -59,7 +59,7 @@ class ObjectTypeParser(GenericTypeParser):
fields = {}
for name, prop in properties.items():
sub_property = kwargs.copy()
sub_property: TypeParserOptions = kwargs.copy()
sub_property["required"] = name in required_keys
parsed_type, parsed_properties = GenericTypeParser.type_from_properties(

View File

@@ -0,0 +1,101 @@
from jambo.parser._type_parser import GenericTypeParser
from jambo.types.type_parser_options import TypeParserOptions
from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, ValidationError
from typing_extensions import Annotated, Any, Union, Unpack, get_args
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) or len(properties["oneOf"]) == 0:
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")
subfield_types = [Annotated[t, Field(**v)] for t, v in sub_types]
# Added with the understanding of discriminator are not in the JsonSchema Spec,
# they were added by OpenAPI and not all implementations may support them,
# and they do not always generate a model one-to-one to the Pydantic model
# TL;DR: Discriminators were added by OpenAPI and not a Official JSON Schema feature
discriminator = properties.get("discriminator")
if discriminator is not None:
validated_type = self._build_type_one_of_with_discriminator(
subfield_types, discriminator
)
else:
validated_type = self._build_type_one_of_with_func(subfield_types)
return validated_type, mapped_properties
@staticmethod
def _build_type_one_of_with_discriminator(
subfield_types: list[Annotated], discriminator_prop: dict
) -> Annotated:
"""
Build a type with a discriminator.
"""
if not isinstance(discriminator_prop, dict):
raise ValueError("Discriminator must be a dictionary")
for field in subfield_types:
field_type, field_info = get_args(field)
if issubclass(field_type, BaseModel):
continue
raise ValueError(
"When using a discriminator, all subfield types must be of type 'object'."
)
property_name = discriminator_prop.get("propertyName")
if property_name is None or not isinstance(property_name, str):
raise ValueError("Discriminator must have a 'propertyName' key")
return Annotated[Union[(*subfield_types,)], Field(discriminator=property_name)]
@staticmethod
def _build_type_one_of_with_func(subfield_types: list[Annotated]) -> Annotated:
"""
Build a type with a validation function for the oneOf constraint.
"""
def validate_one_of(value: Any) -> Any:
matched_count = 0
for field_type in subfield_types:
try:
TypeAdapter(field_type).validate_python(value)
matched_count += 1
except ValidationError:
continue
if matched_count == 0:
raise ValueError("Value does not match any of the oneOf schemas")
elif matched_count > 1:
raise ValueError(
"Value matches multiple oneOf schemas, exactly one expected"
)
return value
return Annotated[Union[(*subfield_types,)], BeforeValidator(validate_one_of)]

View File

@@ -1,10 +1,12 @@
from jambo.parser._type_parser import GenericTypeParser
from jambo.types.type_parser_options import TypeParserOptions
from pydantic import EmailStr, HttpUrl, IPvAnyAddress
from pydantic import AnyUrl, EmailStr
from typing_extensions import Unpack
from datetime import date, datetime, time
from datetime import date, datetime, time, timedelta
from ipaddress import IPv4Address, IPv6Address
from uuid import UUID
class StringTypeParser(GenericTypeParser):
@@ -20,14 +22,22 @@ class StringTypeParser(GenericTypeParser):
}
format_type_mapping = {
"email": EmailStr,
"uri": HttpUrl,
"ipv4": IPvAnyAddress,
"ipv6": IPvAnyAddress,
"hostname": str,
# 7.3.1. Dates, Times, and Duration
"date": date,
"time": time,
"date-time": datetime,
"duration": timedelta,
# 7.3.2. Email Addresses
"email": EmailStr,
# 7.3.3. Hostnames
"hostname": str,
# 7.3.4. IP Addresses
"ipv4": IPv4Address,
"ipv6": IPv6Address,
# 7.3.5. Resource Identifiers
"uri": AnyUrl,
# "iri" # Not supported by pydantic and currently not supported by jambo
"uuid": UUID,
}
format_pattern_mapping = {
@@ -37,9 +47,7 @@ class StringTypeParser(GenericTypeParser):
def from_properties_impl(
self, name, properties, **kwargs: Unpack[TypeParserOptions]
):
mapped_properties = self.mappings_properties_builder(
properties, **kwargs
)
mapped_properties = self.mappings_properties_builder(properties, **kwargs)
format_type = properties.get("format")
if not format_type:

View File

@@ -1,12 +1,13 @@
from jambo.parser import ConstTypeParser
from typing_extensions import Annotated, get_args, get_origin
from typing_extensions import Annotated, Literal, get_args, get_origin
from unittest import TestCase
class TestConstTypeParser(TestCase):
def test_const_type_parser(self):
def test_const_type_parser_hashable_value(self):
"""Test const parser with hashable values (uses Literal)"""
parser = ConstTypeParser()
expected_const_value = "United States of America"
@@ -16,8 +17,60 @@ class TestConstTypeParser(TestCase):
"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.assertIn(str, get_args(parsed_type))
self.assertIn(list, 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)

View File

@@ -0,0 +1,29 @@
from jambo.parser import NullTypeParser
from unittest import TestCase
class TestNullTypeParser(TestCase):
def test_null_parser_no_options(self):
parser = NullTypeParser()
properties = {"type": "null"}
type_parsing, type_validator = parser.from_properties_impl(
"placeholder", properties
)
self.assertEqual(type_parsing, type(None))
self.assertEqual(type_validator, {"default": None})
def test_null_parser_with_invalid_default(self):
parser = NullTypeParser()
properties = {"type": "null", "default": "invalid"}
type_parsing, type_validator = parser.from_properties_impl(
"placeholder", properties
)
self.assertEqual(type_parsing, type(None))
self.assertEqual(type_validator, {"default": None})

View File

@@ -0,0 +1,519 @@
from jambo import SchemaConverter
from jambo.parser.oneof_type_parser import OneOfTypeParser
from unittest import TestCase
class TestOneOfTypeParser(TestCase):
def test_oneof_raises_on_invalid_property(self):
with self.assertRaises(ValueError):
OneOfTypeParser().from_properties_impl(
"test_field",
{
# Invalid schema, should have property "oneOf"
},
required=True,
context={},
ref_cache={},
)
with self.assertRaises(ValueError):
SchemaConverter.build(
{
"title": "Test",
"type": "object",
"properties": {
"value": {
"oneOf": [], # should throw because oneOf requires at least one schema
}
},
}
)
def test_oneof_basic_integer_and_string(self):
schema = {
"title": "Person",
"description": "A person with an ID that can be either an integer or a formatted string",
"type": "object",
"properties": {
"id": {
"oneOf": [
{"type": "integer", "minimum": 1},
{"type": "string", "pattern": "^[A-Z]{2}[0-9]{4}$"},
]
},
},
"required": ["id"],
}
Model = SchemaConverter.build(schema)
obj1 = Model(id=123)
self.assertEqual(obj1.id, 123)
obj2 = Model(id="AB1234")
self.assertEqual(obj2.id, "AB1234")
def test_oneof_validation_failures(self):
schema = {
"title": "Person",
"type": "object",
"properties": {
"id": {
"oneOf": [
{"type": "integer", "minimum": 1},
{"type": "string", "pattern": "^[A-Z]{2}[0-9]{4}$"},
]
},
},
"required": ["id"],
}
Model = SchemaConverter.build(schema)
with self.assertRaises(ValueError):
Model(id=-5)
with self.assertRaises(ValueError):
Model(id="invalid")
with self.assertRaises(ValueError):
Model(id=123.45)
def test_oneof_with_conflicting_schemas(self):
schema = {
"title": "Value",
"type": "object",
"properties": {
"data": {
"oneOf": [
{"type": "number", "multipleOf": 2},
{"type": "number", "multipleOf": 3},
]
},
},
"required": ["data"],
}
Model = SchemaConverter.build(schema)
obj1 = Model(data=4)
self.assertEqual(obj1.data, 4)
obj2 = Model(data=9)
self.assertEqual(obj2.data, 9)
with self.assertRaises(ValueError) as cm:
Model(data=6)
self.assertIn("matches multiple oneOf schemas", str(cm.exception))
with self.assertRaises(ValueError):
Model(data=5)
def test_oneof_with_objects(self):
schema = {
"title": "Contact",
"type": "object",
"properties": {
"contact_info": {
"oneOf": [
{
"type": "object",
"properties": {
"email": {"type": "string", "format": "email"}
},
"required": ["email"],
"additionalProperties": False,
},
{
"type": "object",
"properties": {
"phone": {"type": "string", "pattern": "^[0-9-]+$"}
},
"required": ["phone"],
"additionalProperties": False,
},
]
},
},
"required": ["contact_info"],
}
Model = SchemaConverter.build(schema)
obj1 = Model(contact_info={"email": "user@example.com"})
self.assertEqual(obj1.contact_info.email, "user@example.com")
obj2 = Model(contact_info={"phone": "123-456-7890"})
self.assertEqual(obj2.contact_info.phone, "123-456-7890")
with self.assertRaises(ValueError):
Model(contact_info={"email": "user@example.com", "phone": "123-456-7890"})
def test_oneof_with_discriminator_basic(self):
schema = {
"title": "Pet",
"type": "object",
"properties": {
"pet": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "cat"},
"meows": {"type": "boolean"},
},
"required": ["type", "meows"],
},
{
"type": "object",
"properties": {
"type": {"const": "dog"},
"barks": {"type": "boolean"},
},
"required": ["type", "barks"],
},
],
"discriminator": {"propertyName": "type"},
}
},
"required": ["pet"],
}
Model = SchemaConverter.build(schema)
cat = Model(pet={"type": "cat", "meows": True})
self.assertEqual(cat.pet.type, "cat")
self.assertEqual(cat.pet.meows, True)
dog = Model(pet={"type": "dog", "barks": False})
self.assertEqual(dog.pet.type, "dog")
self.assertEqual(dog.pet.barks, False)
with self.assertRaises(ValueError):
Model(pet={"type": "cat", "barks": True})
with self.assertRaises(ValueError):
Model(pet={"type": "bird", "flies": True})
def test_oneof_with_invalid_types(self):
with self.assertRaises(ValueError):
SchemaConverter.build(
{
"title": "Pet",
"type": "object",
"properties": {
"pet": {
"oneOf": [
{
"type": "number",
},
{
"type": "string",
},
],
"discriminator": {"propertyName": "type"},
}
},
"required": ["pet"],
}
)
def test_oneof_with_discriminator_mapping(self):
schema = {
"title": "Vehicle",
"type": "object",
"properties": {
"vehicle": {
"oneOf": [
{
"type": "object",
"properties": {
"vehicle_type": {"const": "car"},
"doors": {
"type": "integer",
"minimum": 2,
"maximum": 4,
},
},
"required": ["vehicle_type", "doors"],
},
{
"type": "object",
"properties": {
"vehicle_type": {"const": "motorcycle"},
"engine_size": {"type": "number", "minimum": 125},
},
"required": ["vehicle_type", "engine_size"],
},
],
"discriminator": {
"propertyName": "vehicle_type",
"mapping": {
"car": "#/properties/vehicle/oneOf/0",
"motorcycle": "#/properties/vehicle/oneOf/1",
},
},
}
},
"required": ["vehicle"],
}
Model = SchemaConverter.build(schema)
car = Model(vehicle={"vehicle_type": "car", "doors": 4})
self.assertEqual(car.vehicle.vehicle_type, "car")
self.assertEqual(car.vehicle.doors, 4)
motorcycle = Model(vehicle={"vehicle_type": "motorcycle", "engine_size": 600.0})
self.assertEqual(motorcycle.vehicle.vehicle_type, "motorcycle")
self.assertEqual(motorcycle.vehicle.engine_size, 600.0)
def test_oneof_with_discriminator_invalid_values(self):
schema = {
"title": "Shape",
"type": "object",
"properties": {
"shape": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "circle"},
"radius": {"type": "number", "minimum": 0},
},
"required": ["type", "radius"],
},
{
"type": "object",
"properties": {
"type": {"const": "square"},
"side": {"type": "number", "minimum": 0},
},
"required": ["type", "side"],
},
],
"discriminator": {"propertyName": "type"},
}
},
"required": ["shape"],
}
Model = SchemaConverter.build(schema)
with self.assertRaises(ValueError):
Model(shape={"type": "triangle", "base": 5, "height": 3})
with self.assertRaises(ValueError):
Model(shape={"type": "circle", "side": 5})
with self.assertRaises(ValueError):
Model(shape={"radius": 5})
def test_oneof_missing_properties(self):
schema = {
"title": "Test",
"type": "object",
"properties": {
"value": {
"notOneOf": [
{"type": "string"},
{"type": "integer"},
]
},
},
}
with self.assertRaises(ValueError):
SchemaConverter.build(schema)
def test_oneof_invalid_properties(self):
schema = {
"title": "Test",
"type": "object",
"properties": {
"value": {"oneOf": None},
},
}
with self.assertRaises(ValueError):
SchemaConverter.build(schema)
def test_oneof_with_default_value(self):
schema = {
"title": "Test",
"type": "object",
"properties": {
"value": {
"oneOf": [
{"type": "string"},
{"type": "integer"},
],
"default": "test",
},
},
}
Model = SchemaConverter.build(schema)
obj = Model()
self.assertEqual(obj.value, "test")
def test_oneof_with_invalid_default_value(self):
schema = {
"title": "Test",
"type": "object",
"properties": {
"value": {
"oneOf": [
{"type": "string", "minLength": 5},
{"type": "integer", "minimum": 10},
],
"default": "hi",
},
},
}
with self.assertRaises(ValueError):
SchemaConverter.build(schema)
def test_oneof_discriminator_without_property_name(self):
# Should throw because the spec determines propertyName is required for discriminator
with self.assertRaises(ValueError):
SchemaConverter.build(
{
"title": "Test",
"type": "object",
"properties": {
"value": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "a"},
"value": {"type": "string"},
},
},
{
"type": "object",
"properties": {
"type": {"const": "b"},
"value": {"type": "integer"},
},
},
],
"discriminator": {}, # discriminator without propertyName
}
},
}
)
def test_oneof_discriminator_with_invalid_discriminator(self):
# Should throw because a valid discriminator is required
with self.assertRaises(ValueError):
SchemaConverter.build(
{
"title": "Test",
"type": "object",
"properties": {
"value": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "a"},
"value": {"type": "string"},
},
},
{
"type": "object",
"properties": {
"type": {"const": "b"},
"value": {"type": "integer"},
},
},
],
"discriminator": "invalid", # discriminator without propertyName
}
},
}
)
def test_oneof_overlapping_strings_from_docs(self):
"""Test the overlapping strings example from documentation"""
schema = {
"title": "SimpleExample",
"type": "object",
"properties": {
"value": {
"oneOf": [
{"type": "string", "maxLength": 6},
{"type": "string", "minLength": 4},
]
}
},
"required": ["value"],
}
Model = SchemaConverter.build(schema)
# Valid: Short string (matches first schema only)
obj1 = Model(value="hi")
self.assertEqual(obj1.value, "hi")
# Valid: Long string (matches second schema only)
obj2 = Model(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:
Model(value="hello") # 5 chars: matches maxLength=6 AND minLength=4
self.assertIn("matches multiple oneOf schemas", str(cm.exception))
def test_oneof_shapes_discriminator_from_docs(self):
"""Test the shapes discriminator example from documentation"""
schema = {
"title": "Shape",
"type": "object",
"properties": {
"shape": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "circle"},
"radius": {"type": "number", "minimum": 0},
},
"required": ["type", "radius"],
},
{
"type": "object",
"properties": {
"type": {"const": "rectangle"},
"width": {"type": "number", "minimum": 0},
"height": {"type": "number", "minimum": 0},
},
"required": ["type", "width", "height"],
},
],
"discriminator": {"propertyName": "type"},
}
},
"required": ["shape"],
}
Model = SchemaConverter.build(schema)
# Valid: Circle
circle = Model(shape={"type": "circle", "radius": 5.0})
self.assertEqual(circle.shape.type, "circle")
self.assertEqual(circle.shape.radius, 5.0)
# Valid: Rectangle
rectangle = Model(shape={"type": "rectangle", "width": 10, "height": 20})
self.assertEqual(rectangle.shape.type, "rectangle")
self.assertEqual(rectangle.shape.width, 10)
self.assertEqual(rectangle.shape.height, 20)
# Invalid: Wrong properties for the type
with self.assertRaises(ValueError):
Model(shape={"type": "circle", "width": 10})

View File

@@ -1,9 +1,11 @@
from jambo.parser import StringTypeParser
from pydantic import EmailStr, HttpUrl, IPvAnyAddress
from pydantic import AnyUrl, EmailStr
from datetime import date, datetime, time
from datetime import date, datetime, time, timedelta
from ipaddress import IPv4Address, IPv6Address
from unittest import TestCase
from uuid import UUID
class TestStringTypeParser(TestCase):
@@ -111,12 +113,14 @@ class TestStringTypeParser(TestCase):
type_parsing, type_validator = parser.from_properties("placeholder", properties)
self.assertEqual(type_parsing, HttpUrl)
self.assertEqual(type_parsing, AnyUrl)
def test_string_parser_with_ip_formats(self):
parser = StringTypeParser()
for ip_format in ["ipv4", "ipv6"]:
formats = {"ipv4": IPv4Address, "ipv6": IPv6Address}
for ip_format, expected_type in formats.items():
properties = {
"type": "string",
"format": ip_format,
@@ -126,7 +130,19 @@ class TestStringTypeParser(TestCase):
"placeholder", properties
)
self.assertEqual(type_parsing, IPvAnyAddress)
self.assertEqual(type_parsing, expected_type)
def test_string_parser_with_uuid_format(self):
parser = StringTypeParser()
properties = {
"type": "string",
"format": "uuid",
}
type_parsing, type_validator = parser.from_properties("placeholder", properties)
self.assertEqual(type_parsing, UUID)
def test_string_parser_with_time_format(self):
parser = StringTypeParser()
@@ -197,3 +213,15 @@ class TestStringTypeParser(TestCase):
type_parsing, type_validator = parser.from_properties("placeholder", properties)
self.assertEqual(type_parsing, datetime)
def test_string_parser_with_timedelta_format(self):
parser = StringTypeParser()
properties = {
"type": "string",
"format": "duration",
}
type_parsing, type_validator = parser.from_properties("placeholder", properties)
self.assertEqual(type_parsing, timedelta)

View File

@@ -1,9 +1,10 @@
from jambo import SchemaConverter
from pydantic import BaseModel, HttpUrl
from pydantic import AnyUrl, BaseModel
from ipaddress import IPv4Address, IPv6Address
from unittest import TestCase
from uuid import UUID
def is_pydantic_model(cls):
@@ -181,7 +182,7 @@ class TestSchemaConverter(TestCase):
self.assertEqual(model(is_active="true").is_active, True)
def test_validation_list(self):
def test_validation_list_with_valid_items(self):
schema = {
"title": "Person",
"description": "A person",
@@ -210,6 +211,46 @@ class TestSchemaConverter(TestCase):
with self.assertRaises(ValueError):
model(friends=["John", "Jane", "Invalid"])
def test_validation_list_with_missing_items(self):
model = SchemaConverter.build(
{
"title": "Person",
"description": "A person",
"type": "object",
"properties": {
"friends": {
"type": "array",
"items": {"type": "string"},
"minItems": 1,
"maxItems": 2,
"default": ["John", "Jane"],
},
},
}
)
self.assertEqual(model().friends, ["John", "Jane"])
model = SchemaConverter.build(
{
"title": "Person",
"description": "A person",
"type": "object",
"properties": {
"friends": {
"type": "array",
"items": {"type": "string"},
"minItems": 1,
"maxItems": 2,
},
},
"required": ["friends"],
}
)
with self.assertRaises(ValueError):
model()
def test_validation_object(self):
schema = {
"title": "Person",
@@ -235,6 +276,9 @@ class TestSchemaConverter(TestCase):
self.assertEqual(obj.address.street, "123 Main St")
self.assertEqual(obj.address.city, "Springfield")
with self.assertRaises(ValueError):
model()
def test_default_for_string(self):
schema = {
"title": "Person",
@@ -420,7 +464,7 @@ class TestSchemaConverter(TestCase):
}
model = SchemaConverter.build(schema)
self.assertEqual(
model(website="https://example.com").website, HttpUrl("https://example.com")
model(website="https://example.com").website, AnyUrl("https://example.com")
)
with self.assertRaises(ValueError):
model(website="invalid-uri")
@@ -450,6 +494,22 @@ class TestSchemaConverter(TestCase):
with self.assertRaises(ValueError):
model(ip="invalid-ipv6")
def test_string_format_uuid(self):
schema = {
"title": "UUIDTest",
"type": "object",
"properties": {"id": {"type": "string", "format": "uuid"}},
}
model = SchemaConverter.build(schema)
self.assertEqual(
model(id="123e4567-e89b-12d3-a456-426614174000").id,
UUID("123e4567-e89b-12d3-a456-426614174000"),
)
with self.assertRaises(ValueError):
model(id="invalid-uuid")
def test_string_format_hostname(self):
schema = {
"title": "HostnameTest",
@@ -657,3 +717,46 @@ class TestSchemaConverter(TestCase):
with self.assertRaises(ValueError):
Model(name="Canada")
def test_const_type_parser_with_non_hashable_value(self):
schema = {
"title": "Country",
"type": "object",
"properties": {
"name": {
"const": ["Brazil"],
}
},
"required": ["name"],
}
Model = SchemaConverter.build(schema)
obj = Model()
self.assertEqual(obj.name, ["Brazil"])
with self.assertRaises(ValueError):
obj.name = ["Argentina"]
with self.assertRaises(ValueError):
Model(name=["Argentina"])
def test_null_type_parser(self):
schema = {
"title": "Test",
"type": "object",
"properties": {
"a_thing": {"type": "null"},
},
}
Model = SchemaConverter.build(schema)
obj = Model()
self.assertIsNone(obj.a_thing)
obj = Model(a_thing=None)
self.assertIsNone(obj.a_thing)
with self.assertRaises(ValueError):
Model(a_thing="not none")