Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a75ad61a21 | |||
|
|
2fd092cd8e | ||
|
|
e12396477f | ||
| 6440c30a91 | |||
|
19d1f72951
|
|||
|
02a28c9586
|
|||
|
eee32a02ae
|
|||
| 00802744dd | |||
|
dd2e7d221c
|
|||
|
558abf5d40
|
|||
| 70afa80ccf | |||
|
422cc2efe0
|
|||
|
|
dd31f62ef2 | ||
| e8bda6bc07 | |||
|
d8fe98639a
|
|||
| 666e12262f | |||
|
ab9646238e
|
|||
| dba492a6dc | |||
|
628abe161d
|
|||
|
136d68d273
|
|||
|
fcea994dd6
|
@@ -1,7 +1,7 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
# Ruff version.
|
# Ruff version.
|
||||||
rev: v0.11.4
|
rev: v0.14.7
|
||||||
hooks:
|
hooks:
|
||||||
# Run the linter.
|
# Run the linter.
|
||||||
- id: ruff
|
- id: ruff
|
||||||
|
|||||||
5
.github/workflows/build.yml
vendored
5
.github/workflows/build.yml
vendored
@@ -23,6 +23,7 @@ jobs:
|
|||||||
- "3.11"
|
- "3.11"
|
||||||
- "3.12"
|
- "3.12"
|
||||||
- "3.13"
|
- "3.13"
|
||||||
|
- "3.14"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -31,7 +32,7 @@ jobs:
|
|||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
# Install a specific version of uv.
|
# Install a specific version of uv.
|
||||||
version: "0.6.14"
|
version: "0.9.15"
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: "uv.lock"
|
cache-dependency-glob: "uv.lock"
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
@@ -68,7 +69,7 @@ jobs:
|
|||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
# Install a specific version of uv.
|
# Install a specific version of uv.
|
||||||
version: "0.6.14"
|
version: "0.9.15"
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: "uv.lock"
|
cache-dependency-glob: "uv.lock"
|
||||||
|
|
||||||
|
|||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
@@ -62,13 +62,7 @@ There are two ways to build models with Jambo:
|
|||||||
1. The original static API: `SchemaConverter.build(schema)` doesn't persist any reference cache between calls and doesn't require any configuration.
|
1. The original static API: `SchemaConverter.build(schema)` doesn't persist any reference cache between calls and doesn't require any configuration.
|
||||||
2. The new instance API: use a `SchemaConverter()` instance and call `build_with_cache`, which exposes and persists a reference cache and helper methods.
|
2. The new instance API: use a `SchemaConverter()` instance and call `build_with_cache`, which exposes and persists a reference cache and helper methods.
|
||||||
|
|
||||||
The instance API is useful when you want to reuse generated subtypes, inspect cached models, or share caches between converters. See the docs for full details: https://jambo.readthedocs.io/en/latest/usage.ref_cache.html
|
The instance API is useful when you want to reuse generated subtypes, inspect cached models, or share caches between converters; all leveraging namespaces via the `$id` property in JSON Schema. See the docs for full details: https://jambo.readthedocs.io/en/latest/usage.ref_cache.html
|
||||||
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> The use of the instance API and ref cache can cause schema and type name collisions if not managed carefully, therefore
|
|
||||||
> it's recommended that each namespace or schema source uses its own `SchemaConverter` instance.
|
|
||||||
> If you don't need cache control, the static API is simpler and sufficient for most use cases.
|
|
||||||
|
|
||||||
|
|
||||||
### Static (compatibility) example
|
### Static (compatibility) example
|
||||||
|
|||||||
@@ -86,8 +86,32 @@ reference cache (a plain dict). Reusing the same converter instance across
|
|||||||
multiple calls will reuse that cache and therefore reuse previously generated
|
multiple calls will reuse that cache and therefore reuse previously generated
|
||||||
model classes.
|
model classes.
|
||||||
|
|
||||||
|
That cache is isolated per namespace via the `$id` property in JSON Schema, so
|
||||||
|
schemas with different `$id` values will not collide in the same cache.
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
|
from jambo import SchemaConverter
|
||||||
|
|
||||||
|
# no $id in this example, therefore a default namespace is used
|
||||||
|
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", "address"],
|
||||||
|
}
|
||||||
|
|
||||||
converter = SchemaConverter() # has its own internal cache
|
converter = SchemaConverter() # has its own internal cache
|
||||||
|
|
||||||
model1 = converter.build_with_cache(schema)
|
model1 = converter.build_with_cache(schema)
|
||||||
@@ -96,6 +120,39 @@ model classes.
|
|||||||
# model1 and model2 are the same object because the instance cache persisted
|
# model1 and model2 are the same object because the instance cache persisted
|
||||||
assert model1 is model2
|
assert model1 is model2
|
||||||
|
|
||||||
|
When passing a schema with a different `$id`, the instance cache keeps types
|
||||||
|
separate:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
schema_a = {
|
||||||
|
"$id": "namespace_a",
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
}
|
||||||
|
|
||||||
|
schema_b = {
|
||||||
|
"$id": "namespace_b",
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
}
|
||||||
|
|
||||||
|
converter = SchemaConverter() # has its own internal cache
|
||||||
|
|
||||||
|
model_a = converter.build_with_cache(schema_a)
|
||||||
|
model_b = converter.build_with_cache(schema_b)
|
||||||
|
|
||||||
|
# different $id values isolate the types in the same cache
|
||||||
|
assert model_a is not model_b
|
||||||
|
|
||||||
If you want to temporarily avoid using the instance cache for a single call,
|
If you want to temporarily avoid using the instance cache for a single call,
|
||||||
use ``without_cache=True``. That causes :py:meth:`SchemaConverter.build_with_cache <jambo.SchemaConverter.build_with_cache>` to
|
use ``without_cache=True``. That causes :py:meth:`SchemaConverter.build_with_cache <jambo.SchemaConverter.build_with_cache>` to
|
||||||
use a fresh, empty cache for the duration of that call only:
|
use a fresh, empty cache for the duration of that call only:
|
||||||
@@ -118,7 +175,7 @@ instance cache.
|
|||||||
Retrieving cached types
|
Retrieving cached types
|
||||||
-----------------------
|
-----------------------
|
||||||
|
|
||||||
:py:meth:`SchemaConverter.get_cached_ref <jambo.SchemaConverter.get_cached_ref>`(name) — returns a cached model class or ``None``.
|
:py:meth:`SchemaConverter.get_cached_ref <jambo.SchemaConverter.get_cached_ref>`(name, namespace="default") — returns a cached model class or ``None``.
|
||||||
|
|
||||||
Retrieving the root type of the schema
|
Retrieving the root type of the schema
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
@@ -212,10 +269,62 @@ When retrieving a type defined in ``$defs``, access it directly by its name.
|
|||||||
cached_address_model = converter.get_cached_ref("address")
|
cached_address_model = converter.get_cached_ref("address")
|
||||||
|
|
||||||
|
|
||||||
|
Isolation by Namespace
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
The instance cache is isolated per namespace via the `$id` property in JSON Schema.
|
||||||
|
When retrieving a cached type, you can specify the namespace to look in
|
||||||
|
(via the ``namespace`` parameter). By default, the ``default`` namespace is used
|
||||||
|
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from jambo import SchemaConverter
|
||||||
|
|
||||||
|
converter = SchemaConverter()
|
||||||
|
|
||||||
|
schema_a = {
|
||||||
|
"$id": "namespace_a",
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
}
|
||||||
|
|
||||||
|
schema_b = {
|
||||||
|
"$id": "namespace_b",
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
}
|
||||||
|
|
||||||
|
person_a = converter.build_with_cache(schema_a)
|
||||||
|
person_b = converter.build_with_cache(schema_b)
|
||||||
|
|
||||||
|
cached_person_a = converter.get_cached_ref("Person", namespace="namespace_a")
|
||||||
|
cached_person_b = converter.get_cached_ref("Person", namespace="namespace_b")
|
||||||
|
|
||||||
|
assert cached_person_a is person_a
|
||||||
|
assert cached_person_b is person_b
|
||||||
|
|
||||||
|
|
||||||
Clearing the cache
|
Clearing the cache
|
||||||
------------------
|
------------------
|
||||||
|
|
||||||
:py:meth:`SchemaConverter.clear_ref_cache <jambo.SchemaConverter.clear_ref_cache>`() — removes all entries from the instance cache.
|
:py:meth:`SchemaConverter.clear_ref_cache <jambo.SchemaConverter.clear_ref_cache>`(namespace: Optional[str]="default") — removes all entries from the instance cache.
|
||||||
|
|
||||||
|
|
||||||
|
When you want to clear the instance cache, use :py:meth:`SchemaConverter.clear_ref_cache <jambo.SchemaConverter.clear_ref_cache>`.
|
||||||
|
You can optionally specify a ``namespace`` to clear only that namespace;
|
||||||
|
otherwise, the default namespace is cleared.
|
||||||
|
|
||||||
|
If you want to clear all namespaces, call :py:meth:`SchemaConverter.clear_ref_cache <jambo.SchemaConverter.clear_ref_cache>` passing `None` as the namespace,
|
||||||
|
which removes all entries from all namespaces.
|
||||||
|
|
||||||
|
|
||||||
Notes and Behavioural Differences
|
Notes and Behavioural Differences
|
||||||
|
|||||||
@@ -99,8 +99,8 @@ the instance method persists and exposes the reference cache and provides helper
|
|||||||
|
|
||||||
.. warning::
|
.. warning::
|
||||||
The instance API with reference cache can lead to schema and type name collisions if not managed carefully.
|
The instance API with reference cache can lead to schema and type name collisions if not managed carefully.
|
||||||
It's recommended that each namespace or schema source uses its own `SchemaConverter` instance.
|
It's recommended that each schema defines its own unique namespace using the `$id` property in JSON Schema,
|
||||||
If you don't need cache control, the static API is simpler and sufficient for most use cases.
|
and then access it's ref_cache by passing it explicitly when needed.
|
||||||
|
|
||||||
For details and examples about the reference cache and the different cache modes (instance cache, per-call cache, ephemeral cache), see:
|
For details and examples about the reference cache and the different cache modes (instance cache, per-call cache, ephemeral cache), see:
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,12 @@ class AnyOfTypeParser(GenericTypeParser):
|
|||||||
# By defining the type as Union of Annotated type we can use the Field validator
|
# By defining the type as Union of Annotated type we can use the Field validator
|
||||||
# to enforce the constraints of each union type when needed.
|
# to enforce the constraints of each union type when needed.
|
||||||
# We use Annotated to attach the Field validators to the type.
|
# We use Annotated to attach the Field validators to the type.
|
||||||
field_types = [
|
field_types = []
|
||||||
Annotated[t, Field(**v)] if v is not None else t for t, v in sub_types
|
for subType, subProp in sub_types:
|
||||||
]
|
default_value = subProp.pop("default", None)
|
||||||
|
if default_value is None:
|
||||||
|
default_value = ...
|
||||||
|
|
||||||
|
field_types.append(Annotated[subType, Field(default_value, **subProp)])
|
||||||
|
|
||||||
return Union[(*field_types,)], mapped_properties
|
return Union[(*field_types,)], mapped_properties
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ class EnumTypeParser(GenericTypeParser):
|
|||||||
|
|
||||||
# Create a new Enum type dynamically
|
# Create a new Enum type dynamically
|
||||||
enum_type = Enum(name, {str(value).upper(): value for value in enum_values}) # type: ignore
|
enum_type = Enum(name, {str(value).upper(): value for value in enum_values}) # type: ignore
|
||||||
|
enum_type.__doc__ = properties.get("description")
|
||||||
|
|
||||||
parsed_properties = self.mappings_properties_builder(properties, **kwargs)
|
parsed_properties = self.mappings_properties_builder(properties, **kwargs)
|
||||||
|
|
||||||
if "default" in parsed_properties and parsed_properties["default"] is not None:
|
if "default" in parsed_properties and parsed_properties["default"] is not None:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class ObjectTypeParser(GenericTypeParser):
|
|||||||
name,
|
name,
|
||||||
properties.get("properties", {}),
|
properties.get("properties", {}),
|
||||||
properties.get("required", []),
|
properties.get("required", []),
|
||||||
|
description=properties.get("description"),
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
type_properties = self.mappings_properties_builder(properties, **kwargs)
|
type_properties = self.mappings_properties_builder(properties, **kwargs)
|
||||||
@@ -48,6 +49,7 @@ class ObjectTypeParser(GenericTypeParser):
|
|||||||
name: str,
|
name: str,
|
||||||
properties: dict[str, JSONSchema],
|
properties: dict[str, JSONSchema],
|
||||||
required_keys: list[str],
|
required_keys: list[str],
|
||||||
|
description: str | None = None,
|
||||||
**kwargs: Unpack[TypeParserOptions],
|
**kwargs: Unpack[TypeParserOptions],
|
||||||
) -> type[BaseModel]:
|
) -> type[BaseModel]:
|
||||||
"""
|
"""
|
||||||
@@ -74,7 +76,9 @@ class ObjectTypeParser(GenericTypeParser):
|
|||||||
model_config = ConfigDict(validate_assignment=True)
|
model_config = ConfigDict(validate_assignment=True)
|
||||||
fields = cls._parse_properties(name, properties, required_keys, **kwargs)
|
fields = cls._parse_properties(name, properties, required_keys, **kwargs)
|
||||||
|
|
||||||
model = create_model(name, __config__=model_config, **fields) # type: ignore
|
model = create_model(
|
||||||
|
name, __config__=model_config, __doc__=description, **fields
|
||||||
|
) # type: ignore
|
||||||
ref_cache[name] = model
|
ref_cache[name] = model
|
||||||
|
|
||||||
return model
|
return model
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ from jambo.exceptions import InvalidSchemaException
|
|||||||
from jambo.parser._type_parser import GenericTypeParser
|
from jambo.parser._type_parser import GenericTypeParser
|
||||||
from jambo.types.type_parser_options import TypeParserOptions
|
from jambo.types.type_parser_options import TypeParserOptions
|
||||||
|
|
||||||
from pydantic import AnyUrl, EmailStr
|
from pydantic import AnyUrl, EmailStr, TypeAdapter, ValidationError
|
||||||
from typing_extensions import Any, Unpack
|
from typing_extensions import Unpack
|
||||||
|
|
||||||
from datetime import date, datetime, time, timedelta
|
from datetime import date, datetime, time, timedelta
|
||||||
from ipaddress import IPv4Address, IPv6Address
|
from ipaddress import IPv4Address, IPv6Address
|
||||||
@@ -62,37 +62,19 @@ class StringTypeParser(GenericTypeParser):
|
|||||||
if format_type in self.format_pattern_mapping:
|
if format_type in self.format_pattern_mapping:
|
||||||
mapped_properties["pattern"] = self.format_pattern_mapping[format_type]
|
mapped_properties["pattern"] = self.format_pattern_mapping[format_type]
|
||||||
|
|
||||||
|
try:
|
||||||
if "examples" in mapped_properties:
|
if "examples" in mapped_properties:
|
||||||
mapped_properties["examples"] = [
|
mapped_properties["examples"] = [
|
||||||
self._parse_example(example, format_type, mapped_type)
|
TypeAdapter(mapped_type).validate_python(example)
|
||||||
for example in mapped_properties["examples"]
|
for example in mapped_properties["examples"]
|
||||||
]
|
]
|
||||||
|
except ValidationError as err:
|
||||||
|
raise InvalidSchemaException(
|
||||||
|
f"Invalid example type for field {name}."
|
||||||
|
) from err
|
||||||
|
|
||||||
if "json_schema_extra" not in mapped_properties:
|
if "json_schema_extra" not in mapped_properties:
|
||||||
mapped_properties["json_schema_extra"] = {}
|
mapped_properties["json_schema_extra"] = {}
|
||||||
mapped_properties["json_schema_extra"]["format"] = format_type
|
mapped_properties["json_schema_extra"]["format"] = format_type
|
||||||
|
|
||||||
return mapped_type, mapped_properties
|
return mapped_type, mapped_properties
|
||||||
|
|
||||||
def _parse_example(
|
|
||||||
self, example: Any, format_type: str, mapped_type: type[Any]
|
|
||||||
) -> Any:
|
|
||||||
"""
|
|
||||||
Parse example from JSON Schema format to python format
|
|
||||||
:param example: Example Value
|
|
||||||
:param format_type: Format Type
|
|
||||||
:param mapped_type: Type to parse
|
|
||||||
:return: Example parsed
|
|
||||||
"""
|
|
||||||
match format_type:
|
|
||||||
case "date" | "time" | "date-time":
|
|
||||||
return mapped_type.fromisoformat(example)
|
|
||||||
case "duration":
|
|
||||||
# TODO: Implement duration parser
|
|
||||||
raise NotImplementedError
|
|
||||||
case "ipv4" | "ipv6":
|
|
||||||
return mapped_type(example)
|
|
||||||
case "uuid":
|
|
||||||
return mapped_type(example)
|
|
||||||
case _:
|
|
||||||
return example
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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
|
from typing_extensions import MutableMapping, Optional
|
||||||
|
|
||||||
|
|
||||||
class SchemaConverter:
|
class SchemaConverter:
|
||||||
@@ -17,10 +17,14 @@ 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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, ref_cache: Optional[RefCacheDict] = None) -> None:
|
_namespace_registry: MutableMapping[str, RefCacheDict]
|
||||||
if ref_cache is None:
|
|
||||||
ref_cache = dict()
|
def __init__(
|
||||||
self._ref_cache = ref_cache
|
self, namespace_registry: Optional[MutableMapping[str, RefCacheDict]] = None
|
||||||
|
) -> None:
|
||||||
|
if namespace_registry is None:
|
||||||
|
namespace_registry = dict()
|
||||||
|
self._namespace_registry = namespace_registry
|
||||||
|
|
||||||
def build_with_cache(
|
def build_with_cache(
|
||||||
self,
|
self,
|
||||||
@@ -43,7 +47,8 @@ class SchemaConverter:
|
|||||||
if without_cache:
|
if without_cache:
|
||||||
local_ref_cache = dict()
|
local_ref_cache = dict()
|
||||||
elif ref_cache is None:
|
elif ref_cache is None:
|
||||||
local_ref_cache = self._ref_cache
|
namespace = schema.get("$id", "default")
|
||||||
|
local_ref_cache = self._namespace_registry.setdefault(namespace, dict())
|
||||||
else:
|
else:
|
||||||
local_ref_cache = ref_cache
|
local_ref_cache = ref_cache
|
||||||
|
|
||||||
@@ -84,6 +89,7 @@ class SchemaConverter:
|
|||||||
schema["title"],
|
schema["title"],
|
||||||
schema.get("properties", {}),
|
schema.get("properties", {}),
|
||||||
schema.get("required", []),
|
schema.get("required", []),
|
||||||
|
description=schema.get("description"),
|
||||||
context=schema,
|
context=schema,
|
||||||
ref_cache=ref_cache,
|
ref_cache=ref_cache,
|
||||||
required=True,
|
required=True,
|
||||||
@@ -107,19 +113,26 @@ class SchemaConverter:
|
|||||||
unsupported_field=unsupported_type,
|
unsupported_field=unsupported_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
def clear_ref_cache(self) -> None:
|
def clear_ref_cache(self, namespace: Optional[str] = "default") -> None:
|
||||||
"""
|
"""
|
||||||
Clears the reference cache.
|
Clears the reference cache.
|
||||||
"""
|
"""
|
||||||
self._ref_cache.clear()
|
if namespace is None:
|
||||||
|
self._namespace_registry.clear()
|
||||||
|
return
|
||||||
|
|
||||||
def get_cached_ref(self, ref_name: str):
|
if namespace in self._namespace_registry:
|
||||||
|
self._namespace_registry[namespace].clear()
|
||||||
|
|
||||||
|
def get_cached_ref(
|
||||||
|
self, ref_name: str, namespace: str = "default"
|
||||||
|
) -> Optional[type]:
|
||||||
"""
|
"""
|
||||||
Gets a cached reference from the reference cache.
|
Gets a cached reference from the reference cache.
|
||||||
:param ref_name: The name of the reference to get.
|
:param ref_name: The name of the reference to get.
|
||||||
:return: The cached reference, or None if not found.
|
:return: The cached reference, or None if not found.
|
||||||
"""
|
"""
|
||||||
cached_type = self._ref_cache.get(ref_name)
|
cached_type = self._namespace_registry.get(namespace, {}).get(ref_name)
|
||||||
|
|
||||||
if isinstance(cached_type, type):
|
if isinstance(cached_type, type):
|
||||||
return cached_type
|
return cached_type
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ classifiers = [
|
|||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
"Programming Language :: Python :: 3.13",
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: 3.14",
|
||||||
]
|
]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|||||||
@@ -49,6 +49,20 @@ class TestEnumTypeParser(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(parsed_properties, {"default": None})
|
self.assertEqual(parsed_properties, {"default": None})
|
||||||
|
|
||||||
|
def test_enum_type_parser_creates_enum_with_description(self):
|
||||||
|
parser = EnumTypeParser()
|
||||||
|
|
||||||
|
schema = {
|
||||||
|
"description": "an enum",
|
||||||
|
"enum": ["value1"],
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed_type, parsed_properties = parser.from_properties_impl(
|
||||||
|
"TestEnum",
|
||||||
|
schema,
|
||||||
|
)
|
||||||
|
self.assertEqual(parsed_type.__doc__, "an enum")
|
||||||
|
|
||||||
def test_enum_type_parser_creates_enum_with_default(self):
|
def test_enum_type_parser_creates_enum_with_default(self):
|
||||||
parser = EnumTypeParser()
|
parser = EnumTypeParser()
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class TestObjectTypeParser(TestCase):
|
|||||||
|
|
||||||
properties = {
|
properties = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"description": "obj desc",
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {"type": "string"},
|
"name": {"type": "string"},
|
||||||
"age": {"type": "integer"},
|
"age": {"type": "integer"},
|
||||||
@@ -33,6 +34,7 @@ class TestObjectTypeParser(TestCase):
|
|||||||
Model, _args = parser.from_properties_impl(
|
Model, _args = parser.from_properties_impl(
|
||||||
"placeholder", properties, ref_cache={}
|
"placeholder", properties, ref_cache={}
|
||||||
)
|
)
|
||||||
|
self.assertEqual(Model.__doc__, "obj desc")
|
||||||
|
|
||||||
obj = Model(name="name", age=10)
|
obj = Model(name="name", age=10)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from jambo.parser import StringTypeParser
|
|||||||
|
|
||||||
from pydantic import AnyUrl, EmailStr
|
from pydantic import AnyUrl, EmailStr
|
||||||
|
|
||||||
import unittest
|
|
||||||
from datetime import date, datetime, time, timedelta, timezone
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
from ipaddress import IPv4Address, IPv6Address, ip_address
|
from ipaddress import IPv4Address, IPv6Address, ip_address
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
@@ -121,7 +120,7 @@ class TestStringTypeParser(TestCase):
|
|||||||
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
self.assertEqual(type_parsing, AnyUrl)
|
self.assertEqual(type_parsing, AnyUrl)
|
||||||
self.assertEqual(type_validator["examples"], ["test://domain/resource"])
|
self.assertEqual(type_validator["examples"], [AnyUrl("test://domain/resource")])
|
||||||
|
|
||||||
def test_string_parser_with_ip_formats(self):
|
def test_string_parser_with_ip_formats(self):
|
||||||
parser = StringTypeParser()
|
parser = StringTypeParser()
|
||||||
@@ -299,7 +298,6 @@ class TestStringTypeParser(TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@unittest.skip("Duration parsing not yet implemented")
|
|
||||||
def test_string_parser_with_timedelta_format(self):
|
def test_string_parser_with_timedelta_format(self):
|
||||||
parser = StringTypeParser()
|
parser = StringTypeParser()
|
||||||
|
|
||||||
@@ -315,9 +313,9 @@ class TestStringTypeParser(TestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
type_validator["examples"],
|
type_validator["examples"],
|
||||||
[
|
[
|
||||||
timedelta(days=7),
|
timedelta(days=428, hours=4, minutes=5, seconds=6),
|
||||||
timedelta(minutes=30),
|
timedelta(minutes=30),
|
||||||
timedelta(hours=4, minutes=5, seconds=6),
|
timedelta(days=7),
|
||||||
timedelta(seconds=0.5),
|
timedelta(seconds=0.5),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class TestSchemaConverter(TestCase):
|
|||||||
self.converter = SchemaConverter()
|
self.converter = SchemaConverter()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.converter.clear_ref_cache()
|
self.converter.clear_ref_cache(namespace=None)
|
||||||
|
|
||||||
def test_invalid_schema(self):
|
def test_invalid_schema(self):
|
||||||
schema = {
|
schema = {
|
||||||
@@ -274,6 +274,7 @@ class TestSchemaConverter(TestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
model = self.converter.build_with_cache(schema)
|
model = self.converter.build_with_cache(schema)
|
||||||
|
self.assertEqual(model.__doc__, "A person")
|
||||||
|
|
||||||
obj = model(address={"street": "123 Main St", "city": "Springfield"})
|
obj = model(address={"street": "123 Main St", "city": "Springfield"})
|
||||||
|
|
||||||
@@ -877,7 +878,6 @@ class TestSchemaConverter(TestCase):
|
|||||||
converter2 = SchemaConverter(ref_cache)
|
converter2 = SchemaConverter(ref_cache)
|
||||||
model2 = converter2.build_with_cache(schema)
|
model2 = converter2.build_with_cache(schema)
|
||||||
|
|
||||||
self.assertIs(converter1._ref_cache, converter2._ref_cache)
|
|
||||||
self.assertIs(model1, model2)
|
self.assertIs(model1, model2)
|
||||||
|
|
||||||
def test_instance_level_ref_cache_isolation_via_without_cache_param(self):
|
def test_instance_level_ref_cache_isolation_via_without_cache_param(self):
|
||||||
@@ -1041,3 +1041,133 @@ class TestSchemaConverter(TestCase):
|
|||||||
|
|
||||||
with self.assertRaises(InvalidSchemaException):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
self.converter.build_with_cache(schema)
|
self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
|
def tests_instance_level_ref_cache_isolation_via_property_id(self):
|
||||||
|
schema1: JSONSchema = {
|
||||||
|
"$id": "http://example.com/schemas/person1.json",
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"emergency_contact": {
|
||||||
|
"$ref": "#",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name", "age"],
|
||||||
|
}
|
||||||
|
|
||||||
|
model1 = self.converter.build_with_cache(schema1)
|
||||||
|
|
||||||
|
schema2: JSONSchema = {
|
||||||
|
"$id": "http://example.com/schemas/person2.json",
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"address": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["name", "age", "address"],
|
||||||
|
}
|
||||||
|
|
||||||
|
model2 = self.converter.build_with_cache(schema2)
|
||||||
|
|
||||||
|
self.assertIsNot(model1, model2)
|
||||||
|
|
||||||
|
def tests_instance_level_ref_cache_colision_when_same_property_id(self):
|
||||||
|
schema1: JSONSchema = {
|
||||||
|
"$id": "http://example.com/schemas/person.json",
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"emergency_contact": {
|
||||||
|
"$ref": "#",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name", "age"],
|
||||||
|
}
|
||||||
|
|
||||||
|
model1 = self.converter.build_with_cache(schema1)
|
||||||
|
|
||||||
|
schema2: JSONSchema = {
|
||||||
|
"$id": "http://example.com/schemas/person.json",
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"address": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["name", "age", "address"],
|
||||||
|
}
|
||||||
|
|
||||||
|
model2 = self.converter.build_with_cache(schema2)
|
||||||
|
|
||||||
|
self.assertIs(model1, model2)
|
||||||
|
|
||||||
|
def test_namespace_isolation_via_on_call_config(self):
|
||||||
|
namespace = "namespace1"
|
||||||
|
|
||||||
|
schema: JSONSchema = {
|
||||||
|
"$id": namespace,
|
||||||
|
"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_cache(schema)
|
||||||
|
|
||||||
|
invalid_cached_model = self.converter.get_cached_ref("Person")
|
||||||
|
self.assertIsNone(invalid_cached_model)
|
||||||
|
|
||||||
|
cached_model = self.converter.get_cached_ref("Person", namespace=namespace)
|
||||||
|
self.assertIs(model, cached_model)
|
||||||
|
|
||||||
|
def test_clear_namespace_registry(self):
|
||||||
|
namespace = "namespace_to_clear"
|
||||||
|
|
||||||
|
schema: JSONSchema = {
|
||||||
|
"$id": namespace,
|
||||||
|
"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_cache(schema)
|
||||||
|
|
||||||
|
cached_model = self.converter.get_cached_ref("Person", namespace=namespace)
|
||||||
|
self.assertIs(model, cached_model)
|
||||||
|
|
||||||
|
self.converter.clear_ref_cache(namespace=namespace)
|
||||||
|
|
||||||
|
cleared_cached_model = self.converter.get_cached_ref(
|
||||||
|
"Person", namespace=namespace
|
||||||
|
)
|
||||||
|
self.assertIsNone(cleared_cached_model)
|
||||||
|
|||||||
Reference in New Issue
Block a user