diff --git a/jambo/parser/__init__.py b/jambo/parser/__init__.py index c71f93d..86d8f56 100644 --- a/jambo/parser/__init__.py +++ b/jambo/parser/__init__.py @@ -1,10 +1,25 @@ # Exports generic type parser -from ._type_parser import GenericTypeParser as GenericTypeParser +from ._type_parser import GenericTypeParser # Exports Implementations -from .int_type_parser import IntTypeParser as IntTypeParser -from .object_type_parser import ObjectTypeParser as ObjectTypeParser -from .string_type_parser import StringTypeParser as StringTypeParser -from .array_type_parser import ArrayTypeParser as ArrayTypeParser -from .boolean_type_parser import BooleanTypeParser as BooleanTypeParser -from .float_type_parser import FloatTypeParser as FloatTypeParser +from .allof_type_parser import AllOfTypeParser +from .anyof_type_parser import AnyOfTypeParser +from .array_type_parser import ArrayTypeParser +from .boolean_type_parser import BooleanTypeParser +from .float_type_parser import 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", +] diff --git a/jambo/parser/_type_parser.py b/jambo/parser/_type_parser.py index 16dd833..20a11d4 100644 --- a/jambo/parser/_type_parser.py +++ b/jambo/parser/_type_parser.py @@ -1,31 +1,54 @@ -from abc import ABC, abstractmethod -from typing import Generic, TypeVar -from typing_extensions import Self +from pydantic import Field, TypeAdapter +from typing_extensions import Annotated, Self + +from abc import ABC, abstractmethod +from typing import Generic, Type, TypeVar -from pydantic import Field T = TypeVar("T") class GenericTypeParser(ABC, Generic[T]): - @property - @abstractmethod - def mapped_type(self) -> type[T]: ... + mapped_type: Type[T] = None - @property - @abstractmethod - def json_schema_type(self) -> str: ... + json_schema_type: str = None - @staticmethod - @abstractmethod - def from_properties( - name: str, properties: dict[str, any] - ) -> tuple[type[T], Field]: ... + default_mappings = { + "default": "default", + "description": "description", + } + + type_mappings: dict[str, str] = None @classmethod def get_impl(cls, type_name: str) -> Self: 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: - return subcls + return subcls() 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) diff --git a/jambo/parser/allof_type_parser.py b/jambo/parser/allof_type_parser.py new file mode 100644 index 0000000..f0b1839 --- /dev/null +++ b/jambo/parser/allof_type_parser.py @@ -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 diff --git a/jambo/parser/anyof_type_parser.py b/jambo/parser/anyof_type_parser.py new file mode 100644 index 0000000..b70591e --- /dev/null +++ b/jambo/parser/anyof_type_parser.py @@ -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 diff --git a/jambo/parser/array_type_parser.py b/jambo/parser/array_type_parser.py index 04bde0b..5ec162c 100644 --- a/jambo/parser/array_type_parser.py +++ b/jambo/parser/array_type_parser.py @@ -1,12 +1,8 @@ -import copy - from jambo.parser._type_parser import GenericTypeParser +import copy from typing import TypeVar -from jambo.utils.properties_builder.mappings_properties_builder import ( - mappings_properties_builder, -) V = TypeVar("V") @@ -16,44 +12,33 @@ class ArrayTypeParser(GenericTypeParser): json_schema_type = "array" - @classmethod - def from_properties(cls, name, properties): + default_mappings = {"description": "description"} + + type_mappings = { + "maxItems": "max_length", + "minItems": "min_length", + } + + def from_properties(self, name, properties, required=False): _item_type, _item_args = GenericTypeParser.get_impl( properties["items"]["type"] - ).from_properties(name, properties["items"]) - - _mappings = { - "maxItems": "max_length", - "minItems": "min_length", - } + ).from_properties(name, properties["items"], required=True) wrapper_type = set if properties.get("uniqueItems", False) else list + field_type = wrapper_type[_item_type] - mapped_properties = mappings_properties_builder( - properties, _mappings, {"description": "description"} + mapped_properties = self.mappings_properties_builder( + properties, + required=required, ) - if "default" in properties: - default_list = properties["default"] - if not isinstance(default_list, list): - raise ValueError( - f"Default value must be a list, got {type(default_list).__name__}" - ) - - 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__}" - ) + default_list = properties.pop("default", None) + if default_list is not None: + self.validate_default( + field_type, + mapped_properties, + default_list, + ) if wrapper_type is list: mapped_properties["default_factory"] = lambda: copy.deepcopy( @@ -64,4 +49,4 @@ class ArrayTypeParser(GenericTypeParser): default_list ) - return wrapper_type[_item_type], mapped_properties + return field_type, mapped_properties diff --git a/jambo/parser/boolean_type_parser.py b/jambo/parser/boolean_type_parser.py index 1dec65d..384da9d 100644 --- a/jambo/parser/boolean_type_parser.py +++ b/jambo/parser/boolean_type_parser.py @@ -1,7 +1,4 @@ from jambo.parser._type_parser import GenericTypeParser -from jambo.utils.properties_builder.mappings_properties_builder import ( - mappings_properties_builder, -) class BooleanTypeParser(GenericTypeParser): @@ -9,9 +6,15 @@ class BooleanTypeParser(GenericTypeParser): json_schema_type = "boolean" - @staticmethod - def from_properties(name, properties): - _mappings = { - "default": "default", - } - return bool, mappings_properties_builder(properties, _mappings) + type_mappings = { + "default": "default", + } + + def from_properties(self, name, properties, required=False): + 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 diff --git a/jambo/parser/float_type_parser.py b/jambo/parser/float_type_parser.py index a6dcdd5..565f69e 100644 --- a/jambo/parser/float_type_parser.py +++ b/jambo/parser/float_type_parser.py @@ -1,7 +1,4 @@ from jambo.parser._type_parser import GenericTypeParser -from jambo.utils.properties_builder.numeric_properties_builder import ( - numeric_properties_builder, -) class FloatTypeParser(GenericTypeParser): @@ -9,6 +6,20 @@ class FloatTypeParser(GenericTypeParser): json_schema_type = "number" - @staticmethod - def from_properties(name, properties): - return float, numeric_properties_builder(properties) + type_mappings = { + "minimum": "ge", + "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 diff --git a/jambo/parser/int_type_parser.py b/jambo/parser/int_type_parser.py index 1ef907b..2a352bb 100644 --- a/jambo/parser/int_type_parser.py +++ b/jambo/parser/int_type_parser.py @@ -1,7 +1,4 @@ from jambo.parser._type_parser import GenericTypeParser -from jambo.utils.properties_builder.numeric_properties_builder import ( - numeric_properties_builder, -) class IntTypeParser(GenericTypeParser): @@ -9,6 +6,20 @@ class IntTypeParser(GenericTypeParser): json_schema_type = "integer" - @staticmethod - def from_properties(name, properties): - return int, numeric_properties_builder(properties) + type_mappings = { + "minimum": "ge", + "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 diff --git a/jambo/parser/object_type_parser.py b/jambo/parser/object_type_parser.py index 7c0c363..f7c9f6a 100644 --- a/jambo/parser/object_type_parser.py +++ b/jambo/parser/object_type_parser.py @@ -7,7 +7,7 @@ class ObjectTypeParser(GenericTypeParser): json_schema_type = "object" @staticmethod - def from_properties(name, properties): + def from_properties(name, properties, required=False): from jambo.schema_converter import SchemaConverter type_parsing = SchemaConverter.build_object(name, properties) diff --git a/jambo/parser/string_type_parser.py b/jambo/parser/string_type_parser.py index dc44ca4..89e8b7e 100644 --- a/jambo/parser/string_type_parser.py +++ b/jambo/parser/string_type_parser.py @@ -1,7 +1,4 @@ from jambo.parser._type_parser import GenericTypeParser -from jambo.utils.properties_builder.mappings_properties_builder import ( - mappings_properties_builder, -) class StringTypeParser(GenericTypeParser): @@ -9,32 +6,17 @@ class StringTypeParser(GenericTypeParser): json_schema_type = "string" - @staticmethod - def from_properties(name, properties): - _mappings = { - "maxLength": "max_length", - "minLength": "min_length", - "pattern": "pattern", - } + type_mappings = { + "maxLength": "max_length", + "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["default"] - if not isinstance(default_value, str): - 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')}" - ) + default_value = properties.get("default") + if default_value is not None: + self.validate_default(str, mapped_properties, default_value) return str, mapped_properties diff --git a/jambo/schema_converter.py b/jambo/schema_converter.py index 5ed8afb..728a2a2 100644 --- a/jambo/schema_converter.py +++ b/jambo/schema_converter.py @@ -2,7 +2,7 @@ from jambo.parser import GenericTypeParser from jambo.types.json_schema_type import JSONSchema from jsonschema.exceptions import SchemaError -from jsonschema.protocols import Validator +from jsonschema.validators import validator_for from pydantic import create_model from pydantic.fields import Field from pydantic.main import ModelT @@ -42,7 +42,8 @@ class SchemaConverter: """ try: - Validator.check_schema(schema) + validator = validator_for(schema) + validator.check_schema(schema) except SchemaError as e: raise ValueError(f"Invalid JSON Schema: {e}") @@ -71,27 +72,25 @@ class SchemaConverter: fields = {} 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 @staticmethod - def _build_field( - name, properties: dict, required_keys: list[str] - ) -> tuple[type, dict]: + def _build_field(name, properties: dict, required=False) -> tuple[type, Field]: + match properties: + 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( - properties["type"] - ).from_properties(name, properties) - - _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"] + _field_type + ).from_properties(name, properties, required) return _field_type, Field(**_field_args) diff --git a/jambo/types/json_schema_type.py b/jambo/types/json_schema_type.py index 77ec7ff..0658db6 100644 --- a/jambo/types/json_schema_type.py +++ b/jambo/types/json_schema_type.py @@ -1,4 +1,4 @@ -from typing import List, Dict, Union, TypedDict, Literal +from typing import Dict, List, Literal, TypedDict, Union JSONSchemaType = Literal[ diff --git a/jambo/utils/__init__.py b/jambo/utils/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/jambo/utils/properties_builder/__init__.py b/jambo/utils/properties_builder/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/jambo/utils/properties_builder/mappings_properties_builder.py b/jambo/utils/properties_builder/mappings_properties_builder.py deleted file mode 100644 index f743891..0000000 --- a/jambo/utils/properties_builder/mappings_properties_builder.py +++ /dev/null @@ -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 - } diff --git a/jambo/utils/properties_builder/numeric_properties_builder.py b/jambo/utils/properties_builder/numeric_properties_builder.py deleted file mode 100644 index f38dea1..0000000 --- a/jambo/utils/properties_builder/numeric_properties_builder.py +++ /dev/null @@ -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 diff --git a/jambo/utils/types/__init__.py b/jambo/utils/types/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/pyproject.toml b/pyproject.toml index 2882920..0bf5bc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,8 +57,20 @@ requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" +# Tests +[tool.coverage.run] +omit = [ + "tests/*", +] + + + # Linters +[tool.ruff.lint] +extend-select = ["I"] + [tool.ruff.lint.isort] +known-first-party = ["jambo"] section-order=[ "future", "first-party", diff --git a/tests/parser/test_allof_type_parser.py b/tests/parser/test_allof_type_parser.py new file mode 100644 index 0000000..e35319c --- /dev/null +++ b/tests/parser/test_allof_type_parser.py @@ -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) diff --git a/tests/parser/test_anyof_type_parser.py b/tests/parser/test_anyof_type_parser.py new file mode 100644 index 0000000..023fcb9 --- /dev/null +++ b/tests/parser/test_anyof_type_parser.py @@ -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) diff --git a/tests/parser/test_array_type_parser.py b/tests/parser/test_array_type_parser.py index 9c06a46..172b98f 100644 --- a/tests/parser/test_array_type_parser.py +++ b/tests/parser/test_array_type_parser.py @@ -1,8 +1,8 @@ +from jambo.parser import ArrayTypeParser + from typing import get_args from unittest import TestCase -from jambo.parser import ArrayTypeParser - class TestArrayTypeParser(TestCase): def test_array_parser_no_options(self): @@ -66,38 +66,25 @@ class TestArrayTypeParser(TestCase): properties = {"items": {"type": "string"}, "default": ["a", 1, "c"]} - with self.assertRaises(ValueError) as context: + with self.assertRaises(ValueError): 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): parser = ArrayTypeParser() properties = {"items": {"type": "string"}, "default": "not_a_list"} - with self.assertRaises(ValueError) as context: + with self.assertRaises(ValueError): 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): parser = ArrayTypeParser() properties = {"items": {"type": "string"}, "default": ["a"], "minItems": 2} - with self.assertRaises(ValueError) as context: + with self.assertRaises(ValueError): 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): parser = ArrayTypeParser() @@ -107,9 +94,5 @@ class TestArrayTypeParser(TestCase): "maxItems": 3, } - with self.assertRaises(ValueError) as context: + with self.assertRaises(ValueError): parser.from_properties("placeholder", properties) - - self.assertEqual( - str(context.exception), "Default list exceeds maxItems limit of 3" - ) diff --git a/tests/parser/test_bool_type_parser.py b/tests/parser/test_bool_type_parser.py index 761ddc0..f21b547 100644 --- a/tests/parser/test_bool_type_parser.py +++ b/tests/parser/test_bool_type_parser.py @@ -1,7 +1,7 @@ -from unittest import TestCase - from jambo.parser import BooleanTypeParser +from unittest import TestCase + class TestBoolTypeParser(TestCase): def test_bool_parser_no_options(self): @@ -12,7 +12,7 @@ class TestBoolTypeParser(TestCase): type_parsing, type_validator = parser.from_properties("placeholder", properties) self.assertEqual(type_parsing, bool) - self.assertEqual(type_validator, {}) + self.assertEqual(type_validator, {"default": None}) def test_bool_parser_with_default(self): parser = BooleanTypeParser() @@ -26,3 +26,14 @@ class TestBoolTypeParser(TestCase): self.assertEqual(type_parsing, bool) 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) diff --git a/tests/parser/test_float_type_parser.py b/tests/parser/test_float_type_parser.py index c8e3ad5..c462d64 100644 --- a/tests/parser/test_float_type_parser.py +++ b/tests/parser/test_float_type_parser.py @@ -1,7 +1,7 @@ -from unittest import TestCase - from jambo.parser import FloatTypeParser +from unittest import TestCase + class TestFloatTypeParser(TestCase): def test_float_parser_no_options(self): @@ -12,7 +12,7 @@ class TestFloatTypeParser(TestCase): type_parsing, type_validator = parser.from_properties("placeholder", properties) self.assertEqual(type_parsing, float) - self.assertEqual(type_validator, {}) + self.assertEqual(type_validator, {"default": None}) def test_float_parser_with_options(self): parser = FloatTypeParser() @@ -61,14 +61,9 @@ class TestFloatTypeParser(TestCase): "multipleOf": 0.5, } - with self.assertRaises(ValueError) as context: + with self.assertRaises(ValueError): 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): parser = FloatTypeParser() @@ -80,14 +75,9 @@ class TestFloatTypeParser(TestCase): "multipleOf": 0.5, } - with self.assertRaises(ValueError) as context: + with self.assertRaises(ValueError): 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): parser = FloatTypeParser() @@ -99,14 +89,9 @@ class TestFloatTypeParser(TestCase): "multipleOf": 0.5, } - with self.assertRaises(ValueError) as context: + with self.assertRaises(ValueError): 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): parser = FloatTypeParser() @@ -118,14 +103,9 @@ class TestFloatTypeParser(TestCase): "multipleOf": 0.5, } - with self.assertRaises(ValueError) as context: + with self.assertRaises(ValueError): 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): parser = FloatTypeParser() @@ -137,14 +117,9 @@ class TestFloatTypeParser(TestCase): "multipleOf": 0.5, } - with self.assertRaises(ValueError) as context: + with self.assertRaises(ValueError): 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): parser = FloatTypeParser() @@ -156,10 +131,5 @@ class TestFloatTypeParser(TestCase): "multipleOf": 2.0, } - with self.assertRaises(ValueError) as context: + with self.assertRaises(ValueError): parser.from_properties("placeholder", properties) - - self.assertEqual( - str(context.exception), - "Default value 5.0 is not a multiple of 2.0", - ) diff --git a/tests/parser/test_int_type_parser.py b/tests/parser/test_int_type_parser.py index e50b340..5cfeed5 100644 --- a/tests/parser/test_int_type_parser.py +++ b/tests/parser/test_int_type_parser.py @@ -1,7 +1,7 @@ -from unittest import TestCase - from jambo.parser import IntTypeParser +from unittest import TestCase + class TestIntTypeParser(TestCase): def test_int_parser_no_options(self): @@ -12,7 +12,7 @@ class TestIntTypeParser(TestCase): type_parsing, type_validator = parser.from_properties("placeholder", properties) self.assertEqual(type_parsing, int) - self.assertEqual(type_validator, {}) + self.assertEqual(type_validator, {"default": None}) def test_int_parser_with_options(self): parser = IntTypeParser() @@ -61,14 +61,9 @@ class TestIntTypeParser(TestCase): "multipleOf": 2, } - with self.assertRaises(ValueError) as context: + with self.assertRaises(ValueError): 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): parser = IntTypeParser() @@ -80,14 +75,9 @@ class TestIntTypeParser(TestCase): "multipleOf": 2, } - with self.assertRaises(ValueError) as context: + with self.assertRaises(ValueError): 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): parser = IntTypeParser() @@ -99,14 +89,9 @@ class TestIntTypeParser(TestCase): "multipleOf": 2, } - with self.assertRaises(ValueError) as context: + with self.assertRaises(ValueError): 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): parser = IntTypeParser() @@ -118,14 +103,9 @@ class TestIntTypeParser(TestCase): "multipleOf": 2, } - with self.assertRaises(ValueError) as context: + with self.assertRaises(ValueError): 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): parser = IntTypeParser() @@ -137,14 +117,9 @@ class TestIntTypeParser(TestCase): "multipleOf": 2, } - with self.assertRaises(ValueError) as context: + with self.assertRaises(ValueError): 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): parser = IntTypeParser() @@ -156,10 +131,5 @@ class TestIntTypeParser(TestCase): "multipleOf": 2, } - with self.assertRaises(ValueError) as context: + with self.assertRaises(ValueError): parser.from_properties("placeholder", properties) - - self.assertEqual( - str(context.exception), - "Default value 5 is not a multiple of 2", - ) diff --git a/tests/parser/test_object_type_parser.py b/tests/parser/test_object_type_parser.py index 1c1fea7..6f56727 100644 --- a/tests/parser/test_object_type_parser.py +++ b/tests/parser/test_object_type_parser.py @@ -1,7 +1,7 @@ -from unittest import TestCase - from jambo.parser import ObjectTypeParser +from unittest import TestCase + class TestObjectTypeParser(TestCase): def test_object_type_parser(self): diff --git a/tests/parser/test_string_type_parser.py b/tests/parser/test_string_type_parser.py index f5d19fe..92161d0 100644 --- a/tests/parser/test_string_type_parser.py +++ b/tests/parser/test_string_type_parser.py @@ -1,7 +1,7 @@ -from unittest import TestCase - from jambo.parser import StringTypeParser +from unittest import TestCase + class TestStringTypeParser(TestCase): def test_string_parser_no_options(self): @@ -57,14 +57,9 @@ class TestStringTypeParser(TestCase): "minLength": 5, } - with self.assertRaises(ValueError) as context: + with self.assertRaises(ValueError): parser.from_properties("placeholder", properties) - self.assertEqual( - str(context.exception), - "Default value for placeholder must be a string, but got .", - ) - def test_string_parser_with_default_invalid_maxlength(self): parser = StringTypeParser() @@ -75,14 +70,9 @@ class TestStringTypeParser(TestCase): "minLength": 1, } - with self.assertRaises(ValueError) as context: + with self.assertRaises(ValueError): 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): parser = StringTypeParser() @@ -93,10 +83,5 @@ class TestStringTypeParser(TestCase): "minLength": 2, } - with self.assertRaises(ValueError) as context: + with self.assertRaises(ValueError): parser.from_properties("placeholder", properties) - - self.assertEqual( - str(context.exception), - "Default value for placeholder is below minLength limit of 2", - ) diff --git a/tests/parser/test_type_parser.py b/tests/parser/test_type_parser.py new file mode 100644 index 0000000..38bb6a1 --- /dev/null +++ b/tests/parser/test_type_parser.py @@ -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) diff --git a/tests/test_schema_converter.py b/tests/test_schema_converter.py index 53a7e52..7f85d3f 100644 --- a/tests/test_schema_converter.py +++ b/tests/test_schema_converter.py @@ -10,6 +10,59 @@ def is_pydantic_model(cls): 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): schema = { "title": "Person", @@ -281,3 +334,66 @@ class TestSchemaConverter(TestCase): self.assertEqual(obj.address.street, "123 Main St") 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)