From 6d1febbcc12dad61a35b6dbd5834c3e9bfb9c17e Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Mon, 14 Apr 2025 03:22:42 -0300 Subject: [PATCH 01/15] Initial allOf Implementation --- jambo/parser/__init__.py | 1 + jambo/parser/allof_type_parser.py | 76 +++++++++++++++++++++++++++++++ jambo/schema_converter.py | 14 +++++- tests/test_schema_converter.py | 30 ++++++++++++ 4 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 jambo/parser/allof_type_parser.py diff --git a/jambo/parser/__init__.py b/jambo/parser/__init__.py index c71f93d..d4a71e3 100644 --- a/jambo/parser/__init__.py +++ b/jambo/parser/__init__.py @@ -8,3 +8,4 @@ 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 as AllOfTypeParser diff --git a/jambo/parser/allof_type_parser.py b/jambo/parser/allof_type_parser.py new file mode 100644 index 0000000..c4677c5 --- /dev/null +++ b/jambo/parser/allof_type_parser.py @@ -0,0 +1,76 @@ +from jambo.parser._type_parser import GenericTypeParser + + +class AllOfTypeParser(GenericTypeParser): + mapped_type = any + + json_schema_type = "allOf" + + @staticmethod + def from_properties(name, properties): + 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 not all(prop.get("type") == _mapped_type for prop in subProperties): + raise ValueError("Invalid JSON Schema: allOf types do not match.") + + combined_properties = AllOfTypeParser._rebuild_properties_from_subproperties( + subProperties + ) + + return GenericTypeParser.get_impl(_mapped_type).from_properties( + name, combined_properties + ) + + @staticmethod + def _rebuild_properties_from_subproperties(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 == "type": + if old_value != new_value: + raise ValueError( + f"Invalid JSON Schema: conflicting types for '{prop_name}'" + ) + return old_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 + + # Handle other properties by just returning the first valued + return old_value diff --git a/jambo/schema_converter.py b/jambo/schema_converter.py index 5ed8afb..8d498c7 100644 --- a/jambo/schema_converter.py +++ b/jambo/schema_converter.py @@ -79,8 +79,20 @@ class SchemaConverter: def _build_field( name, properties: dict, required_keys: list[str] ) -> tuple[type, dict]: + match properties: + case {"anyOf": _}: + _field_type = "anyOf" + case {"allOf": _}: + _field_type = "allOf" + case {"oneOf": _}: + _field_type = "oneOf" + case {"type": _}: + _field_type = properties["type"] + case _: + raise ValueError(f"Invalid JSON Schema: {properties}") + _field_type, _field_args = GenericTypeParser.get_impl( - properties["type"] + _field_type ).from_properties(name, properties) _field_args = _field_args or {} diff --git a/tests/test_schema_converter.py b/tests/test_schema_converter.py index 53a7e52..62655a0 100644 --- a/tests/test_schema_converter.py +++ b/tests/test_schema_converter.py @@ -281,3 +281,33 @@ 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": 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="") -- 2.49.1 From 459d9da0b94a6520b4d300dfe0ba79a0d70cf0a9 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Thu, 17 Apr 2025 03:02:14 -0300 Subject: [PATCH 02/15] Final Implementation of AllOf Keyword --- jambo/parser/allof_type_parser.py | 36 +++++++++++++++++++++---------- pyproject.toml | 4 ++++ tests/test_schema_converter.py | 1 + 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/jambo/parser/allof_type_parser.py b/jambo/parser/allof_type_parser.py index c4677c5..6f23236 100644 --- a/jambo/parser/allof_type_parser.py +++ b/jambo/parser/allof_type_parser.py @@ -16,11 +16,17 @@ class AllOfTypeParser(GenericTypeParser): 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 _mapped_type is None: + raise ValueError("Invalid JSON Schema: 'type' is not specified.") - if not all(prop.get("type") == _mapped_type for prop in subProperties): - raise ValueError("Invalid JSON Schema: allOf types do not match.") + 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 = AllOfTypeParser._rebuild_properties_from_subproperties( subProperties @@ -46,13 +52,6 @@ class AllOfTypeParser(GenericTypeParser): @staticmethod def _validate_prop(prop_name, old_value, new_value): - if prop_name == "type": - if old_value != new_value: - raise ValueError( - f"Invalid JSON Schema: conflicting types for '{prop_name}'" - ) - return old_value - if prop_name == "description": return f"{old_value} | {new_value}" @@ -72,5 +71,20 @@ class AllOfTypeParser(GenericTypeParser): 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/pyproject.toml b/pyproject.toml index 2882920..9660943 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,11 @@ build-backend = "hatchling.build" # Linters +[tool.ruff] +extend-select = ["I"] + [tool.ruff.lint.isort] +known-first-party = ["jambo"] section-order=[ "future", "first-party", diff --git a/tests/test_schema_converter.py b/tests/test_schema_converter.py index 62655a0..4cfd9b5 100644 --- a/tests/test_schema_converter.py +++ b/tests/test_schema_converter.py @@ -290,6 +290,7 @@ class TestSchemaConverter(TestCase): "properties": { "name": { "allOf": [ + {"type": "string", "maxLength": 11}, {"type": "string", "maxLength": 4}, {"type": "string", "minLength": 1}, {"type": "string", "minLength": 2}, -- 2.49.1 From d5149061a142d39e1a4b039285f851e2f65b78a9 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Thu, 17 Apr 2025 03:04:38 -0300 Subject: [PATCH 03/15] Formats Import Orders --- jambo/parser/__init__.py | 8 +- jambo/parser/_type_parser.py | 7 +- jambo/parser/array_type_parser.py | 9 +- jambo/types/json_schema_type.py | 2 +- pyproject.toml | 2 +- tests/parser/test_allof_type_parser.py | 288 ++++++++++++++++++++++++ tests/parser/test_array_type_parser.py | 4 +- tests/parser/test_bool_type_parser.py | 4 +- tests/parser/test_float_type_parser.py | 4 +- tests/parser/test_int_type_parser.py | 4 +- tests/parser/test_object_type_parser.py | 4 +- tests/parser/test_string_type_parser.py | 4 +- 12 files changed, 314 insertions(+), 26 deletions(-) create mode 100644 tests/parser/test_allof_type_parser.py diff --git a/jambo/parser/__init__.py b/jambo/parser/__init__.py index d4a71e3..b8af117 100644 --- a/jambo/parser/__init__.py +++ b/jambo/parser/__init__.py @@ -1,11 +1,11 @@ # Exports generic type parser from ._type_parser import GenericTypeParser as GenericTypeParser +from .allof_type_parser import AllOfTypeParser as AllOfTypeParser +from .array_type_parser import ArrayTypeParser as ArrayTypeParser +from .boolean_type_parser import BooleanTypeParser as BooleanTypeParser +from .float_type_parser import FloatTypeParser as FloatTypeParser # 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 as AllOfTypeParser diff --git a/jambo/parser/_type_parser.py b/jambo/parser/_type_parser.py index 16dd833..e64ae77 100644 --- a/jambo/parser/_type_parser.py +++ b/jambo/parser/_type_parser.py @@ -1,8 +1,9 @@ -from abc import ABC, abstractmethod -from typing import Generic, TypeVar +from pydantic import Field from typing_extensions import Self -from pydantic import Field +from abc import ABC, abstractmethod +from typing import Generic, TypeVar + T = TypeVar("T") diff --git a/jambo/parser/array_type_parser.py b/jambo/parser/array_type_parser.py index 04bde0b..20a6125 100644 --- a/jambo/parser/array_type_parser.py +++ b/jambo/parser/array_type_parser.py @@ -1,13 +1,12 @@ -import copy - from jambo.parser._type_parser import GenericTypeParser - -from typing import TypeVar - from jambo.utils.properties_builder.mappings_properties_builder import ( mappings_properties_builder, ) +import copy +from typing import TypeVar + + V = TypeVar("V") 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/pyproject.toml b/pyproject.toml index 9660943..2b83921 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ build-backend = "hatchling.build" # Linters -[tool.ruff] +[tool.ruff.lint] extend-select = ["I"] [tool.ruff.lint.isort] diff --git a/tests/parser/test_allof_type_parser.py b/tests/parser/test_allof_type_parser.py new file mode 100644 index 0000000..9edc865 --- /dev/null +++ b/tests/parser/test_allof_type_parser.py @@ -0,0 +1,288 @@ +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": { + "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_array_type_parser.py b/tests/parser/test_array_type_parser.py index 9c06a46..4177cce 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): diff --git a/tests/parser/test_bool_type_parser.py b/tests/parser/test_bool_type_parser.py index 761ddc0..1ba25aa 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): diff --git a/tests/parser/test_float_type_parser.py b/tests/parser/test_float_type_parser.py index c8e3ad5..c25ab49 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): diff --git a/tests/parser/test_int_type_parser.py b/tests/parser/test_int_type_parser.py index e50b340..64da2bd 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): 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..9cdf901 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): -- 2.49.1 From dc350aaa8bed176bf3e812e5c2b552a849651f87 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Thu, 17 Apr 2025 03:07:08 -0300 Subject: [PATCH 04/15] Adds Test for AllOfTypeParser Case --- tests/parser/test_allof_type_parser.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/parser/test_allof_type_parser.py b/tests/parser/test_allof_type_parser.py index 9edc865..95ce68e 100644 --- a/tests/parser/test_allof_type_parser.py +++ b/tests/parser/test_allof_type_parser.py @@ -243,10 +243,14 @@ class TestAllOfTypeParser(TestCase): }, { "properties": { + "name": { + "type": "string", + "default": "John", + }, "age": { "type": "integer", "default": 30, - } + }, }, }, ], -- 2.49.1 From 5fdb4fa72479530a5a0214fc77e967ed39787976 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Thu, 17 Apr 2025 16:06:55 -0300 Subject: [PATCH 05/15] Removes OneOf due to complexity and niche use case After further analysis, the functionality was deemed too complex to implement for such a niche use case and will therefore be removed from the implementation backlog --- jambo/parser/__init__.py | 1 + jambo/parser/anyof_type_parser.py | 28 ++++++++++++++++++++++++++ jambo/schema_converter.py | 2 -- tests/parser/test_anyof_type_parser.py | 25 +++++++++++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 jambo/parser/anyof_type_parser.py create mode 100644 tests/parser/test_anyof_type_parser.py diff --git a/jambo/parser/__init__.py b/jambo/parser/__init__.py index b8af117..603a23a 100644 --- a/jambo/parser/__init__.py +++ b/jambo/parser/__init__.py @@ -1,6 +1,7 @@ # Exports generic type parser from ._type_parser import GenericTypeParser as GenericTypeParser from .allof_type_parser import AllOfTypeParser as AllOfTypeParser +from .anyof_type_parser import AnyOfTypeParser as AnyOfTypeParser from .array_type_parser import ArrayTypeParser as ArrayTypeParser from .boolean_type_parser import BooleanTypeParser as BooleanTypeParser from .float_type_parser import FloatTypeParser as FloatTypeParser diff --git a/jambo/parser/anyof_type_parser.py b/jambo/parser/anyof_type_parser.py new file mode 100644 index 0000000..8ee5543 --- /dev/null +++ b/jambo/parser/anyof_type_parser.py @@ -0,0 +1,28 @@ +from jambo.parser._type_parser import GenericTypeParser + +from typing import Union + + +class AnyOfTypeParser(GenericTypeParser): + mapped_type = Union + + json_schema_type = "anyOf" + + @staticmethod + def from_properties(name, properties): + 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']}") + + subProperties = properties["anyOf"] + + types = [ + GenericTypeParser.get_impl(subProperty["type"]).from_properties( + name, subProperty + ) + for subProperty in subProperties + ] + + return Union[*(t for t, v in types)], {} diff --git a/jambo/schema_converter.py b/jambo/schema_converter.py index 8d498c7..8dadb7e 100644 --- a/jambo/schema_converter.py +++ b/jambo/schema_converter.py @@ -84,8 +84,6 @@ class SchemaConverter: _field_type = "anyOf" case {"allOf": _}: _field_type = "allOf" - case {"oneOf": _}: - _field_type = "oneOf" case {"type": _}: _field_type = properties["type"] case _: diff --git a/tests/parser/test_anyof_type_parser.py b/tests/parser/test_anyof_type_parser.py new file mode 100644 index 0000000..cb97e1b --- /dev/null +++ b/tests/parser/test_anyof_type_parser.py @@ -0,0 +1,25 @@ +from jambo.parser.anyof_type_parser import AnyOfTypeParser + +from typing import Union, get_args, get_origin +from unittest import TestCase + + +class TestAnyOfTypeParser(TestCase): + 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) + + # check union type has string and int + self.assertEqual(get_origin(type_parsing), Union) + self.assertIn(str, get_args(type_parsing)) + self.assertIn(int, get_args(type_parsing)) -- 2.49.1 From 5c3d3a39ba2f060377702455096b56c6c66ed67e Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Sat, 19 Apr 2025 05:57:43 -0300 Subject: [PATCH 06/15] Implements Feature Complete AnyOf Keyword --- jambo/parser/_type_parser.py | 28 +++++++------ jambo/parser/allof_type_parser.py | 2 +- jambo/parser/anyof_type_parser.py | 36 ++++++++++++++-- jambo/parser/array_type_parser.py | 18 +++++--- jambo/parser/boolean_type_parser.py | 2 +- jambo/parser/float_type_parser.py | 4 +- jambo/parser/int_type_parser.py | 4 +- jambo/parser/object_type_parser.py | 2 +- jambo/parser/string_type_parser.py | 24 +++-------- jambo/schema_converter.py | 20 ++------- .../mappings_properties_builder.py | 7 +++- .../numeric_properties_builder.py | 7 ++-- tests/parser/test_anyof_type_parser.py | 42 ++++++++++++++++++- tests/parser/test_bool_type_parser.py | 2 +- tests/parser/test_float_type_parser.py | 2 +- tests/parser/test_int_type_parser.py | 2 +- tests/parser/test_string_type_parser.py | 21 ++-------- 17 files changed, 133 insertions(+), 90 deletions(-) diff --git a/jambo/parser/_type_parser.py b/jambo/parser/_type_parser.py index e64ae77..024fed3 100644 --- a/jambo/parser/_type_parser.py +++ b/jambo/parser/_type_parser.py @@ -1,32 +1,36 @@ -from pydantic import Field -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, TypeVar +from typing import Generic, Type, TypeVar 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]: ... + name: str, properties: dict[str, any], required: bool = False + ) -> tuple[T, dict]: ... @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}") + + @staticmethod + def validate_default(field_type: type, field_prop: dict, value): + 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 index 6f23236..7a65b49 100644 --- a/jambo/parser/allof_type_parser.py +++ b/jambo/parser/allof_type_parser.py @@ -7,7 +7,7 @@ class AllOfTypeParser(GenericTypeParser): json_schema_type = "allOf" @staticmethod - def from_properties(name, properties): + def from_properties(name, properties, required=False): subProperties = properties.get("allOf") if not subProperties: raise ValueError("Invalid JSON Schema: 'allOf' is not specified.") diff --git a/jambo/parser/anyof_type_parser.py b/jambo/parser/anyof_type_parser.py index 8ee5543..49408be 100644 --- a/jambo/parser/anyof_type_parser.py +++ b/jambo/parser/anyof_type_parser.py @@ -1,5 +1,8 @@ from jambo.parser._type_parser import GenericTypeParser +from pydantic import Field +from typing_extensions import Annotated + from typing import Union @@ -9,20 +12,47 @@ class AnyOfTypeParser(GenericTypeParser): json_schema_type = "anyOf" @staticmethod - def from_properties(name, properties): + def from_properties(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"] - types = [ + sub_types = [ GenericTypeParser.get_impl(subProperty["type"]).from_properties( name, subProperty ) for subProperty in subProperties ] - return Union[*(t for t, v in types)], {} + default_value = properties.get("default") + if default_value is not None: + for sub_type, sub_property in sub_types: + try: + GenericTypeParser.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, we can use the Field validator to enforce + # the constraints on the union type. + # 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 20a6125..d2d6e30 100644 --- a/jambo/parser/array_type_parser.py +++ b/jambo/parser/array_type_parser.py @@ -15,11 +15,11 @@ class ArrayTypeParser(GenericTypeParser): json_schema_type = "array" - @classmethod - def from_properties(cls, name, properties): + @staticmethod + def from_properties(name, properties, required=False): _item_type, _item_args = GenericTypeParser.get_impl( properties["items"]["type"] - ).from_properties(name, properties["items"]) + ).from_properties(name, properties["items"], required=True) _mappings = { "maxItems": "max_length", @@ -29,11 +29,14 @@ class ArrayTypeParser(GenericTypeParser): wrapper_type = set if properties.get("uniqueItems", False) else list mapped_properties = mappings_properties_builder( - properties, _mappings, {"description": "description"} + properties, + _mappings, + required=required, + default_mappings={"description": "description"}, ) - if "default" in properties: - default_list = properties["default"] + default_list = properties.get("default") + if default_list is not None: if not isinstance(default_list, list): raise ValueError( f"Default value must be a list, got {type(default_list).__name__}" @@ -63,4 +66,7 @@ class ArrayTypeParser(GenericTypeParser): default_list ) + if "default_factory" in mapped_properties and "default" in mapped_properties: + del mapped_properties["default"] + return wrapper_type[_item_type], mapped_properties diff --git a/jambo/parser/boolean_type_parser.py b/jambo/parser/boolean_type_parser.py index 1dec65d..4b21cf8 100644 --- a/jambo/parser/boolean_type_parser.py +++ b/jambo/parser/boolean_type_parser.py @@ -10,7 +10,7 @@ class BooleanTypeParser(GenericTypeParser): json_schema_type = "boolean" @staticmethod - def from_properties(name, properties): + def from_properties(name, properties, required=False): _mappings = { "default": "default", } diff --git a/jambo/parser/float_type_parser.py b/jambo/parser/float_type_parser.py index a6dcdd5..5326c08 100644 --- a/jambo/parser/float_type_parser.py +++ b/jambo/parser/float_type_parser.py @@ -10,5 +10,5 @@ class FloatTypeParser(GenericTypeParser): json_schema_type = "number" @staticmethod - def from_properties(name, properties): - return float, numeric_properties_builder(properties) + def from_properties(name, properties, required=False): + return float, numeric_properties_builder(properties, required) diff --git a/jambo/parser/int_type_parser.py b/jambo/parser/int_type_parser.py index 1ef907b..82bbfb9 100644 --- a/jambo/parser/int_type_parser.py +++ b/jambo/parser/int_type_parser.py @@ -10,5 +10,5 @@ class IntTypeParser(GenericTypeParser): json_schema_type = "integer" @staticmethod - def from_properties(name, properties): - return int, numeric_properties_builder(properties) + def from_properties(name, properties, required=False): + return int, numeric_properties_builder(properties, required) 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..7b77c75 100644 --- a/jambo/parser/string_type_parser.py +++ b/jambo/parser/string_type_parser.py @@ -10,31 +10,17 @@ class StringTypeParser(GenericTypeParser): json_schema_type = "string" @staticmethod - def from_properties(name, properties): + def from_properties(name, properties, required=False): _mappings = { "maxLength": "max_length", "minLength": "min_length", "pattern": "pattern", } - mapped_properties = mappings_properties_builder(properties, _mappings) + mapped_properties = mappings_properties_builder(properties, _mappings, 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: + StringTypeParser.validate_default(str, mapped_properties, default_value) return str, mapped_properties diff --git a/jambo/schema_converter.py b/jambo/schema_converter.py index 8dadb7e..39a5848 100644 --- a/jambo/schema_converter.py +++ b/jambo/schema_converter.py @@ -71,14 +71,13 @@ 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, dict]: match properties: case {"anyOf": _}: _field_type = "anyOf" @@ -91,17 +90,6 @@ class SchemaConverter: _field_type, _field_args = GenericTypeParser.get_impl( _field_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"] + ).from_properties(name, properties, required) return _field_type, Field(**_field_args) diff --git a/jambo/utils/properties_builder/mappings_properties_builder.py b/jambo/utils/properties_builder/mappings_properties_builder.py index f743891..e78fc8e 100644 --- a/jambo/utils/properties_builder/mappings_properties_builder.py +++ b/jambo/utils/properties_builder/mappings_properties_builder.py @@ -1,4 +1,9 @@ -def mappings_properties_builder(properties, mappings, default_mappings=None): +def mappings_properties_builder( + properties, mappings, required=False, default_mappings=None +): + if not required: + properties["default"] = properties.get("default", None) + default_mappings = default_mappings or { "default": "default", "description": "description", diff --git a/jambo/utils/properties_builder/numeric_properties_builder.py b/jambo/utils/properties_builder/numeric_properties_builder.py index f38dea1..343ef4c 100644 --- a/jambo/utils/properties_builder/numeric_properties_builder.py +++ b/jambo/utils/properties_builder/numeric_properties_builder.py @@ -3,7 +3,7 @@ from jambo.utils.properties_builder.mappings_properties_builder import ( ) -def numeric_properties_builder(properties): +def numeric_properties_builder(properties, required=False): _mappings = { "minimum": "ge", "exclusiveMinimum": "gt", @@ -13,9 +13,10 @@ def numeric_properties_builder(properties): "default": "default", } - mapped_properties = mappings_properties_builder(properties, _mappings) + mapped_properties = mappings_properties_builder(properties, _mappings, required) - if "default" in properties: + default_value = properties.get("default") + if default_value is not None: default_value = properties["default"] if not isinstance(default_value, (int, float)): raise ValueError( diff --git a/tests/parser/test_anyof_type_parser.py b/tests/parser/test_anyof_type_parser.py index cb97e1b..896b394 100644 --- a/tests/parser/test_anyof_type_parser.py +++ b/tests/parser/test_anyof_type_parser.py @@ -1,5 +1,7 @@ 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 @@ -21,5 +23,41 @@ class TestAnyOfTypeParser(TestCase): # check union type has string and int self.assertEqual(get_origin(type_parsing), Union) - self.assertIn(str, get_args(type_parsing)) - self.assertIn(int, get_args(type_parsing)) + + 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) diff --git a/tests/parser/test_bool_type_parser.py b/tests/parser/test_bool_type_parser.py index 1ba25aa..92c1513 100644 --- a/tests/parser/test_bool_type_parser.py +++ b/tests/parser/test_bool_type_parser.py @@ -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() diff --git a/tests/parser/test_float_type_parser.py b/tests/parser/test_float_type_parser.py index c25ab49..66a29f0 100644 --- a/tests/parser/test_float_type_parser.py +++ b/tests/parser/test_float_type_parser.py @@ -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() diff --git a/tests/parser/test_int_type_parser.py b/tests/parser/test_int_type_parser.py index 64da2bd..84c9e17 100644 --- a/tests/parser/test_int_type_parser.py +++ b/tests/parser/test_int_type_parser.py @@ -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() diff --git a/tests/parser/test_string_type_parser.py b/tests/parser/test_string_type_parser.py index 9cdf901..92161d0 100644 --- a/tests/parser/test_string_type_parser.py +++ b/tests/parser/test_string_type_parser.py @@ -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", - ) -- 2.49.1 From c6a37dab7492950cb41d23647dcc3781fb091c89 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Sat, 19 Apr 2025 15:44:27 -0300 Subject: [PATCH 07/15] Better Defaults Validation Implementation --- jambo/parser/anyof_type_parser.py | 4 +-- jambo/parser/array_type_parser.py | 27 +++++------------ jambo/parser/boolean_type_parser.py | 9 +++++- jambo/parser/float_type_parser.py | 20 ++++++++++-- jambo/parser/int_type_parser.py | 20 ++++++++++-- tests/parser/test_array_type_parser.py | 25 +++------------ tests/parser/test_float_type_parser.py | 42 ++++---------------------- tests/parser/test_int_type_parser.py | 42 ++++---------------------- 8 files changed, 67 insertions(+), 122 deletions(-) diff --git a/jambo/parser/anyof_type_parser.py b/jambo/parser/anyof_type_parser.py index 49408be..76b1a32 100644 --- a/jambo/parser/anyof_type_parser.py +++ b/jambo/parser/anyof_type_parser.py @@ -50,8 +50,8 @@ class AnyOfTypeParser(GenericTypeParser): if not required: mapped_properties["default"] = mapped_properties.get("default") - # By defining the type as Union, we can use the Field validator to enforce - # the constraints on the union type. + # 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] diff --git a/jambo/parser/array_type_parser.py b/jambo/parser/array_type_parser.py index d2d6e30..b96a399 100644 --- a/jambo/parser/array_type_parser.py +++ b/jambo/parser/array_type_parser.py @@ -27,6 +27,7 @@ class ArrayTypeParser(GenericTypeParser): } wrapper_type = set if properties.get("uniqueItems", False) else list + field_type = wrapper_type[_item_type] mapped_properties = mappings_properties_builder( properties, @@ -37,25 +38,11 @@ class ArrayTypeParser(GenericTypeParser): default_list = properties.get("default") if default_list is not None: - 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__}" - ) + ArrayTypeParser.validate_default( + field_type, + mapped_properties, + default_list, + ) if wrapper_type is list: mapped_properties["default_factory"] = lambda: copy.deepcopy( @@ -69,4 +56,4 @@ class ArrayTypeParser(GenericTypeParser): if "default_factory" in mapped_properties and "default" in mapped_properties: del mapped_properties["default"] - 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 4b21cf8..e9f0ab0 100644 --- a/jambo/parser/boolean_type_parser.py +++ b/jambo/parser/boolean_type_parser.py @@ -14,4 +14,11 @@ class BooleanTypeParser(GenericTypeParser): _mappings = { "default": "default", } - return bool, mappings_properties_builder(properties, _mappings) + + mapped_properties = mappings_properties_builder(properties, _mappings, 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 5326c08..4e1b075 100644 --- a/jambo/parser/float_type_parser.py +++ b/jambo/parser/float_type_parser.py @@ -1,6 +1,6 @@ from jambo.parser._type_parser import GenericTypeParser -from jambo.utils.properties_builder.numeric_properties_builder import ( - numeric_properties_builder, +from jambo.utils.properties_builder.mappings_properties_builder import ( + mappings_properties_builder, ) @@ -11,4 +11,18 @@ class FloatTypeParser(GenericTypeParser): @staticmethod def from_properties(name, properties, required=False): - return float, numeric_properties_builder(properties, required) + _mappings = { + "minimum": "ge", + "exclusiveMinimum": "gt", + "maximum": "le", + "exclusiveMaximum": "lt", + "multipleOf": "multiple_of", + "default": "default", + } + mapped_properties = mappings_properties_builder(properties, _mappings, required) + + default_value = mapped_properties.get("default") + if default_value is not None: + FloatTypeParser.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 82bbfb9..3e0f92e 100644 --- a/jambo/parser/int_type_parser.py +++ b/jambo/parser/int_type_parser.py @@ -1,6 +1,6 @@ from jambo.parser._type_parser import GenericTypeParser -from jambo.utils.properties_builder.numeric_properties_builder import ( - numeric_properties_builder, +from jambo.utils.properties_builder.mappings_properties_builder import ( + mappings_properties_builder, ) @@ -11,4 +11,18 @@ class IntTypeParser(GenericTypeParser): @staticmethod def from_properties(name, properties, required=False): - return int, numeric_properties_builder(properties, required) + _mappings = { + "minimum": "ge", + "exclusiveMinimum": "gt", + "maximum": "le", + "exclusiveMaximum": "lt", + "multipleOf": "multiple_of", + "default": "default", + } + mapped_properties = mappings_properties_builder(properties, _mappings, required) + + default_value = mapped_properties.get("default") + if default_value is not None: + IntTypeParser.validate_default(int, mapped_properties, default_value) + + return int, mapped_properties diff --git a/tests/parser/test_array_type_parser.py b/tests/parser/test_array_type_parser.py index 4177cce..172b98f 100644 --- a/tests/parser/test_array_type_parser.py +++ b/tests/parser/test_array_type_parser.py @@ -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_float_type_parser.py b/tests/parser/test_float_type_parser.py index 66a29f0..c462d64 100644 --- a/tests/parser/test_float_type_parser.py +++ b/tests/parser/test_float_type_parser.py @@ -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 84c9e17..5cfeed5 100644 --- a/tests/parser/test_int_type_parser.py +++ b/tests/parser/test_int_type_parser.py @@ -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", - ) -- 2.49.1 From 42bc0148b814bd4158fe4100bc7f9831bf68e2d3 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Sat, 19 Apr 2025 15:46:37 -0300 Subject: [PATCH 08/15] Adds Test for Boolean Default Value --- tests/parser/test_bool_type_parser.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/parser/test_bool_type_parser.py b/tests/parser/test_bool_type_parser.py index 92c1513..f21b547 100644 --- a/tests/parser/test_bool_type_parser.py +++ b/tests/parser/test_bool_type_parser.py @@ -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) -- 2.49.1 From d74e700233e41dbb93448659acf804bf66636b2c Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Sat, 19 Apr 2025 15:48:54 -0300 Subject: [PATCH 09/15] Removes Unecessary Case from ArrayParser --- jambo/parser/array_type_parser.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/jambo/parser/array_type_parser.py b/jambo/parser/array_type_parser.py index b96a399..b72c3ed 100644 --- a/jambo/parser/array_type_parser.py +++ b/jambo/parser/array_type_parser.py @@ -36,7 +36,7 @@ class ArrayTypeParser(GenericTypeParser): default_mappings={"description": "description"}, ) - default_list = properties.get("default") + default_list = properties.pop("default", None) if default_list is not None: ArrayTypeParser.validate_default( field_type, @@ -53,7 +53,4 @@ class ArrayTypeParser(GenericTypeParser): default_list ) - if "default_factory" in mapped_properties and "default" in mapped_properties: - del mapped_properties["default"] - return field_type, mapped_properties -- 2.49.1 From 20e4a6996822fde9abbc7a86db92f024e7d42e27 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Sat, 19 Apr 2025 16:45:32 -0300 Subject: [PATCH 10/15] Move Aux Function to the GenericTypeParser Class --- jambo/parser/_type_parser.py | 32 +++++++++--- jambo/parser/allof_type_parser.py | 10 ++-- jambo/parser/anyof_type_parser.py | 7 +-- jambo/parser/array_type_parser.py | 24 ++++----- jambo/parser/boolean_type_parser.py | 14 ++--- jambo/parser/float_type_parser.py | 27 +++++----- jambo/parser/int_type_parser.py | 27 +++++----- jambo/parser/string_type_parser.py | 20 +++---- jambo/utils/__init__.py | 0 jambo/utils/properties_builder/__init__.py | 0 .../mappings_properties_builder.py | 16 ------ .../numeric_properties_builder.py | 52 ------------------- jambo/utils/types/__init__.py | 0 tests/parser/test_allof_type_parser.py | 20 +++---- tests/parser/test_anyof_type_parser.py | 4 +- 15 files changed, 89 insertions(+), 164 deletions(-) delete mode 100644 jambo/utils/__init__.py delete mode 100644 jambo/utils/properties_builder/__init__.py delete mode 100644 jambo/utils/properties_builder/mappings_properties_builder.py delete mode 100644 jambo/utils/properties_builder/numeric_properties_builder.py delete mode 100644 jambo/utils/types/__init__.py diff --git a/jambo/parser/_type_parser.py b/jambo/parser/_type_parser.py index 024fed3..20a11d4 100644 --- a/jambo/parser/_type_parser.py +++ b/jambo/parser/_type_parser.py @@ -13,11 +13,12 @@ class GenericTypeParser(ABC, Generic[T]): json_schema_type: str = None - @staticmethod - @abstractmethod - def from_properties( - name: str, properties: dict[str, any], required: bool = False - ) -> tuple[T, dict]: ... + default_mappings = { + "default": "default", + "description": "description", + } + + type_mappings: dict[str, str] = None @classmethod def get_impl(cls, type_name: str) -> Self: @@ -30,7 +31,24 @@ class GenericTypeParser(ABC, Generic[T]): raise ValueError(f"Unknown type: {type_name}") - @staticmethod - def validate_default(field_type: type, field_prop: dict, value): + @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 index 7a65b49..f0b1839 100644 --- a/jambo/parser/allof_type_parser.py +++ b/jambo/parser/allof_type_parser.py @@ -6,8 +6,7 @@ class AllOfTypeParser(GenericTypeParser): json_schema_type = "allOf" - @staticmethod - def from_properties(name, properties, required=False): + def from_properties(self, name, properties, required=False): subProperties = properties.get("allOf") if not subProperties: raise ValueError("Invalid JSON Schema: 'allOf' is not specified.") @@ -28,16 +27,13 @@ class AllOfTypeParser(GenericTypeParser): # 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 = AllOfTypeParser._rebuild_properties_from_subproperties( - subProperties - ) + combined_properties = self._rebuild_properties_from_subproperties(subProperties) return GenericTypeParser.get_impl(_mapped_type).from_properties( name, combined_properties ) - @staticmethod - def _rebuild_properties_from_subproperties(subProperties): + def _rebuild_properties_from_subproperties(self, subProperties): properties = {} for subProperty in subProperties: for name, prop in subProperty.items(): diff --git a/jambo/parser/anyof_type_parser.py b/jambo/parser/anyof_type_parser.py index 76b1a32..b70591e 100644 --- a/jambo/parser/anyof_type_parser.py +++ b/jambo/parser/anyof_type_parser.py @@ -11,8 +11,7 @@ class AnyOfTypeParser(GenericTypeParser): json_schema_type = "anyOf" - @staticmethod - def from_properties(name, properties, required=False): + def from_properties(self, name, properties, required=False): if "anyOf" not in properties: raise ValueError(f"Invalid JSON Schema: {properties}") @@ -34,9 +33,7 @@ class AnyOfTypeParser(GenericTypeParser): if default_value is not None: for sub_type, sub_property in sub_types: try: - GenericTypeParser.validate_default( - sub_type, sub_property, default_value - ) + self.validate_default(sub_type, sub_property, default_value) break except ValueError: continue diff --git a/jambo/parser/array_type_parser.py b/jambo/parser/array_type_parser.py index b72c3ed..5ec162c 100644 --- a/jambo/parser/array_type_parser.py +++ b/jambo/parser/array_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, -) import copy from typing import TypeVar @@ -15,30 +12,29 @@ class ArrayTypeParser(GenericTypeParser): json_schema_type = "array" - @staticmethod - def from_properties(name, properties, required=False): + 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"], required=True) - _mappings = { - "maxItems": "max_length", - "minItems": "min_length", - } - 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, required=required, - default_mappings={"description": "description"}, ) default_list = properties.pop("default", None) if default_list is not None: - ArrayTypeParser.validate_default( + self.validate_default( field_type, mapped_properties, default_list, diff --git a/jambo/parser/boolean_type_parser.py b/jambo/parser/boolean_type_parser.py index e9f0ab0..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,13 +6,12 @@ class BooleanTypeParser(GenericTypeParser): json_schema_type = "boolean" - @staticmethod - def from_properties(name, properties, required=False): - _mappings = { - "default": "default", - } + type_mappings = { + "default": "default", + } - mapped_properties = mappings_properties_builder(properties, _mappings, required) + 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): diff --git a/jambo/parser/float_type_parser.py b/jambo/parser/float_type_parser.py index 4e1b075..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.mappings_properties_builder import ( - mappings_properties_builder, -) class FloatTypeParser(GenericTypeParser): @@ -9,20 +6,20 @@ class FloatTypeParser(GenericTypeParser): json_schema_type = "number" - @staticmethod - def from_properties(name, properties, required=False): - _mappings = { - "minimum": "ge", - "exclusiveMinimum": "gt", - "maximum": "le", - "exclusiveMaximum": "lt", - "multipleOf": "multiple_of", - "default": "default", - } - mapped_properties = mappings_properties_builder(properties, _mappings, required) + 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: - FloatTypeParser.validate_default(float, mapped_properties, default_value) + 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 3e0f92e..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.mappings_properties_builder import ( - mappings_properties_builder, -) class IntTypeParser(GenericTypeParser): @@ -9,20 +6,20 @@ class IntTypeParser(GenericTypeParser): json_schema_type = "integer" - @staticmethod - def from_properties(name, properties, required=False): - _mappings = { - "minimum": "ge", - "exclusiveMinimum": "gt", - "maximum": "le", - "exclusiveMaximum": "lt", - "multipleOf": "multiple_of", - "default": "default", - } - mapped_properties = mappings_properties_builder(properties, _mappings, required) + 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: - IntTypeParser.validate_default(int, mapped_properties, default_value) + self.validate_default(int, mapped_properties, default_value) return int, mapped_properties diff --git a/jambo/parser/string_type_parser.py b/jambo/parser/string_type_parser.py index 7b77c75..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,18 +6,17 @@ class StringTypeParser(GenericTypeParser): json_schema_type = "string" - @staticmethod - def from_properties(name, properties, required=False): - _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, required) + 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: - StringTypeParser.validate_default(str, mapped_properties, default_value) + self.validate_default(str, mapped_properties, default_value) return str, mapped_properties 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 e78fc8e..0000000 --- a/jambo/utils/properties_builder/mappings_properties_builder.py +++ /dev/null @@ -1,16 +0,0 @@ -def mappings_properties_builder( - properties, mappings, required=False, default_mappings=None -): - if not required: - properties["default"] = properties.get("default", 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 343ef4c..0000000 --- a/jambo/utils/properties_builder/numeric_properties_builder.py +++ /dev/null @@ -1,52 +0,0 @@ -from jambo.utils.properties_builder.mappings_properties_builder import ( - mappings_properties_builder, -) - - -def numeric_properties_builder(properties, required=False): - _mappings = { - "minimum": "ge", - "exclusiveMinimum": "gt", - "maximum": "le", - "exclusiveMaximum": "lt", - "multipleOf": "multiple_of", - "default": "default", - } - - mapped_properties = mappings_properties_builder(properties, _mappings, required) - - default_value = properties.get("default") - if default_value is not None: - 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/tests/parser/test_allof_type_parser.py b/tests/parser/test_allof_type_parser.py index 95ce68e..e35319c 100644 --- a/tests/parser/test_allof_type_parser.py +++ b/tests/parser/test_allof_type_parser.py @@ -38,7 +38,7 @@ class TestAllOfTypeParser(TestCase): ], } - type_parsing, type_validator = AllOfTypeParser.from_properties( + type_parsing, type_validator = AllOfTypeParser().from_properties( "placeholder", properties ) @@ -83,7 +83,7 @@ class TestAllOfTypeParser(TestCase): ], } - type_parsing, type_validator = AllOfTypeParser.from_properties( + type_parsing, type_validator = AllOfTypeParser().from_properties( "placeholder", properties ) @@ -112,7 +112,7 @@ class TestAllOfTypeParser(TestCase): ], } - type_parsing, type_validator = AllOfTypeParser.from_properties( + type_parsing, type_validator = AllOfTypeParser().from_properties( "placeholder", properties ) @@ -133,7 +133,7 @@ class TestAllOfTypeParser(TestCase): ] } - type_parsing, type_validator = AllOfTypeParser.from_properties( + type_parsing, type_validator = AllOfTypeParser().from_properties( "placeholder", properties ) @@ -155,7 +155,7 @@ class TestAllOfTypeParser(TestCase): } with self.assertRaises(ValueError): - AllOfTypeParser.from_properties("placeholder", properties) + AllOfTypeParser().from_properties("placeholder", properties) def test_all_of_invalid_type_not_present(self): properties = { @@ -168,7 +168,7 @@ class TestAllOfTypeParser(TestCase): } with self.assertRaises(ValueError): - AllOfTypeParser.from_properties("placeholder", properties) + AllOfTypeParser().from_properties("placeholder", properties) def test_all_of_invalid_type_in_fields(self): properties = { @@ -181,7 +181,7 @@ class TestAllOfTypeParser(TestCase): } with self.assertRaises(ValueError): - AllOfTypeParser.from_properties("placeholder", properties) + AllOfTypeParser().from_properties("placeholder", properties) def test_all_of_description_field(self): """ @@ -218,7 +218,7 @@ class TestAllOfTypeParser(TestCase): ], } - type_parsing, _ = AllOfTypeParser.from_properties("placeholder", properties) + type_parsing, _ = AllOfTypeParser().from_properties("placeholder", properties) self.assertEqual( type_parsing.schema()["properties"]["name"]["description"], @@ -256,7 +256,7 @@ class TestAllOfTypeParser(TestCase): ], } - type_parsing, _ = AllOfTypeParser.from_properties("placeholder", properties) + type_parsing, _ = AllOfTypeParser().from_properties("placeholder", properties) obj = type_parsing() self.assertEqual(obj.name, "John") self.assertEqual(obj.age, 30) @@ -289,4 +289,4 @@ class TestAllOfTypeParser(TestCase): } with self.assertRaises(ValueError): - AllOfTypeParser.from_properties("placeholder", properties) + AllOfTypeParser().from_properties("placeholder", properties) diff --git a/tests/parser/test_anyof_type_parser.py b/tests/parser/test_anyof_type_parser.py index 896b394..d16a3c1 100644 --- a/tests/parser/test_anyof_type_parser.py +++ b/tests/parser/test_anyof_type_parser.py @@ -19,7 +19,7 @@ class TestAnyOfTypeParser(TestCase): ], } - type_parsing, _ = AnyOfTypeParser.from_properties("placeholder", properties) + type_parsing, _ = AnyOfTypeParser().from_properties("placeholder", properties) # check union type has string and int self.assertEqual(get_origin(type_parsing), Union) @@ -45,7 +45,7 @@ class TestAnyOfTypeParser(TestCase): "default": 42, } - type_parsing, type_validator = AnyOfTypeParser.from_properties( + type_parsing, type_validator = AnyOfTypeParser().from_properties( "placeholder", properties ) -- 2.49.1 From 509ee60b75cb8527682f2b3597a1704d84c615fb Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Sat, 19 Apr 2025 16:51:27 -0300 Subject: [PATCH 11/15] Fixes Import Order jambo.parser --- jambo/parser/__init__.py | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/jambo/parser/__init__.py b/jambo/parser/__init__.py index 603a23a..86d8f56 100644 --- a/jambo/parser/__init__.py +++ b/jambo/parser/__init__.py @@ -1,12 +1,25 @@ # Exports generic type parser -from ._type_parser import GenericTypeParser as GenericTypeParser -from .allof_type_parser import AllOfTypeParser as AllOfTypeParser -from .anyof_type_parser import AnyOfTypeParser as AnyOfTypeParser -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 ._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 .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", +] -- 2.49.1 From 863494ab9c369a31f2d8796fdff5f6a9c8c390f0 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Sat, 19 Apr 2025 16:57:56 -0300 Subject: [PATCH 12/15] Finalizes AnyOfTypeParser Tests --- jambo/schema_converter.py | 2 +- tests/parser/test_anyof_type_parser.py | 35 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/jambo/schema_converter.py b/jambo/schema_converter.py index 39a5848..1f9834f 100644 --- a/jambo/schema_converter.py +++ b/jambo/schema_converter.py @@ -77,7 +77,7 @@ class SchemaConverter: return fields @staticmethod - def _build_field(name, properties: dict, required=False) -> tuple[type, dict]: + def _build_field(name, properties: dict, required=False) -> tuple[type, Field]: match properties: case {"anyOf": _}: _field_type = "anyOf" diff --git a/tests/parser/test_anyof_type_parser.py b/tests/parser/test_anyof_type_parser.py index d16a3c1..b0f5220 100644 --- a/tests/parser/test_anyof_type_parser.py +++ b/tests/parser/test_anyof_type_parser.py @@ -7,6 +7,25 @@ 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. @@ -61,3 +80,19 @@ class TestAnyOfTypeParser(TestCase): 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) -- 2.49.1 From b409ce49a566d188590fcf52887db7d905deb792 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Sat, 19 Apr 2025 17:23:38 -0300 Subject: [PATCH 13/15] Fixes Validation of JsonSchema --- jambo/schema_converter.py | 5 +- tests/parser/test_type_parser.py | 31 ++++++++++++ tests/test_schema_converter.py | 85 ++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 tests/parser/test_type_parser.py diff --git a/jambo/schema_converter.py b/jambo/schema_converter.py index 1f9834f..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}") 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 4cfd9b5..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", @@ -312,3 +365,35 @@ class TestSchemaConverter(TestCase): 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) -- 2.49.1 From 1c546d252f1a95103689ca1f3b987b0085a1c0dc Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Sat, 19 Apr 2025 17:26:33 -0300 Subject: [PATCH 14/15] Omits Test Dir in Test Coverage --- pyproject.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2b83921..0bf5bc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,14 @@ requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" +# Tests +[tool.coverage.run] +omit = [ + "tests/*", +] + + + # Linters [tool.ruff.lint] extend-select = ["I"] -- 2.49.1 From f9f986e3c8cd56bb5853c62d811f5e01fdf92b15 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Sat, 19 Apr 2025 17:30:11 -0300 Subject: [PATCH 15/15] Fixes Minor Element in AnyOf Test --- tests/parser/test_anyof_type_parser.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/parser/test_anyof_type_parser.py b/tests/parser/test_anyof_type_parser.py index b0f5220..023fcb9 100644 --- a/tests/parser/test_anyof_type_parser.py +++ b/tests/parser/test_anyof_type_parser.py @@ -38,7 +38,9 @@ class TestAnyOfTypeParser(TestCase): ], } - type_parsing, _ = AnyOfTypeParser().from_properties("placeholder", properties) + type_parsing, _ = AnyOfTypeParser().from_properties( + "placeholder", properties, required=True + ) # check union type has string and int self.assertEqual(get_origin(type_parsing), Union) -- 2.49.1