48 Commits

Author SHA1 Message Date
b1b5e71a81 Merge pull request #48 from HideyoshiNakazone/feature/explicit-exception-type
feat: more pythonic error parent class
2025-09-14 01:42:11 -03:00
156c825a67 feat: more pythonic error parent class 2025-09-14 01:40:59 -03:00
b4954c3b2e Merge pull request #47 from HideyoshiNakazone/feature/explicit-exception-type
Feature/explicit exception type
2025-09-14 01:13:27 -03:00
7f44e84bce feat: updates outdated docs for exceptions 2025-09-14 01:12:43 -03:00
8c6a04bbdf feat: adds simple tests for internal exceptions 2025-09-14 01:09:48 -03:00
e31002af32 feat: fixes tests to validate the type of exception thrown 2025-09-14 00:47:24 -03:00
30290771b1 feat: alters all standart errors and messages for more specific errors 2025-09-14 00:10:33 -03:00
f4d84d2749 feat: better exceptions for GenericTypeParser and AllOfTypeParser 2025-09-13 21:11:11 -03:00
e61d48881f feat: initial implementation of explicit exception types 2025-09-13 20:43:30 -03:00
f5ad857326 Merge pull request #46 from HideyoshiNakazone/feature/better-internal-typing
Better Internat Static Typing
2025-09-13 19:49:17 -03:00
e45086e29e feat: adds static type check to ci/cd 2025-09-13 19:48:17 -03:00
c1f04606ad fix: removes unecessary check 2025-09-13 19:36:53 -03:00
5eb086bafd Better Internat Static Typing 2025-09-13 00:16:41 -03:00
5c30e752e3 Merge pull request #45 from HideyoshiNakazone/chore/fixes-license-pyproject
chore: fixes license in pyproject - no change was made
2025-09-12 11:36:55 -03:00
53418f2b2b chore: fixes license in pyproject - no change was made 2025-09-12 11:36:11 -03:00
002b75c53a Merge pull request #44 from h0rv/feature/add-py-typed-support
feat: Add py.typed marker file for proper typing support
2025-09-12 10:23:13 -03:00
Robby
1167b8a540 feat: Add py.typed marker file for proper typing support
- Add py.typed marker file to jambo package directory
- Enable static type checkers to recognize and use type annotations from the library

This allows IDEs and tools like mypy, pyright to properly type-check code using this library.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-10 13:59:39 -04:00
3992057c95 Merge pull request #43 from HideyoshiNakazone/maintenance/format-lint-code
(improvement): Formats and Lints Code - Minor Changes
2025-08-20 01:13:25 -03:00
71380073e4 (improvement): Formats and Lints Code - Minor Changes 2025-08-20 01:12:56 -03:00
4055efa5bf Merge pull request #42 from HideyoshiNakazone/improvement/better-string-validations
(improvement): Adds More Type Formats to String Parser
2025-08-20 00:31:46 -03:00
97aed6e9aa (improvement): Adds tests for UUID String Format 2025-08-20 00:30:54 -03:00
d3a2f1e76c (improvement): Adds More Type Formats to String Parser 2025-08-20 00:25:02 -03:00
0a3671974f Merge pull request #41 from HideyoshiNakazone/feature/fixes-docs
(fix): Fixes docs
2025-08-20 00:00:30 -03:00
8761ee5ef6 (fix): Fixes docs 2025-08-20 00:00:03 -03:00
85b5900392 Merge pull request #40 from HideyoshiNakazone/fix/adds-check-for-discriminator-type
(fix): Adds check for discriminator type
2025-08-19 22:31:02 -03:00
7e11c817a7 (fix): Adds check for discriminator type 2025-08-19 22:28:58 -03:00
dc5853c5b2 Merge pull request #39 from HideyoshiNakazone/feature/fixes-readme
(project): Fixes Readme
2025-08-19 20:48:27 -03:00
1e5b686c23 (project): Fixes Readme 2025-08-19 20:47:58 -03:00
bbe4c6979e Merge pull request #37 from HideyoshiNakazone/feature/implements-one-of
[FEATURE] Implements OneOf
2025-08-19 20:45:30 -03:00
c5e70402db (feat): Adds Warning to Docs About Discriminator Keyword 2025-08-19 20:44:16 -03:00
15944549a0 (feat): Adds Aditional Tests 2025-08-19 20:40:49 -03:00
79932bb595 (feature): Removes _has_meaningful_constraints
Removes _has_meaningful_constraints since nowhere in the spec says that a subproperty should have a meaningful value other that its type
2025-08-19 20:29:25 -03:00
86894fa918 (feature): Fix OneOf behavior on invalid discriminator
According to the spec, propertyName is required when using a discriminator. If it is missing, the schema is invalid and should throw.
2025-08-19 20:20:20 -03:00
b386d4954e Merge remote-tracking branch 'origin/main' into feature/implements-one-of 2025-08-19 19:02:43 -03:00
1cab13a4a0 Merge pull request #38 from HideyoshiNakazone/feature/better-const-typing
[FEATURE] Adds Better Const Typing
2025-08-19 19:02:09 -03:00
6dad6e0c68 (feat): Adds Aditional Test for Non-Hashable Const Values 2025-08-19 18:58:33 -03:00
fbbff0bd9e Removes Changes Not Feature Specific 2025-08-19 18:49:45 -03:00
Thomas
9aec7c3e3b feat(jambo): Add oneOf parser (#5)
* Add support for `oneOf` type parsing with validation and example cases

* Improve `oneOf` type parsing: refine validators, add discriminator support, and expand test coverage

* Add hashable and non-hashable value support to `ConstTypeParser` with expanded test cases

* Refine `field_props` check in `_type_parser` for cleaner default handling

* Update `StringTypeParser` to refine `format` handling and enrich `json_schema_extra`

* Remove outdated `oneOf` examples from docs, expand test cases and provide refined examples with discriminator support
2025-08-19 18:44:01 -03:00
cc6f2d42d5 Separates PR for Better Testing and Readability 2025-08-19 18:40:30 -03:00
Thomas
9797fb35d9 feat(jambo): Add oneOf parser (#5)
* Add support for `oneOf` type parsing with validation and example cases

* Improve `oneOf` type parsing: refine validators, add discriminator support, and expand test coverage

* Add hashable and non-hashable value support to `ConstTypeParser` with expanded test cases

* Refine `field_props` check in `_type_parser` for cleaner default handling

* Update `StringTypeParser` to refine `format` handling and enrich `json_schema_extra`

* Remove outdated `oneOf` examples from docs, expand test cases and provide refined examples with discriminator support
2025-08-19 18:31:51 -03:00
81a5fffef0 Merge pull request #32 from fredsonnenwald/add-null
Add null type parser
2025-08-18 23:39:11 -03:00
00d88388f8 Fixes Behavior of Pydantic None Type and Adds More Tests 2025-08-18 23:33:16 -03:00
609af7c32b Merge pull request #35 from fredsonnenwald/add-duration
add string duration -> timedelta
2025-08-18 23:05:08 -03:00
c59c1e8768 Merge pull request #36 from HideyoshiNakazone/fix/required-array-field-not-honored
Fix/required array field not honored
2025-08-18 23:00:59 -03:00
7b9464f458 Fixes Array So No DefaultFactory is Created When no Default is Set and Field is Required 2025-08-18 22:53:28 -03:00
617f1aab2b Adds Failing Test Case to Test 2025-08-18 22:27:49 -03:00
Fred Sonnenwald
976708934f add string duration -> timedelta 2025-08-08 12:38:33 +01:00
Fred Sonnenwald
e9d61a1268 Add null type parser 2025-06-30 12:23:47 +01:00
49 changed files with 1769 additions and 298 deletions

View File

@@ -44,6 +44,9 @@ jobs:
uv run poe tests
uv run poe tests-report
- name: Static type check
run: uv run poe type-check
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:

View File

@@ -37,6 +37,7 @@ Created to simplifying the process of dynamically generating Pydantic models for
- nested objects
- allOf
- anyOf
- oneOf
- ref
- enum
- const

View File

@@ -0,0 +1,37 @@
jambo.exceptions package
========================
Submodules
----------
jambo.exceptions.internal\_assertion\_exception module
------------------------------------------------------
.. automodule:: jambo.exceptions.internal_assertion_exception
:members:
:show-inheritance:
:undoc-members:
jambo.exceptions.invalid\_schema\_exception module
--------------------------------------------------
.. automodule:: jambo.exceptions.invalid_schema_exception
:members:
:show-inheritance:
:undoc-members:
jambo.exceptions.unsupported\_schema\_exception module
------------------------------------------------------
.. automodule:: jambo.exceptions.unsupported_schema_exception
:members:
:show-inheritance:
:undoc-members:
Module contents
---------------
.. automodule:: jambo.exceptions
:members:
:show-inheritance:
:undoc-members:

View File

@@ -36,6 +36,22 @@ jambo.parser.boolean\_type\_parser module
:show-inheritance:
:undoc-members:
jambo.parser.const\_type\_parser module
---------------------------------------
.. automodule:: jambo.parser.const_type_parser
:members:
:show-inheritance:
:undoc-members:
jambo.parser.enum\_type\_parser module
--------------------------------------
.. automodule:: jambo.parser.enum_type_parser
:members:
:show-inheritance:
:undoc-members:
jambo.parser.float\_type\_parser module
---------------------------------------
@@ -52,6 +68,14 @@ jambo.parser.int\_type\_parser module
:show-inheritance:
:undoc-members:
jambo.parser.null\_type\_parser module
--------------------------------------
.. automodule:: jambo.parser.null_type_parser
:members:
:show-inheritance:
:undoc-members:
jambo.parser.object\_type\_parser module
----------------------------------------
@@ -60,6 +84,14 @@ jambo.parser.object\_type\_parser module
:show-inheritance:
:undoc-members:
jambo.parser.oneof\_type\_parser module
---------------------------------------
.. automodule:: jambo.parser.oneof_type_parser
:members:
:show-inheritance:
:undoc-members:
jambo.parser.ref\_type\_parser module
-------------------------------------

View File

@@ -7,6 +7,7 @@ Subpackages
.. toctree::
:maxdepth: 4
jambo.exceptions
jambo.parser
jambo.types

112
docs/source/usage.oneof.rst Normal file
View File

@@ -0,0 +1,112 @@
OneOf Type
=================
The OneOf type is used to specify that an object must conform to exactly one of the specified schemas. Unlike AnyOf which allows matching multiple schemas, OneOf enforces that the data matches one and only one of the provided schemas.
Examples
-----------------
1. **Overlapping String Example** - A field that accepts strings with overlapping constraints:
.. code-block:: python
from jambo import SchemaConverter
schema = {
"title": "SimpleExample",
"type": "object",
"properties": {
"value": {
"oneOf": [
{"type": "string", "maxLength": 6},
{"type": "string", "minLength": 4}
]
}
},
"required": ["value"]
}
Model = SchemaConverter.build(schema)
# Valid: Short string (matches first schema only)
obj1 = Model(value="hi")
print(obj1.value) # Output: hi
# Valid: Long string (matches second schema only)
obj2 = Model(value="very long string")
print(obj2.value) # Output: very long string
# Invalid: Medium string (matches BOTH schemas - violates oneOf)
try:
obj3 = Model(value="hello") # 5 chars: matches maxLength=6 AND minLength=4
except ValueError as e:
print("Validation fails as expected:", e)
2. **Discriminator Example** - Different shapes with a type field:
.. code-block:: python
from jambo import SchemaConverter
schema = {
"title": "Shape",
"type": "object",
"properties": {
"shape": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "circle"},
"radius": {"type": "number", "minimum": 0}
},
"required": ["type", "radius"]
},
{
"type": "object",
"properties": {
"type": {"const": "rectangle"},
"width": {"type": "number", "minimum": 0},
"height": {"type": "number", "minimum": 0}
},
"required": ["type", "width", "height"]
}
],
"discriminator": {
"propertyName": "type"
}
}
},
"required": ["shape"]
}
Model = SchemaConverter.build(schema)
# Valid: Circle
circle = Model(shape={"type": "circle", "radius": 5.0})
print(circle.shape.type) # Output: circle
# Valid: Rectangle
rectangle = Model(shape={"type": "rectangle", "width": 10, "height": 20})
print(rectangle.shape.type) # Output: rectangle
# Invalid: Wrong properties for the type
try:
invalid = Model(shape={"type": "circle", "width": 10})
except ValueError as e:
print("Validation fails as expected:", e)
.. note::
OneOf ensures exactly one schema matches. The discriminator helps Pydantic efficiently determine which schema to use based on a specific property value.
.. warning::
If your data could match multiple schemas in a oneOf, validation will fail. Ensure schemas are mutually exclusive.
.. warning::
The discriminator feature is not officially in the JSON Schema specification, it was introduced by OpenAPI. Use it with caution and ensure it fits your use case.

View File

@@ -45,5 +45,6 @@ For more complex schemas and types see our documentation on
usage.reference
usage.allof
usage.anyof
usage.oneof
usage.enum
usage.const

View File

@@ -0,0 +1,10 @@
from .internal_assertion_exception import InternalAssertionException
from .invalid_schema_exception import InvalidSchemaException
from .unsupported_schema_exception import UnsupportedSchemaException
__all__ = [
"InternalAssertionException",
"InvalidSchemaException",
"UnsupportedSchemaException",
]

View File

@@ -0,0 +1,16 @@
class InternalAssertionException(RuntimeError):
"""Exception raised for internal assertions."""
def __init__(
self,
message: str,
) -> None:
# Normalize message by stripping redundant prefix if present
message = message.removeprefix("Internal Assertion Failed: ")
super().__init__(message)
def __str__(self) -> str:
return (
f"Internal Assertion Failed: {super().__str__()}\n"
"This is likely a bug in Jambo. Please report it at"
)

View File

@@ -0,0 +1,27 @@
from typing_extensions import Optional
class InvalidSchemaException(ValueError):
"""Exception raised for invalid JSON schemas."""
def __init__(
self,
message: str,
invalid_field: Optional[str] = None,
cause: Optional[BaseException] = None,
) -> None:
# Normalize message by stripping redundant prefix if present
message = message.removeprefix("Invalid JSON Schema: ")
self.invalid_field = invalid_field
self.cause = cause
super().__init__(message)
def __str__(self) -> str:
base_msg = f"Invalid JSON Schema: {super().__str__()}"
if self.invalid_field:
return f"{base_msg} (invalid field: {self.invalid_field})"
if self.cause:
return (
f"{base_msg} (caused by {self.cause.__class__.__name__}: {self.cause})"
)
return base_msg

View File

@@ -0,0 +1,23 @@
from typing_extensions import Optional
class UnsupportedSchemaException(ValueError):
"""Exception raised for unsupported JSON schemas."""
def __init__(
self,
message: str,
unsupported_field: Optional[str] = None,
cause: Optional[BaseException] = None,
) -> None:
# Normalize message by stripping redundant prefix if present
message = message.removeprefix("Unsupported JSON Schema: ")
self.unsupported_field = unsupported_field
self.cause = cause
super().__init__(message)
def __str__(self) -> str:
base_msg = f"Unsupported JSON Schema: {super().__str__()}"
if self.unsupported_field:
return f"{base_msg} (unsupported field: {self.unsupported_field})"
return base_msg

View File

@@ -7,7 +7,9 @@ from .const_type_parser import ConstTypeParser
from .enum_type_parser import EnumTypeParser
from .float_type_parser import FloatTypeParser
from .int_type_parser import IntTypeParser
from .null_type_parser import NullTypeParser
from .object_type_parser import ObjectTypeParser
from .oneof_type_parser import OneOfTypeParser
from .ref_type_parser import RefTypeParser
from .string_type_parser import StringTypeParser
@@ -22,7 +24,9 @@ __all__ = [
"BooleanTypeParser",
"FloatTypeParser",
"IntTypeParser",
"NullTypeParser",
"ObjectTypeParser",
"OneOfTypeParser",
"StringTypeParser",
"RefTypeParser",
]

View File

@@ -1,16 +1,17 @@
from jambo.types.type_parser_options import TypeParserOptions
from jambo.exceptions import InvalidSchemaException
from jambo.types.type_parser_options import JSONSchema, TypeParserOptions
from pydantic import Field, TypeAdapter
from typing_extensions import Annotated, Any, Generic, Self, TypeVar, Unpack
from typing_extensions import Annotated, Any, ClassVar, Generic, Self, TypeVar, Unpack
from abc import ABC, abstractmethod
T = TypeVar("T")
T = TypeVar("T", bound=type)
class GenericTypeParser(ABC, Generic[T]):
json_schema_type: str = None
json_schema_type: ClassVar[str]
type_mappings: dict[str, str] = {}
@@ -21,7 +22,7 @@ class GenericTypeParser(ABC, Generic[T]):
@abstractmethod
def from_properties_impl(
self, name: str, properties: dict[str, Any], **kwargs: Unpack[TypeParserOptions]
self, name: str, properties: JSONSchema, **kwargs: Unpack[TypeParserOptions]
) -> tuple[T, dict]:
"""
Abstract method to convert properties to a type and its fields properties.
@@ -32,7 +33,7 @@ class GenericTypeParser(ABC, Generic[T]):
"""
def from_properties(
self, name: str, properties: dict[str, Any], **kwargs: Unpack[TypeParserOptions]
self, name: str, properties: JSONSchema, **kwargs: Unpack[TypeParserOptions]
) -> tuple[T, dict]:
"""
Converts properties to a type and its fields properties.
@@ -46,15 +47,15 @@ class GenericTypeParser(ABC, Generic[T]):
)
if not self._validate_default(parsed_type, parsed_properties):
raise ValueError(
f"Default value {properties.get('default')} is not valid for type {parsed_type.__name__}"
raise InvalidSchemaException(
"Default value is not valid", invalid_field=name
)
return parsed_type, parsed_properties
@classmethod
def type_from_properties(
cls, name: str, properties: dict[str, Any], **kwargs: Unpack[TypeParserOptions]
cls, name: str, properties: JSONSchema, **kwargs: Unpack[TypeParserOptions]
) -> tuple[type, dict]:
"""
Factory method to fetch the appropriate type parser based on properties
@@ -69,17 +70,19 @@ class GenericTypeParser(ABC, Generic[T]):
return parser().from_properties(name=name, properties=properties, **kwargs)
@classmethod
def _get_impl(cls, properties: dict[str, Any]) -> type[Self]:
def _get_impl(cls, properties: JSONSchema) -> type[Self]:
for subcls in cls.__subclasses__():
schema_type, schema_value = subcls._get_schema_type()
if schema_type not in properties:
continue
if schema_value is None or schema_value == properties[schema_type]:
if schema_value is None or schema_value == properties[schema_type]: # type: ignore
return subcls
raise ValueError("Unknown type")
raise InvalidSchemaException(
"No suitable type parser found", invalid_field=str(properties)
)
@classmethod
def _get_schema_type(cls) -> tuple[str, str | None]:
@@ -108,7 +111,7 @@ class GenericTypeParser(ABC, Generic[T]):
}
@staticmethod
def _validate_default(field_type: type, field_prop: dict) -> bool:
def _validate_default(field_type: T, field_prop: dict) -> bool:
value = field_prop.get("default")
if value is None and field_prop.get("default_factory") is not None:
@@ -118,7 +121,7 @@ class GenericTypeParser(ABC, Generic[T]):
return True
try:
field = Annotated[field_type, Field(**field_prop)]
field = Annotated[field_type, Field(**field_prop)] # type: ignore
TypeAdapter(field).validate_python(value)
except Exception as _:
return False

View File

@@ -1,7 +1,9 @@
from jambo.exceptions import InvalidSchemaException
from jambo.parser._type_parser import GenericTypeParser
from jambo.types.json_schema_type import JSONSchema
from jambo.types.type_parser_options import TypeParserOptions
from typing_extensions import Any, Unpack
from typing_extensions import Unpack
class AllOfTypeParser(GenericTypeParser):
@@ -10,7 +12,7 @@ class AllOfTypeParser(GenericTypeParser):
json_schema_type = "allOf"
def from_properties_impl(
self, name, properties, **kwargs: Unpack[TypeParserOptions]
self, name: str, properties: JSONSchema, **kwargs: Unpack[TypeParserOptions]
):
sub_properties = properties.get("allOf", [])
@@ -29,32 +31,39 @@ class AllOfTypeParser(GenericTypeParser):
@staticmethod
def _get_type_parser(
sub_properties: list[dict[str, Any]],
sub_properties: list[JSONSchema],
) -> type[GenericTypeParser]:
if not sub_properties:
raise ValueError("Invalid JSON Schema: 'allOf' is empty.")
raise InvalidSchemaException(
"'allOf' must contain at least one schema", invalid_field="allOf"
)
parsers = set(
parsers: set[type[GenericTypeParser]] = set(
GenericTypeParser._get_impl(sub_property) for sub_property in sub_properties
)
if len(parsers) != 1:
raise ValueError("Invalid JSON Schema: allOf types do not match.")
raise InvalidSchemaException(
"All sub-schemas in 'allOf' must resolve to the same type",
invalid_field="allOf",
)
return parsers.pop()
@staticmethod
def _rebuild_properties_from_subproperties(
sub_properties: list[dict[str, Any]],
) -> dict[str, Any]:
properties = {}
sub_properties: list[JSONSchema],
) -> JSONSchema:
properties: JSONSchema = {}
for subProperty in sub_properties:
for name, prop in subProperty.items():
if name not in properties:
properties[name] = prop
properties[name] = prop # type: ignore
else:
# Merge properties if they exist in both sub-properties
properties[name] = AllOfTypeParser._validate_prop(
name, properties[name], prop
properties[name] = AllOfTypeParser._validate_prop( # type: ignore
name,
properties[name], # type: ignore
prop,
)
return properties
@@ -65,8 +74,8 @@ class AllOfTypeParser(GenericTypeParser):
if prop_name == "default":
if old_value != new_value:
raise ValueError(
f"Invalid JSON Schema: conflicting defaults for '{prop_name}'"
raise InvalidSchemaException(
f"Conflicting defaults for '{prop_name}'", invalid_field=prop_name
)
return old_value

View File

@@ -1,3 +1,4 @@
from jambo.exceptions import InvalidSchemaException
from jambo.parser._type_parser import GenericTypeParser
from jambo.types.type_parser_options import TypeParserOptions
@@ -14,10 +15,15 @@ class AnyOfTypeParser(GenericTypeParser):
self, name, properties, **kwargs: Unpack[TypeParserOptions]
):
if "anyOf" not in properties:
raise ValueError(f"Invalid JSON Schema: {properties}")
raise InvalidSchemaException(
f"AnyOf type {name} must have 'anyOf' property defined.",
invalid_field="anyOf",
)
if not isinstance(properties["anyOf"], list):
raise ValueError(f"Invalid JSON Schema: {properties['anyOf']}")
raise InvalidSchemaException(
"AnyOf must be a list of types.", invalid_field="anyOf"
)
mapped_properties = self.mappings_properties_builder(properties, **kwargs)

View File

@@ -1,3 +1,4 @@
from jambo.exceptions import InvalidSchemaException
from jambo.parser._type_parser import GenericTypeParser
from jambo.types.type_parser_options import TypeParserOptions
@@ -26,8 +27,15 @@ class ArrayTypeParser(GenericTypeParser):
):
item_properties = kwargs.copy()
item_properties["required"] = True
if (items := properties.get("items")) is None:
raise InvalidSchemaException(
f"Array type {name} must have 'items' property defined.",
invalid_field="items",
)
_item_type, _item_args = GenericTypeParser.type_from_properties(
name, properties["items"], **item_properties
name, items, **item_properties
)
wrapper_type = set if properties.get("uniqueItems", False) else list
@@ -35,7 +43,7 @@ class ArrayTypeParser(GenericTypeParser):
mapped_properties = self.mappings_properties_builder(properties, **kwargs)
if "default" not in mapped_properties:
if "default" in properties or not kwargs.get("required", False):
mapped_properties["default_factory"] = self._build_default_factory(
properties.get("default"), wrapper_type
)
@@ -47,8 +55,9 @@ class ArrayTypeParser(GenericTypeParser):
return lambda: None
if not isinstance(default_list, Iterable):
raise ValueError(
f"Default value for array must be an iterable, got {type(default_list)}"
raise InvalidSchemaException(
f"Default value for array must be an iterable, got {type(default_list)}",
invalid_field="default",
)
return lambda: copy.deepcopy(wrapper_type(default_list))

View File

@@ -1,3 +1,4 @@
from jambo.exceptions import InvalidSchemaException
from jambo.parser._type_parser import GenericTypeParser
from jambo.types.type_parser_options import TypeParserOptions
@@ -20,6 +21,9 @@ class BooleanTypeParser(GenericTypeParser):
default_value = properties.get("default")
if default_value is not None and not isinstance(default_value, bool):
raise ValueError(f"Default value for {name} must be a boolean.")
raise InvalidSchemaException(
f"Default value for {name} must be a boolean.",
invalid_field="default",
)
return bool, mapped_properties

View File

@@ -1,9 +1,10 @@
from jambo.exceptions import InvalidSchemaException
from jambo.parser._type_parser import GenericTypeParser
from jambo.types.json_schema_type import JSONSchemaNativeTypes
from jambo.types.type_parser_options import TypeParserOptions
from pydantic import AfterValidator
from typing_extensions import Annotated, Any, Unpack
from typing_extensions import Annotated, Any, Literal, Unpack
class ConstTypeParser(GenericTypeParser):
@@ -18,13 +19,17 @@ class ConstTypeParser(GenericTypeParser):
self, name, properties, **kwargs: Unpack[TypeParserOptions]
):
if "const" not in properties:
raise ValueError(f"Const type {name} must have 'const' property defined.")
raise InvalidSchemaException(
f"Const type {name} must have 'const' property defined.",
invalid_field="const",
)
const_value = properties["const"]
if not isinstance(const_value, JSONSchemaNativeTypes):
raise ValueError(
f"Const type {name} must have 'const' value of allowed types: {JSONSchemaNativeTypes}."
raise InvalidSchemaException(
f"Const type {name} must have 'const' value of allowed types: {JSONSchemaNativeTypes}.",
invalid_field="const",
)
const_type = self._build_const_type(const_value)
@@ -33,6 +38,14 @@ class ConstTypeParser(GenericTypeParser):
return const_type, parsed_properties
def _build_const_type(self, const_value):
# Try to use Literal for hashable types (required for discriminated unions)
# Fall back to validator approach for non-hashable types
try:
# Test if the value is hashable (can be used in Literal)
hash(const_value)
return Literal[const_value]
except TypeError:
# Non-hashable type (like list, dict), use validator approach
def _validate_const_value(value: Any) -> Any:
if value != const_value:
raise ValueError(

View File

@@ -1,6 +1,7 @@
from jambo.exceptions import InvalidSchemaException
from jambo.parser._type_parser import GenericTypeParser
from jambo.types.json_schema_type import JSONSchemaNativeTypes
from jambo.types.type_parser_options import TypeParserOptions
from jambo.types.type_parser_options import JSONSchema, TypeParserOptions
from typing_extensions import Unpack
@@ -11,30 +12,33 @@ class EnumTypeParser(GenericTypeParser):
json_schema_type = "enum"
def from_properties_impl(
self, name, properties, **kwargs: Unpack[TypeParserOptions]
self, name: str, properties: JSONSchema, **kwargs: Unpack[TypeParserOptions]
):
if "enum" not in properties:
raise ValueError(f"Enum type {name} must have 'enum' property defined.")
raise InvalidSchemaException(
f"Enum type {name} must have 'enum' property defined.",
invalid_field="enum",
)
enum_values = properties["enum"]
if not isinstance(enum_values, list):
raise ValueError(f"Enum type {name} must have 'enum' as a list of values.")
raise InvalidSchemaException(
f"Enum type {name} must have 'enum' as a list of values.",
invalid_field="enum",
)
if any(
not isinstance(value, JSONSchemaNativeTypes) for value in enum_values
):
raise ValueError(
f"Enum type {name} must have 'enum' values of allowed types: {JSONSchemaNativeTypes}."
if any(not isinstance(value, JSONSchemaNativeTypes) for value in enum_values):
raise InvalidSchemaException(
f"Enum type {name} must have 'enum' values of allowed types: {JSONSchemaNativeTypes}.",
invalid_field="enum",
)
# Create a new Enum type dynamically
enum_type = Enum(name, {str(value).upper(): value for value in enum_values})
enum_type = Enum(name, {str(value).upper(): value for value in enum_values}) # type: ignore
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:
parsed_properties["default"] = enum_type(parsed_properties["default"])
return enum_type, parsed_properties

View File

@@ -0,0 +1,18 @@
from jambo.parser._type_parser import GenericTypeParser
from jambo.types.type_parser_options import TypeParserOptions
from typing_extensions import Unpack
class NullTypeParser(GenericTypeParser):
mapped_type = type(None)
json_schema_type = "type:null"
def from_properties_impl(
self, name, properties, **kwargs: Unpack[TypeParserOptions]
):
mapped_properties = self.mappings_properties_builder(properties, **kwargs)
mapped_properties["default"] = None
return self.mapped_type, mapped_properties

View File

@@ -1,8 +1,10 @@
from jambo.parser._type_parser import GenericTypeParser
from jambo.types.json_schema_type import JSONSchema
from jambo.types.type_parser_options import TypeParserOptions
from pydantic import BaseModel, ConfigDict, Field, create_model
from typing_extensions import Any, Unpack
from pydantic.fields import FieldInfo
from typing_extensions import Unpack
class ObjectTypeParser(GenericTypeParser):
@@ -11,7 +13,7 @@ class ObjectTypeParser(GenericTypeParser):
json_schema_type = "type:object"
def from_properties_impl(
self, name: str, properties: dict[str, Any], **kwargs: Unpack[TypeParserOptions]
self, name: str, properties: JSONSchema, **kwargs: Unpack[TypeParserOptions]
) -> tuple[type[BaseModel], dict]:
type_parsing = self.to_model(
name,
@@ -32,38 +34,40 @@ class ObjectTypeParser(GenericTypeParser):
def to_model(
cls,
name: str,
schema: dict[str, Any],
properties: dict[str, JSONSchema],
required_keys: list[str],
**kwargs: Unpack[TypeParserOptions],
) -> type[BaseModel]:
"""
Converts JSON Schema object properties to a Pydantic model.
:param name: The name of the model.
:param schema: The properties of the JSON Schema object.
:param properties: The properties of the JSON Schema object.
: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(schema, required_keys, **kwargs)
fields = cls._parse_properties(properties, required_keys, **kwargs)
return create_model(name, __config__=model_config, **fields)
return create_model(name, __config__=model_config, **fields) # type: ignore
@classmethod
def _parse_properties(
cls,
properties: dict[str, Any],
properties: dict[str, JSONSchema],
required_keys: list[str],
**kwargs: Unpack[TypeParserOptions],
) -> dict[str, tuple[type, Field]]:
) -> dict[str, tuple[type, FieldInfo]]:
required_keys = required_keys or []
fields = {}
for name, prop in properties.items():
sub_property = kwargs.copy()
sub_property: TypeParserOptions = kwargs.copy()
sub_property["required"] = name in required_keys
parsed_type, parsed_properties = GenericTypeParser.type_from_properties(
name, prop, **sub_property
name,
prop,
**sub_property, # type: ignore
)
fields[name] = (parsed_type, Field(**parsed_properties))

View File

@@ -0,0 +1,115 @@
from jambo.exceptions import InvalidSchemaException
from jambo.parser._type_parser import GenericTypeParser
from jambo.types.type_parser_options import TypeParserOptions
from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, ValidationError
from typing_extensions import Annotated, Any, Union, Unpack, get_args
Annotation = Annotated[Any, ...]
class OneOfTypeParser(GenericTypeParser):
mapped_type = Union
json_schema_type = "oneOf"
def from_properties_impl(
self, name, properties, **kwargs: Unpack[TypeParserOptions]
):
if "oneOf" not in properties:
raise InvalidSchemaException(
f"Invalid JSON Schema: {properties}", invalid_field="oneOf"
)
if not isinstance(properties["oneOf"], list) or len(properties["oneOf"]) == 0:
raise InvalidSchemaException(
f"Invalid JSON Schema: {properties['oneOf']}", invalid_field="oneOf"
)
mapped_properties = self.mappings_properties_builder(properties, **kwargs)
sub_properties = properties["oneOf"]
sub_types = [
GenericTypeParser.type_from_properties(name, subProperty, **kwargs)
for subProperty in sub_properties
]
if not kwargs.get("required", False):
mapped_properties["default"] = mapped_properties.get("default")
subfield_types = [Annotated[t, Field(**v)] for t, v in sub_types]
# Added with the understanding of discriminator are not in the JsonSchema Spec,
# they were added by OpenAPI and not all implementations may support them,
# and they do not always generate a model one-to-one to the Pydantic model
# TL;DR: Discriminators were added by OpenAPI and not a Official JSON Schema feature
discriminator = properties.get("discriminator")
if discriminator is not None:
validated_type = self._build_type_one_of_with_discriminator(
subfield_types, discriminator
)
else:
validated_type = self._build_type_one_of_with_func(subfield_types)
return validated_type, mapped_properties
@staticmethod
def _build_type_one_of_with_discriminator(
subfield_types: list[Annotation], discriminator_prop: dict
) -> Annotation:
"""
Build a type with a discriminator.
"""
if not isinstance(discriminator_prop, dict):
raise InvalidSchemaException(
"Discriminator must be a dictionary", invalid_field="discriminator"
)
for field in subfield_types:
field_type, field_info = get_args(field)
if issubclass(field_type, BaseModel):
continue
raise InvalidSchemaException(
"When using a discriminator, all subfield types must be of type 'object'.",
invalid_field="discriminator",
)
property_name = discriminator_prop.get("propertyName")
if property_name is None or not isinstance(property_name, str):
raise InvalidSchemaException(
"Discriminator must have a 'propertyName' key",
invalid_field="propertyName",
)
return Annotated[Union[(*subfield_types,)], Field(discriminator=property_name)]
@staticmethod
def _build_type_one_of_with_func(subfield_types: list[Annotation]) -> Annotation:
"""
Build a type with a validation function for the oneOf constraint.
"""
def validate_one_of(value: Any) -> Any:
matched_count = 0
for field_type in subfield_types:
try:
TypeAdapter(field_type).validate_python(value)
matched_count += 1
except ValidationError:
continue
if matched_count == 0:
raise ValueError("Value does not match any of the oneOf schemas")
elif matched_count > 1:
raise ValueError(
"Value matches multiple oneOf schemas, exactly one expected"
)
return value
return Annotated[Union[(*subfield_types,)], BeforeValidator(validate_one_of)]

View File

@@ -1,10 +1,12 @@
from jambo.exceptions import InternalAssertionException, InvalidSchemaException
from jambo.parser import GenericTypeParser
from jambo.types.json_schema_type import JSONSchema
from jambo.types.type_parser_options import TypeParserOptions
from typing_extensions import Any, ForwardRef, Literal, TypeVar, Union, Unpack
from typing_extensions import ForwardRef, Literal, Union, Unpack
RefType = TypeVar("RefType", bound=Union[type, ForwardRef])
RefType = Union[type, ForwardRef]
RefStrategy = Literal["forward_ref", "def_ref"]
@@ -13,21 +15,22 @@ class RefTypeParser(GenericTypeParser):
json_schema_type = "$ref"
def from_properties_impl(
self, name: str, properties: dict[str, Any], **kwargs: Unpack[TypeParserOptions]
self, name: str, properties: JSONSchema, **kwargs: Unpack[TypeParserOptions]
) -> tuple[RefType, dict]:
if "$ref" not in properties:
raise ValueError(f"RefTypeParser: Missing $ref in properties for {name}")
raise InvalidSchemaException(
f"Missing $ref in properties for {name}", invalid_field="$ref"
)
context = kwargs.get("context")
if context is None:
raise RuntimeError(
f"RefTypeParser: Missing `content` in properties for {name}"
if kwargs.get("context") is None:
raise InternalAssertionException(
"`context` must be provided in kwargs for RefTypeParser"
)
ref_cache = kwargs.get("ref_cache")
if ref_cache is None:
raise RuntimeError(
f"RefTypeParser: Missing `ref_cache` in properties for {name}"
raise InternalAssertionException(
"`ref_cache` must be provided in kwargs for RefTypeParser"
)
mapped_properties = self.mappings_properties_builder(properties, **kwargs)
@@ -41,19 +44,19 @@ class RefTypeParser(GenericTypeParser):
# If the reference is either processing or already cached
return ref_state, mapped_properties
ref_cache[ref_name] = self._parse_from_strategy(
ref_strategy, ref_name, ref_property, **kwargs
)
ref = self._parse_from_strategy(ref_strategy, ref_name, ref_property, **kwargs)
ref_cache[ref_name] = ref
return ref_cache[ref_name], mapped_properties
return ref, mapped_properties
def _parse_from_strategy(
self,
ref_strategy: RefStrategy,
ref_name: str,
ref_property: dict[str, Any],
ref_property: JSONSchema,
**kwargs: Unpack[TypeParserOptions],
):
) -> RefType:
mapped_type: RefType
match ref_strategy:
case "forward_ref":
mapped_type = ForwardRef(ref_name)
@@ -62,14 +65,14 @@ class RefTypeParser(GenericTypeParser):
ref_name, ref_property, **kwargs
)
case _:
raise ValueError(
f"RefTypeParser: Unsupported $ref {ref_property['$ref']}"
raise InvalidSchemaException(
f"Unsupported $ref {ref_property['$ref']}", invalid_field="$ref"
)
return mapped_type
def _get_ref_from_cache(
self, ref_name: str, ref_cache: dict[str, type]
self, ref_name: str, ref_cache: dict[str, ForwardRef | type | None]
) -> RefType | type | None:
try:
ref_state = ref_cache[ref_name]
@@ -84,42 +87,48 @@ class RefTypeParser(GenericTypeParser):
# If the reference is not in the cache, we will set it to None
ref_cache[ref_name] = None
return None
def _examine_ref_strategy(
self, name: str, properties: dict[str, Any], **kwargs: Unpack[TypeParserOptions]
) -> tuple[RefStrategy, str, dict] | None:
if properties["$ref"] == "#":
self, name: str, properties: JSONSchema, **kwargs: Unpack[TypeParserOptions]
) -> tuple[RefStrategy, str, JSONSchema]:
if properties.get("$ref") == "#":
ref_name = kwargs["context"].get("title")
if ref_name is None:
raise ValueError(
"RefTypeParser: Missing title in properties for $ref of Root Reference"
raise InvalidSchemaException(
"Missing title in properties for $ref of Root Reference",
invalid_field="title",
)
return "forward_ref", ref_name, {}
if properties["$ref"].startswith("#/$defs/"):
if properties.get("$ref", "").startswith("#/$defs/"):
target_name, target_property = self._extract_target_ref(
name, properties, **kwargs
)
return "def_ref", target_name, target_property
raise ValueError(
"RefTypeParser: Only Root and $defs references are supported at the moment"
raise InvalidSchemaException(
"Only Root and $defs references are supported at the moment",
invalid_field="$ref",
)
def _extract_target_ref(
self, name: str, properties: dict[str, Any], **kwargs: Unpack[TypeParserOptions]
) -> tuple[str, dict]:
self, name: str, properties: JSONSchema, **kwargs: Unpack[TypeParserOptions]
) -> tuple[str, JSONSchema]:
target_name = None
target_property = kwargs["context"]
for prop_name in properties["$ref"].split("/")[1:]:
if prop_name not in target_property:
raise ValueError(
f"RefTypeParser: Missing {prop_name} in"
" properties for $ref {properties['$ref']}"
raise InvalidSchemaException(
f"Missing {prop_name} in properties for $ref {properties['$ref']}",
invalid_field=prop_name,
)
target_name = prop_name
target_property = target_property[prop_name]
target_property = target_property[prop_name] # type: ignore
if target_name is None or target_property is None:
raise ValueError(f"RefTypeParser: Invalid $ref {properties['$ref']}")
if not isinstance(target_name, str) or target_property is None:
raise InvalidSchemaException(
f"Invalid $ref {properties['$ref']}", invalid_field="$ref"
)
return target_name, target_property

View File

@@ -1,10 +1,13 @@
from jambo.exceptions import InvalidSchemaException
from jambo.parser._type_parser import GenericTypeParser
from jambo.types.type_parser_options import TypeParserOptions
from pydantic import EmailStr, HttpUrl, IPvAnyAddress
from pydantic import AnyUrl, EmailStr
from typing_extensions import Unpack
from datetime import date, datetime, time
from datetime import date, datetime, time, timedelta
from ipaddress import IPv4Address, IPv6Address
from uuid import UUID
class StringTypeParser(GenericTypeParser):
@@ -20,14 +23,22 @@ class StringTypeParser(GenericTypeParser):
}
format_type_mapping = {
"email": EmailStr,
"uri": HttpUrl,
"ipv4": IPvAnyAddress,
"ipv6": IPvAnyAddress,
"hostname": str,
# [7.3.1](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7.3.1). Dates, Times, and Duration
"date": date,
"time": time,
"date-time": datetime,
"duration": timedelta,
# [7.3.2](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7.3.2). Email Addresses
"email": EmailStr,
# [7.3.3](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7.3.3). Hostnames
"hostname": str,
# [7.3.4](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7.3.4). IP Addresses
"ipv4": IPv4Address,
"ipv6": IPv6Address,
# [7.3.5](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7.3.5). Resource Identifiers
"uri": AnyUrl,
# "iri" # Not supported by pydantic and currently not supported by jambo
"uuid": UUID,
}
format_pattern_mapping = {
@@ -37,16 +48,16 @@ class StringTypeParser(GenericTypeParser):
def from_properties_impl(
self, name, properties, **kwargs: Unpack[TypeParserOptions]
):
mapped_properties = self.mappings_properties_builder(
properties, **kwargs
)
mapped_properties = self.mappings_properties_builder(properties, **kwargs)
format_type = properties.get("format")
if not format_type:
return str, mapped_properties
if format_type not in self.format_type_mapping:
raise ValueError(f"Unsupported string format: {format_type}")
raise InvalidSchemaException(
f"Unsupported string format: {format_type}", invalid_field="format"
)
mapped_type = self.format_type_mapping[format_type]
if format_type in self.format_pattern_mapping:

0
jambo/py.typed Normal file
View File

View File

@@ -1,5 +1,6 @@
from jambo.exceptions import InvalidSchemaException, UnsupportedSchemaException
from jambo.parser import ObjectTypeParser, RefTypeParser
from jambo.types.json_schema_type import JSONSchema
from jambo.types import JSONSchema
from jsonschema.exceptions import SchemaError
from jsonschema.validators import validator_for
@@ -25,12 +26,16 @@ class SchemaConverter:
try:
validator = validator_for(schema)
validator.check_schema(schema)
except SchemaError as e:
raise ValueError(f"Invalid JSON Schema: {e}")
validator.check_schema(schema) # type: ignore
except SchemaError as err:
raise InvalidSchemaException(
"Validation of JSON Schema failed.", cause=err
) from err
if "title" not in schema:
raise ValueError("JSON Schema must have a title.")
raise InvalidSchemaException(
"Schema must have a title.", invalid_field="title"
)
schema_type = SchemaConverter._get_schema_type(schema)
@@ -38,10 +43,11 @@ class SchemaConverter:
case "object":
return ObjectTypeParser.to_model(
schema["title"],
schema["properties"],
schema.get("properties", {}),
schema.get("required", []),
context=schema,
ref_cache=dict(),
required=True,
)
case "$ref":
@@ -50,13 +56,20 @@ class SchemaConverter:
schema,
context=schema,
ref_cache=dict(),
required=True,
)
return parsed_model
case _:
raise TypeError(f"Unsupported schema type: {schema_type}")
unsupported_type = (
f"type:{schema_type}" if schema_type else "missing type"
)
raise UnsupportedSchemaException(
"Only object and $ref schema types are supported.",
unsupported_field=unsupported_type,
)
@staticmethod
def _get_schema_type(schema: JSONSchema) -> str:
def _get_schema_type(schema: JSONSchema) -> str | None:
"""
Returns the type of the schema.
:param schema: The JSON Schema to check.
@@ -65,4 +78,4 @@ class SchemaConverter:
if "$ref" in schema:
return "$ref"
return schema.get("type", "undefined")
return schema.get("type")

View File

@@ -0,0 +1,16 @@
from .json_schema_type import (
JSONSchema,
JSONSchemaNativeTypes,
JSONSchemaType,
JSONType,
)
from .type_parser_options import TypeParserOptions
__all__ = [
"JSONSchemaType",
"JSONSchemaNativeTypes",
"JSONType",
"JSONSchema",
"TypeParserOptions",
]

View File

@@ -1,93 +1,80 @@
from typing_extensions import Dict, List, Literal, TypedDict, Union
from __future__ import annotations
from typing_extensions import (
Dict,
List,
Literal,
TypedDict,
Union,
)
from types import NoneType
# Primitive JSON types
JSONSchemaType = Literal[
"string", "number", "integer", "boolean", "object", "array", "null"
]
JSONSchemaNativeTypes: tuple[type, ...] = (
str,
int,
float,
int,
bool,
list,
set,
NoneType,
)
JSONType = Union[str, int, float, bool, None, Dict[str, "JSONType"], List["JSONType"]]
class JSONSchema(TypedDict, total=False):
# Basic metadata
title: str
description: str
default: JSONType
examples: List[JSONType]
# Type definitions
type: Union[JSONSchemaType, List[JSONSchemaType]]
# Object-specific keywords
properties: Dict[str, "JSONSchema"]
required: List[str]
additionalProperties: Union[bool, "JSONSchema"]
minProperties: int
maxProperties: int
patternProperties: Dict[str, "JSONSchema"]
dependencies: Dict[str, Union[List[str], "JSONSchema"]]
# Array-specific keywords
items: Union["JSONSchema", List["JSONSchema"]]
additionalItems: Union[bool, "JSONSchema"]
minItems: int
maxItems: int
uniqueItems: bool
# String-specific keywords
minLength: int
maxLength: int
pattern: str
format: str
# Number-specific keywords
minimum: float
maximum: float
exclusiveMinimum: float
exclusiveMaximum: float
multipleOf: float
# Enum and const
enum: List[JSONType]
const: JSONType
# Conditionals
if_: "JSONSchema" # 'if' is a reserved word in Python
then: "JSONSchema"
else_: "JSONSchema" # 'else' is also a reserved word
# Combination keywords
allOf: List["JSONSchema"]
anyOf: List["JSONSchema"]
oneOf: List["JSONSchema"]
not_: "JSONSchema" # 'not' is a reserved word
# Fix forward references
JSONSchema.__annotations__["properties"] = Dict[str, JSONSchema]
JSONSchema.__annotations__["items"] = Union[JSONSchema, List[JSONSchema]]
JSONSchema.__annotations__["additionalItems"] = Union[bool, JSONSchema]
JSONSchema.__annotations__["additionalProperties"] = Union[bool, JSONSchema]
JSONSchema.__annotations__["patternProperties"] = Dict[str, JSONSchema]
JSONSchema.__annotations__["dependencies"] = Dict[str, Union[List[str], JSONSchema]]
JSONSchema.__annotations__["if_"] = JSONSchema
JSONSchema.__annotations__["then"] = JSONSchema
JSONSchema.__annotations__["else_"] = JSONSchema
JSONSchema.__annotations__["allOf"] = List[JSONSchema]
JSONSchema.__annotations__["anyOf"] = List[JSONSchema]
JSONSchema.__annotations__["oneOf"] = List[JSONSchema]
JSONSchema.__annotations__["not_"] = JSONSchema
# Dynamically define TypedDict with JSON Schema keywords
JSONSchema = TypedDict(
"JSONSchema",
{
"$id": str,
"$schema": str,
"$ref": str,
"$anchor": str,
"$comment": str,
"$defs": Dict[str, "JSONSchema"],
"title": str,
"description": str,
"default": JSONType,
"examples": List[JSONType],
"type": JSONSchemaType,
"enum": List[JSONType],
"const": JSONType,
"properties": Dict[str, "JSONSchema"],
"patternProperties": Dict[str, "JSONSchema"],
"additionalProperties": Union[bool, "JSONSchema"],
"required": List[str],
"minProperties": int,
"maxProperties": int,
"dependencies": Dict[str, Union[List[str], "JSONSchema"]],
"items": "JSONSchema",
"prefixItems": List["JSONSchema"],
"additionalItems": Union[bool, "JSONSchema"],
"contains": "JSONSchema",
"minItems": int,
"maxItems": int,
"uniqueItems": bool,
"minLength": int,
"maxLength": int,
"pattern": str,
"format": str,
"minimum": float,
"maximum": float,
"exclusiveMinimum": Union[bool, float],
"exclusiveMaximum": Union[bool, float],
"multipleOf": float,
"if": "JSONSchema",
"then": "JSONSchema",
"else": "JSONSchema",
"allOf": List["JSONSchema"],
"anyOf": List["JSONSchema"],
"oneOf": List["JSONSchema"],
"not": "JSONSchema",
},
total=False, # all fields optional
)

View File

@@ -1,9 +1,9 @@
from jambo.types.json_schema_type import JSONSchema
from typing_extensions import TypedDict
from typing_extensions import ForwardRef, TypedDict
class TypeParserOptions(TypedDict):
required: bool
context: JSONSchema
ref_cache: dict[str, type]
ref_cache: dict[str, ForwardRef | type | None]

View File

@@ -18,7 +18,7 @@ classifiers = [
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
license = { file = "LICENSE" }
license = "MIT"
readme = "README.md"
# Project Dependencies
@@ -31,12 +31,14 @@ dependencies = [
[dependency-groups]
dev = [
"coverage>=7.8.0",
"mypy>=1.18.1",
"poethepoet>=0.33.1",
"pre-commit>=4.2.0",
"ruff>=0.11.4",
"sphinx>=8.1.3",
"sphinx-autobuild>=2024.10.3",
"sphinx-rtd-theme>=3.0.2",
"types-jsonschema>=4.25.1.20250822",
]
@@ -50,6 +52,7 @@ repository = "https://github.com/HideyoshiNakazone/jambo.git"
create-hooks = "bash .githooks/set-hooks.sh"
tests = "python -m coverage run -m unittest discover -v"
tests-report = "python -m coverage xml"
type-check = "mypy jambo"
serve-docs = "sphinx-autobuild docs/source docs/build"
# Build System

View File

View File

@@ -0,0 +1,21 @@
from jambo.exceptions.internal_assertion_exception import InternalAssertionException
from unittest import TestCase
class TestInternalAssertionException(TestCase):
def test_inheritance(self):
self.assertTrue(issubclass(InternalAssertionException, RuntimeError))
def test_message(self):
message = "This is an internal assertion error."
expected_message = (
f"Internal Assertion Failed: {message}\n"
"This is likely a bug in Jambo. Please report it at"
)
with self.assertRaises(InternalAssertionException) as ctx:
raise InternalAssertionException(message)
self.assertEqual(str(ctx.exception), expected_message)

View File

@@ -0,0 +1,44 @@
from jambo.exceptions.invalid_schema_exception import InvalidSchemaException
from unittest import TestCase
class TestInternalAssertionException(TestCase):
def test_inheritance(self):
self.assertTrue(issubclass(InvalidSchemaException, ValueError))
def test_message(self):
message = "This is an internal assertion error."
expected_message = f"Invalid JSON Schema: {message}"
with self.assertRaises(InvalidSchemaException) as ctx:
raise InvalidSchemaException(message)
self.assertEqual(str(ctx.exception), expected_message)
def test_invalid_field(self):
message = "This is an internal assertion error."
invalid_field = "testField"
expected_message = (
f"Invalid JSON Schema: {message} (invalid field: {invalid_field})"
)
with self.assertRaises(InvalidSchemaException) as ctx:
raise InvalidSchemaException(message, invalid_field=invalid_field)
self.assertEqual(str(ctx.exception), expected_message)
def test_cause(self):
message = "This is an internal assertion error."
cause = ValueError("Underlying cause")
expected_message = (
f"Invalid JSON Schema: {message} (caused by ValueError: Underlying cause)"
)
with self.assertRaises(InvalidSchemaException) as ctx:
raise InvalidSchemaException(message, cause=cause)
self.assertEqual(str(ctx.exception), expected_message)

View File

@@ -0,0 +1,31 @@
from jambo.exceptions.unsupported_schema_exception import UnsupportedSchemaException
from unittest import TestCase
class TestUnsupportedSchemaException(TestCase):
def test_inheritance(self):
self.assertTrue(issubclass(UnsupportedSchemaException, ValueError))
def test_message(self):
message = "This is an internal assertion error."
expected_message = f"Unsupported JSON Schema: {message}"
with self.assertRaises(UnsupportedSchemaException) as ctx:
raise UnsupportedSchemaException(message)
self.assertEqual(str(ctx.exception), expected_message)
def test_unsupported_field(self):
message = "This is an internal assertion error."
invalid_field = "testField"
expected_message = (
f"Unsupported JSON Schema: {message} (unsupported field: {invalid_field})"
)
with self.assertRaises(UnsupportedSchemaException) as ctx:
raise UnsupportedSchemaException(message, unsupported_field=invalid_field)
self.assertEqual(str(ctx.exception), expected_message)

View File

@@ -1,5 +1,8 @@
from jambo.exceptions import InvalidSchemaException
from jambo.parser.allof_type_parser import AllOfTypeParser
from pydantic import ValidationError
from unittest import TestCase
@@ -42,13 +45,13 @@ class TestAllOfTypeParser(TestCase):
"placeholder", properties
)
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
type_parsing(name="John", age=101)
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
type_parsing(name="", age=30)
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
type_parsing(name="John Invalid", age=30)
obj = type_parsing(name="John", age=30)
@@ -87,10 +90,10 @@ class TestAllOfTypeParser(TestCase):
"placeholder", properties
)
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
type_parsing(name="John")
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
type_parsing(age=30)
obj = type_parsing(name="John", age=30)
@@ -154,7 +157,7 @@ class TestAllOfTypeParser(TestCase):
]
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
AllOfTypeParser().from_properties("placeholder", properties)
def test_all_of_invalid_type_not_present(self):
@@ -167,7 +170,7 @@ class TestAllOfTypeParser(TestCase):
]
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
AllOfTypeParser().from_properties("placeholder", properties)
def test_all_of_invalid_type_in_fields(self):
@@ -180,7 +183,7 @@ class TestAllOfTypeParser(TestCase):
]
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
AllOfTypeParser().from_properties("placeholder", properties)
def test_all_of_invalid_type_not_all_equal(self):
@@ -196,7 +199,7 @@ class TestAllOfTypeParser(TestCase):
]
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
AllOfTypeParser().from_properties("placeholder", properties)
def test_all_of_description_field(self):
@@ -304,5 +307,5 @@ class TestAllOfTypeParser(TestCase):
],
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
AllOfTypeParser().from_properties("placeholder", properties)

View File

@@ -1,3 +1,4 @@
from jambo.exceptions import InvalidSchemaException
from jambo.parser.anyof_type_parser import AnyOfTypeParser
from typing_extensions import Annotated, Union, get_args, get_origin
@@ -14,7 +15,7 @@ class TestAnyOfTypeParser(TestCase):
],
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
AnyOfTypeParser().from_properties("placeholder", properties)
def test_any_of_with_invalid_properties(self):
@@ -22,7 +23,7 @@ class TestAnyOfTypeParser(TestCase):
"anyOf": None,
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
AnyOfTypeParser().from_properties("placeholder", properties)
def test_any_of_string_or_int(self):
@@ -95,5 +96,5 @@ class TestAnyOfTypeParser(TestCase):
"default": 3.14,
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
AnyOfTypeParser().from_properties("placeholder", properties)

View File

@@ -1,3 +1,4 @@
from jambo.exceptions import InvalidSchemaException
from jambo.parser import ArrayTypeParser
from typing_extensions import get_args
@@ -18,6 +19,17 @@ class TestArrayTypeParser(TestCase):
self.assertEqual(type_parsing.__origin__, list)
self.assertEqual(element_type, str)
def test_array_parser_with_no_items(self):
parser = ArrayTypeParser()
properties = {
"default": ["a", "b", "c", "d"],
"maxItems": 3,
}
with self.assertRaises(InvalidSchemaException):
parser.from_properties("placeholder", properties)
def test_array_parser_with_options_unique(self):
parser = ArrayTypeParser()
@@ -67,7 +79,7 @@ class TestArrayTypeParser(TestCase):
properties = {"items": {"type": "string"}, "default": ["a", 1, "c"]}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parser.from_properties("placeholder", properties)
def test_array_parser_with_invalid_default_type(self):
@@ -75,15 +87,15 @@ class TestArrayTypeParser(TestCase):
properties = {"items": {"type": "string"}, "default": 000}
with self.assertRaises(ValueError):
parser.from_properties("placeholder", properties)
with self.assertRaises(InvalidSchemaException):
parser.from_properties("placeholder", properties=properties)
def test_array_parser_with_invalid_default_min(self):
parser = ArrayTypeParser()
properties = {"items": {"type": "string"}, "default": ["a"], "minItems": 2}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parser.from_properties("placeholder", properties)
def test_array_parser_with_invalid_default_max(self):
@@ -95,5 +107,5 @@ class TestArrayTypeParser(TestCase):
"maxItems": 3,
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parser.from_properties("placeholder", properties)

View File

@@ -1,3 +1,4 @@
from jambo.exceptions import InvalidSchemaException
from jambo.parser import BooleanTypeParser
from unittest import TestCase
@@ -39,5 +40,5 @@ class TestBoolTypeParser(TestCase):
"default": "invalid",
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parser.from_properties_impl("placeholder", properties)

View File

@@ -1,12 +1,14 @@
from jambo.exceptions import InvalidSchemaException
from jambo.parser import ConstTypeParser
from typing_extensions import Annotated, get_args, get_origin
from typing_extensions import Annotated, Literal, get_args, get_origin
from unittest import TestCase
class TestConstTypeParser(TestCase):
def test_const_type_parser(self):
def test_const_type_parser_hashable_value(self):
"""Test const parser with hashable values (uses Literal)"""
parser = ConstTypeParser()
expected_const_value = "United States of America"
@@ -16,8 +18,60 @@ class TestConstTypeParser(TestCase):
"country", properties
)
# Check that we get a Literal type for hashable values
self.assertEqual(get_origin(parsed_type), Literal)
self.assertEqual(get_args(parsed_type), (expected_const_value,))
self.assertEqual(parsed_properties["default"], expected_const_value)
def test_const_type_parser_non_hashable_value(self):
"""Test const parser with non-hashable values (uses Annotated with validator)"""
parser = ConstTypeParser()
expected_const_value = [1, 2, 3] # Lists are not hashable
properties = {"const": expected_const_value}
parsed_type, parsed_properties = parser.from_properties_impl(
"list_const", properties
)
# Check that we get an Annotated type for non-hashable values
self.assertEqual(get_origin(parsed_type), Annotated)
self.assertIn(str, get_args(parsed_type))
self.assertIn(list, get_args(parsed_type))
self.assertEqual(parsed_properties["default"], expected_const_value)
def test_const_type_parser_integer_value(self):
"""Test const parser with integer values (uses Literal)"""
parser = ConstTypeParser()
expected_const_value = 42
properties = {"const": expected_const_value}
parsed_type, parsed_properties = parser.from_properties_impl(
"int_const", properties
)
# Check that we get a Literal type for hashable values
self.assertEqual(get_origin(parsed_type), Literal)
self.assertEqual(get_args(parsed_type), (expected_const_value,))
self.assertEqual(parsed_properties["default"], expected_const_value)
def test_const_type_parser_boolean_value(self):
"""Test const parser with boolean values (uses Literal)"""
parser = ConstTypeParser()
expected_const_value = True
properties = {"const": expected_const_value}
parsed_type, parsed_properties = parser.from_properties_impl(
"bool_const", properties
)
# Check that we get a Literal type for hashable values
self.assertEqual(get_origin(parsed_type), Literal)
self.assertEqual(get_args(parsed_type), (expected_const_value,))
self.assertEqual(parsed_properties["default"], expected_const_value)
@@ -27,7 +81,7 @@ class TestConstTypeParser(TestCase):
expected_const_value = "United States of America"
properties = {"notConst": expected_const_value}
with self.assertRaises(ValueError) as context:
with self.assertRaises(InvalidSchemaException) as context:
parser.from_properties_impl("invalid_country", properties)
self.assertIn(
@@ -40,7 +94,7 @@ class TestConstTypeParser(TestCase):
properties = {"const": {}}
with self.assertRaises(ValueError) as context:
with self.assertRaises(InvalidSchemaException) as context:
parser.from_properties_impl("invalid_country", properties)
self.assertIn(

View File

@@ -1,3 +1,4 @@
from jambo.exceptions import InvalidSchemaException
from jambo.parser import EnumTypeParser
from enum import Enum
@@ -10,7 +11,7 @@ class TestEnumTypeParser(TestCase):
schema = {}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parsed_type, parsed_properties = parser.from_properties_impl(
"TestEnum",
schema,
@@ -23,7 +24,7 @@ class TestEnumTypeParser(TestCase):
"enum": "not_a_list",
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parsed_type, parsed_properties = parser.from_properties_impl(
"TestEnum",
schema,
@@ -86,5 +87,5 @@ class TestEnumTypeParser(TestCase):
"enum": ["value1", 42, dict()],
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parser.from_properties_impl("TestEnum", schema)

View File

@@ -1,3 +1,4 @@
from jambo.exceptions import InvalidSchemaException
from jambo.parser import FloatTypeParser
from unittest import TestCase
@@ -61,7 +62,7 @@ class TestFloatTypeParser(TestCase):
"multipleOf": 0.5,
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parser.from_properties("placeholder", properties)
def test_float_parser_with_default_invalid_maximum(self):
@@ -75,7 +76,7 @@ class TestFloatTypeParser(TestCase):
"multipleOf": 0.5,
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parser.from_properties("placeholder", properties)
def test_float_parser_with_default_invalid_minimum(self):
@@ -89,7 +90,7 @@ class TestFloatTypeParser(TestCase):
"multipleOf": 0.5,
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parser.from_properties("placeholder", properties)
def test_float_parser_with_default_invalid_exclusive_maximum(self):
@@ -103,7 +104,7 @@ class TestFloatTypeParser(TestCase):
"multipleOf": 0.5,
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parser.from_properties("placeholder", properties)
def test_float_parser_with_default_invalid_exclusive_minimum(self):
@@ -117,7 +118,7 @@ class TestFloatTypeParser(TestCase):
"multipleOf": 0.5,
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parser.from_properties("placeholder", properties)
def test_float_parser_with_default_invalid_multiple(self):
@@ -131,5 +132,5 @@ class TestFloatTypeParser(TestCase):
"multipleOf": 2.0,
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parser.from_properties("placeholder", properties)

View File

@@ -1,3 +1,4 @@
from jambo.exceptions import InvalidSchemaException
from jambo.parser import IntTypeParser
from unittest import TestCase
@@ -61,7 +62,7 @@ class TestIntTypeParser(TestCase):
"multipleOf": 2,
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parser.from_properties("placeholder", properties)
def test_int_parser_with_default_invalid_maximum(self):
@@ -75,7 +76,7 @@ class TestIntTypeParser(TestCase):
"multipleOf": 2,
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parser.from_properties("placeholder", properties)
def test_int_parser_with_default_invalid_minimum(self):
@@ -89,7 +90,7 @@ class TestIntTypeParser(TestCase):
"multipleOf": 2,
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parser.from_properties("placeholder", properties)
def test_int_parser_with_default_invalid_exclusive_maximum(self):
@@ -103,7 +104,7 @@ class TestIntTypeParser(TestCase):
"multipleOf": 2,
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parser.from_properties("placeholder", properties)
def test_int_parser_with_default_invalid_exclusive_minimum(self):
@@ -117,7 +118,7 @@ class TestIntTypeParser(TestCase):
"multipleOf": 2,
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parser.from_properties("placeholder", properties)
def test_int_parser_with_default_invalid_multipleOf(self):
@@ -131,5 +132,5 @@ class TestIntTypeParser(TestCase):
"multipleOf": 2,
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parser.from_properties("placeholder", properties)

View File

@@ -0,0 +1,29 @@
from jambo.parser import NullTypeParser
from unittest import TestCase
class TestNullTypeParser(TestCase):
def test_null_parser_no_options(self):
parser = NullTypeParser()
properties = {"type": "null"}
type_parsing, type_validator = parser.from_properties_impl(
"placeholder", properties
)
self.assertEqual(type_parsing, type(None))
self.assertEqual(type_validator, {"default": None})
def test_null_parser_with_invalid_default(self):
parser = NullTypeParser()
properties = {"type": "null", "default": "invalid"}
type_parsing, type_validator = parser.from_properties_impl(
"placeholder", properties
)
self.assertEqual(type_parsing, type(None))
self.assertEqual(type_validator, {"default": None})

View File

@@ -0,0 +1,534 @@
from jambo import SchemaConverter
from jambo.exceptions import InvalidSchemaException
from jambo.parser.oneof_type_parser import OneOfTypeParser
from pydantic import ValidationError
from unittest import TestCase
class TestOneOfTypeParser(TestCase):
def test_oneof_raises_on_invalid_property(self):
with self.assertRaises(InvalidSchemaException):
OneOfTypeParser().from_properties_impl(
"test_field",
{
# Invalid schema, should have property "oneOf"
},
required=True,
context={},
ref_cache={},
)
with self.assertRaises(InvalidSchemaException):
OneOfTypeParser().from_properties_impl(
"test_field",
{
"oneOf": [], # should throw because oneOf must be a list with at least one item
},
required=True,
context={},
ref_cache={},
)
with self.assertRaises(InvalidSchemaException):
SchemaConverter.build(
{
"title": "Test",
"type": "object",
"properties": {
"value": {
"oneOf": [], # should throw because oneOf requires at least one schema
}
},
}
)
def test_oneof_basic_integer_and_string(self):
schema = {
"title": "Person",
"description": "A person with an ID that can be either an integer or a formatted string",
"type": "object",
"properties": {
"id": {
"oneOf": [
{"type": "integer", "minimum": 1},
{"type": "string", "pattern": "^[A-Z]{2}[0-9]{4}$"},
]
},
},
"required": ["id"],
}
Model = SchemaConverter.build(schema)
obj1 = Model(id=123)
self.assertEqual(obj1.id, 123)
obj2 = Model(id="AB1234")
self.assertEqual(obj2.id, "AB1234")
def test_oneof_validation_failures(self):
schema = {
"title": "Person",
"type": "object",
"properties": {
"id": {
"oneOf": [
{"type": "integer", "minimum": 1},
{"type": "string", "pattern": "^[A-Z]{2}[0-9]{4}$"},
]
},
},
"required": ["id"],
}
Model = SchemaConverter.build(schema)
with self.assertRaises(ValidationError):
Model(id=-5)
with self.assertRaises(ValidationError):
Model(id="invalid")
with self.assertRaises(ValidationError):
Model(id=123.45)
def test_oneof_with_conflicting_schemas(self):
schema = {
"title": "Value",
"type": "object",
"properties": {
"data": {
"oneOf": [
{"type": "number", "multipleOf": 2},
{"type": "number", "multipleOf": 3},
]
},
},
"required": ["data"],
}
Model = SchemaConverter.build(schema)
obj1 = Model(data=4)
self.assertEqual(obj1.data, 4)
obj2 = Model(data=9)
self.assertEqual(obj2.data, 9)
with self.assertRaises(ValidationError) as cm:
Model(data=6)
self.assertIn("matches multiple oneOf schemas", str(cm.exception))
with self.assertRaises(ValidationError):
Model(data=5)
def test_oneof_with_objects(self):
schema = {
"title": "Contact",
"type": "object",
"properties": {
"contact_info": {
"oneOf": [
{
"type": "object",
"properties": {
"email": {"type": "string", "format": "email"}
},
"required": ["email"],
"additionalProperties": False,
},
{
"type": "object",
"properties": {
"phone": {"type": "string", "pattern": "^[0-9-]+$"}
},
"required": ["phone"],
"additionalProperties": False,
},
]
},
},
"required": ["contact_info"],
}
Model = SchemaConverter.build(schema)
obj1 = Model(contact_info={"email": "user@example.com"})
self.assertEqual(obj1.contact_info.email, "user@example.com")
obj2 = Model(contact_info={"phone": "123-456-7890"})
self.assertEqual(obj2.contact_info.phone, "123-456-7890")
with self.assertRaises(ValidationError):
Model(contact_info={"email": "user@example.com", "phone": "123-456-7890"})
def test_oneof_with_discriminator_basic(self):
schema = {
"title": "Pet",
"type": "object",
"properties": {
"pet": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "cat"},
"meows": {"type": "boolean"},
},
"required": ["type", "meows"],
},
{
"type": "object",
"properties": {
"type": {"const": "dog"},
"barks": {"type": "boolean"},
},
"required": ["type", "barks"],
},
],
"discriminator": {"propertyName": "type"},
}
},
"required": ["pet"],
}
Model = SchemaConverter.build(schema)
cat = Model(pet={"type": "cat", "meows": True})
self.assertEqual(cat.pet.type, "cat")
self.assertEqual(cat.pet.meows, True)
dog = Model(pet={"type": "dog", "barks": False})
self.assertEqual(dog.pet.type, "dog")
self.assertEqual(dog.pet.barks, False)
with self.assertRaises(ValidationError):
Model(pet={"type": "cat", "barks": True})
with self.assertRaises(ValidationError):
Model(pet={"type": "bird", "flies": True})
def test_oneof_with_invalid_types(self):
with self.assertRaises(InvalidSchemaException):
SchemaConverter.build(
{
"title": "Pet",
"type": "object",
"properties": {
"pet": {
"oneOf": [
{
"type": "number",
},
{
"type": "string",
},
],
"discriminator": {"propertyName": "type"},
}
},
"required": ["pet"],
}
)
def test_oneof_with_discriminator_mapping(self):
schema = {
"title": "Vehicle",
"type": "object",
"properties": {
"vehicle": {
"oneOf": [
{
"type": "object",
"properties": {
"vehicle_type": {"const": "car"},
"doors": {
"type": "integer",
"minimum": 2,
"maximum": 4,
},
},
"required": ["vehicle_type", "doors"],
},
{
"type": "object",
"properties": {
"vehicle_type": {"const": "motorcycle"},
"engine_size": {"type": "number", "minimum": 125},
},
"required": ["vehicle_type", "engine_size"],
},
],
"discriminator": {
"propertyName": "vehicle_type",
"mapping": {
"car": "#/properties/vehicle/oneOf/0",
"motorcycle": "#/properties/vehicle/oneOf/1",
},
},
}
},
"required": ["vehicle"],
}
Model = SchemaConverter.build(schema)
car = Model(vehicle={"vehicle_type": "car", "doors": 4})
self.assertEqual(car.vehicle.vehicle_type, "car")
self.assertEqual(car.vehicle.doors, 4)
motorcycle = Model(vehicle={"vehicle_type": "motorcycle", "engine_size": 600.0})
self.assertEqual(motorcycle.vehicle.vehicle_type, "motorcycle")
self.assertEqual(motorcycle.vehicle.engine_size, 600.0)
def test_oneof_with_discriminator_invalid_values(self):
schema = {
"title": "Shape",
"type": "object",
"properties": {
"shape": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "circle"},
"radius": {"type": "number", "minimum": 0},
},
"required": ["type", "radius"],
},
{
"type": "object",
"properties": {
"type": {"const": "square"},
"side": {"type": "number", "minimum": 0},
},
"required": ["type", "side"],
},
],
"discriminator": {"propertyName": "type"},
}
},
"required": ["shape"],
}
Model = SchemaConverter.build(schema)
with self.assertRaises(ValidationError):
Model(shape={"type": "triangle", "base": 5, "height": 3})
with self.assertRaises(ValidationError):
Model(shape={"type": "circle", "side": 5})
with self.assertRaises(ValidationError):
Model(shape={"radius": 5})
def test_oneof_missing_properties(self):
schema = {
"title": "Test",
"type": "object",
"properties": {
"value": {
"notOneOf": [
{"type": "string"},
{"type": "integer"},
]
},
},
}
with self.assertRaises(InvalidSchemaException):
SchemaConverter.build(schema)
def test_oneof_invalid_properties(self):
schema = {
"title": "Test",
"type": "object",
"properties": {
"value": {"oneOf": None},
},
}
with self.assertRaises(InvalidSchemaException):
SchemaConverter.build(schema)
def test_oneof_with_default_value(self):
schema = {
"title": "Test",
"type": "object",
"properties": {
"value": {
"oneOf": [
{"type": "string"},
{"type": "integer"},
],
"default": "test",
},
},
}
Model = SchemaConverter.build(schema)
obj = Model()
self.assertEqual(obj.value, "test")
def test_oneof_with_invalid_default_value(self):
schema = {
"title": "Test",
"type": "object",
"properties": {
"value": {
"oneOf": [
{"type": "string", "minLength": 5},
{"type": "integer", "minimum": 10},
],
"default": "hi",
},
},
}
with self.assertRaises(InvalidSchemaException):
SchemaConverter.build(schema)
def test_oneof_discriminator_without_property_name(self):
# Should throw because the spec determines propertyName is required for discriminator
with self.assertRaises(InvalidSchemaException):
SchemaConverter.build(
{
"title": "Test",
"type": "object",
"properties": {
"value": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "a"},
"value": {"type": "string"},
},
},
{
"type": "object",
"properties": {
"type": {"const": "b"},
"value": {"type": "integer"},
},
},
],
"discriminator": {}, # discriminator without propertyName
}
},
}
)
def test_oneof_discriminator_with_invalid_discriminator(self):
# Should throw because a valid discriminator is required
with self.assertRaises(InvalidSchemaException):
SchemaConverter.build(
{
"title": "Test",
"type": "object",
"properties": {
"value": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "a"},
"value": {"type": "string"},
},
},
{
"type": "object",
"properties": {
"type": {"const": "b"},
"value": {"type": "integer"},
},
},
],
"discriminator": "invalid", # discriminator without propertyName
}
},
}
)
def test_oneof_overlapping_strings_from_docs(self):
"""Test the overlapping strings example from documentation"""
schema = {
"title": "SimpleExample",
"type": "object",
"properties": {
"value": {
"oneOf": [
{"type": "string", "maxLength": 6},
{"type": "string", "minLength": 4},
]
}
},
"required": ["value"],
}
Model = SchemaConverter.build(schema)
# Valid: Short string (matches first schema only)
obj1 = Model(value="hi")
self.assertEqual(obj1.value, "hi")
# Valid: Long string (matches second schema only)
obj2 = Model(value="very long string")
self.assertEqual(obj2.value, "very long string")
# Invalid: Medium string (matches BOTH schemas - violates oneOf)
with self.assertRaises(ValidationError) as cm:
Model(value="hello") # 5 chars: matches maxLength=6 AND minLength=4
self.assertIn("matches multiple oneOf schemas", str(cm.exception))
def test_oneof_shapes_discriminator_from_docs(self):
"""Test the shapes discriminator example from documentation"""
schema = {
"title": "Shape",
"type": "object",
"properties": {
"shape": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {"const": "circle"},
"radius": {"type": "number", "minimum": 0},
},
"required": ["type", "radius"],
},
{
"type": "object",
"properties": {
"type": {"const": "rectangle"},
"width": {"type": "number", "minimum": 0},
"height": {"type": "number", "minimum": 0},
},
"required": ["type", "width", "height"],
},
],
"discriminator": {"propertyName": "type"},
}
},
"required": ["shape"],
}
Model = SchemaConverter.build(schema)
# Valid: Circle
circle = Model(shape={"type": "circle", "radius": 5.0})
self.assertEqual(circle.shape.type, "circle")
self.assertEqual(circle.shape.radius, 5.0)
# Valid: Rectangle
rectangle = Model(shape={"type": "rectangle", "width": 10, "height": 20})
self.assertEqual(rectangle.shape.type, "rectangle")
self.assertEqual(rectangle.shape.width, 10)
self.assertEqual(rectangle.shape.height, 20)
# Invalid: Wrong properties for the type
with self.assertRaises(ValidationError):
Model(shape={"type": "circle", "width": 10})

View File

@@ -1,5 +1,8 @@
from jambo.exceptions import InternalAssertionException, InvalidSchemaException
from jambo.parser import ObjectTypeParser, RefTypeParser
from pydantic import ValidationError
from typing import ForwardRef
from unittest import TestCase
@@ -16,7 +19,7 @@ class TestRefTypeParser(TestCase):
"required": ["name", "age"],
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
RefTypeParser().from_properties(
"person",
properties,
@@ -40,7 +43,7 @@ class TestRefTypeParser(TestCase):
},
}
with self.assertRaises(RuntimeError):
with self.assertRaises(InternalAssertionException):
RefTypeParser().from_properties(
"person",
properties,
@@ -63,7 +66,7 @@ class TestRefTypeParser(TestCase):
},
}
with self.assertRaises(RuntimeError):
with self.assertRaises(InternalAssertionException):
RefTypeParser().from_properties(
"person",
properties,
@@ -77,7 +80,7 @@ class TestRefTypeParser(TestCase):
"$ref": "https://example.com/schemas/person.json",
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
RefTypeParser().from_properties(
"person",
properties,
@@ -110,7 +113,7 @@ class TestRefTypeParser(TestCase):
},
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
ObjectTypeParser().from_properties(
"person",
properties,
@@ -126,7 +129,7 @@ class TestRefTypeParser(TestCase):
"$defs": {},
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
RefTypeParser().from_properties(
"person",
properties,
@@ -142,7 +145,7 @@ class TestRefTypeParser(TestCase):
"$defs": {"person": None},
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
RefTypeParser().from_properties(
"person",
properties,
@@ -232,7 +235,7 @@ class TestRefTypeParser(TestCase):
"required": ["name", "age"],
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
ObjectTypeParser().from_properties(
"person",
properties,
@@ -264,7 +267,7 @@ class TestRefTypeParser(TestCase):
)
# checks if when created via FowardRef the model is validated correctly.
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
model(
name="John",
age=30,
@@ -421,7 +424,7 @@ class TestRefTypeParser(TestCase):
},
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
ref_strategy, ref_name, ref_property = RefTypeParser()._parse_from_strategy(
"invalid_strategy",
"person",

View File

@@ -1,9 +1,12 @@
from jambo.exceptions import InvalidSchemaException
from jambo.parser import StringTypeParser
from pydantic import EmailStr, HttpUrl, IPvAnyAddress
from pydantic import AnyUrl, EmailStr
from datetime import date, datetime, time
from datetime import date, datetime, time, timedelta
from ipaddress import IPv4Address, IPv6Address
from unittest import TestCase
from uuid import UUID
class TestStringTypeParser(TestCase):
@@ -60,7 +63,7 @@ class TestStringTypeParser(TestCase):
"minLength": 5,
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parser.from_properties("placeholder", properties)
def test_string_parser_with_default_invalid_maxlength(self):
@@ -73,7 +76,7 @@ class TestStringTypeParser(TestCase):
"minLength": 1,
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parser.from_properties("placeholder", properties)
def test_string_parser_with_default_invalid_minlength(self):
@@ -86,7 +89,7 @@ class TestStringTypeParser(TestCase):
"minLength": 2,
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
parser.from_properties("placeholder", properties)
def test_string_parser_with_email_format(self):
@@ -111,12 +114,14 @@ class TestStringTypeParser(TestCase):
type_parsing, type_validator = parser.from_properties("placeholder", properties)
self.assertEqual(type_parsing, HttpUrl)
self.assertEqual(type_parsing, AnyUrl)
def test_string_parser_with_ip_formats(self):
parser = StringTypeParser()
for ip_format in ["ipv4", "ipv6"]:
formats = {"ipv4": IPv4Address, "ipv6": IPv6Address}
for ip_format, expected_type in formats.items():
properties = {
"type": "string",
"format": ip_format,
@@ -126,7 +131,19 @@ class TestStringTypeParser(TestCase):
"placeholder", properties
)
self.assertEqual(type_parsing, IPvAnyAddress)
self.assertEqual(type_parsing, expected_type)
def test_string_parser_with_uuid_format(self):
parser = StringTypeParser()
properties = {
"type": "string",
"format": "uuid",
}
type_parsing, type_validator = parser.from_properties("placeholder", properties)
self.assertEqual(type_parsing, UUID)
def test_string_parser_with_time_format(self):
parser = StringTypeParser()
@@ -167,11 +184,12 @@ class TestStringTypeParser(TestCase):
"format": "unsupported-format",
}
with self.assertRaises(ValueError) as context:
with self.assertRaises(InvalidSchemaException) as context:
parser.from_properties("placeholder", properties)
self.assertEqual(
str(context.exception), "Unsupported string format: unsupported-format"
str(context.exception),
"Invalid JSON Schema: Unsupported string format: unsupported-format (invalid field: format)",
)
def test_string_parser_with_date_format(self):
@@ -197,3 +215,15 @@ class TestStringTypeParser(TestCase):
type_parsing, type_validator = parser.from_properties("placeholder", properties)
self.assertEqual(type_parsing, datetime)
def test_string_parser_with_timedelta_format(self):
parser = StringTypeParser()
properties = {
"type": "string",
"format": "duration",
}
type_parsing, type_validator = parser.from_properties("placeholder", properties)
self.assertEqual(type_parsing, timedelta)

View File

@@ -1,3 +1,4 @@
from jambo.exceptions import InvalidSchemaException
from jambo.parser import StringTypeParser
from jambo.parser._type_parser import GenericTypeParser
@@ -17,5 +18,5 @@ class TestGenericTypeParser(TestCase):
StringTypeParser.json_schema_type = "type:string"
def test_get_impl_invalid_type(self):
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
GenericTypeParser._get_impl({"type": "invalid_type"})

View File

@@ -1,9 +1,11 @@
from jambo import SchemaConverter
from jambo.exceptions import InvalidSchemaException, UnsupportedSchemaException
from pydantic import BaseModel, HttpUrl
from pydantic import AnyUrl, BaseModel, ValidationError
from ipaddress import IPv4Address, IPv6Address
from unittest import TestCase
from uuid import UUID
def is_pydantic_model(cls):
@@ -22,7 +24,21 @@ class TestSchemaConverter(TestCase):
},
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
SchemaConverter.build(schema)
def test_invalid_schema_type(self):
schema = {
"title": 1,
"description": "A person",
"type": 1,
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
},
}
with self.assertRaises(InvalidSchemaException):
SchemaConverter.build(schema)
def test_build_expects_title(self):
@@ -35,7 +51,7 @@ class TestSchemaConverter(TestCase):
},
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
SchemaConverter.build(schema)
def test_build_expects_object(self):
@@ -45,7 +61,7 @@ class TestSchemaConverter(TestCase):
"type": "string",
}
with self.assertRaises(TypeError):
with self.assertRaises(UnsupportedSchemaException):
SchemaConverter.build(schema)
def test_is_invalid_field(self):
@@ -61,7 +77,7 @@ class TestSchemaConverter(TestCase):
# 'required': ['name', 'age', 'is_active', 'friends', 'address'],
}
with self.assertRaises(ValueError) as context:
with self.assertRaises(InvalidSchemaException) as context:
SchemaConverter.build(schema)
self.assertTrue("Unknown type" in str(context.exception))
@@ -102,16 +118,16 @@ class TestSchemaConverter(TestCase):
self.assertEqual(model(name="John", age=30).name, "John")
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
model(name=123, age=30, email="teste@hideyoshi.com")
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
model(name="John Invalid", age=45, email="teste@hideyoshi.com")
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
model(name="", age=45, email="teste@hideyoshi.com")
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
model(name="John", age=45, email="hideyoshi.com")
def test_validation_integer(self):
@@ -133,10 +149,10 @@ class TestSchemaConverter(TestCase):
self.assertEqual(model(age=30).age, 30)
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
model(age=-1)
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
model(age=121)
def test_validation_float(self):
@@ -158,10 +174,10 @@ class TestSchemaConverter(TestCase):
self.assertEqual(model(age=30).age, 30.0)
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
model(age=-1.0)
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
model(age=121.0)
def test_validation_boolean(self):
@@ -181,7 +197,7 @@ class TestSchemaConverter(TestCase):
self.assertEqual(model(is_active="true").is_active, True)
def test_validation_list(self):
def test_validation_list_with_valid_items(self):
schema = {
"title": "Person",
"description": "A person",
@@ -204,12 +220,52 @@ class TestSchemaConverter(TestCase):
model(friends=["John", "Jane", "John"]).friends, {"John", "Jane"}
)
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
model(friends=[])
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
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(
{
"title": "Person",
"description": "A person",
"type": "object",
"properties": {
"friends": {
"type": "array",
"items": {"type": "string"},
"minItems": 1,
"maxItems": 2,
},
},
"required": ["friends"],
}
)
with self.assertRaises(ValidationError):
model()
def test_validation_object(self):
schema = {
"title": "Person",
@@ -235,6 +291,9 @@ class TestSchemaConverter(TestCase):
self.assertEqual(obj.address.street, "123 Main St")
self.assertEqual(obj.address.city, "Springfield")
with self.assertRaises(ValidationError):
model()
def test_default_for_string(self):
schema = {
"title": "Person",
@@ -271,7 +330,7 @@ class TestSchemaConverter(TestCase):
"required": ["name"],
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
SchemaConverter.build(schema_max_length)
def test_default_for_list(self):
@@ -363,10 +422,10 @@ class TestSchemaConverter(TestCase):
self.assertEqual(obj.name, "J")
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
Model(name="John Invalid")
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
Model(name="")
def test_any_of(self):
@@ -392,13 +451,13 @@ class TestSchemaConverter(TestCase):
obj = Model(id="12345678901")
self.assertEqual(obj.id, "12345678901")
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
Model(id="")
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
Model(id="12345678901234567890")
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
Model(id=11)
def test_string_format_email(self):
@@ -407,9 +466,11 @@ class TestSchemaConverter(TestCase):
"type": "object",
"properties": {"email": {"type": "string", "format": "email"}},
}
model = SchemaConverter.build(schema)
self.assertEqual(model(email="test@example.com").email, "test@example.com")
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
model(email="invalid-email")
def test_string_format_uri(self):
@@ -418,11 +479,13 @@ class TestSchemaConverter(TestCase):
"type": "object",
"properties": {"website": {"type": "string", "format": "uri"}},
}
model = SchemaConverter.build(schema)
self.assertEqual(
model(website="https://example.com").website, HttpUrl("https://example.com")
model(website="https://example.com").website, AnyUrl("https://example.com")
)
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
model(website="invalid-uri")
def test_string_format_ipv4(self):
@@ -431,9 +494,11 @@ class TestSchemaConverter(TestCase):
"type": "object",
"properties": {"ip": {"type": "string", "format": "ipv4"}},
}
model = SchemaConverter.build(schema)
self.assertEqual(model(ip="192.168.1.1").ip, IPv4Address("192.168.1.1"))
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
model(ip="256.256.256.256")
def test_string_format_ipv6(self):
@@ -442,23 +507,44 @@ class TestSchemaConverter(TestCase):
"type": "object",
"properties": {"ip": {"type": "string", "format": "ipv6"}},
}
model = SchemaConverter.build(schema)
self.assertEqual(
model(ip="2001:0db8:85a3:0000:0000:8a2e:0370:7334").ip,
IPv6Address("2001:0db8:85a3:0000:0000:8a2e:0370:7334"),
)
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
model(ip="invalid-ipv6")
def test_string_format_uuid(self):
schema = {
"title": "UUIDTest",
"type": "object",
"properties": {"id": {"type": "string", "format": "uuid"}},
}
model = SchemaConverter.build(schema)
self.assertEqual(
model(id="123e4567-e89b-12d3-a456-426614174000").id,
UUID("123e4567-e89b-12d3-a456-426614174000"),
)
with self.assertRaises(ValidationError):
model(id="invalid-uuid")
def test_string_format_hostname(self):
schema = {
"title": "HostnameTest",
"type": "object",
"properties": {"hostname": {"type": "string", "format": "hostname"}},
}
model = SchemaConverter.build(schema)
self.assertEqual(model(hostname="example.com").hostname, "example.com")
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
model(hostname="invalid..hostname")
def test_string_format_datetime(self):
@@ -467,12 +553,14 @@ class TestSchemaConverter(TestCase):
"type": "object",
"properties": {"timestamp": {"type": "string", "format": "date-time"}},
}
model = SchemaConverter.build(schema)
self.assertEqual(
model(timestamp="2024-01-01T12:00:00Z").timestamp.isoformat(),
"2024-01-01T12:00:00+00:00",
)
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
model(timestamp="invalid-datetime")
def test_string_format_time(self):
@@ -481,11 +569,13 @@ class TestSchemaConverter(TestCase):
"type": "object",
"properties": {"time": {"type": "string", "format": "time"}},
}
model = SchemaConverter.build(schema)
self.assertEqual(
model(time="20:20:39+00:00").time.isoformat(), "20:20:39+00:00"
)
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
model(time="25:00:00")
def test_string_format_unsupported(self):
@@ -494,7 +584,8 @@ class TestSchemaConverter(TestCase):
"type": "object",
"properties": {"field": {"type": "string", "format": "unsupported"}},
}
with self.assertRaises(ValueError):
with self.assertRaises(InvalidSchemaException):
SchemaConverter.build(schema)
def test_ref_with_root_ref(self):
@@ -652,8 +743,51 @@ class TestSchemaConverter(TestCase):
obj = Model()
self.assertEqual(obj.name, "United States of America")
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
obj.name = "Canada"
with self.assertRaises(ValueError):
with self.assertRaises(ValidationError):
Model(name="Canada")
def test_const_type_parser_with_non_hashable_value(self):
schema = {
"title": "Country",
"type": "object",
"properties": {
"name": {
"const": ["Brazil"],
}
},
"required": ["name"],
}
Model = SchemaConverter.build(schema)
obj = Model()
self.assertEqual(obj.name, ["Brazil"])
with self.assertRaises(ValidationError):
obj.name = ["Argentina"]
with self.assertRaises(ValidationError):
Model(name=["Argentina"])
def test_null_type_parser(self):
schema = {
"title": "Test",
"type": "object",
"properties": {
"a_thing": {"type": "null"},
},
}
Model = SchemaConverter.build(schema)
obj = Model()
self.assertIsNone(obj.a_thing)
obj = Model(a_thing=None)
self.assertIsNone(obj.a_thing)
with self.assertRaises(ValidationError):
Model(a_thing="not none")

79
uv.lock generated
View File

@@ -326,6 +326,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "coverage" },
{ name = "mypy" },
{ name = "poethepoet" },
{ name = "pre-commit" },
{ name = "ruff" },
@@ -333,6 +334,7 @@ dev = [
{ name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "sphinx-autobuild" },
{ name = "sphinx-rtd-theme" },
{ name = "types-jsonschema" },
]
[package.metadata]
@@ -345,12 +347,14 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "coverage", specifier = ">=7.8.0" },
{ name = "mypy", specifier = ">=1.18.1" },
{ name = "poethepoet", specifier = ">=0.33.1" },
{ name = "pre-commit", specifier = ">=4.2.0" },
{ name = "ruff", specifier = ">=0.11.4" },
{ name = "sphinx", specifier = ">=8.1.3" },
{ name = "sphinx-autobuild", specifier = ">=2024.10.3" },
{ name = "sphinx-rtd-theme", specifier = ">=3.0.2" },
{ name = "types-jsonschema", specifier = ">=4.25.1.20250822" },
]
[[package]]
@@ -450,6 +454,60 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
]
[[package]]
name = "mypy"
version = "1.18.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/14/a3/931e09fc02d7ba96da65266884da4e4a8806adcdb8a57faaacc6edf1d538/mypy-1.18.1.tar.gz", hash = "sha256:9e988c64ad3ac5987f43f5154f884747faf62141b7f842e87465b45299eea5a9", size = 3448447, upload-time = "2025-09-11T23:00:47.067Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/06/29ea5a34c23938ae93bc0040eb2900eb3f0f2ef4448cc59af37ab3ddae73/mypy-1.18.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2761b6ae22a2b7d8e8607fb9b81ae90bc2e95ec033fd18fa35e807af6c657763", size = 12811535, upload-time = "2025-09-11T22:58:55.399Z" },
{ url = "https://files.pythonhosted.org/packages/a8/40/04c38cb04fa9f1dc224b3e9634021a92c47b1569f1c87dfe6e63168883bb/mypy-1.18.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5b10e3ea7f2eec23b4929a3fabf84505da21034a4f4b9613cda81217e92b74f3", size = 11897559, upload-time = "2025-09-11T22:59:48.041Z" },
{ url = "https://files.pythonhosted.org/packages/46/bf/4c535bd45ea86cebbc1a3b6a781d442f53a4883f322ebd2d442db6444d0b/mypy-1.18.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:261fbfced030228bc0f724d5d92f9ae69f46373bdfd0e04a533852677a11dbea", size = 12507430, upload-time = "2025-09-11T22:59:30.415Z" },
{ url = "https://files.pythonhosted.org/packages/e2/e1/cbefb16f2be078d09e28e0b9844e981afb41f6ffc85beb68b86c6976e641/mypy-1.18.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4dc6b34a1c6875e6286e27d836a35c0d04e8316beac4482d42cfea7ed2527df8", size = 13243717, upload-time = "2025-09-11T22:59:11.297Z" },
{ url = "https://files.pythonhosted.org/packages/65/e8/3e963da63176f16ca9caea7fa48f1bc8766de317cd961528c0391565fd47/mypy-1.18.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1cabb353194d2942522546501c0ff75c4043bf3b63069cb43274491b44b773c9", size = 13492052, upload-time = "2025-09-11T23:00:09.29Z" },
{ url = "https://files.pythonhosted.org/packages/4b/09/d5d70c252a3b5b7530662d145437bd1de15f39fa0b48a27ee4e57d254aa1/mypy-1.18.1-cp310-cp310-win_amd64.whl", hash = "sha256:738b171690c8e47c93569635ee8ec633d2cdb06062f510b853b5f233020569a9", size = 9765846, upload-time = "2025-09-11T22:58:26.198Z" },
{ url = "https://files.pythonhosted.org/packages/32/28/47709d5d9e7068b26c0d5189c8137c8783e81065ad1102b505214a08b548/mypy-1.18.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6c903857b3e28fc5489e54042684a9509039ea0aedb2a619469438b544ae1961", size = 12734635, upload-time = "2025-09-11T23:00:24.983Z" },
{ url = "https://files.pythonhosted.org/packages/7c/12/ee5c243e52497d0e59316854041cf3b3130131b92266d0764aca4dec3c00/mypy-1.18.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2a0c8392c19934c2b6c65566d3a6abdc6b51d5da7f5d04e43f0eb627d6eeee65", size = 11817287, upload-time = "2025-09-11T22:59:07.38Z" },
{ url = "https://files.pythonhosted.org/packages/48/bd/2aeb950151005fe708ab59725afed7c4aeeb96daf844f86a05d4b8ac34f8/mypy-1.18.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f85eb7efa2ec73ef63fc23b8af89c2fe5bf2a4ad985ed2d3ff28c1bb3c317c92", size = 12430464, upload-time = "2025-09-11T22:58:48.084Z" },
{ url = "https://files.pythonhosted.org/packages/71/e8/7a20407aafb488acb5734ad7fb5e8c2ef78d292ca2674335350fa8ebef67/mypy-1.18.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82ace21edf7ba8af31c3308a61dc72df30500f4dbb26f99ac36b4b80809d7e94", size = 13164555, upload-time = "2025-09-11T23:00:13.803Z" },
{ url = "https://files.pythonhosted.org/packages/e8/c9/5f39065252e033b60f397096f538fb57c1d9fd70a7a490f314df20dd9d64/mypy-1.18.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a2dfd53dfe632f1ef5d161150a4b1f2d0786746ae02950eb3ac108964ee2975a", size = 13359222, upload-time = "2025-09-11T23:00:33.469Z" },
{ url = "https://files.pythonhosted.org/packages/85/b6/d54111ef3c1e55992cd2ec9b8b6ce9c72a407423e93132cae209f7e7ba60/mypy-1.18.1-cp311-cp311-win_amd64.whl", hash = "sha256:320f0ad4205eefcb0e1a72428dde0ad10be73da9f92e793c36228e8ebf7298c0", size = 9760441, upload-time = "2025-09-11T23:00:44.826Z" },
{ url = "https://files.pythonhosted.org/packages/e7/14/1c3f54d606cb88a55d1567153ef3a8bc7b74702f2ff5eb64d0994f9e49cb/mypy-1.18.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:502cde8896be8e638588b90fdcb4c5d5b8c1b004dfc63fd5604a973547367bb9", size = 12911082, upload-time = "2025-09-11T23:00:41.465Z" },
{ url = "https://files.pythonhosted.org/packages/90/83/235606c8b6d50a8eba99773add907ce1d41c068edb523f81eb0d01603a83/mypy-1.18.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7509549b5e41be279afc1228242d0e397f1af2919a8f2877ad542b199dc4083e", size = 11919107, upload-time = "2025-09-11T22:58:40.903Z" },
{ url = "https://files.pythonhosted.org/packages/ca/25/4e2ce00f8d15b99d0c68a2536ad63e9eac033f723439ef80290ec32c1ff5/mypy-1.18.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5956ecaabb3a245e3f34100172abca1507be687377fe20e24d6a7557e07080e2", size = 12472551, upload-time = "2025-09-11T22:58:37.272Z" },
{ url = "https://files.pythonhosted.org/packages/32/bb/92642a9350fc339dd9dcefcf6862d171b52294af107d521dce075f32f298/mypy-1.18.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8750ceb014a96c9890421c83f0db53b0f3b8633e2864c6f9bc0a8e93951ed18d", size = 13340554, upload-time = "2025-09-11T22:59:38.756Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ee/38d01db91c198fb6350025d28f9719ecf3c8f2c55a0094bfbf3ef478cc9a/mypy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fb89ea08ff41adf59476b235293679a6eb53a7b9400f6256272fb6029bec3ce5", size = 13530933, upload-time = "2025-09-11T22:59:20.228Z" },
{ url = "https://files.pythonhosted.org/packages/da/8d/6d991ae631f80d58edbf9d7066e3f2a96e479dca955d9a968cd6e90850a3/mypy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:2657654d82fcd2a87e02a33e0d23001789a554059bbf34702d623dafe353eabf", size = 9828426, upload-time = "2025-09-11T23:00:21.007Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ec/ef4a7260e1460a3071628a9277a7579e7da1b071bc134ebe909323f2fbc7/mypy-1.18.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d70d2b5baf9b9a20bc9c730015615ae3243ef47fb4a58ad7b31c3e0a59b5ef1f", size = 12918671, upload-time = "2025-09-11T22:58:29.814Z" },
{ url = "https://files.pythonhosted.org/packages/a1/82/0ea6c3953f16223f0b8eda40c1aeac6bd266d15f4902556ae6e91f6fca4c/mypy-1.18.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b8367e33506300f07a43012fc546402f283c3f8bcff1dc338636affb710154ce", size = 11913023, upload-time = "2025-09-11T23:00:29.049Z" },
{ url = "https://files.pythonhosted.org/packages/ae/ef/5e2057e692c2690fc27b3ed0a4dbde4388330c32e2576a23f0302bc8358d/mypy-1.18.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:913f668ec50c3337b89df22f973c1c8f0b29ee9e290a8b7fe01cc1ef7446d42e", size = 12473355, upload-time = "2025-09-11T23:00:04.544Z" },
{ url = "https://files.pythonhosted.org/packages/98/43/b7e429fc4be10e390a167b0cd1810d41cb4e4add4ae50bab96faff695a3b/mypy-1.18.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a0e70b87eb27b33209fa4792b051c6947976f6ab829daa83819df5f58330c71", size = 13346944, upload-time = "2025-09-11T22:58:23.024Z" },
{ url = "https://files.pythonhosted.org/packages/89/4e/899dba0bfe36bbd5b7c52e597de4cf47b5053d337b6d201a30e3798e77a6/mypy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c378d946e8a60be6b6ede48c878d145546fb42aad61df998c056ec151bf6c746", size = 13512574, upload-time = "2025-09-11T22:59:52.152Z" },
{ url = "https://files.pythonhosted.org/packages/f5/f8/7661021a5b0e501b76440454d786b0f01bb05d5c4b125fcbda02023d0250/mypy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:2cd2c1e0f3a7465f22731987fff6fc427e3dcbb4ca5f7db5bbeaff2ff9a31f6d", size = 9837684, upload-time = "2025-09-11T22:58:44.454Z" },
{ url = "https://files.pythonhosted.org/packages/bf/87/7b173981466219eccc64c107cf8e5ab9eb39cc304b4c07df8e7881533e4f/mypy-1.18.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ba24603c58e34dd5b096dfad792d87b304fc6470cbb1c22fd64e7ebd17edcc61", size = 12900265, upload-time = "2025-09-11T22:59:03.4Z" },
{ url = "https://files.pythonhosted.org/packages/ae/cc/b10e65bae75b18a5ac8f81b1e8e5867677e418f0dd2c83b8e2de9ba96ebd/mypy-1.18.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ed36662fb92ae4cb3cacc682ec6656208f323bbc23d4b08d091eecfc0863d4b5", size = 11942890, upload-time = "2025-09-11T23:00:00.607Z" },
{ url = "https://files.pythonhosted.org/packages/39/d4/aeefa07c44d09f4c2102e525e2031bc066d12e5351f66b8a83719671004d/mypy-1.18.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:040ecc95e026f71a9ad7956fea2724466602b561e6a25c2e5584160d3833aaa8", size = 12472291, upload-time = "2025-09-11T22:59:43.425Z" },
{ url = "https://files.pythonhosted.org/packages/c6/07/711e78668ff8e365f8c19735594ea95938bff3639a4c46a905e3ed8ff2d6/mypy-1.18.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:937e3ed86cb731276706e46e03512547e43c391a13f363e08d0fee49a7c38a0d", size = 13318610, upload-time = "2025-09-11T23:00:17.604Z" },
{ url = "https://files.pythonhosted.org/packages/ca/85/df3b2d39339c31d360ce299b418c55e8194ef3205284739b64962f6074e7/mypy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1f95cc4f01c0f1701ca3b0355792bccec13ecb2ec1c469e5b85a6ef398398b1d", size = 13513697, upload-time = "2025-09-11T22:58:59.534Z" },
{ url = "https://files.pythonhosted.org/packages/b1/df/462866163c99ea73bb28f0eb4d415c087e30de5d36ee0f5429d42e28689b/mypy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:e4f16c0019d48941220ac60b893615be2f63afedaba6a0801bdcd041b96991ce", size = 9985739, upload-time = "2025-09-11T22:58:51.644Z" },
{ url = "https://files.pythonhosted.org/packages/e0/1d/4b97d3089b48ef3d904c9ca69fab044475bd03245d878f5f0b3ea1daf7ce/mypy-1.18.1-py3-none-any.whl", hash = "sha256:b76a4de66a0ac01da1be14ecc8ae88ddea33b8380284a9e3eae39d57ebcbe26e", size = 2352212, upload-time = "2025-09-11T22:59:26.576Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "nodeenv"
version = "1.9.1"
@@ -477,6 +535,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955, upload-time = "2020-09-16T19:21:11.409Z" },
]
[[package]]
name = "pathspec"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
]
[[package]]
name = "platformdirs"
version = "4.3.7"
@@ -1048,6 +1115,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" },
]
[[package]]
name = "types-jsonschema"
version = "4.25.1.20250822"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/64/7f/369b54dad6eb6b5adc1fb1c53edbed18e6c32cbc600357135308902fdbdc/types_jsonschema-4.25.1.20250822.tar.gz", hash = "sha256:aac69ed4b23f49aaceb7fcb834141d61b9e4e6a7f6008cb2f0d3b831dfa8464a", size = 15628, upload-time = "2025-08-22T03:04:18.293Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/3d/bc1d171f032fcf63cedd4ade241f3f4e66d7e3bb53ee1da3c8f2f043eb0b/types_jsonschema-4.25.1.20250822-py3-none-any.whl", hash = "sha256:f82c2d7fa1ce1c0b84ba1de4ed6798469768188884db04e66421913a4e181294", size = 15923, upload-time = "2025-08-22T03:04:17.346Z" },
]
[[package]]
name = "typing-extensions"
version = "4.12.2"