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 57bc177..82ca587 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 @@ -54,14 +57,32 @@ class ObjectTypeParser(GenericTypeParser): :param required_keys: List of required keys in the schema. :return: A Pydantic model class. """ - model_config = ConfigDict(validate_assignment=True) - fields = cls._parse_properties(properties, required_keys, **kwargs) + ref_cache = kwargs.get("ref_cache") + if ref_cache is None: + raise InternalAssertionException( + "`ref_cache` must be provided in kwargs for ObjectTypeParser" + ) - return create_model(name, __config__=model_config, **fields) # type: ignore + 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 + ref_cache[name] = model + + return model @classmethod def _parse_properties( cls, + name: str, properties: dict[str, JSONSchema], required_keys: list[str], **kwargs: Unpack[TypeParserOptions], @@ -69,15 +90,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/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..1ba52d6 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,50 @@ 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, + without_cache: bool = False, + ) -> type[BaseModel]: """ Converts a JSON Schema to a Pydantic model. - :param schema: The JSON Schema to convert. - :return: A Pydantic model class. + 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. """ + 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( + schema: JSONSchema, ref_cache: Optional[RefCacheDict] = None + ) -> 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. + :return: The generated Pydantic model. + """ + if ref_cache is None: + ref_cache = dict() try: validator = validator_for(schema) @@ -46,7 +84,7 @@ class SchemaConverter: schema.get("properties", {}), schema.get("required", []), context=schema, - ref_cache=dict(), + ref_cache=ref_cache, required=True, ) @@ -55,7 +93,7 @@ class SchemaConverter: schema["title"], schema, context=schema, - ref_cache=dict(), + ref_cache=ref_cache, required=True, ) return parsed_model @@ -68,6 +106,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 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..975f85d 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() @@ -15,7 +30,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 +56,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 +80,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"]() @@ -71,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 + ) diff --git a/tests/test_schema_converter.py b/tests/test_schema_converter.py index cbc6f72..245ca8e 100644 --- a/tests/test_schema_converter.py +++ b/tests/test_schema_converter.py @@ -15,6 +15,12 @@ def is_pydantic_model(cls): class TestSchemaConverter(TestCase): + def setUp(self): + self.converter = SchemaConverter() + + def tearDown(self): + self.converter.clear_ref_cache() + def test_invalid_schema(self): schema = { "title": 1, @@ -27,7 +33,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 +47,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 +60,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 +70,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 +86,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 +101,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 +122,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 +153,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 +178,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 +199,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 +222,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 +235,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 +273,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 +297,7 @@ class TestSchemaConverter(TestCase): "required": ["name"], } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) obj = model(name="John") @@ -333,7 +320,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 +337,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 +358,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 +380,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 +404,7 @@ class TestSchemaConverter(TestCase): }, } - Model = SchemaConverter.build(schema) + Model = self.converter.build_with_instance(schema) obj = Model( name="J", @@ -445,7 +433,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 +457,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 +470,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 +485,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 +498,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 +514,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 +531,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 +544,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 +560,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 +576,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 +592,7 @@ class TestSchemaConverter(TestCase): "required": ["name", "age"], } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) obj = model( name="John", @@ -639,7 +627,7 @@ class TestSchemaConverter(TestCase): }, } - model = SchemaConverter.build(schema) + model = self.converter.build_with_instance(schema) obj = model( name="John", @@ -678,7 +666,7 @@ class TestSchemaConverter(TestCase): }, } - Model = SchemaConverter.build(schema) + Model = self.converter.build_with_instance(schema) obj = Model( name="John", @@ -704,7 +692,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 +711,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 +728,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 +751,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 +771,7 @@ class TestSchemaConverter(TestCase): }, } - Model = SchemaConverter.build(schema) + Model = self.converter.build_with_instance(schema) obj = Model() self.assertIsNone(obj.a_thing) @@ -825,7 +813,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 +827,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", @@ -866,3 +854,147 @@ 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) + + 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", + "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) + + def test_get_type_from_cache_not_found(self): + 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)