Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4055efa5bf | |||
|
97aed6e9aa
|
|||
|
d3a2f1e76c
|
|||
| 0a3671974f | |||
|
8761ee5ef6
|
|||
| 85b5900392 | |||
|
7e11c817a7
|
|||
| dc5853c5b2 | |||
|
1e5b686c23
|
|||
| bbe4c6979e | |||
|
c5e70402db
|
|||
|
15944549a0
|
|||
|
79932bb595
|
|||
|
86894fa918
|
|||
|
b386d4954e
|
|||
| 1cab13a4a0 | |||
|
6dad6e0c68
|
|||
|
fbbff0bd9e
|
|||
|
|
9aec7c3e3b
|
||
|
cc6f2d42d5
|
|||
|
|
9797fb35d9
|
||
| 81a5fffef0 | |||
|
00d88388f8
|
|||
| 609af7c32b | |||
| c59c1e8768 | |||
|
7b9464f458
|
|||
|
617f1aab2b
|
|||
|
|
976708934f | ||
|
|
e9d61a1268 |
@@ -37,6 +37,7 @@ Created to simplifying the process of dynamically generating Pydantic models for
|
|||||||
- nested objects
|
- nested objects
|
||||||
- allOf
|
- allOf
|
||||||
- anyOf
|
- anyOf
|
||||||
|
- oneOf
|
||||||
- ref
|
- ref
|
||||||
- enum
|
- enum
|
||||||
- const
|
- const
|
||||||
|
|||||||
112
docs/source/usage.oneof.rst
Normal file
112
docs/source/usage.oneof.rst
Normal 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.
|
||||||
@@ -45,5 +45,6 @@ For more complex schemas and types see our documentation on
|
|||||||
usage.reference
|
usage.reference
|
||||||
usage.allof
|
usage.allof
|
||||||
usage.anyof
|
usage.anyof
|
||||||
|
usage.oneof
|
||||||
usage.enum
|
usage.enum
|
||||||
usage.const
|
usage.const
|
||||||
@@ -7,7 +7,9 @@ from .const_type_parser import ConstTypeParser
|
|||||||
from .enum_type_parser import EnumTypeParser
|
from .enum_type_parser import EnumTypeParser
|
||||||
from .float_type_parser import FloatTypeParser
|
from .float_type_parser import FloatTypeParser
|
||||||
from .int_type_parser import IntTypeParser
|
from .int_type_parser import IntTypeParser
|
||||||
|
from .null_type_parser import NullTypeParser
|
||||||
from .object_type_parser import ObjectTypeParser
|
from .object_type_parser import ObjectTypeParser
|
||||||
|
from .oneof_type_parser import OneOfTypeParser
|
||||||
from .ref_type_parser import RefTypeParser
|
from .ref_type_parser import RefTypeParser
|
||||||
from .string_type_parser import StringTypeParser
|
from .string_type_parser import StringTypeParser
|
||||||
|
|
||||||
@@ -22,7 +24,9 @@ __all__ = [
|
|||||||
"BooleanTypeParser",
|
"BooleanTypeParser",
|
||||||
"FloatTypeParser",
|
"FloatTypeParser",
|
||||||
"IntTypeParser",
|
"IntTypeParser",
|
||||||
|
"NullTypeParser",
|
||||||
"ObjectTypeParser",
|
"ObjectTypeParser",
|
||||||
|
"OneOfTypeParser",
|
||||||
"StringTypeParser",
|
"StringTypeParser",
|
||||||
"RefTypeParser",
|
"RefTypeParser",
|
||||||
]
|
]
|
||||||
@@ -35,7 +35,7 @@ class ArrayTypeParser(GenericTypeParser):
|
|||||||
|
|
||||||
mapped_properties = self.mappings_properties_builder(properties, **kwargs)
|
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(
|
mapped_properties["default_factory"] = self._build_default_factory(
|
||||||
properties.get("default"), wrapper_type
|
properties.get("default"), wrapper_type
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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, Unpack
|
from typing_extensions import Annotated, Any, Literal, Unpack
|
||||||
|
|
||||||
|
|
||||||
class ConstTypeParser(GenericTypeParser):
|
class ConstTypeParser(GenericTypeParser):
|
||||||
@@ -33,11 +33,19 @@ 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):
|
||||||
def _validate_const_value(value: Any) -> Any:
|
# Try to use Literal for hashable types (required for discriminated unions)
|
||||||
if value != const_value:
|
# Fall back to validator approach for non-hashable types
|
||||||
raise ValueError(
|
try:
|
||||||
f"Value must be equal to the constant value: {const_value}"
|
# Test if the value is hashable (can be used in Literal)
|
||||||
)
|
hash(const_value)
|
||||||
return 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)]
|
||||||
18
jambo/parser/null_type_parser.py
Normal file
18
jambo/parser/null_type_parser.py
Normal 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
|
||||||
@@ -59,7 +59,7 @@ class ObjectTypeParser(GenericTypeParser):
|
|||||||
|
|
||||||
fields = {}
|
fields = {}
|
||||||
for name, prop in properties.items():
|
for name, prop in properties.items():
|
||||||
sub_property = kwargs.copy()
|
sub_property: TypeParserOptions = kwargs.copy()
|
||||||
sub_property["required"] = name in required_keys
|
sub_property["required"] = name in required_keys
|
||||||
|
|
||||||
parsed_type, parsed_properties = GenericTypeParser.type_from_properties(
|
parsed_type, parsed_properties = GenericTypeParser.type_from_properties(
|
||||||
|
|||||||
101
jambo/parser/oneof_type_parser.py
Normal file
101
jambo/parser/oneof_type_parser.py
Normal 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)]
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
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 EmailStr, HttpUrl, IPvAnyAddress
|
from pydantic import AnyUrl, EmailStr
|
||||||
from typing_extensions import Unpack
|
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):
|
class StringTypeParser(GenericTypeParser):
|
||||||
@@ -20,14 +22,22 @@ class StringTypeParser(GenericTypeParser):
|
|||||||
}
|
}
|
||||||
|
|
||||||
format_type_mapping = {
|
format_type_mapping = {
|
||||||
"email": EmailStr,
|
# 7.3.1. Dates, Times, and Duration
|
||||||
"uri": HttpUrl,
|
|
||||||
"ipv4": IPvAnyAddress,
|
|
||||||
"ipv6": IPvAnyAddress,
|
|
||||||
"hostname": str,
|
|
||||||
"date": date,
|
"date": date,
|
||||||
"time": time,
|
"time": time,
|
||||||
"date-time": datetime,
|
"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 = {
|
format_pattern_mapping = {
|
||||||
@@ -37,9 +47,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:
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
from jambo.parser import ConstTypeParser
|
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
|
from unittest import TestCase
|
||||||
|
|
||||||
|
|
||||||
class TestConstTypeParser(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()
|
parser = ConstTypeParser()
|
||||||
|
|
||||||
expected_const_value = "United States of America"
|
expected_const_value = "United States of America"
|
||||||
@@ -16,8 +17,60 @@ 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(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)
|
self.assertEqual(parsed_properties["default"], expected_const_value)
|
||||||
|
|
||||||
|
|||||||
29
tests/parser/test_null_type_parser.py
Normal file
29
tests/parser/test_null_type_parser.py
Normal 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})
|
||||||
519
tests/parser/test_oneof_type_parser.py
Normal file
519
tests/parser/test_oneof_type_parser.py
Normal 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})
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
from jambo.parser import StringTypeParser
|
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 unittest import TestCase
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
class TestStringTypeParser(TestCase):
|
class TestStringTypeParser(TestCase):
|
||||||
@@ -111,12 +113,14 @@ class TestStringTypeParser(TestCase):
|
|||||||
|
|
||||||
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
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):
|
def test_string_parser_with_ip_formats(self):
|
||||||
parser = StringTypeParser()
|
parser = StringTypeParser()
|
||||||
|
|
||||||
for ip_format in ["ipv4", "ipv6"]:
|
formats = {"ipv4": IPv4Address, "ipv6": IPv6Address}
|
||||||
|
|
||||||
|
for ip_format, expected_type in formats.items():
|
||||||
properties = {
|
properties = {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": ip_format,
|
"format": ip_format,
|
||||||
@@ -126,7 +130,19 @@ class TestStringTypeParser(TestCase):
|
|||||||
"placeholder", properties
|
"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):
|
def test_string_parser_with_time_format(self):
|
||||||
parser = StringTypeParser()
|
parser = StringTypeParser()
|
||||||
@@ -197,3 +213,15 @@ class TestStringTypeParser(TestCase):
|
|||||||
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
self.assertEqual(type_parsing, datetime)
|
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)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from jambo import SchemaConverter
|
from jambo import SchemaConverter
|
||||||
|
|
||||||
from pydantic import BaseModel, HttpUrl
|
from pydantic import AnyUrl, BaseModel
|
||||||
|
|
||||||
from ipaddress import IPv4Address, IPv6Address
|
from ipaddress import IPv4Address, IPv6Address
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
def is_pydantic_model(cls):
|
def is_pydantic_model(cls):
|
||||||
@@ -181,7 +182,7 @@ class TestSchemaConverter(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(model(is_active="true").is_active, True)
|
self.assertEqual(model(is_active="true").is_active, True)
|
||||||
|
|
||||||
def test_validation_list(self):
|
def test_validation_list_with_valid_items(self):
|
||||||
schema = {
|
schema = {
|
||||||
"title": "Person",
|
"title": "Person",
|
||||||
"description": "A person",
|
"description": "A person",
|
||||||
@@ -210,6 +211,46 @@ class TestSchemaConverter(TestCase):
|
|||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
model(friends=["John", "Jane", "Invalid"])
|
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):
|
def test_validation_object(self):
|
||||||
schema = {
|
schema = {
|
||||||
"title": "Person",
|
"title": "Person",
|
||||||
@@ -235,6 +276,9 @@ class TestSchemaConverter(TestCase):
|
|||||||
self.assertEqual(obj.address.street, "123 Main St")
|
self.assertEqual(obj.address.street, "123 Main St")
|
||||||
self.assertEqual(obj.address.city, "Springfield")
|
self.assertEqual(obj.address.city, "Springfield")
|
||||||
|
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
model()
|
||||||
|
|
||||||
def test_default_for_string(self):
|
def test_default_for_string(self):
|
||||||
schema = {
|
schema = {
|
||||||
"title": "Person",
|
"title": "Person",
|
||||||
@@ -420,7 +464,7 @@ class TestSchemaConverter(TestCase):
|
|||||||
}
|
}
|
||||||
model = SchemaConverter.build(schema)
|
model = SchemaConverter.build(schema)
|
||||||
self.assertEqual(
|
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):
|
with self.assertRaises(ValueError):
|
||||||
model(website="invalid-uri")
|
model(website="invalid-uri")
|
||||||
@@ -450,6 +494,22 @@ class TestSchemaConverter(TestCase):
|
|||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
model(ip="invalid-ipv6")
|
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):
|
def test_string_format_hostname(self):
|
||||||
schema = {
|
schema = {
|
||||||
"title": "HostnameTest",
|
"title": "HostnameTest",
|
||||||
@@ -657,3 +717,46 @@ class TestSchemaConverter(TestCase):
|
|||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
Model(name="Canada")
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user