From 10bad254d7359fa711ce8f15ff2ca6e77e6e07d2 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Mon, 24 Nov 2025 01:14:40 -0300 Subject: [PATCH 01/12] feat: initial implementation of instance level ref cache --- jambo/parser/ref_type_parser.py | 3 +- jambo/schema_converter.py | 65 ++++++++++++++++++++++++++---- jambo/types/__init__.py | 3 +- jambo/types/type_parser_options.py | 7 +++- 4 files changed, 67 insertions(+), 11 deletions(-) diff --git a/jambo/parser/ref_type_parser.py b/jambo/parser/ref_type_parser.py index 123260f..dbf2cb2 100644 --- a/jambo/parser/ref_type_parser.py +++ b/jambo/parser/ref_type_parser.py @@ -1,5 +1,6 @@ from jambo.exceptions import InternalAssertionException, InvalidSchemaException from jambo.parser import GenericTypeParser +from jambo.types import RefCacheDict from jambo.types.json_schema_type import JSONSchema from jambo.types.type_parser_options import TypeParserOptions @@ -72,7 +73,7 @@ class RefTypeParser(GenericTypeParser): return mapped_type def _get_ref_from_cache( - self, ref_name: str, ref_cache: dict[str, ForwardRef | type | None] + self, ref_name: str, ref_cache: RefCacheDict ) -> RefType | type | None: try: ref_state = ref_cache[ref_name] diff --git a/jambo/schema_converter.py b/jambo/schema_converter.py index 3d38dc7..7550b34 100644 --- a/jambo/schema_converter.py +++ b/jambo/schema_converter.py @@ -1,10 +1,11 @@ from jambo.exceptions import InvalidSchemaException, UnsupportedSchemaException from jambo.parser import ObjectTypeParser, RefTypeParser -from jambo.types import JSONSchema +from jambo.types import JSONSchema, RefCacheDict from jsonschema.exceptions import SchemaError from jsonschema.validators import validator_for from pydantic import BaseModel +from typing_extensions import Optional class SchemaConverter: @@ -16,13 +17,44 @@ class SchemaConverter: fields and types. The generated model can be used for data validation and serialization. """ - @staticmethod - def build(schema: JSONSchema) -> type[BaseModel]: + def __init__(self, ref_cache: Optional[RefCacheDict] = None) -> None: + if ref_cache is None: + ref_cache = dict() + self._ref_cache = ref_cache + + def build_with_instance( + self, + schema: JSONSchema, + ref_cache: Optional[RefCacheDict] = None, + with_clean_cache: bool = True, + ) -> type[BaseModel]: """ Converts a JSON Schema to a Pydantic model. - :param schema: The JSON Schema to convert. - :return: A Pydantic model class. + :param schema: The JSON Schema to convert. + :param ref_cache: An optional reference cache to use during conversion, if provided `with_clean_cache` will be ignored. + :param with_clean_cache: Whether to use a clean reference cache for this conversion. Set to True due to API compatibility. Will be set to False in future versions. + :return: The generated Pydantic model. """ + if ref_cache is None: + ref_cache = self._ref_cache + + return self.build(schema, ref_cache, with_clean_cache) + + @staticmethod + def build( + schema: JSONSchema, + ref_cache: Optional[RefCacheDict] = None, + with_clean_cache: bool = True, + ) -> type[BaseModel]: + """ + Converts a JSON Schema to a Pydantic model. + :param schema: The JSON Schema to convert. + :param ref_cache: An optional reference cache to use during conversion, if provided `with_clean_cache` will be ignored. + :param with_clean_cache: Whether to use a clean reference cache for this conversion. Set to rue due to API compatibility. Will be set to False in future versions. + :return: The generated Pydantic model. + """ + if ref_cache is None or with_clean_cache: + ref_cache = dict() try: validator = validator_for(schema) @@ -46,7 +78,7 @@ class SchemaConverter: schema.get("properties", {}), schema.get("required", []), context=schema, - ref_cache=dict(), + ref_cache=ref_cache, required=True, ) @@ -55,7 +87,7 @@ class SchemaConverter: schema["title"], schema, context=schema, - ref_cache=dict(), + ref_cache=ref_cache, required=True, ) return parsed_model @@ -68,6 +100,25 @@ class SchemaConverter: unsupported_field=unsupported_type, ) + def clear_ref_cache(self) -> None: + """ + Clears the reference cache. + """ + self._ref_cache.clear() + + def get_cached_ref(self, ref_name: str): + """ + Gets a cached reference from the reference cache. + :param ref_name: The name of the reference to get. + :return: The cached reference, or None if not found. + """ + cached_type = self._ref_cache.get(ref_name) + + if isinstance(cached_type, type): + return cached_type + + return None + @staticmethod def _get_schema_type(schema: JSONSchema) -> str | None: """ diff --git a/jambo/types/__init__.py b/jambo/types/__init__.py index d5c88a2..35c5839 100644 --- a/jambo/types/__init__.py +++ b/jambo/types/__init__.py @@ -4,7 +4,7 @@ from .json_schema_type import ( JSONSchemaType, JSONType, ) -from .type_parser_options import TypeParserOptions +from .type_parser_options import RefCacheDict, TypeParserOptions __all__ = [ @@ -12,5 +12,6 @@ __all__ = [ "JSONSchemaNativeTypes", "JSONType", "JSONSchema", + "RefCacheDict", "TypeParserOptions", ] diff --git a/jambo/types/type_parser_options.py b/jambo/types/type_parser_options.py index baf518b..4b41f21 100644 --- a/jambo/types/type_parser_options.py +++ b/jambo/types/type_parser_options.py @@ -1,9 +1,12 @@ from jambo.types.json_schema_type import JSONSchema -from typing_extensions import ForwardRef, TypedDict +from typing_extensions import ForwardRef, MutableMapping, TypedDict + + +RefCacheDict = MutableMapping[str, ForwardRef | type | None] class TypeParserOptions(TypedDict): required: bool context: JSONSchema - ref_cache: dict[str, ForwardRef | type | None] + ref_cache: RefCacheDict From abc8bc2e40b0ec1bff8c4416589f062220e81372 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Mon, 24 Nov 2025 01:29:21 -0300 Subject: [PATCH 02/12] feat: saves object after parsing --- jambo/parser/object_type_parser.py | 16 ++++++++++++++ tests/parser/test_allof_type_parser.py | 28 ++++++++++++++----------- tests/parser/test_object_type_parser.py | 12 ++++++++--- 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/jambo/parser/object_type_parser.py b/jambo/parser/object_type_parser.py index 57bc177..d3c27ca 100644 --- a/jambo/parser/object_type_parser.py +++ b/jambo/parser/object_type_parser.py @@ -1,3 +1,4 @@ +from jambo.exceptions import InternalAssertionException from jambo.parser._type_parser import GenericTypeParser from jambo.types.json_schema_type import JSONSchema from jambo.types.type_parser_options import TypeParserOptions @@ -6,6 +7,8 @@ from pydantic import BaseModel, ConfigDict, Field, create_model from pydantic.fields import FieldInfo from typing_extensions import Unpack +import warnings + class ObjectTypeParser(GenericTypeParser): mapped_type = object @@ -15,6 +18,12 @@ class ObjectTypeParser(GenericTypeParser): def from_properties_impl( self, name: str, properties: JSONSchema, **kwargs: Unpack[TypeParserOptions] ) -> tuple[type[BaseModel], dict]: + ref_cache = kwargs.get("ref_cache") + if ref_cache is None: + raise InternalAssertionException( + "`ref_cache` must be provided in kwargs for RefTypeParser" + ) + type_parsing = self.to_model( name, properties.get("properties", {}), @@ -37,6 +46,13 @@ class ObjectTypeParser(GenericTypeParser): type_parsing.model_validate(example) for example in example_values ] + if name in ref_cache: + warnings.warn( + f"Type '{name}' is already in the ref_cache and will be overwritten.", + UserWarning, + ) + ref_cache[name] = type_parsing + return type_parsing, type_properties @classmethod diff --git a/tests/parser/test_allof_type_parser.py b/tests/parser/test_allof_type_parser.py index a957161..29cbf62 100644 --- a/tests/parser/test_allof_type_parser.py +++ b/tests/parser/test_allof_type_parser.py @@ -42,7 +42,7 @@ class TestAllOfTypeParser(TestCase): } type_parsing, type_validator = AllOfTypeParser().from_properties( - "placeholder", properties + "placeholder", properties, ref_cache={} ) with self.assertRaises(ValidationError): @@ -87,7 +87,7 @@ class TestAllOfTypeParser(TestCase): } type_parsing, type_validator = AllOfTypeParser().from_properties( - "placeholder", properties + "placeholder", properties, ref_cache={} ) with self.assertRaises(ValidationError): @@ -116,7 +116,7 @@ class TestAllOfTypeParser(TestCase): } type_parsing, type_validator = AllOfTypeParser().from_properties( - "placeholder", properties + "placeholder", properties, ref_cache={} ) self.assertEqual(type_parsing, str) @@ -137,7 +137,7 @@ class TestAllOfTypeParser(TestCase): } type_parsing, type_validator = AllOfTypeParser().from_properties( - "placeholder", properties + "placeholder", properties, ref_cache={} ) self.assertEqual(type_parsing, str) @@ -158,7 +158,7 @@ class TestAllOfTypeParser(TestCase): } with self.assertRaises(InvalidSchemaException): - AllOfTypeParser().from_properties("placeholder", properties) + AllOfTypeParser().from_properties("placeholder", properties, ref_cache={}) def test_all_of_invalid_type_not_present(self): properties = { @@ -171,7 +171,7 @@ class TestAllOfTypeParser(TestCase): } with self.assertRaises(InvalidSchemaException): - AllOfTypeParser().from_properties("placeholder", properties) + AllOfTypeParser().from_properties("placeholder", properties, ref_cache={}) def test_all_of_invalid_type_in_fields(self): properties = { @@ -184,7 +184,7 @@ class TestAllOfTypeParser(TestCase): } with self.assertRaises(InvalidSchemaException): - AllOfTypeParser().from_properties("placeholder", properties) + AllOfTypeParser().from_properties("placeholder", properties, ref_cache={}) def test_all_of_invalid_type_not_all_equal(self): """ @@ -200,7 +200,7 @@ class TestAllOfTypeParser(TestCase): } with self.assertRaises(InvalidSchemaException): - AllOfTypeParser().from_properties("placeholder", properties) + AllOfTypeParser().from_properties("placeholder", properties, ref_cache={}) def test_all_of_description_field(self): """ @@ -237,7 +237,9 @@ class TestAllOfTypeParser(TestCase): ], } - type_parsing, _ = AllOfTypeParser().from_properties("placeholder", properties) + type_parsing, _ = AllOfTypeParser().from_properties( + "placeholder", properties, ref_cache={} + ) self.assertEqual( type_parsing.model_json_schema()["properties"]["name"]["description"], @@ -275,7 +277,9 @@ class TestAllOfTypeParser(TestCase): ], } - type_parsing, _ = AllOfTypeParser().from_properties("placeholder", properties) + type_parsing, _ = AllOfTypeParser().from_properties( + "placeholder", properties, ref_cache={} + ) obj = type_parsing() self.assertEqual(obj.name, "John") self.assertEqual(obj.age, 30) @@ -308,7 +312,7 @@ class TestAllOfTypeParser(TestCase): } with self.assertRaises(InvalidSchemaException): - AllOfTypeParser().from_properties("placeholder", properties) + AllOfTypeParser().from_properties("placeholder", properties, ref_cache={}) def test_all_of_with_root_examples(self): """ @@ -344,7 +348,7 @@ class TestAllOfTypeParser(TestCase): } type_parsed, type_properties = AllOfTypeParser().from_properties( - "placeholder", properties + "placeholder", properties, ref_cache={} ) self.assertEqual( diff --git a/tests/parser/test_object_type_parser.py b/tests/parser/test_object_type_parser.py index 99ddcd6..025149d 100644 --- a/tests/parser/test_object_type_parser.py +++ b/tests/parser/test_object_type_parser.py @@ -15,7 +15,9 @@ class TestObjectTypeParser(TestCase): }, } - Model, _args = parser.from_properties_impl("placeholder", properties) + Model, _args = parser.from_properties_impl( + "placeholder", properties, ref_cache={} + ) obj = Model(name="name", age=10) @@ -39,7 +41,9 @@ class TestObjectTypeParser(TestCase): ], } - _, type_validator = parser.from_properties_impl("placeholder", properties) + _, type_validator = parser.from_properties_impl( + "placeholder", properties, ref_cache={} + ) test_example = type_validator["examples"][0] @@ -61,7 +65,9 @@ class TestObjectTypeParser(TestCase): }, } - _, type_validator = parser.from_properties_impl("placeholder", properties) + _, type_validator = parser.from_properties_impl( + "placeholder", properties, ref_cache={} + ) # Check default value default_obj = type_validator["default_factory"]() From 4de711075e04385dae56f5533f4f8f35638f0385 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Mon, 24 Nov 2025 01:34:41 -0300 Subject: [PATCH 03/12] feat: removes unecessary api keyword --- jambo/schema_converter.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/jambo/schema_converter.py b/jambo/schema_converter.py index 7550b34..6358786 100644 --- a/jambo/schema_converter.py +++ b/jambo/schema_converter.py @@ -26,25 +26,18 @@ class SchemaConverter: self, schema: JSONSchema, ref_cache: Optional[RefCacheDict] = None, - with_clean_cache: bool = True, ) -> type[BaseModel]: """ Converts a JSON Schema to a Pydantic model. :param schema: The JSON Schema to convert. - :param ref_cache: An optional reference cache to use during conversion, if provided `with_clean_cache` will be ignored. - :param with_clean_cache: Whether to use a clean reference cache for this conversion. Set to True due to API compatibility. Will be set to False in future versions. + :param ref_cache: An optional reference cache to use during conversion. :return: The generated Pydantic model. """ - if ref_cache is None: - ref_cache = self._ref_cache - - return self.build(schema, ref_cache, with_clean_cache) + return self.build(schema, ref_cache or self._ref_cache) @staticmethod def build( - schema: JSONSchema, - ref_cache: Optional[RefCacheDict] = None, - with_clean_cache: bool = True, + schema: JSONSchema, ref_cache: Optional[RefCacheDict] = None ) -> type[BaseModel]: """ Converts a JSON Schema to a Pydantic model. @@ -53,7 +46,7 @@ class SchemaConverter: :param with_clean_cache: Whether to use a clean reference cache for this conversion. Set to rue due to API compatibility. Will be set to False in future versions. :return: The generated Pydantic model. """ - if ref_cache is None or with_clean_cache: + if ref_cache is None: ref_cache = dict() try: From 328eb66034ec37f9314cd3b3028f3f32bdc36971 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Mon, 24 Nov 2025 01:48:48 -0300 Subject: [PATCH 04/12] fix: fixes save object after parsing --- jambo/parser/object_type_parser.py | 5 +++-- jambo/schema_converter.py | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/jambo/parser/object_type_parser.py b/jambo/parser/object_type_parser.py index d3c27ca..92b6a9f 100644 --- a/jambo/parser/object_type_parser.py +++ b/jambo/parser/object_type_parser.py @@ -46,9 +46,10 @@ class ObjectTypeParser(GenericTypeParser): type_parsing.model_validate(example) for example in example_values ] - if name in ref_cache: + if name in ref_cache and isinstance(ref_cache[name], type): warnings.warn( - f"Type '{name}' is already in the ref_cache and will be overwritten.", + f"Type '{name}' is already in the ref_cache and will be overwritten." + " This may indicate a circular reference in the schema or a collision in the schema.", UserWarning, ) ref_cache[name] = type_parsing diff --git a/jambo/schema_converter.py b/jambo/schema_converter.py index 6358786..1ba52d6 100644 --- a/jambo/schema_converter.py +++ b/jambo/schema_converter.py @@ -26,14 +26,28 @@ class SchemaConverter: self, schema: JSONSchema, ref_cache: Optional[RefCacheDict] = None, + without_cache: bool = False, ) -> type[BaseModel]: """ Converts a JSON Schema to a Pydantic model. + This is the instance method version of `build` and uses the instance's reference cache if none is provided. + Use this method if you want to utilize the instance's reference cache. + :param schema: The JSON Schema to convert. :param ref_cache: An optional reference cache to use during conversion. + :param without_cache: Whether to use a clean reference cache for this conversion. :return: The generated Pydantic model. """ - return self.build(schema, ref_cache or self._ref_cache) + local_ref_cache: RefCacheDict + + if without_cache: + local_ref_cache = dict() + elif ref_cache is None: + local_ref_cache = self._ref_cache + else: + local_ref_cache = ref_cache + + return self.build(schema, local_ref_cache) @staticmethod def build( @@ -43,7 +57,6 @@ class SchemaConverter: Converts a JSON Schema to a Pydantic model. :param schema: The JSON Schema to convert. :param ref_cache: An optional reference cache to use during conversion, if provided `with_clean_cache` will be ignored. - :param with_clean_cache: Whether to use a clean reference cache for this conversion. Set to rue due to API compatibility. Will be set to False in future versions. :return: The generated Pydantic model. """ if ref_cache is None: From c2b9e8daf821f3530d89ea24196b1a411abcd1bd Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Mon, 24 Nov 2025 18:20:08 -0300 Subject: [PATCH 05/12] fix: fixes implementation of save object to cache and adds tests --- jambo/parser/object_type_parser.py | 35 +++++++++++++++++------------- tests/test_schema_converter.py | 25 +++++++++++++++++++++ 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/jambo/parser/object_type_parser.py b/jambo/parser/object_type_parser.py index 92b6a9f..fc10cb5 100644 --- a/jambo/parser/object_type_parser.py +++ b/jambo/parser/object_type_parser.py @@ -18,12 +18,6 @@ class ObjectTypeParser(GenericTypeParser): def from_properties_impl( self, name: str, properties: JSONSchema, **kwargs: Unpack[TypeParserOptions] ) -> tuple[type[BaseModel], dict]: - ref_cache = kwargs.get("ref_cache") - if ref_cache is None: - raise InternalAssertionException( - "`ref_cache` must be provided in kwargs for RefTypeParser" - ) - type_parsing = self.to_model( name, properties.get("properties", {}), @@ -46,14 +40,6 @@ class ObjectTypeParser(GenericTypeParser): type_parsing.model_validate(example) for example in example_values ] - if name in ref_cache and isinstance(ref_cache[name], type): - warnings.warn( - f"Type '{name}' is already in the ref_cache and will be overwritten." - " This may indicate a circular reference in the schema or a collision in the schema.", - UserWarning, - ) - ref_cache[name] = type_parsing - return type_parsing, type_properties @classmethod @@ -71,10 +57,29 @@ class ObjectTypeParser(GenericTypeParser): :param required_keys: List of required keys in the schema. :return: A Pydantic model class. """ + ref_cache = kwargs.get("ref_cache") + if ref_cache is None: + raise InternalAssertionException( + "`ref_cache` must be provided in kwargs for ObjectTypeParser" + ) + + if (model := ref_cache.get(name)) is not None and isinstance(model, type): + return model + model_config = ConfigDict(validate_assignment=True) fields = cls._parse_properties(properties, required_keys, **kwargs) - return create_model(name, __config__=model_config, **fields) # type: ignore + model = create_model(name, __config__=model_config, **fields) # type: ignore + + if name in ref_cache and isinstance(ref_cache[name], type): + warnings.warn( + f"Type '{name}' is already in the ref_cache and will be overwritten." + " This may indicate a circular reference in the schema or a collision in the schema.", + UserWarning, + ) + ref_cache[name] = model + + return model @classmethod def _parse_properties( diff --git a/tests/test_schema_converter.py b/tests/test_schema_converter.py index cbc6f72..887a548 100644 --- a/tests/test_schema_converter.py +++ b/tests/test_schema_converter.py @@ -866,3 +866,28 @@ class TestSchemaConverter(TestCase): ) self.assertFalse(object_.model_fields["description"].is_required()) # FAIL + + def test_instance_level_ref_cache(self): + ref_cache = {} + + schema = { + "title": "Person", + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + "emergency_contact": { + "$ref": "#", + }, + }, + "required": ["name", "age"], + } + + converter1 = SchemaConverter(ref_cache) + model1 = converter1.build_with_instance(schema) + + converter2 = SchemaConverter(ref_cache) + model2 = converter2.build_with_instance(schema) + + self.assertIs(converter1._ref_cache, converter2._ref_cache) + self.assertIs(model1, model2) From 5ec30cd565adebc1bd30e35a7c3158088d65241d Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Mon, 24 Nov 2025 19:32:42 -0300 Subject: [PATCH 06/12] feat: changes tests to use instance level build --- tests/test_schema_converter.py | 105 +++++++++++++++------------------ 1 file changed, 47 insertions(+), 58 deletions(-) diff --git a/tests/test_schema_converter.py b/tests/test_schema_converter.py index 887a548..1e86f52 100644 --- a/tests/test_schema_converter.py +++ b/tests/test_schema_converter.py @@ -15,6 +15,13 @@ def is_pydantic_model(cls): class TestSchemaConverter(TestCase): + def setUp(self): + self.ref_cache = {} + self.converter = SchemaConverter(ref_cache=self.ref_cache) + + def tearDown(self): + self.converter.clear_ref_cache() + def test_invalid_schema(self): schema = { "title": 1, @@ -27,7 +34,7 @@ class TestSchemaConverter(TestCase): } with self.assertRaises(InvalidSchemaException): - SchemaConverter.build(schema) + self.converter.build_with_instance(schema) def test_invalid_schema_type(self): schema = { @@ -41,7 +48,7 @@ class TestSchemaConverter(TestCase): } with self.assertRaises(InvalidSchemaException): - SchemaConverter.build(schema) + self.converter.build_with_instance(schema) def test_build_expects_title(self): schema = { @@ -54,7 +61,7 @@ class TestSchemaConverter(TestCase): } with self.assertRaises(InvalidSchemaException): - SchemaConverter.build(schema) + self.converter.build_with_instance(schema) def test_build_expects_object(self): schema = { @@ -64,7 +71,7 @@ class TestSchemaConverter(TestCase): } with self.assertRaises(UnsupportedSchemaException): - SchemaConverter.build(schema) + self.converter.build_with_instance(schema) def test_is_invalid_field(self): schema = { @@ -80,7 +87,7 @@ class TestSchemaConverter(TestCase): } with self.assertRaises(InvalidSchemaException) as context: - SchemaConverter.build(schema) + self.converter.build_with_instance(schema) self.assertTrue("Unknown type" in str(context.exception)) def test_jsonschema_to_pydantic(self): @@ -95,7 +102,7 @@ class TestSchemaConverter(TestCase): "required": ["name"], } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) self.assertTrue(is_pydantic_model(model)) @@ -116,7 +123,7 @@ class TestSchemaConverter(TestCase): "required": ["name"], } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) self.assertEqual(model(name="John", age=30).name, "John") @@ -147,7 +154,7 @@ class TestSchemaConverter(TestCase): "required": ["age"], } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) self.assertEqual(model(age=30).age, 30) @@ -172,7 +179,7 @@ class TestSchemaConverter(TestCase): "required": ["age"], } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) self.assertEqual(model(age=30).age, 30.0) @@ -193,7 +200,7 @@ class TestSchemaConverter(TestCase): "required": ["is_active"], } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) self.assertEqual(model(is_active=True).is_active, True) @@ -216,7 +223,7 @@ class TestSchemaConverter(TestCase): "required": ["friends"], } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) self.assertEqual( model(friends=["John", "Jane", "John"]).friends, {"John", "Jane"} @@ -229,26 +236,7 @@ class TestSchemaConverter(TestCase): model(friends=["John", "Jane", "Invalid"]) def test_validation_list_with_missing_items(self): - model = SchemaConverter.build( - { - "title": "Person", - "description": "A person", - "type": "object", - "properties": { - "friends": { - "type": "array", - "items": {"type": "string"}, - "minItems": 1, - "maxItems": 2, - "default": ["John", "Jane"], - }, - }, - } - ) - - self.assertEqual(model().friends, ["John", "Jane"]) - - model = SchemaConverter.build( + model = self.converter.build_with_instance( { "title": "Person", "description": "A person", @@ -286,7 +274,7 @@ class TestSchemaConverter(TestCase): "required": ["address"], } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) obj = model(address={"street": "123 Main St", "city": "Springfield"}) @@ -310,7 +298,7 @@ class TestSchemaConverter(TestCase): "required": ["name"], } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) obj = model(name="John") @@ -333,7 +321,7 @@ class TestSchemaConverter(TestCase): } with self.assertRaises(InvalidSchemaException): - SchemaConverter.build(schema_max_length) + self.converter.build_with_instance(schema_max_length) def test_default_for_list(self): schema_list = { @@ -350,10 +338,11 @@ class TestSchemaConverter(TestCase): "required": ["friends"], } - model_list = SchemaConverter.build(schema_list) + model_list = self.converter.build_with_instance(schema_list) self.assertEqual(model_list().friends, ["John", "Jane"]) + def test_default_for_list_with_unique_items(self): # Test for default with uniqueItems schema_set = { "title": "Person", @@ -370,7 +359,7 @@ class TestSchemaConverter(TestCase): "required": ["friends"], } - model_set = SchemaConverter.build(schema_set) + model_set = self.converter.build_with_instance(schema_set) self.assertEqual(model_set().friends, {"John", "Jane"}) @@ -392,7 +381,7 @@ class TestSchemaConverter(TestCase): "required": ["address"], } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) obj = model(address={"street": "123 Main St", "city": "Springfield"}) @@ -416,7 +405,7 @@ class TestSchemaConverter(TestCase): }, } - Model = SchemaConverter.build(schema) + Model = self.converter.build_with_instance(schema) obj = Model( name="J", @@ -445,7 +434,7 @@ class TestSchemaConverter(TestCase): }, } - Model = SchemaConverter.build(schema) + Model = self.converter.build_with_instance(schema) obj = Model(id=1) self.assertEqual(obj.id, 1) @@ -469,7 +458,7 @@ class TestSchemaConverter(TestCase): "properties": {"email": {"type": "string", "format": "email"}}, } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) self.assertEqual(model(email="test@example.com").email, "test@example.com") with self.assertRaises(ValidationError): @@ -482,7 +471,7 @@ class TestSchemaConverter(TestCase): "properties": {"website": {"type": "string", "format": "uri"}}, } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) self.assertEqual( model(website="https://example.com").website, AnyUrl("https://example.com") ) @@ -497,7 +486,7 @@ class TestSchemaConverter(TestCase): "properties": {"ip": {"type": "string", "format": "ipv4"}}, } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) self.assertEqual(model(ip="192.168.1.1").ip, IPv4Address("192.168.1.1")) with self.assertRaises(ValidationError): @@ -510,7 +499,7 @@ class TestSchemaConverter(TestCase): "properties": {"ip": {"type": "string", "format": "ipv6"}}, } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) self.assertEqual( model(ip="2001:0db8:85a3:0000:0000:8a2e:0370:7334").ip, IPv6Address("2001:0db8:85a3:0000:0000:8a2e:0370:7334"), @@ -526,7 +515,7 @@ class TestSchemaConverter(TestCase): "properties": {"id": {"type": "string", "format": "uuid"}}, } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) self.assertEqual( model(id="123e4567-e89b-12d3-a456-426614174000").id, @@ -543,7 +532,7 @@ class TestSchemaConverter(TestCase): "properties": {"hostname": {"type": "string", "format": "hostname"}}, } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) self.assertEqual(model(hostname="example.com").hostname, "example.com") with self.assertRaises(ValidationError): @@ -556,7 +545,7 @@ class TestSchemaConverter(TestCase): "properties": {"timestamp": {"type": "string", "format": "date-time"}}, } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) self.assertEqual( model(timestamp="2024-01-01T12:00:00Z").timestamp.isoformat(), "2024-01-01T12:00:00+00:00", @@ -572,7 +561,7 @@ class TestSchemaConverter(TestCase): "properties": {"time": {"type": "string", "format": "time"}}, } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) self.assertEqual( model(time="20:20:39+00:00").time.isoformat(), "20:20:39+00:00" ) @@ -588,7 +577,7 @@ class TestSchemaConverter(TestCase): } with self.assertRaises(InvalidSchemaException): - SchemaConverter.build(schema) + self.converter.build_with_instance(schema) def test_ref_with_root_ref(self): schema = { @@ -604,7 +593,7 @@ class TestSchemaConverter(TestCase): "required": ["name", "age"], } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) obj = model( name="John", @@ -639,7 +628,7 @@ class TestSchemaConverter(TestCase): }, } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) obj = model( name="John", @@ -678,7 +667,7 @@ class TestSchemaConverter(TestCase): }, } - Model = SchemaConverter.build(schema) + Model = self.converter.build_with_instance(schema) obj = Model( name="John", @@ -704,7 +693,7 @@ class TestSchemaConverter(TestCase): "required": ["status"], } - Model = SchemaConverter.build(schema) + Model = self.converter.build_with_instance(schema) obj = Model(status="active") self.assertEqual(obj.status.value, "active") @@ -723,7 +712,7 @@ class TestSchemaConverter(TestCase): "required": ["status"], } - Model = SchemaConverter.build(schema) + Model = self.converter.build_with_instance(schema) obj = Model() self.assertEqual(obj.status.value, "active") @@ -740,7 +729,7 @@ class TestSchemaConverter(TestCase): "required": ["name"], } - Model = SchemaConverter.build(schema) + Model = self.converter.build_with_instance(schema) obj = Model() self.assertEqual(obj.name, "United States of America") @@ -763,7 +752,7 @@ class TestSchemaConverter(TestCase): "required": ["name"], } - Model = SchemaConverter.build(schema) + Model = self.converter.build_with_instance(schema) obj = Model() self.assertEqual(obj.name, ["Brazil"]) @@ -783,7 +772,7 @@ class TestSchemaConverter(TestCase): }, } - Model = SchemaConverter.build(schema) + Model = self.converter.build_with_instance(schema) obj = Model() self.assertIsNone(obj.a_thing) @@ -825,7 +814,7 @@ class TestSchemaConverter(TestCase): }, } - schema_type = SchemaConverter.build(schema) + schema_type = self.converter.build_with_instance(schema) # check for me that the types generated by the oneOf in the typing.Annotated have different names operating_system_field = schema_type.model_fields["operating_system"] @@ -839,7 +828,7 @@ class TestSchemaConverter(TestCase): def test_object_invalid_require(self): # https://github.com/HideyoshiNakazone/jambo/issues/60 - object_ = SchemaConverter.build( + object_ = self.converter.build_with_instance( { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "TEST", From 57f8b571deccef4164ae8f812d085ee1f30ec910 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Mon, 24 Nov 2025 19:38:53 -0300 Subject: [PATCH 07/12] feat: adds tests for SchemaConverter.get_cached_ref --- tests/test_schema_converter.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_schema_converter.py b/tests/test_schema_converter.py index 1e86f52..a5cd948 100644 --- a/tests/test_schema_converter.py +++ b/tests/test_schema_converter.py @@ -880,3 +880,23 @@ class TestSchemaConverter(TestCase): self.assertIs(converter1._ref_cache, converter2._ref_cache) self.assertIs(model1, model2) + + def test_get_type_from_cache(self): + schema = { + "title": "Person", + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + "emergency_contact": { + "$ref": "#", + }, + }, + "required": ["name", "age"], + } + + model = self.converter.build_with_instance(schema) + + cached_model = self.converter.get_cached_ref("Person") + + self.assertIs(model, cached_model) From 3a8ca951db1d4f6850ebd7ee4c376a9a32ba75b6 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Mon, 24 Nov 2025 19:52:54 -0300 Subject: [PATCH 08/12] feat: adds tests for isolation method in ref_cache --- tests/test_schema_converter.py | 41 ++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/tests/test_schema_converter.py b/tests/test_schema_converter.py index a5cd948..ce799cb 100644 --- a/tests/test_schema_converter.py +++ b/tests/test_schema_converter.py @@ -16,8 +16,7 @@ def is_pydantic_model(cls): class TestSchemaConverter(TestCase): def setUp(self): - self.ref_cache = {} - self.converter = SchemaConverter(ref_cache=self.ref_cache) + self.converter = SchemaConverter() def tearDown(self): self.converter.clear_ref_cache() @@ -881,6 +880,44 @@ class TestSchemaConverter(TestCase): self.assertIs(converter1._ref_cache, converter2._ref_cache) self.assertIs(model1, model2) + def test_instance_level_ref_cache_isolation_via_without_cache_param(self): + schema = { + "title": "Person", + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + "emergency_contact": { + "$ref": "#", + }, + }, + "required": ["name", "age"], + } + + model1 = self.converter.build_with_instance(schema, without_cache=True) + model2 = self.converter.build_with_instance(schema, without_cache=True) + + self.assertIsNot(model1, model2) + + def test_instance_level_ref_cache_isolation_via_provided_cache(self): + schema = { + "title": "Person", + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + "emergency_contact": { + "$ref": "#", + }, + }, + "required": ["name", "age"], + } + + model1 = self.converter.build_with_instance(schema, ref_cache={}) + model2 = self.converter.build_with_instance(schema, ref_cache={}) + + self.assertIsNot(model1, model2) + def test_get_type_from_cache(self): schema = { "title": "Person", From 9837a99ec929171b54260130242b9daf7ead7ad4 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Mon, 24 Nov 2025 19:53:29 -0300 Subject: [PATCH 09/12] feat: adds tests for type not found in ref_cache --- tests/test_schema_converter.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_schema_converter.py b/tests/test_schema_converter.py index ce799cb..6cd66ef 100644 --- a/tests/test_schema_converter.py +++ b/tests/test_schema_converter.py @@ -937,3 +937,8 @@ class TestSchemaConverter(TestCase): cached_model = self.converter.get_cached_ref("Person") self.assertIs(model, cached_model) + + def test_get_type_from_cache_not_found(self): + cached_model = self.converter.get_cached_ref("NonExistentModel") + + self.assertIsNone(cached_model) From 4baaeed349916b902262538613d333a8a5d71920 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Mon, 24 Nov 2025 20:00:42 -0300 Subject: [PATCH 10/12] feat: adds test for ObjectTypeParser asserting for the presence of a ref_cache --- tests/parser/test_object_type_parser.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/parser/test_object_type_parser.py b/tests/parser/test_object_type_parser.py index 025149d..2021ca5 100644 --- a/tests/parser/test_object_type_parser.py +++ b/tests/parser/test_object_type_parser.py @@ -1,9 +1,24 @@ +from jambo.exceptions import InternalAssertionException from jambo.parser import ObjectTypeParser from unittest import TestCase class TestObjectTypeParser(TestCase): + def test_object_type_parser_throws_without_ref_cache(self): + parser = ObjectTypeParser() + + properties = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + }, + } + + with self.assertRaises(InternalAssertionException): + parser.from_properties_impl("placeholder", properties) + def test_object_type_parser(self): parser = ObjectTypeParser() From 682f19654d0ec79ac769979df5e1de6525faa8d7 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Mon, 24 Nov 2025 20:52:02 -0300 Subject: [PATCH 11/12] feat: better methodology for accessing cached references of: objects, subobjects and defs --- jambo/parser/anyof_type_parser.py | 2 +- jambo/parser/object_type_parser.py | 13 +++---- tests/test_schema_converter.py | 56 ++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/jambo/parser/anyof_type_parser.py b/jambo/parser/anyof_type_parser.py index 9b09754..c0295ff 100644 --- a/jambo/parser/anyof_type_parser.py +++ b/jambo/parser/anyof_type_parser.py @@ -31,7 +31,7 @@ class AnyOfTypeParser(GenericTypeParser): sub_types = [ GenericTypeParser.type_from_properties( - f"{name}_sub{i}", subProperty, **kwargs + f"{name}.sub{i}", subProperty, **kwargs ) for i, subProperty in enumerate(sub_properties) ] diff --git a/jambo/parser/object_type_parser.py b/jambo/parser/object_type_parser.py index fc10cb5..475c785 100644 --- a/jambo/parser/object_type_parser.py +++ b/jambo/parser/object_type_parser.py @@ -67,7 +67,7 @@ class ObjectTypeParser(GenericTypeParser): return model model_config = ConfigDict(validate_assignment=True) - fields = cls._parse_properties(properties, required_keys, **kwargs) + fields = cls._parse_properties(name, properties, required_keys, **kwargs) model = create_model(name, __config__=model_config, **fields) # type: ignore @@ -84,6 +84,7 @@ class ObjectTypeParser(GenericTypeParser): @classmethod def _parse_properties( cls, + name: str, properties: dict[str, JSONSchema], required_keys: list[str], **kwargs: Unpack[TypeParserOptions], @@ -91,15 +92,15 @@ class ObjectTypeParser(GenericTypeParser): required_keys = required_keys or [] fields = {} - for name, prop in properties.items(): + for field_name, field_prop in properties.items(): sub_property: TypeParserOptions = kwargs.copy() - sub_property["required"] = name in required_keys + sub_property["required"] = field_name in required_keys parsed_type, parsed_properties = GenericTypeParser.type_from_properties( - name, - prop, + f"{name}.{field_name}", + field_prop, **sub_property, # type: ignore ) - fields[name] = (parsed_type, Field(**parsed_properties)) + fields[field_name] = (parsed_type, Field(**parsed_properties)) return fields diff --git a/tests/test_schema_converter.py b/tests/test_schema_converter.py index 6cd66ef..245ca8e 100644 --- a/tests/test_schema_converter.py +++ b/tests/test_schema_converter.py @@ -942,3 +942,59 @@ class TestSchemaConverter(TestCase): cached_model = self.converter.get_cached_ref("NonExistentModel") self.assertIsNone(cached_model) + + def test_get_type_from_cache_nested_type(self): + schema = { + "title": "Person", + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + "address": { + "type": "object", + "properties": { + "street": {"type": "string"}, + "city": {"type": "string"}, + }, + "required": ["street", "city"], + }, + }, + "required": ["name", "age", "address"], + } + + model = self.converter.build_with_instance(schema) + + cached_model = self.converter.get_cached_ref("Person.address") + + self.assertIsNotNone(cached_model) + self.assertIs(model.model_fields["address"].annotation, cached_model) + + def test_get_type_from_cache_with_def(self): + schema = { + "title": "person", + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + "address": {"$ref": "#/$defs/address"}, + }, + "$defs": { + "address": { + "type": "object", + "properties": { + "street": {"type": "string"}, + "city": {"type": "string"}, + }, + "required": ["street", "city"], + } + }, + } + + person_model = self.converter.build_with_instance(schema) + cached_person_model = self.converter.get_cached_ref("person") + + self.assertIs(person_model, cached_person_model) + + cached_address_model = self.converter.get_cached_ref("address") + + self.assertIsNotNone(cached_address_model) From a3cbd5bc3d96fcf5cf12659f87c59470470922b1 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Mon, 24 Nov 2025 21:06:15 -0300 Subject: [PATCH 12/12] feat: better warning for cache colision --- jambo/parser/object_type_parser.py | 12 +++++------- tests/parser/test_object_type_parser.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/jambo/parser/object_type_parser.py b/jambo/parser/object_type_parser.py index 475c785..82ca587 100644 --- a/jambo/parser/object_type_parser.py +++ b/jambo/parser/object_type_parser.py @@ -64,19 +64,17 @@ class ObjectTypeParser(GenericTypeParser): ) if (model := ref_cache.get(name)) is not None and isinstance(model, type): + warnings.warn( + f"Type '{name}' is already in the ref_cache and therefore cached value will be used." + " This may indicate a namming collision in the schema or just a normal optimization," + " if this behavior is desired pass a clean ref_cache or use the param `without_cache`" + ) return model model_config = ConfigDict(validate_assignment=True) fields = cls._parse_properties(name, properties, required_keys, **kwargs) model = create_model(name, __config__=model_config, **fields) # type: ignore - - if name in ref_cache and isinstance(ref_cache[name], type): - warnings.warn( - f"Type '{name}' is already in the ref_cache and will be overwritten." - " This may indicate a circular reference in the schema or a collision in the schema.", - UserWarning, - ) ref_cache[name] = model return model diff --git a/tests/parser/test_object_type_parser.py b/tests/parser/test_object_type_parser.py index 2021ca5..975f85d 100644 --- a/tests/parser/test_object_type_parser.py +++ b/tests/parser/test_object_type_parser.py @@ -92,3 +92,18 @@ class TestObjectTypeParser(TestCase): # Chekc default factory new object id new_obj = type_validator["default_factory"]() self.assertNotEqual(id(default_obj), id(new_obj)) + + def test_object_type_parser_warns_if_object_override_in_cache(self): + ref_cache = {} + + parser = ObjectTypeParser() + + properties = {"type": "object", "properties": {}} + + with self.assertWarns(UserWarning): + _, type_validator = parser.from_properties_impl( + "placeholder", properties, ref_cache=ref_cache + ) + _, type_validator = parser.from_properties_impl( + "placeholder", properties, ref_cache=ref_cache + )