21 Commits
v0.1.4 ... main

Author SHA1 Message Date
a75ad61a21 Merge pull request #78 from mikix/model-desc
feat: support object-level descriptions
2026-01-14 16:14:35 -03:00
Michael Terry
2fd092cd8e feat: support enum-level descriptions
By saving them as a enum class docstring.
2026-01-14 14:01:02 -05:00
Michael Terry
e12396477f feat: support object-level descriptions
By saving them as a pydantic model docstring.

Fixes https://github.com/HideyoshiNakazone/jambo/issues/77
2026-01-14 13:14:48 -05:00
6440c30a91 Merge pull request #76 from HideyoshiNakazone/chore/adds-python3.14-metadata
chore: adds python3.14 metadata
2025-12-08 19:07:19 -03:00
19d1f72951 fix: minor fix in internal typing 2025-12-08 19:04:17 -03:00
02a28c9586 chore: updates uv version in ci 2025-12-08 18:56:55 -03:00
eee32a02ae chore: adds python3.14 to metadata 2025-12-08 18:56:01 -03:00
00802744dd Merge pull request #74 from HideyoshiNakazone/chore/run-tests-on-python3.14
chore: updates project to be python3.14 compatible
2025-12-06 17:09:21 -03:00
dd2e7d221c chore: updates uv version in github action file 2025-12-06 17:05:42 -03:00
558abf5d40 chore: updates project to be python3.14 compatible 2025-12-06 17:01:24 -03:00
70afa80ccf Merge pull request #73 from JCHacking/string-duration
feat: duration string parser
2025-12-06 16:53:27 -03:00
422cc2efe0 feat: captures any str validation exception and converts it into ValidationError
converts any exception thrown in the str parser example validation into ValidationError so that the user knows that this is a error in the schema and not a parsing validation exception
2025-12-05 20:15:08 -03:00
JCHacking
dd31f62ef2 feat: duration string parser 2025-12-01 17:38:31 +01:00
e8bda6bc07 Merge pull request #71 from HideyoshiNakazone/fix/fixes-annotation-definition-anyof
fix: fixes annotation definition in anyof parser
2025-11-28 18:30:38 -03:00
d8fe98639a fix: fixes annotation definition in anyof parser 2025-11-28 18:28:49 -03:00
666e12262f Merge pull request #68 from HideyoshiNakazone/feature/cache-per-namespace
chore: minor adjustment of docs
2025-11-26 15:31:10 -03:00
ab9646238e chore: minor adjustment of docs 2025-11-26 15:30:38 -03:00
dba492a6dc Merge pull request #67 from HideyoshiNakazone/feature/cache-per-namespace
feat: adds caching per namespace
2025-11-26 15:28:17 -03:00
628abe161d feat: adds newly added feature to the docs 2025-11-26 15:23:29 -03:00
136d68d273 feat: alter tests to clear all namespaces on tearDown 2025-11-26 15:07:22 -03:00
fcea994dd6 feat: adds caching per namespace 2025-11-26 15:05:10 -03:00
17 changed files with 1053 additions and 588 deletions

View File

@@ -1,7 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.11.4
rev: v0.14.7
hooks:
# Run the linter.
- id: ruff

View File

@@ -23,6 +23,7 @@ jobs:
- "3.11"
- "3.12"
- "3.13"
- "3.14"
steps:
- uses: actions/checkout@v4
@@ -31,7 +32,7 @@ jobs:
uses: astral-sh/setup-uv@v5
with:
# Install a specific version of uv.
version: "0.6.14"
version: "0.9.15"
enable-cache: true
cache-dependency-glob: "uv.lock"
python-version: ${{ matrix.python-version }}
@@ -68,7 +69,7 @@ jobs:
uses: astral-sh/setup-uv@v5
with:
# Install a specific version of uv.
version: "0.6.14"
version: "0.9.15"
enable-cache: true
cache-dependency-glob: "uv.lock"

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.14

View File

@@ -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.
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
> [!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.
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
### Static (compatibility) example

View File

@@ -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
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
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
model1 = converter.build_with_cache(schema)
@@ -96,6 +120,39 @@ model classes.
# model1 and model2 are the same object because the instance cache persisted
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,
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:
@@ -118,7 +175,7 @@ instance cache.
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
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -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")
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
------------------
: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

View File

@@ -99,9 +99,9 @@ the instance method persists and exposes the reference cache and provides helper
.. warning::
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.
If you don't need cache control, the static API is simpler and sufficient for most use cases.
It's recommended that each schema defines its own unique namespace using the `$id` property in JSON Schema,
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:
.. toctree::

View File

@@ -42,8 +42,12 @@ class AnyOfTypeParser(GenericTypeParser):
# By defining the type as Union of Annotated type we can use the Field validator
# to enforce the constraints of each union type when needed.
# We use Annotated to attach the Field validators to the type.
field_types = [
Annotated[t, Field(**v)] if v is not None else t for t, v in sub_types
]
field_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

View File

@@ -36,6 +36,8 @@ class EnumTypeParser(GenericTypeParser):
# Create a new Enum type dynamically
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)
if "default" in parsed_properties and parsed_properties["default"] is not None:

View File

@@ -22,6 +22,7 @@ class ObjectTypeParser(GenericTypeParser):
name,
properties.get("properties", {}),
properties.get("required", []),
description=properties.get("description"),
**kwargs,
)
type_properties = self.mappings_properties_builder(properties, **kwargs)
@@ -48,6 +49,7 @@ class ObjectTypeParser(GenericTypeParser):
name: str,
properties: dict[str, JSONSchema],
required_keys: list[str],
description: str | None = None,
**kwargs: Unpack[TypeParserOptions],
) -> type[BaseModel]:
"""
@@ -74,7 +76,9 @@ class ObjectTypeParser(GenericTypeParser):
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
model = create_model(
name, __config__=model_config, __doc__=description, **fields
) # type: ignore
ref_cache[name] = model
return model

View File

@@ -2,8 +2,8 @@ from jambo.exceptions import InvalidSchemaException
from jambo.parser._type_parser import GenericTypeParser
from jambo.types.type_parser_options import TypeParserOptions
from pydantic import AnyUrl, EmailStr
from typing_extensions import Any, Unpack
from pydantic import AnyUrl, EmailStr, TypeAdapter, ValidationError
from typing_extensions import Unpack
from datetime import date, datetime, time, timedelta
from ipaddress import IPv4Address, IPv6Address
@@ -62,37 +62,19 @@ class StringTypeParser(GenericTypeParser):
if format_type in self.format_pattern_mapping:
mapped_properties["pattern"] = self.format_pattern_mapping[format_type]
if "examples" in mapped_properties:
mapped_properties["examples"] = [
self._parse_example(example, format_type, mapped_type)
for example in mapped_properties["examples"]
]
try:
if "examples" in mapped_properties:
mapped_properties["examples"] = [
TypeAdapter(mapped_type).validate_python(example)
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:
mapped_properties["json_schema_extra"] = {}
mapped_properties["json_schema_extra"]["format"] = format_type
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

View File

@@ -5,7 +5,7 @@ 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
from typing_extensions import MutableMapping, Optional
class SchemaConverter:
@@ -17,10 +17,14 @@ class SchemaConverter:
fields and types. The generated model can be used for data validation and serialization.
"""
def __init__(self, ref_cache: Optional[RefCacheDict] = None) -> None:
if ref_cache is None:
ref_cache = dict()
self._ref_cache = ref_cache
_namespace_registry: MutableMapping[str, RefCacheDict]
def __init__(
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(
self,
@@ -43,7 +47,8 @@ class SchemaConverter:
if without_cache:
local_ref_cache = dict()
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:
local_ref_cache = ref_cache
@@ -84,6 +89,7 @@ class SchemaConverter:
schema["title"],
schema.get("properties", {}),
schema.get("required", []),
description=schema.get("description"),
context=schema,
ref_cache=ref_cache,
required=True,
@@ -107,19 +113,26 @@ class SchemaConverter:
unsupported_field=unsupported_type,
)
def clear_ref_cache(self) -> None:
def clear_ref_cache(self, namespace: Optional[str] = "default") -> None:
"""
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.
: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)
cached_type = self._namespace_registry.get(namespace, {}).get(ref_name)
if isinstance(cached_type, type):
return cached_type

View File

@@ -17,6 +17,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
license = "MIT"
readme = "README.md"

View File

@@ -49,6 +49,20 @@ class TestEnumTypeParser(TestCase):
)
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):
parser = EnumTypeParser()

View File

@@ -24,6 +24,7 @@ class TestObjectTypeParser(TestCase):
properties = {
"type": "object",
"description": "obj desc",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
@@ -33,6 +34,7 @@ class TestObjectTypeParser(TestCase):
Model, _args = parser.from_properties_impl(
"placeholder", properties, ref_cache={}
)
self.assertEqual(Model.__doc__, "obj desc")
obj = Model(name="name", age=10)

View File

@@ -3,7 +3,6 @@ from jambo.parser import StringTypeParser
from pydantic import AnyUrl, EmailStr
import unittest
from datetime import date, datetime, time, timedelta, timezone
from ipaddress import IPv4Address, IPv6Address, ip_address
from unittest import TestCase
@@ -121,7 +120,7 @@ class TestStringTypeParser(TestCase):
type_parsing, type_validator = parser.from_properties("placeholder", properties)
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):
parser = StringTypeParser()
@@ -299,7 +298,6 @@ class TestStringTypeParser(TestCase):
},
)
@unittest.skip("Duration parsing not yet implemented")
def test_string_parser_with_timedelta_format(self):
parser = StringTypeParser()
@@ -315,9 +313,9 @@ class TestStringTypeParser(TestCase):
self.assertEqual(
type_validator["examples"],
[
timedelta(days=7),
timedelta(days=428, hours=4, minutes=5, seconds=6),
timedelta(minutes=30),
timedelta(hours=4, minutes=5, seconds=6),
timedelta(days=7),
timedelta(seconds=0.5),
],
)

View File

@@ -19,7 +19,7 @@ class TestSchemaConverter(TestCase):
self.converter = SchemaConverter()
def tearDown(self):
self.converter.clear_ref_cache()
self.converter.clear_ref_cache(namespace=None)
def test_invalid_schema(self):
schema = {
@@ -274,6 +274,7 @@ class TestSchemaConverter(TestCase):
}
model = self.converter.build_with_cache(schema)
self.assertEqual(model.__doc__, "A person")
obj = model(address={"street": "123 Main St", "city": "Springfield"})
@@ -877,7 +878,6 @@ class TestSchemaConverter(TestCase):
converter2 = SchemaConverter(ref_cache)
model2 = converter2.build_with_cache(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):
@@ -1041,3 +1041,133 @@ class TestSchemaConverter(TestCase):
with self.assertRaises(InvalidSchemaException):
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)

1254
uv.lock generated

File diff suppressed because it is too large Load Diff