Merge pull request #11 from HideyoshiNakazone/any-all-ref-implementation

Implements: allOf, anyOf

Finalizes the implementation of allOf and anyOf, but the implementation of oneOf was cancelled for the time being
This commit was merged in pull request #11.
This commit is contained in:
2025-04-19 17:32:58 -03:00
committed by GitHub
28 changed files with 896 additions and 318 deletions

View File

@@ -1,10 +1,25 @@
# Exports generic type parser # Exports generic type parser
from ._type_parser import GenericTypeParser as GenericTypeParser from ._type_parser import GenericTypeParser
# Exports Implementations # Exports Implementations
from .int_type_parser import IntTypeParser as IntTypeParser from .allof_type_parser import AllOfTypeParser
from .object_type_parser import ObjectTypeParser as ObjectTypeParser from .anyof_type_parser import AnyOfTypeParser
from .string_type_parser import StringTypeParser as StringTypeParser from .array_type_parser import ArrayTypeParser
from .array_type_parser import ArrayTypeParser as ArrayTypeParser from .boolean_type_parser import BooleanTypeParser
from .boolean_type_parser import BooleanTypeParser as BooleanTypeParser from .float_type_parser import FloatTypeParser
from .float_type_parser import FloatTypeParser as FloatTypeParser from .int_type_parser import IntTypeParser
from .object_type_parser import ObjectTypeParser
from .string_type_parser import StringTypeParser
__all__ = [
"GenericTypeParser",
"AllOfTypeParser",
"AnyOfTypeParser",
"ArrayTypeParser",
"BooleanTypeParser",
"FloatTypeParser",
"IntTypeParser",
"ObjectTypeParser",
"StringTypeParser",
]

View File

@@ -1,31 +1,54 @@
from abc import ABC, abstractmethod from pydantic import Field, TypeAdapter
from typing import Generic, TypeVar from typing_extensions import Annotated, Self
from typing_extensions import Self
from abc import ABC, abstractmethod
from typing import Generic, Type, TypeVar
from pydantic import Field
T = TypeVar("T") T = TypeVar("T")
class GenericTypeParser(ABC, Generic[T]): class GenericTypeParser(ABC, Generic[T]):
@property mapped_type: Type[T] = None
@abstractmethod
def mapped_type(self) -> type[T]: ...
@property json_schema_type: str = None
@abstractmethod
def json_schema_type(self) -> str: ...
@staticmethod default_mappings = {
@abstractmethod "default": "default",
def from_properties( "description": "description",
name: str, properties: dict[str, any] }
) -> tuple[type[T], Field]: ...
type_mappings: dict[str, str] = None
@classmethod @classmethod
def get_impl(cls, type_name: str) -> Self: def get_impl(cls, type_name: str) -> Self:
for subcls in cls.__subclasses__(): for subcls in cls.__subclasses__():
if subcls.json_schema_type is None:
raise RuntimeError(f"Unknown type: {type_name}")
if subcls.json_schema_type == type_name: if subcls.json_schema_type == type_name:
return subcls return subcls()
raise ValueError(f"Unknown type: {type_name}") raise ValueError(f"Unknown type: {type_name}")
@abstractmethod
def from_properties(
self, name: str, properties: dict[str, any], required: bool = False
) -> tuple[T, dict]: ...
def mappings_properties_builder(self, properties, required=False) -> dict[str, any]:
if self.type_mappings is None:
raise NotImplementedError("Type mappings not defined")
if not required:
properties["default"] = properties.get("default", None)
mappings = self.default_mappings | self.type_mappings
return {
mappings[key]: value for key, value in properties.items() if key in mappings
}
def validate_default(self, field_type: type, field_prop: dict, value) -> None:
field = Annotated[field_type, Field(**field_prop)]
TypeAdapter(field).validate_python(value)

View File

@@ -0,0 +1,86 @@
from jambo.parser._type_parser import GenericTypeParser
class AllOfTypeParser(GenericTypeParser):
mapped_type = any
json_schema_type = "allOf"
def from_properties(self, name, properties, required=False):
subProperties = properties.get("allOf")
if not subProperties:
raise ValueError("Invalid JSON Schema: 'allOf' is not specified.")
_mapped_type = properties.get("type")
if _mapped_type is None:
_mapped_type = subProperties[0].get("type")
if _mapped_type is None:
raise ValueError("Invalid JSON Schema: 'type' is not specified.")
if any(
[prop.get("type", _mapped_type) != _mapped_type for prop in subProperties]
):
raise ValueError("Invalid JSON Schema: allOf types do not match.")
for subProperty in subProperties:
# If a sub-property has not defined a type, we need to set it to the top-level type
subProperty["type"] = _mapped_type
combined_properties = self._rebuild_properties_from_subproperties(subProperties)
return GenericTypeParser.get_impl(_mapped_type).from_properties(
name, combined_properties
)
def _rebuild_properties_from_subproperties(self, subProperties):
properties = {}
for subProperty in subProperties:
for name, prop in subProperty.items():
if name not in properties:
properties[name] = prop
else:
# Merge properties if they exist in both sub-properties
properties[name] = AllOfTypeParser._validate_prop(
name, properties[name], prop
)
return properties
@staticmethod
def _validate_prop(prop_name, old_value, new_value):
if prop_name == "description":
return f"{old_value} | {new_value}"
if prop_name == "default":
if old_value != new_value:
raise ValueError(
f"Invalid JSON Schema: conflicting defaults for '{prop_name}'"
)
return old_value
if prop_name == "required":
return old_value + new_value
if prop_name in ("maxLength", "maximum", "exclusiveMaximum"):
return old_value if old_value > new_value else new_value
if prop_name in ("minLength", "minimum", "exclusiveMinimum"):
return old_value if old_value < new_value else new_value
if prop_name == "properties":
for key, value in new_value.items():
if key not in old_value:
old_value[key] = value
continue
for sub_key, sub_value in value.items():
if sub_key not in old_value[key]:
old_value[key][sub_key] = sub_value
else:
# Merge properties if they exist in both sub-properties
old_value[key][sub_key] = AllOfTypeParser._validate_prop(
sub_key, old_value[key][sub_key], sub_value
)
# Handle other properties by just returning the first valued
return old_value

View File

@@ -0,0 +1,55 @@
from jambo.parser._type_parser import GenericTypeParser
from pydantic import Field
from typing_extensions import Annotated
from typing import Union
class AnyOfTypeParser(GenericTypeParser):
mapped_type = Union
json_schema_type = "anyOf"
def from_properties(self, name, properties, required=False):
if "anyOf" not in properties:
raise ValueError(f"Invalid JSON Schema: {properties}")
if not isinstance(properties["anyOf"], list):
raise ValueError(f"Invalid JSON Schema: {properties['anyOf']}")
mapped_properties = dict()
subProperties = properties["anyOf"]
sub_types = [
GenericTypeParser.get_impl(subProperty["type"]).from_properties(
name, subProperty
)
for subProperty in subProperties
]
default_value = properties.get("default")
if default_value is not None:
for sub_type, sub_property in sub_types:
try:
self.validate_default(sub_type, sub_property, default_value)
break
except ValueError:
continue
else:
raise ValueError(
f"Invalid default value {default_value} for anyOf types: {sub_types}"
)
mapped_properties["default"] = default_value
if not required:
mapped_properties["default"] = mapped_properties.get("default")
# By defining the type as Union of Annotated type we can use the Field validator
# to enforce the constraints of each union type when needed.
# We use Annotated to attach the Field validators to the type.
field_types = [Annotated[t, Field(**v)] if v else t for t, v in sub_types]
return Union[(*field_types,)], mapped_properties

View File

@@ -1,12 +1,8 @@
import copy
from jambo.parser._type_parser import GenericTypeParser from jambo.parser._type_parser import GenericTypeParser
import copy
from typing import TypeVar from typing import TypeVar
from jambo.utils.properties_builder.mappings_properties_builder import (
mappings_properties_builder,
)
V = TypeVar("V") V = TypeVar("V")
@@ -16,44 +12,33 @@ class ArrayTypeParser(GenericTypeParser):
json_schema_type = "array" json_schema_type = "array"
@classmethod default_mappings = {"description": "description"}
def from_properties(cls, name, properties):
type_mappings = {
"maxItems": "max_length",
"minItems": "min_length",
}
def from_properties(self, name, properties, required=False):
_item_type, _item_args = GenericTypeParser.get_impl( _item_type, _item_args = GenericTypeParser.get_impl(
properties["items"]["type"] properties["items"]["type"]
).from_properties(name, properties["items"]) ).from_properties(name, properties["items"], required=True)
_mappings = {
"maxItems": "max_length",
"minItems": "min_length",
}
wrapper_type = set if properties.get("uniqueItems", False) else list wrapper_type = set if properties.get("uniqueItems", False) else list
field_type = wrapper_type[_item_type]
mapped_properties = mappings_properties_builder( mapped_properties = self.mappings_properties_builder(
properties, _mappings, {"description": "description"} properties,
required=required,
) )
if "default" in properties: default_list = properties.pop("default", None)
default_list = properties["default"] if default_list is not None:
if not isinstance(default_list, list): self.validate_default(
raise ValueError( field_type,
f"Default value must be a list, got {type(default_list).__name__}" mapped_properties,
) default_list,
)
if len(default_list) > properties.get("maxItems", float("inf")):
raise ValueError(
f"Default list exceeds maxItems limit of {properties.get('maxItems')}"
)
if len(default_list) < properties.get("minItems", 0):
raise ValueError(
f"Default list is below minItems limit of {properties.get('minItems')}"
)
if not all(isinstance(item, _item_type) for item in default_list):
raise ValueError(
f"All items in the default list must be of type {_item_type.__name__}"
)
if wrapper_type is list: if wrapper_type is list:
mapped_properties["default_factory"] = lambda: copy.deepcopy( mapped_properties["default_factory"] = lambda: copy.deepcopy(
@@ -64,4 +49,4 @@ class ArrayTypeParser(GenericTypeParser):
default_list default_list
) )
return wrapper_type[_item_type], mapped_properties return field_type, mapped_properties

View File

@@ -1,7 +1,4 @@
from jambo.parser._type_parser import GenericTypeParser from jambo.parser._type_parser import GenericTypeParser
from jambo.utils.properties_builder.mappings_properties_builder import (
mappings_properties_builder,
)
class BooleanTypeParser(GenericTypeParser): class BooleanTypeParser(GenericTypeParser):
@@ -9,9 +6,15 @@ class BooleanTypeParser(GenericTypeParser):
json_schema_type = "boolean" json_schema_type = "boolean"
@staticmethod type_mappings = {
def from_properties(name, properties): "default": "default",
_mappings = { }
"default": "default",
} def from_properties(self, name, properties, required=False):
return bool, mappings_properties_builder(properties, _mappings) mapped_properties = self.mappings_properties_builder(properties, required)
default_value = properties.get("default")
if default_value is not None and not isinstance(default_value, bool):
raise ValueError(f"Default value for {name} must be a boolean.")
return bool, mapped_properties

View File

@@ -1,7 +1,4 @@
from jambo.parser._type_parser import GenericTypeParser from jambo.parser._type_parser import GenericTypeParser
from jambo.utils.properties_builder.numeric_properties_builder import (
numeric_properties_builder,
)
class FloatTypeParser(GenericTypeParser): class FloatTypeParser(GenericTypeParser):
@@ -9,6 +6,20 @@ class FloatTypeParser(GenericTypeParser):
json_schema_type = "number" json_schema_type = "number"
@staticmethod type_mappings = {
def from_properties(name, properties): "minimum": "ge",
return float, numeric_properties_builder(properties) "exclusiveMinimum": "gt",
"maximum": "le",
"exclusiveMaximum": "lt",
"multipleOf": "multiple_of",
"default": "default",
}
def from_properties(self, name, properties, required=False):
mapped_properties = self.mappings_properties_builder(properties, required)
default_value = mapped_properties.get("default")
if default_value is not None:
self.validate_default(float, mapped_properties, default_value)
return float, mapped_properties

View File

@@ -1,7 +1,4 @@
from jambo.parser._type_parser import GenericTypeParser from jambo.parser._type_parser import GenericTypeParser
from jambo.utils.properties_builder.numeric_properties_builder import (
numeric_properties_builder,
)
class IntTypeParser(GenericTypeParser): class IntTypeParser(GenericTypeParser):
@@ -9,6 +6,20 @@ class IntTypeParser(GenericTypeParser):
json_schema_type = "integer" json_schema_type = "integer"
@staticmethod type_mappings = {
def from_properties(name, properties): "minimum": "ge",
return int, numeric_properties_builder(properties) "exclusiveMinimum": "gt",
"maximum": "le",
"exclusiveMaximum": "lt",
"multipleOf": "multiple_of",
"default": "default",
}
def from_properties(self, name, properties, required=False):
mapped_properties = self.mappings_properties_builder(properties, required)
default_value = mapped_properties.get("default")
if default_value is not None:
self.validate_default(int, mapped_properties, default_value)
return int, mapped_properties

View File

@@ -7,7 +7,7 @@ class ObjectTypeParser(GenericTypeParser):
json_schema_type = "object" json_schema_type = "object"
@staticmethod @staticmethod
def from_properties(name, properties): def from_properties(name, properties, required=False):
from jambo.schema_converter import SchemaConverter from jambo.schema_converter import SchemaConverter
type_parsing = SchemaConverter.build_object(name, properties) type_parsing = SchemaConverter.build_object(name, properties)

View File

@@ -1,7 +1,4 @@
from jambo.parser._type_parser import GenericTypeParser from jambo.parser._type_parser import GenericTypeParser
from jambo.utils.properties_builder.mappings_properties_builder import (
mappings_properties_builder,
)
class StringTypeParser(GenericTypeParser): class StringTypeParser(GenericTypeParser):
@@ -9,32 +6,17 @@ class StringTypeParser(GenericTypeParser):
json_schema_type = "string" json_schema_type = "string"
@staticmethod type_mappings = {
def from_properties(name, properties): "maxLength": "max_length",
_mappings = { "minLength": "min_length",
"maxLength": "max_length", "pattern": "pattern",
"minLength": "min_length", }
"pattern": "pattern",
}
mapped_properties = mappings_properties_builder(properties, _mappings) def from_properties(self, name, properties, required=False):
mapped_properties = self.mappings_properties_builder(properties, required)
if "default" in properties: default_value = properties.get("default")
default_value = properties["default"] if default_value is not None:
if not isinstance(default_value, str): self.validate_default(str, mapped_properties, default_value)
raise ValueError(
f"Default value for {name} must be a string, "
f"but got <{type(properties['default']).__name__}>."
)
if len(default_value) > properties.get("maxLength", float("inf")):
raise ValueError(
f"Default value for {name} exceeds maxLength limit of {properties.get('maxLength')}"
)
if len(default_value) < properties.get("minLength", 0):
raise ValueError(
f"Default value for {name} is below minLength limit of {properties.get('minLength')}"
)
return str, mapped_properties return str, mapped_properties

View File

@@ -2,7 +2,7 @@ from jambo.parser import GenericTypeParser
from jambo.types.json_schema_type import JSONSchema from jambo.types.json_schema_type import JSONSchema
from jsonschema.exceptions import SchemaError from jsonschema.exceptions import SchemaError
from jsonschema.protocols import Validator from jsonschema.validators import validator_for
from pydantic import create_model from pydantic import create_model
from pydantic.fields import Field from pydantic.fields import Field
from pydantic.main import ModelT from pydantic.main import ModelT
@@ -42,7 +42,8 @@ class SchemaConverter:
""" """
try: try:
Validator.check_schema(schema) validator = validator_for(schema)
validator.check_schema(schema)
except SchemaError as e: except SchemaError as e:
raise ValueError(f"Invalid JSON Schema: {e}") raise ValueError(f"Invalid JSON Schema: {e}")
@@ -71,27 +72,25 @@ class SchemaConverter:
fields = {} fields = {}
for name, prop in properties.items(): for name, prop in properties.items():
fields[name] = SchemaConverter._build_field(name, prop, required_keys) is_required = name in required_keys
fields[name] = SchemaConverter._build_field(name, prop, is_required)
return fields return fields
@staticmethod @staticmethod
def _build_field( def _build_field(name, properties: dict, required=False) -> tuple[type, Field]:
name, properties: dict, required_keys: list[str] match properties:
) -> tuple[type, dict]: case {"anyOf": _}:
_field_type = "anyOf"
case {"allOf": _}:
_field_type = "allOf"
case {"type": _}:
_field_type = properties["type"]
case _:
raise ValueError(f"Invalid JSON Schema: {properties}")
_field_type, _field_args = GenericTypeParser.get_impl( _field_type, _field_args = GenericTypeParser.get_impl(
properties["type"] _field_type
).from_properties(name, properties) ).from_properties(name, properties, required)
_field_args = _field_args or {}
if description := properties.get("description"):
_field_args["description"] = description
if name not in required_keys:
_field_args["default"] = properties.get("default", None)
if "default_factory" in _field_args and "default" in _field_args:
del _field_args["default"]
return _field_type, Field(**_field_args) return _field_type, Field(**_field_args)

View File

@@ -1,4 +1,4 @@
from typing import List, Dict, Union, TypedDict, Literal from typing import Dict, List, Literal, TypedDict, Union
JSONSchemaType = Literal[ JSONSchemaType = Literal[

View File

@@ -1,11 +0,0 @@
def mappings_properties_builder(properties, mappings, default_mappings=None):
default_mappings = default_mappings or {
"default": "default",
"description": "description",
}
mappings = default_mappings | mappings
return {
mappings[key]: value for key, value in properties.items() if key in mappings
}

View File

@@ -1,51 +0,0 @@
from jambo.utils.properties_builder.mappings_properties_builder import (
mappings_properties_builder,
)
def numeric_properties_builder(properties):
_mappings = {
"minimum": "ge",
"exclusiveMinimum": "gt",
"maximum": "le",
"exclusiveMaximum": "lt",
"multipleOf": "multiple_of",
"default": "default",
}
mapped_properties = mappings_properties_builder(properties, _mappings)
if "default" in properties:
default_value = properties["default"]
if not isinstance(default_value, (int, float)):
raise ValueError(
f"Default value must be a number, got {type(default_value).__name__}"
)
if default_value > properties.get("maximum", float("inf")):
raise ValueError(
f"Default value exceeds maximum limit of {properties.get('maximum')}"
)
if default_value < properties.get("minimum", float("-inf")):
raise ValueError(
f"Default value is below minimum limit of {properties.get('minimum')}"
)
if default_value >= properties.get("exclusiveMaximum", float("inf")):
raise ValueError(
f"Default value exceeds exclusive maximum limit of {properties.get('exclusiveMaximum')}"
)
if default_value <= properties.get("exclusiveMinimum", float("-inf")):
raise ValueError(
f"Default value is below exclusive minimum limit of {properties.get('exclusiveMinimum')}"
)
if "multipleOf" in properties:
if default_value % properties["multipleOf"] != 0:
raise ValueError(
f"Default value {default_value} is not a multiple of {properties['multipleOf']}"
)
return mapped_properties

View File

@@ -57,8 +57,20 @@ requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
# Tests
[tool.coverage.run]
omit = [
"tests/*",
]
# Linters # Linters
[tool.ruff.lint]
extend-select = ["I"]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]
known-first-party = ["jambo"]
section-order=[ section-order=[
"future", "future",
"first-party", "first-party",

View File

@@ -0,0 +1,292 @@
from jambo.parser.allof_type_parser import AllOfTypeParser
from unittest import TestCase
class TestAllOfTypeParser(TestCase):
def test_all_of_type_parser_object_type(self):
"""
Test the AllOfTypeParser with an object type and validate the properties.
When using allOf with object it should be able to validate the properties
and join them correctly.
"""
properties = {
"type": "object",
"allOf": [
{
"properties": {
"name": {
"type": "string",
"minLength": 1,
}
},
},
{
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 4,
},
"age": {
"type": "integer",
"maximum": 100,
"minimum": 0,
},
},
},
],
}
type_parsing, type_validator = AllOfTypeParser().from_properties(
"placeholder", properties
)
with self.assertRaises(ValueError):
type_parsing(name="John", age=101)
with self.assertRaises(ValueError):
type_parsing(name="", age=30)
with self.assertRaises(ValueError):
type_parsing(name="John Invalid", age=30)
obj = type_parsing(name="John", age=30)
self.assertEqual(obj.name, "John")
self.assertEqual(obj.age, 30)
def test_all_of_type_parser_object_type_required(self):
"""
Tests the required properties of the AllOfTypeParser with an object type.
"""
properties = {
"type": "object",
"allOf": [
{
"properties": {
"name": {
"type": "string",
}
},
"required": ["name"],
},
{
"type": "object",
"properties": {
"age": {
"type": "integer",
}
},
"required": ["age"],
},
],
}
type_parsing, type_validator = AllOfTypeParser().from_properties(
"placeholder", properties
)
with self.assertRaises(ValueError):
type_parsing(name="John")
with self.assertRaises(ValueError):
type_parsing(age=30)
obj = type_parsing(name="John", age=30)
self.assertEqual(obj.name, "John")
self.assertEqual(obj.age, 30)
def test_all_of_type_top_level_type(self):
"""
Tests the AllOfTypeParser with a top-level type and validate the properties.
"""
properties = {
"type": "string",
"allOf": [
{"maxLength": 11},
{"maxLength": 4},
{"minLength": 1},
{"minLength": 2},
],
}
type_parsing, type_validator = AllOfTypeParser().from_properties(
"placeholder", properties
)
self.assertEqual(type_parsing, str)
self.assertEqual(type_validator["max_length"], 11)
self.assertEqual(type_validator["min_length"], 1)
def test_all_of_type_parser_in_fields(self):
"""
Tests the AllOfTypeParser when set in the fields of a model.
"""
properties = {
"allOf": [
{"type": "string", "maxLength": 11},
{"type": "string", "maxLength": 4},
{"type": "string", "minLength": 1},
{"type": "string", "minLength": 2},
]
}
type_parsing, type_validator = AllOfTypeParser().from_properties(
"placeholder", properties
)
self.assertEqual(type_parsing, str)
self.assertEqual(type_validator["max_length"], 11)
self.assertEqual(type_validator["min_length"], 1)
def test_invalid_all_of(self):
"""
Tests that an error is raised when the allOf type is not present.
"""
properties = {
"wrongKey": [
{"type": "string", "maxLength": 11},
{"type": "string", "maxLength": 4},
{"type": "string", "minLength": 1},
{"type": "string", "minLength": 2},
]
}
with self.assertRaises(ValueError):
AllOfTypeParser().from_properties("placeholder", properties)
def test_all_of_invalid_type_not_present(self):
properties = {
"allOf": [
{"maxLength": 11},
{"maxLength": 4},
{"minLength": 1},
{"minLength": 2},
]
}
with self.assertRaises(ValueError):
AllOfTypeParser().from_properties("placeholder", properties)
def test_all_of_invalid_type_in_fields(self):
properties = {
"allOf": [
{"type": "string", "maxLength": 11},
{"type": "integer", "maxLength": 4},
{"type": "string", "minLength": 1},
{"minLength": 2},
]
}
with self.assertRaises(ValueError):
AllOfTypeParser().from_properties("placeholder", properties)
def test_all_of_description_field(self):
"""
Tests the AllOfTypeParser with a description field.
"""
properties = {
"type": "object",
"allOf": [
{
"properties": {
"name": {
"type": "string",
"description": "One",
}
},
},
{
"properties": {
"name": {
"type": "string",
"description": "Of",
}
},
},
{
"properties": {
"name": {
"type": "string",
"description": "Us",
}
},
},
],
}
type_parsing, _ = AllOfTypeParser().from_properties("placeholder", properties)
self.assertEqual(
type_parsing.schema()["properties"]["name"]["description"],
"One | Of | Us",
)
def test_all_of_with_defaults(self):
"""
Tests the AllOfTypeParser with a default value.
"""
properties = {
"type": "object",
"allOf": [
{
"properties": {
"name": {
"type": "string",
"default": "John",
}
},
},
{
"properties": {
"name": {
"type": "string",
"default": "John",
},
"age": {
"type": "integer",
"default": 30,
},
},
},
],
}
type_parsing, _ = AllOfTypeParser().from_properties("placeholder", properties)
obj = type_parsing()
self.assertEqual(obj.name, "John")
self.assertEqual(obj.age, 30)
def test_all_of_with_conflicting_defaults(self):
"""
Tests the AllOfTypeParser with conflicting default values.
"""
properties = {
"type": "object",
"allOf": [
{
"properties": {
"name": {
"type": "string",
"default": "John",
}
},
},
{
"properties": {
"name": {
"type": "string",
"default": "Doe",
}
},
},
],
}
with self.assertRaises(ValueError):
AllOfTypeParser().from_properties("placeholder", properties)

View File

@@ -0,0 +1,100 @@
from jambo.parser.anyof_type_parser import AnyOfTypeParser
from typing_extensions import Annotated
from typing import Union, get_args, get_origin
from unittest import TestCase
class TestAnyOfTypeParser(TestCase):
def test_any_with_missing_properties(self):
properties = {
"notAnyOf": [
{"type": "string"},
{"type": "integer"},
],
}
with self.assertRaises(ValueError):
AnyOfTypeParser().from_properties("placeholder", properties)
def test_any_of_with_invalid_properties(self):
properties = {
"anyOf": None,
}
with self.assertRaises(ValueError):
AnyOfTypeParser().from_properties("placeholder", properties)
def test_any_of_string_or_int(self):
"""
Tests the AnyOfTypeParser with a string or int type.
"""
properties = {
"anyOf": [
{"type": "string"},
{"type": "integer"},
],
}
type_parsing, _ = AnyOfTypeParser().from_properties(
"placeholder", properties, required=True
)
# check union type has string and int
self.assertEqual(get_origin(type_parsing), Union)
type_1, type_2 = get_args(type_parsing)
self.assertEqual(get_origin(type_1), Annotated)
self.assertIn(str, get_args(type_1))
self.assertEqual(get_origin(type_2), Annotated)
self.assertIn(int, get_args(type_2))
def test_any_of_string_or_int_with_default(self):
"""
Tests the AnyOfTypeParser with a string or int type and a default value.
"""
properties = {
"anyOf": [
{"type": "string"},
{"type": "integer"},
],
"default": 42,
}
type_parsing, type_validator = AnyOfTypeParser().from_properties(
"placeholder", properties
)
# check union type has string and int
self.assertEqual(get_origin(type_parsing), Union)
type_1, type_2 = get_args(type_parsing)
self.assertEqual(get_origin(type_1), Annotated)
self.assertIn(str, get_args(type_1))
self.assertEqual(get_origin(type_2), Annotated)
self.assertIn(int, get_args(type_2))
self.assertEqual(type_validator["default"], 42)
def test_any_string_or_int_with_invalid_defaults(self):
"""
Tests the AnyOfTypeParser with a string or int type and an invalid default value.
"""
properties = {
"anyOf": [
{"type": "string"},
{"type": "integer"},
],
"default": 3.14,
}
with self.assertRaises(ValueError):
AnyOfTypeParser().from_properties("placeholder", properties)

View File

@@ -1,8 +1,8 @@
from jambo.parser import ArrayTypeParser
from typing import get_args from typing import get_args
from unittest import TestCase from unittest import TestCase
from jambo.parser import ArrayTypeParser
class TestArrayTypeParser(TestCase): class TestArrayTypeParser(TestCase):
def test_array_parser_no_options(self): def test_array_parser_no_options(self):
@@ -66,38 +66,25 @@ class TestArrayTypeParser(TestCase):
properties = {"items": {"type": "string"}, "default": ["a", 1, "c"]} properties = {"items": {"type": "string"}, "default": ["a", 1, "c"]}
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError):
parser.from_properties("placeholder", properties) parser.from_properties("placeholder", properties)
self.assertEqual(
str(context.exception),
"All items in the default list must be of type str",
)
def test_array_parser_with_invalid_default_type(self): def test_array_parser_with_invalid_default_type(self):
parser = ArrayTypeParser() parser = ArrayTypeParser()
properties = {"items": {"type": "string"}, "default": "not_a_list"} properties = {"items": {"type": "string"}, "default": "not_a_list"}
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError):
parser.from_properties("placeholder", properties) parser.from_properties("placeholder", properties)
self.assertEqual(
str(context.exception), "Default value must be a list, got str"
)
def test_array_parser_with_invalid_default_min(self): def test_array_parser_with_invalid_default_min(self):
parser = ArrayTypeParser() parser = ArrayTypeParser()
properties = {"items": {"type": "string"}, "default": ["a"], "minItems": 2} properties = {"items": {"type": "string"}, "default": ["a"], "minItems": 2}
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError):
parser.from_properties("placeholder", properties) parser.from_properties("placeholder", properties)
self.assertEqual(
str(context.exception), "Default list is below minItems limit of 2"
)
def test_array_parser_with_invalid_default_max(self): def test_array_parser_with_invalid_default_max(self):
parser = ArrayTypeParser() parser = ArrayTypeParser()
@@ -107,9 +94,5 @@ class TestArrayTypeParser(TestCase):
"maxItems": 3, "maxItems": 3,
} }
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError):
parser.from_properties("placeholder", properties) parser.from_properties("placeholder", properties)
self.assertEqual(
str(context.exception), "Default list exceeds maxItems limit of 3"
)

View File

@@ -1,7 +1,7 @@
from unittest import TestCase
from jambo.parser import BooleanTypeParser from jambo.parser import BooleanTypeParser
from unittest import TestCase
class TestBoolTypeParser(TestCase): class TestBoolTypeParser(TestCase):
def test_bool_parser_no_options(self): def test_bool_parser_no_options(self):
@@ -12,7 +12,7 @@ class TestBoolTypeParser(TestCase):
type_parsing, type_validator = parser.from_properties("placeholder", properties) type_parsing, type_validator = parser.from_properties("placeholder", properties)
self.assertEqual(type_parsing, bool) self.assertEqual(type_parsing, bool)
self.assertEqual(type_validator, {}) self.assertEqual(type_validator, {"default": None})
def test_bool_parser_with_default(self): def test_bool_parser_with_default(self):
parser = BooleanTypeParser() parser = BooleanTypeParser()
@@ -26,3 +26,14 @@ class TestBoolTypeParser(TestCase):
self.assertEqual(type_parsing, bool) self.assertEqual(type_parsing, bool)
self.assertEqual(type_validator["default"], True) self.assertEqual(type_validator["default"], True)
def test_bool_parser_with_invalid_default(self):
parser = BooleanTypeParser()
properties = {
"type": "boolean",
"default": "invalid",
}
with self.assertRaises(ValueError):
parser.from_properties("placeholder", properties)

View File

@@ -1,7 +1,7 @@
from unittest import TestCase
from jambo.parser import FloatTypeParser from jambo.parser import FloatTypeParser
from unittest import TestCase
class TestFloatTypeParser(TestCase): class TestFloatTypeParser(TestCase):
def test_float_parser_no_options(self): def test_float_parser_no_options(self):
@@ -12,7 +12,7 @@ class TestFloatTypeParser(TestCase):
type_parsing, type_validator = parser.from_properties("placeholder", properties) type_parsing, type_validator = parser.from_properties("placeholder", properties)
self.assertEqual(type_parsing, float) self.assertEqual(type_parsing, float)
self.assertEqual(type_validator, {}) self.assertEqual(type_validator, {"default": None})
def test_float_parser_with_options(self): def test_float_parser_with_options(self):
parser = FloatTypeParser() parser = FloatTypeParser()
@@ -61,14 +61,9 @@ class TestFloatTypeParser(TestCase):
"multipleOf": 0.5, "multipleOf": 0.5,
} }
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError):
parser.from_properties("placeholder", properties) parser.from_properties("placeholder", properties)
self.assertEqual(
str(context.exception),
"Default value must be a number, got str",
)
def test_float_parser_with_default_invalid_maximum(self): def test_float_parser_with_default_invalid_maximum(self):
parser = FloatTypeParser() parser = FloatTypeParser()
@@ -80,14 +75,9 @@ class TestFloatTypeParser(TestCase):
"multipleOf": 0.5, "multipleOf": 0.5,
} }
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError):
parser.from_properties("placeholder", properties) parser.from_properties("placeholder", properties)
self.assertEqual(
str(context.exception),
"Default value exceeds maximum limit of 10.5",
)
def test_float_parser_with_default_invalid_minimum(self): def test_float_parser_with_default_invalid_minimum(self):
parser = FloatTypeParser() parser = FloatTypeParser()
@@ -99,14 +89,9 @@ class TestFloatTypeParser(TestCase):
"multipleOf": 0.5, "multipleOf": 0.5,
} }
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError):
parser.from_properties("placeholder", properties) parser.from_properties("placeholder", properties)
self.assertEqual(
str(context.exception),
"Default value is below minimum limit of 1.0",
)
def test_float_parser_with_default_invalid_exclusive_maximum(self): def test_float_parser_with_default_invalid_exclusive_maximum(self):
parser = FloatTypeParser() parser = FloatTypeParser()
@@ -118,14 +103,9 @@ class TestFloatTypeParser(TestCase):
"multipleOf": 0.5, "multipleOf": 0.5,
} }
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError):
parser.from_properties("placeholder", properties) parser.from_properties("placeholder", properties)
self.assertEqual(
str(context.exception),
"Default value exceeds exclusive maximum limit of 10.5",
)
def test_float_parser_with_default_invalid_exclusive_minimum(self): def test_float_parser_with_default_invalid_exclusive_minimum(self):
parser = FloatTypeParser() parser = FloatTypeParser()
@@ -137,14 +117,9 @@ class TestFloatTypeParser(TestCase):
"multipleOf": 0.5, "multipleOf": 0.5,
} }
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError):
parser.from_properties("placeholder", properties) parser.from_properties("placeholder", properties)
self.assertEqual(
str(context.exception),
"Default value is below exclusive minimum limit of 1.0",
)
def test_float_parser_with_default_invalid_multiple(self): def test_float_parser_with_default_invalid_multiple(self):
parser = FloatTypeParser() parser = FloatTypeParser()
@@ -156,10 +131,5 @@ class TestFloatTypeParser(TestCase):
"multipleOf": 2.0, "multipleOf": 2.0,
} }
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError):
parser.from_properties("placeholder", properties) parser.from_properties("placeholder", properties)
self.assertEqual(
str(context.exception),
"Default value 5.0 is not a multiple of 2.0",
)

View File

@@ -1,7 +1,7 @@
from unittest import TestCase
from jambo.parser import IntTypeParser from jambo.parser import IntTypeParser
from unittest import TestCase
class TestIntTypeParser(TestCase): class TestIntTypeParser(TestCase):
def test_int_parser_no_options(self): def test_int_parser_no_options(self):
@@ -12,7 +12,7 @@ class TestIntTypeParser(TestCase):
type_parsing, type_validator = parser.from_properties("placeholder", properties) type_parsing, type_validator = parser.from_properties("placeholder", properties)
self.assertEqual(type_parsing, int) self.assertEqual(type_parsing, int)
self.assertEqual(type_validator, {}) self.assertEqual(type_validator, {"default": None})
def test_int_parser_with_options(self): def test_int_parser_with_options(self):
parser = IntTypeParser() parser = IntTypeParser()
@@ -61,14 +61,9 @@ class TestIntTypeParser(TestCase):
"multipleOf": 2, "multipleOf": 2,
} }
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError):
parser.from_properties("placeholder", properties) parser.from_properties("placeholder", properties)
self.assertEqual(
str(context.exception),
"Default value must be a number, got str",
)
def test_int_parser_with_default_invalid_maximum(self): def test_int_parser_with_default_invalid_maximum(self):
parser = IntTypeParser() parser = IntTypeParser()
@@ -80,14 +75,9 @@ class TestIntTypeParser(TestCase):
"multipleOf": 2, "multipleOf": 2,
} }
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError):
parser.from_properties("placeholder", properties) parser.from_properties("placeholder", properties)
self.assertEqual(
str(context.exception),
"Default value exceeds maximum limit of 10",
)
def test_int_parser_with_default_invalid_minimum(self): def test_int_parser_with_default_invalid_minimum(self):
parser = IntTypeParser() parser = IntTypeParser()
@@ -99,14 +89,9 @@ class TestIntTypeParser(TestCase):
"multipleOf": 2, "multipleOf": 2,
} }
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError):
parser.from_properties("placeholder", properties) parser.from_properties("placeholder", properties)
self.assertEqual(
str(context.exception),
"Default value is below minimum limit of 1",
)
def test_int_parser_with_default_invalid_exclusive_maximum(self): def test_int_parser_with_default_invalid_exclusive_maximum(self):
parser = IntTypeParser() parser = IntTypeParser()
@@ -118,14 +103,9 @@ class TestIntTypeParser(TestCase):
"multipleOf": 2, "multipleOf": 2,
} }
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError):
parser.from_properties("placeholder", properties) parser.from_properties("placeholder", properties)
self.assertEqual(
str(context.exception),
"Default value exceeds exclusive maximum limit of 10",
)
def test_int_parser_with_default_invalid_exclusive_minimum(self): def test_int_parser_with_default_invalid_exclusive_minimum(self):
parser = IntTypeParser() parser = IntTypeParser()
@@ -137,14 +117,9 @@ class TestIntTypeParser(TestCase):
"multipleOf": 2, "multipleOf": 2,
} }
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError):
parser.from_properties("placeholder", properties) parser.from_properties("placeholder", properties)
self.assertEqual(
str(context.exception),
"Default value is below exclusive minimum limit of 1",
)
def test_int_parser_with_default_invalid_multipleOf(self): def test_int_parser_with_default_invalid_multipleOf(self):
parser = IntTypeParser() parser = IntTypeParser()
@@ -156,10 +131,5 @@ class TestIntTypeParser(TestCase):
"multipleOf": 2, "multipleOf": 2,
} }
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError):
parser.from_properties("placeholder", properties) parser.from_properties("placeholder", properties)
self.assertEqual(
str(context.exception),
"Default value 5 is not a multiple of 2",
)

View File

@@ -1,7 +1,7 @@
from unittest import TestCase
from jambo.parser import ObjectTypeParser from jambo.parser import ObjectTypeParser
from unittest import TestCase
class TestObjectTypeParser(TestCase): class TestObjectTypeParser(TestCase):
def test_object_type_parser(self): def test_object_type_parser(self):

View File

@@ -1,7 +1,7 @@
from unittest import TestCase
from jambo.parser import StringTypeParser from jambo.parser import StringTypeParser
from unittest import TestCase
class TestStringTypeParser(TestCase): class TestStringTypeParser(TestCase):
def test_string_parser_no_options(self): def test_string_parser_no_options(self):
@@ -57,14 +57,9 @@ class TestStringTypeParser(TestCase):
"minLength": 5, "minLength": 5,
} }
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError):
parser.from_properties("placeholder", properties) parser.from_properties("placeholder", properties)
self.assertEqual(
str(context.exception),
"Default value for placeholder must be a string, but got <int>.",
)
def test_string_parser_with_default_invalid_maxlength(self): def test_string_parser_with_default_invalid_maxlength(self):
parser = StringTypeParser() parser = StringTypeParser()
@@ -75,14 +70,9 @@ class TestStringTypeParser(TestCase):
"minLength": 1, "minLength": 1,
} }
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError):
parser.from_properties("placeholder", properties) parser.from_properties("placeholder", properties)
self.assertEqual(
str(context.exception),
"Default value for placeholder exceeds maxLength limit of 2",
)
def test_string_parser_with_default_invalid_minlength(self): def test_string_parser_with_default_invalid_minlength(self):
parser = StringTypeParser() parser = StringTypeParser()
@@ -93,10 +83,5 @@ class TestStringTypeParser(TestCase):
"minLength": 2, "minLength": 2,
} }
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError):
parser.from_properties("placeholder", properties) parser.from_properties("placeholder", properties)
self.assertEqual(
str(context.exception),
"Default value for placeholder is below minLength limit of 2",
)

View File

@@ -0,0 +1,31 @@
from jambo.parser._type_parser import GenericTypeParser
from unittest import TestCase
class InvalidGenericTypeParser(GenericTypeParser):
mapped_type = str
json_schema_type = "invalid"
def from_properties(
self, name: str, properties: dict[str, any], required: bool = False
): ...
class TestGenericTypeParser(TestCase):
def test_invalid_get_impl(self):
# Assuming GenericTypeParser is imported from the module
with self.assertRaises(ValueError):
GenericTypeParser.get_impl("another_invalid_type")
def test_invalid_json_schema_type(self):
InvalidGenericTypeParser.json_schema_type = None
# This is more for the developer's sanity check
with self.assertRaises(RuntimeError):
GenericTypeParser.get_impl("another_invalid_type")
def test_invalid_mappings_properties_builder(self):
parser = InvalidGenericTypeParser()
with self.assertRaises(NotImplementedError):
parser.mappings_properties_builder({}, required=False)

View File

@@ -10,6 +10,59 @@ def is_pydantic_model(cls):
class TestSchemaConverter(TestCase): class TestSchemaConverter(TestCase):
def test_build_expects_title(self):
schema = {
"description": "A person",
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
},
}
with self.assertRaises(ValueError):
SchemaConverter.build(schema)
def test_build_expects_valid_schema(self):
invalid_schema = {
"type": "object",
"properties": {
"name": {
"type": "strng"
} # typo: "strng" is not a valid JSON Schema type
},
"required": ["name"],
}
with self.assertRaises(ValueError):
SchemaConverter.build_object("placeholder", invalid_schema)
def test_build_expects_object(self):
schema = {
"title": "Person",
"description": "A person",
"type": "string",
}
with self.assertRaises(TypeError):
SchemaConverter.build(schema)
def test_is_invalid_field(self):
schema = {
"title": "Person",
"description": "A person",
"type": "object",
"properties": {
"id": {
"notType": "string",
}
},
# 'required': ['name', 'age', 'is_active', 'friends', 'address'],
}
with self.assertRaises(ValueError):
SchemaConverter.build(schema)
def test_jsonschema_to_pydantic(self): def test_jsonschema_to_pydantic(self):
schema = { schema = {
"title": "Person", "title": "Person",
@@ -281,3 +334,66 @@ 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")
def test_all_of(self):
schema = {
"title": "Person",
"description": "A person",
"type": "object",
"properties": {
"name": {
"allOf": [
{"type": "string", "maxLength": 11},
{"type": "string", "maxLength": 4},
{"type": "string", "minLength": 1},
{"type": "string", "minLength": 2},
]
},
},
}
Model = SchemaConverter.build(schema)
obj = Model(
name="J",
)
self.assertEqual(obj.name, "J")
with self.assertRaises(ValueError):
Model(name="John Invalid")
with self.assertRaises(ValueError):
Model(name="")
def test_any_of(self):
schema = {
"title": "Person",
"description": "A person",
"type": "object",
"properties": {
"id": {
"anyOf": [
{"type": "string", "maxLength": 11, "minLength": 1},
{"type": "integer", "maximum": 10},
]
},
},
}
Model = SchemaConverter.build(schema)
obj = Model(id=1)
self.assertEqual(obj.id, 1)
obj = Model(id="12345678901")
self.assertEqual(obj.id, "12345678901")
with self.assertRaises(ValueError):
Model(id="")
with self.assertRaises(ValueError):
Model(id="12345678901234567890")
with self.assertRaises(ValueError):
Model(id=11)