feature: add instance level ref cache #63

Merged
HideyoshiNakazone merged 12 commits from feature/add-instance-level-ref-cache into main 2025-11-25 00:07:55 +00:00
9 changed files with 348 additions and 93 deletions

View File

@@ -31,7 +31,7 @@ class AnyOfTypeParser(GenericTypeParser):
sub_types = [ sub_types = [
GenericTypeParser.type_from_properties( GenericTypeParser.type_from_properties(
f"{name}_sub{i}", subProperty, **kwargs f"{name}.sub{i}", subProperty, **kwargs
) )
for i, subProperty in enumerate(sub_properties) for i, subProperty in enumerate(sub_properties)
] ]

View File

@@ -1,3 +1,4 @@
from jambo.exceptions import InternalAssertionException
from jambo.parser._type_parser import GenericTypeParser from jambo.parser._type_parser import GenericTypeParser
from jambo.types.json_schema_type import JSONSchema from jambo.types.json_schema_type import JSONSchema
from jambo.types.type_parser_options import TypeParserOptions 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 pydantic.fields import FieldInfo
from typing_extensions import Unpack from typing_extensions import Unpack
import warnings
class ObjectTypeParser(GenericTypeParser): class ObjectTypeParser(GenericTypeParser):
mapped_type = object mapped_type = object
@@ -54,14 +57,32 @@ class ObjectTypeParser(GenericTypeParser):
:param required_keys: List of required keys in the schema. :param required_keys: List of required keys in the schema.
:return: A Pydantic model class. :return: A Pydantic model class.
""" """
model_config = ConfigDict(validate_assignment=True) ref_cache = kwargs.get("ref_cache")
fields = cls._parse_properties(properties, required_keys, **kwargs) 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 @classmethod
def _parse_properties( def _parse_properties(
cls, cls,
name: str,
properties: dict[str, JSONSchema], properties: dict[str, JSONSchema],
required_keys: list[str], required_keys: list[str],
**kwargs: Unpack[TypeParserOptions], **kwargs: Unpack[TypeParserOptions],
@@ -69,15 +90,15 @@ class ObjectTypeParser(GenericTypeParser):
required_keys = required_keys or [] required_keys = required_keys or []
fields = {} fields = {}
for name, prop in properties.items(): for field_name, field_prop in properties.items():
sub_property: TypeParserOptions = kwargs.copy() 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( parsed_type, parsed_properties = GenericTypeParser.type_from_properties(
name, f"{name}.{field_name}",
prop, field_prop,
**sub_property, # type: ignore **sub_property, # type: ignore
) )
fields[name] = (parsed_type, Field(**parsed_properties)) fields[field_name] = (parsed_type, Field(**parsed_properties))
return fields return fields

View File

@@ -1,5 +1,6 @@
from jambo.exceptions import InternalAssertionException, InvalidSchemaException from jambo.exceptions import InternalAssertionException, InvalidSchemaException
from jambo.parser import GenericTypeParser from jambo.parser import GenericTypeParser
from jambo.types import RefCacheDict
from jambo.types.json_schema_type import JSONSchema from jambo.types.json_schema_type import JSONSchema
from jambo.types.type_parser_options import TypeParserOptions from jambo.types.type_parser_options import TypeParserOptions
@@ -72,7 +73,7 @@ class RefTypeParser(GenericTypeParser):
return mapped_type return mapped_type
def _get_ref_from_cache( 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: ) -> RefType | type | None:
try: try:
ref_state = ref_cache[ref_name] ref_state = ref_cache[ref_name]

View File

@@ -1,10 +1,11 @@
from jambo.exceptions import InvalidSchemaException, UnsupportedSchemaException from jambo.exceptions import InvalidSchemaException, UnsupportedSchemaException
from jambo.parser import ObjectTypeParser, RefTypeParser from jambo.parser import ObjectTypeParser, RefTypeParser
from jambo.types import JSONSchema from jambo.types import JSONSchema, RefCacheDict
from jsonschema.exceptions import SchemaError from jsonschema.exceptions import SchemaError
from jsonschema.validators import validator_for from jsonschema.validators import validator_for
from pydantic import BaseModel from pydantic import BaseModel
from typing_extensions import Optional
class SchemaConverter: class SchemaConverter:
@@ -16,13 +17,50 @@ class SchemaConverter:
fields and types. The generated model can be used for data validation and serialization. fields and types. The generated model can be used for data validation and serialization.
""" """
@staticmethod def __init__(self, ref_cache: Optional[RefCacheDict] = None) -> None:
def build(schema: JSONSchema) -> type[BaseModel]: 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. Converts a JSON Schema to a Pydantic model.
:param schema: The JSON Schema to convert. This is the instance method version of `build` and uses the instance's reference cache if none is provided.
:return: A Pydantic model class. 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: try:
validator = validator_for(schema) validator = validator_for(schema)
@@ -46,7 +84,7 @@ class SchemaConverter:
schema.get("properties", {}), schema.get("properties", {}),
schema.get("required", []), schema.get("required", []),
context=schema, context=schema,
ref_cache=dict(), ref_cache=ref_cache,
required=True, required=True,
) )
@@ -55,7 +93,7 @@ class SchemaConverter:
schema["title"], schema["title"],
schema, schema,
context=schema, context=schema,
ref_cache=dict(), ref_cache=ref_cache,
required=True, required=True,
) )
return parsed_model return parsed_model
@@ -68,6 +106,25 @@ class SchemaConverter:
unsupported_field=unsupported_type, 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 @staticmethod
def _get_schema_type(schema: JSONSchema) -> str | None: def _get_schema_type(schema: JSONSchema) -> str | None:
""" """

View File

@@ -4,7 +4,7 @@ from .json_schema_type import (
JSONSchemaType, JSONSchemaType,
JSONType, JSONType,
) )
from .type_parser_options import TypeParserOptions from .type_parser_options import RefCacheDict, TypeParserOptions
__all__ = [ __all__ = [
@@ -12,5 +12,6 @@ __all__ = [
"JSONSchemaNativeTypes", "JSONSchemaNativeTypes",
"JSONType", "JSONType",
"JSONSchema", "JSONSchema",
"RefCacheDict",
"TypeParserOptions", "TypeParserOptions",
] ]

View File

@@ -1,9 +1,12 @@
from jambo.types.json_schema_type import JSONSchema 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): class TypeParserOptions(TypedDict):
required: bool required: bool
context: JSONSchema context: JSONSchema
ref_cache: dict[str, ForwardRef | type | None] ref_cache: RefCacheDict

View File

@@ -42,7 +42,7 @@ class TestAllOfTypeParser(TestCase):
} }
type_parsing, type_validator = AllOfTypeParser().from_properties( type_parsing, type_validator = AllOfTypeParser().from_properties(
"placeholder", properties "placeholder", properties, ref_cache={}
) )
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
@@ -87,7 +87,7 @@ class TestAllOfTypeParser(TestCase):
} }
type_parsing, type_validator = AllOfTypeParser().from_properties( type_parsing, type_validator = AllOfTypeParser().from_properties(
"placeholder", properties "placeholder", properties, ref_cache={}
) )
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
@@ -116,7 +116,7 @@ class TestAllOfTypeParser(TestCase):
} }
type_parsing, type_validator = AllOfTypeParser().from_properties( type_parsing, type_validator = AllOfTypeParser().from_properties(
"placeholder", properties "placeholder", properties, ref_cache={}
) )
self.assertEqual(type_parsing, str) self.assertEqual(type_parsing, str)
@@ -137,7 +137,7 @@ class TestAllOfTypeParser(TestCase):
} }
type_parsing, type_validator = AllOfTypeParser().from_properties( type_parsing, type_validator = AllOfTypeParser().from_properties(
"placeholder", properties "placeholder", properties, ref_cache={}
) )
self.assertEqual(type_parsing, str) self.assertEqual(type_parsing, str)
@@ -158,7 +158,7 @@ class TestAllOfTypeParser(TestCase):
} }
with self.assertRaises(InvalidSchemaException): 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): def test_all_of_invalid_type_not_present(self):
properties = { properties = {
@@ -171,7 +171,7 @@ class TestAllOfTypeParser(TestCase):
} }
with self.assertRaises(InvalidSchemaException): 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): def test_all_of_invalid_type_in_fields(self):
properties = { properties = {
@@ -184,7 +184,7 @@ class TestAllOfTypeParser(TestCase):
} }
with self.assertRaises(InvalidSchemaException): 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): def test_all_of_invalid_type_not_all_equal(self):
""" """
@@ -200,7 +200,7 @@ class TestAllOfTypeParser(TestCase):
} }
with self.assertRaises(InvalidSchemaException): with self.assertRaises(InvalidSchemaException):
AllOfTypeParser().from_properties("placeholder", properties) AllOfTypeParser().from_properties("placeholder", properties, ref_cache={})
def test_all_of_description_field(self): 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( self.assertEqual(
type_parsing.model_json_schema()["properties"]["name"]["description"], 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() obj = type_parsing()
self.assertEqual(obj.name, "John") self.assertEqual(obj.name, "John")
self.assertEqual(obj.age, 30) self.assertEqual(obj.age, 30)
@@ -308,7 +312,7 @@ class TestAllOfTypeParser(TestCase):
} }
with self.assertRaises(InvalidSchemaException): with self.assertRaises(InvalidSchemaException):
AllOfTypeParser().from_properties("placeholder", properties) AllOfTypeParser().from_properties("placeholder", properties, ref_cache={})
def test_all_of_with_root_examples(self): def test_all_of_with_root_examples(self):
""" """
@@ -344,7 +348,7 @@ class TestAllOfTypeParser(TestCase):
} }
type_parsed, type_properties = AllOfTypeParser().from_properties( type_parsed, type_properties = AllOfTypeParser().from_properties(
"placeholder", properties "placeholder", properties, ref_cache={}
) )
self.assertEqual( self.assertEqual(

View File

@@ -1,9 +1,24 @@
from jambo.exceptions import InternalAssertionException
from jambo.parser import ObjectTypeParser from jambo.parser import ObjectTypeParser
from unittest import TestCase from unittest import TestCase
class TestObjectTypeParser(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): def test_object_type_parser(self):
parser = ObjectTypeParser() 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) 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] 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 # Check default value
default_obj = type_validator["default_factory"]() default_obj = type_validator["default_factory"]()
@@ -71,3 +92,18 @@ class TestObjectTypeParser(TestCase):
# Chekc default factory new object id # Chekc default factory new object id
new_obj = type_validator["default_factory"]() new_obj = type_validator["default_factory"]()
self.assertNotEqual(id(default_obj), id(new_obj)) 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
)

View File

@@ -15,6 +15,12 @@ def is_pydantic_model(cls):
class TestSchemaConverter(TestCase): class TestSchemaConverter(TestCase):
def setUp(self):
self.converter = SchemaConverter()
def tearDown(self):
self.converter.clear_ref_cache()
def test_invalid_schema(self): def test_invalid_schema(self):
schema = { schema = {
"title": 1, "title": 1,
@@ -27,7 +33,7 @@ class TestSchemaConverter(TestCase):
} }
with self.assertRaises(InvalidSchemaException): with self.assertRaises(InvalidSchemaException):
SchemaConverter.build(schema) self.converter.build_with_instance(schema)
def test_invalid_schema_type(self): def test_invalid_schema_type(self):
schema = { schema = {
@@ -41,7 +47,7 @@ class TestSchemaConverter(TestCase):
} }
with self.assertRaises(InvalidSchemaException): with self.assertRaises(InvalidSchemaException):
SchemaConverter.build(schema) self.converter.build_with_instance(schema)
def test_build_expects_title(self): def test_build_expects_title(self):
schema = { schema = {
@@ -54,7 +60,7 @@ class TestSchemaConverter(TestCase):
} }
with self.assertRaises(InvalidSchemaException): with self.assertRaises(InvalidSchemaException):
SchemaConverter.build(schema) self.converter.build_with_instance(schema)
def test_build_expects_object(self): def test_build_expects_object(self):
schema = { schema = {
@@ -64,7 +70,7 @@ class TestSchemaConverter(TestCase):
} }
with self.assertRaises(UnsupportedSchemaException): with self.assertRaises(UnsupportedSchemaException):
SchemaConverter.build(schema) self.converter.build_with_instance(schema)
def test_is_invalid_field(self): def test_is_invalid_field(self):
schema = { schema = {
@@ -80,7 +86,7 @@ class TestSchemaConverter(TestCase):
} }
with self.assertRaises(InvalidSchemaException) as context: with self.assertRaises(InvalidSchemaException) as context:
SchemaConverter.build(schema) self.converter.build_with_instance(schema)
self.assertTrue("Unknown type" in str(context.exception)) self.assertTrue("Unknown type" in str(context.exception))
def test_jsonschema_to_pydantic(self): def test_jsonschema_to_pydantic(self):
@@ -95,7 +101,7 @@ class TestSchemaConverter(TestCase):
"required": ["name"], "required": ["name"],
} }
model = SchemaConverter.build(schema) model = self.converter.build_with_instance(schema)
self.assertTrue(is_pydantic_model(model)) self.assertTrue(is_pydantic_model(model))
@@ -116,7 +122,7 @@ class TestSchemaConverter(TestCase):
"required": ["name"], "required": ["name"],
} }
model = SchemaConverter.build(schema) model = self.converter.build_with_instance(schema)
self.assertEqual(model(name="John", age=30).name, "John") self.assertEqual(model(name="John", age=30).name, "John")
@@ -147,7 +153,7 @@ class TestSchemaConverter(TestCase):
"required": ["age"], "required": ["age"],
} }
model = SchemaConverter.build(schema) model = self.converter.build_with_instance(schema)
self.assertEqual(model(age=30).age, 30) self.assertEqual(model(age=30).age, 30)
@@ -172,7 +178,7 @@ class TestSchemaConverter(TestCase):
"required": ["age"], "required": ["age"],
} }
model = SchemaConverter.build(schema) model = self.converter.build_with_instance(schema)
self.assertEqual(model(age=30).age, 30.0) self.assertEqual(model(age=30).age, 30.0)
@@ -193,7 +199,7 @@ class TestSchemaConverter(TestCase):
"required": ["is_active"], "required": ["is_active"],
} }
model = SchemaConverter.build(schema) model = self.converter.build_with_instance(schema)
self.assertEqual(model(is_active=True).is_active, True) self.assertEqual(model(is_active=True).is_active, True)
@@ -216,7 +222,7 @@ class TestSchemaConverter(TestCase):
"required": ["friends"], "required": ["friends"],
} }
model = SchemaConverter.build(schema) model = self.converter.build_with_instance(schema)
self.assertEqual( self.assertEqual(
model(friends=["John", "Jane", "John"]).friends, {"John", "Jane"} model(friends=["John", "Jane", "John"]).friends, {"John", "Jane"}
@@ -229,26 +235,7 @@ class TestSchemaConverter(TestCase):
model(friends=["John", "Jane", "Invalid"]) model(friends=["John", "Jane", "Invalid"])
def test_validation_list_with_missing_items(self): def test_validation_list_with_missing_items(self):
model = SchemaConverter.build( model = self.converter.build_with_instance(
{
"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(
{ {
"title": "Person", "title": "Person",
"description": "A person", "description": "A person",
@@ -286,7 +273,7 @@ class TestSchemaConverter(TestCase):
"required": ["address"], "required": ["address"],
} }
model = SchemaConverter.build(schema) model = self.converter.build_with_instance(schema)
obj = model(address={"street": "123 Main St", "city": "Springfield"}) obj = model(address={"street": "123 Main St", "city": "Springfield"})
@@ -310,7 +297,7 @@ class TestSchemaConverter(TestCase):
"required": ["name"], "required": ["name"],
} }
model = SchemaConverter.build(schema) model = self.converter.build_with_instance(schema)
obj = model(name="John") obj = model(name="John")
@@ -333,7 +320,7 @@ class TestSchemaConverter(TestCase):
} }
with self.assertRaises(InvalidSchemaException): with self.assertRaises(InvalidSchemaException):
SchemaConverter.build(schema_max_length) self.converter.build_with_instance(schema_max_length)
def test_default_for_list(self): def test_default_for_list(self):
schema_list = { schema_list = {
@@ -350,10 +337,11 @@ class TestSchemaConverter(TestCase):
"required": ["friends"], "required": ["friends"],
} }
model_list = SchemaConverter.build(schema_list) model_list = self.converter.build_with_instance(schema_list)
self.assertEqual(model_list().friends, ["John", "Jane"]) self.assertEqual(model_list().friends, ["John", "Jane"])
def test_default_for_list_with_unique_items(self):
# Test for default with uniqueItems # Test for default with uniqueItems
schema_set = { schema_set = {
"title": "Person", "title": "Person",
@@ -370,7 +358,7 @@ class TestSchemaConverter(TestCase):
"required": ["friends"], "required": ["friends"],
} }
model_set = SchemaConverter.build(schema_set) model_set = self.converter.build_with_instance(schema_set)
self.assertEqual(model_set().friends, {"John", "Jane"}) self.assertEqual(model_set().friends, {"John", "Jane"})
@@ -392,7 +380,7 @@ class TestSchemaConverter(TestCase):
"required": ["address"], "required": ["address"],
} }
model = SchemaConverter.build(schema) model = self.converter.build_with_instance(schema)
obj = model(address={"street": "123 Main St", "city": "Springfield"}) 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( obj = Model(
name="J", name="J",
@@ -445,7 +433,7 @@ class TestSchemaConverter(TestCase):
}, },
} }
Model = SchemaConverter.build(schema) Model = self.converter.build_with_instance(schema)
obj = Model(id=1) obj = Model(id=1)
self.assertEqual(obj.id, 1) self.assertEqual(obj.id, 1)
@@ -469,7 +457,7 @@ class TestSchemaConverter(TestCase):
"properties": {"email": {"type": "string", "format": "email"}}, "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") self.assertEqual(model(email="test@example.com").email, "test@example.com")
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
@@ -482,7 +470,7 @@ class TestSchemaConverter(TestCase):
"properties": {"website": {"type": "string", "format": "uri"}}, "properties": {"website": {"type": "string", "format": "uri"}},
} }
model = SchemaConverter.build(schema) model = self.converter.build_with_instance(schema)
self.assertEqual( self.assertEqual(
model(website="https://example.com").website, AnyUrl("https://example.com") model(website="https://example.com").website, AnyUrl("https://example.com")
) )
@@ -497,7 +485,7 @@ class TestSchemaConverter(TestCase):
"properties": {"ip": {"type": "string", "format": "ipv4"}}, "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")) self.assertEqual(model(ip="192.168.1.1").ip, IPv4Address("192.168.1.1"))
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
@@ -510,7 +498,7 @@ class TestSchemaConverter(TestCase):
"properties": {"ip": {"type": "string", "format": "ipv6"}}, "properties": {"ip": {"type": "string", "format": "ipv6"}},
} }
model = SchemaConverter.build(schema) model = self.converter.build_with_instance(schema)
self.assertEqual( self.assertEqual(
model(ip="2001:0db8:85a3:0000:0000:8a2e:0370:7334").ip, model(ip="2001:0db8:85a3:0000:0000:8a2e:0370:7334").ip,
IPv6Address("2001:0db8:85a3:0000:0000:8a2e:0370:7334"), IPv6Address("2001:0db8:85a3:0000:0000:8a2e:0370:7334"),
@@ -526,7 +514,7 @@ class TestSchemaConverter(TestCase):
"properties": {"id": {"type": "string", "format": "uuid"}}, "properties": {"id": {"type": "string", "format": "uuid"}},
} }
model = SchemaConverter.build(schema) model = self.converter.build_with_instance(schema)
self.assertEqual( self.assertEqual(
model(id="123e4567-e89b-12d3-a456-426614174000").id, model(id="123e4567-e89b-12d3-a456-426614174000").id,
@@ -543,7 +531,7 @@ class TestSchemaConverter(TestCase):
"properties": {"hostname": {"type": "string", "format": "hostname"}}, "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") self.assertEqual(model(hostname="example.com").hostname, "example.com")
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
@@ -556,7 +544,7 @@ class TestSchemaConverter(TestCase):
"properties": {"timestamp": {"type": "string", "format": "date-time"}}, "properties": {"timestamp": {"type": "string", "format": "date-time"}},
} }
model = SchemaConverter.build(schema) model = self.converter.build_with_instance(schema)
self.assertEqual( self.assertEqual(
model(timestamp="2024-01-01T12:00:00Z").timestamp.isoformat(), model(timestamp="2024-01-01T12:00:00Z").timestamp.isoformat(),
"2024-01-01T12:00:00+00:00", "2024-01-01T12:00:00+00:00",
@@ -572,7 +560,7 @@ class TestSchemaConverter(TestCase):
"properties": {"time": {"type": "string", "format": "time"}}, "properties": {"time": {"type": "string", "format": "time"}},
} }
model = SchemaConverter.build(schema) model = self.converter.build_with_instance(schema)
self.assertEqual( self.assertEqual(
model(time="20:20:39+00:00").time.isoformat(), "20:20:39+00:00" 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): with self.assertRaises(InvalidSchemaException):
SchemaConverter.build(schema) self.converter.build_with_instance(schema)
def test_ref_with_root_ref(self): def test_ref_with_root_ref(self):
schema = { schema = {
@@ -604,7 +592,7 @@ class TestSchemaConverter(TestCase):
"required": ["name", "age"], "required": ["name", "age"],
} }
model = SchemaConverter.build(schema) model = self.converter.build_with_instance(schema)
obj = model( obj = model(
name="John", name="John",
@@ -639,7 +627,7 @@ class TestSchemaConverter(TestCase):
}, },
} }
model = SchemaConverter.build(schema) model = self.converter.build_with_instance(schema)
obj = model( obj = model(
name="John", name="John",
@@ -678,7 +666,7 @@ class TestSchemaConverter(TestCase):
}, },
} }
Model = SchemaConverter.build(schema) Model = self.converter.build_with_instance(schema)
obj = Model( obj = Model(
name="John", name="John",
@@ -704,7 +692,7 @@ class TestSchemaConverter(TestCase):
"required": ["status"], "required": ["status"],
} }
Model = SchemaConverter.build(schema) Model = self.converter.build_with_instance(schema)
obj = Model(status="active") obj = Model(status="active")
self.assertEqual(obj.status.value, "active") self.assertEqual(obj.status.value, "active")
@@ -723,7 +711,7 @@ class TestSchemaConverter(TestCase):
"required": ["status"], "required": ["status"],
} }
Model = SchemaConverter.build(schema) Model = self.converter.build_with_instance(schema)
obj = Model() obj = Model()
self.assertEqual(obj.status.value, "active") self.assertEqual(obj.status.value, "active")
@@ -740,7 +728,7 @@ class TestSchemaConverter(TestCase):
"required": ["name"], "required": ["name"],
} }
Model = SchemaConverter.build(schema) Model = self.converter.build_with_instance(schema)
obj = Model() obj = Model()
self.assertEqual(obj.name, "United States of America") self.assertEqual(obj.name, "United States of America")
@@ -763,7 +751,7 @@ class TestSchemaConverter(TestCase):
"required": ["name"], "required": ["name"],
} }
Model = SchemaConverter.build(schema) Model = self.converter.build_with_instance(schema)
obj = Model() obj = Model()
self.assertEqual(obj.name, ["Brazil"]) 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() obj = Model()
self.assertIsNone(obj.a_thing) 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 # 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"] operating_system_field = schema_type.model_fields["operating_system"]
@@ -839,7 +827,7 @@ class TestSchemaConverter(TestCase):
def test_object_invalid_require(self): def test_object_invalid_require(self):
# https://github.com/HideyoshiNakazone/jambo/issues/60 # 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", "$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "TEST", "title": "TEST",
@@ -866,3 +854,147 @@ class TestSchemaConverter(TestCase):
) )
self.assertFalse(object_.model_fields["description"].is_required()) # FAIL 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)