Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00802744dd | |||
|
dd2e7d221c
|
|||
|
558abf5d40
|
|||
| 70afa80ccf | |||
|
422cc2efe0
|
|||
|
|
dd31f62ef2 | ||
| e8bda6bc07 | |||
|
d8fe98639a
|
|||
| 666e12262f | |||
|
ab9646238e
|
|||
| dba492a6dc | |||
|
628abe161d
|
|||
|
136d68d273
|
|||
|
fcea994dd6
|
|||
| 39a9612106 | |||
|
27e756dadf
|
|||
|
40106e4765
|
|||
|
d418ad96ad
|
|||
| 79e65b994e | |||
|
beed4e5e97
|
|||
| b705a3a70b | |||
|
268ac85667
|
|||
|
20872d4a91
|
|||
| 34910b55d7 | |||
|
a3cbd5bc3d
|
|||
|
682f19654d
|
|||
|
4baaeed349
|
|||
|
9837a99ec9
|
|||
|
3a8ca951db
|
|||
|
57f8b571de
|
|||
|
5ec30cd565
|
|||
|
c2b9e8daf8
|
|||
|
328eb66034
|
|||
|
4de711075e
|
|||
|
abc8bc2e40
|
|||
|
10bad254d7
|
|||
| b5e2d703cb | |||
|
|
44fa0cf16a | ||
| d11e3191c3 | |||
| 2da409e6df | |||
| e775b53f7d | |||
|
f15913c58e
|
|||
|
f80a1bbda3
|
|||
| b31c990b54 | |||
|
a0d15726d4
|
|||
| 59f062ec37 | |||
|
5036059272
|
|||
|
90639b6426
|
|||
|
e43e92cb9e
|
|||
|
ffbd124cf9
|
|||
|
cfbe1f38c8
|
|||
|
9823e69329
|
|||
|
84292cf3c0
|
|||
|
8b1520741b
|
|||
|
c7e366cf08
|
|||
|
ebcc8a295e
|
|||
|
07f301db1c
|
|||
|
c9330dfd6d
|
|||
|
|
9bc16ff1aa | ||
|
|
43ce95cc9a | ||
| 81c149120e | |||
| 171dddabab | |||
|
f0192ee6d3
|
|||
|
|
82feea0ab1 | ||
| 4d5ac1c885 | |||
|
92c174c189
|
|||
| b1b5e71a81 | |||
|
156c825a67
|
|||
| b4954c3b2e | |||
|
7f44e84bce
|
|||
|
8c6a04bbdf
|
|||
|
e31002af32
|
|||
|
30290771b1
|
|||
|
f4d84d2749
|
|||
|
e61d48881f
|
|||
| f5ad857326 | |||
|
e45086e29e
|
|||
|
c1f04606ad
|
|||
|
5eb086bafd
|
|||
| 5c30e752e3 | |||
|
53418f2b2b
|
|||
| 002b75c53a | |||
|
|
1167b8a540 | ||
| 3992057c95 | |||
|
71380073e4
|
@@ -1,7 +1,7 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
# Ruff version.
|
# Ruff version.
|
||||||
rev: v0.11.4
|
rev: v0.14.7
|
||||||
hooks:
|
hooks:
|
||||||
# Run the linter.
|
# Run the linter.
|
||||||
- id: ruff
|
- id: ruff
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -2,7 +2,7 @@
|
|||||||
name: Bug report
|
name: Bug report
|
||||||
about: Create a report to help us improve
|
about: Create a report to help us improve
|
||||||
title: "[BUG] Title Here"
|
title: "[BUG] Title Here"
|
||||||
labels: enhancement
|
labels: bug
|
||||||
assignees: HideyoshiNakazone
|
assignees: HideyoshiNakazone
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
6
.github/workflows/build.yml
vendored
6
.github/workflows/build.yml
vendored
@@ -23,6 +23,7 @@ jobs:
|
|||||||
- "3.11"
|
- "3.11"
|
||||||
- "3.12"
|
- "3.12"
|
||||||
- "3.13"
|
- "3.13"
|
||||||
|
- "3.14"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -31,7 +32,7 @@ jobs:
|
|||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
# Install a specific version of uv.
|
# Install a specific version of uv.
|
||||||
version: "0.6.14"
|
version: "0.9.15"
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: "uv.lock"
|
cache-dependency-glob: "uv.lock"
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
@@ -44,6 +45,9 @@ jobs:
|
|||||||
uv run poe tests
|
uv run poe tests
|
||||||
uv run poe tests-report
|
uv run poe tests-report
|
||||||
|
|
||||||
|
- name: Static type check
|
||||||
|
run: uv run poe type-check
|
||||||
|
|
||||||
- name: Upload coverage reports to Codecov
|
- name: Upload coverage reports to Codecov
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
|
|||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
48
README.md
48
README.md
@@ -1,8 +1,8 @@
|
|||||||
# Jambo - JSON Schema to Pydantic Converter
|
# Jambo - JSON Schema to Pydantic Converter
|
||||||
|
|
||||||
<p align="center">
|
<p style="text-align:center">
|
||||||
<a href="https://github.com/HideyoshiNakazone/jambo" target="_blank">
|
<a href="https://github.com/HideyoshiNakazone/jambo" target="_blank">
|
||||||
<img src="https://img.shields.io/github/last-commit/HideyoshiNakazone/jambo.svg">
|
<img src="https://img.shields.io/github/last-commit/HideyoshiNakazone/jambo.svg" alt="Last commit">
|
||||||
<img src="https://github.com/HideyoshiNakazone/jambo/actions/workflows/build.yml/badge.svg" alt="Tests">
|
<img src="https://github.com/HideyoshiNakazone/jambo/actions/workflows/build.yml/badge.svg" alt="Tests">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://codecov.io/gh/HideyoshiNakazone/jambo" target="_blank">
|
<a href="https://codecov.io/gh/HideyoshiNakazone/jambo" target="_blank">
|
||||||
@@ -19,12 +19,13 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Jambo** is a Python package that automatically converts [JSON Schema](https://json-schema.org/) definitions into [Pydantic](https://docs.pydantic.dev/) models.
|
**Jambo** is a Python package that automatically converts [JSON Schema](https://json-schema.org/) definitions into [Pydantic](https://docs.pydantic.dev/) models.
|
||||||
It's designed to streamline schema validation and enforce type safety using Pydantic's powerful validation features.
|
It's designed to streamline schema validation and enforce type safety using Pydantic's validation features.
|
||||||
|
|
||||||
Created to simplifying the process of dynamically generating Pydantic models for AI frameworks like [LangChain](https://www.langchain.com/), [CrewAI](https://www.crewai.com/), and others.
|
Created to simplify the process of dynamically generating Pydantic models for AI frameworks like [LangChain](https://www.langchain.com/), [CrewAI](https://www.crewai.com/), and others.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- ✅ Convert JSON Schema into Pydantic models dynamically;
|
- ✅ Convert JSON Schema into Pydantic models dynamically;
|
||||||
@@ -56,10 +57,19 @@ pip install jambo
|
|||||||
|
|
||||||
## 🚀 Usage
|
## 🚀 Usage
|
||||||
|
|
||||||
|
There are two ways to build models with Jambo:
|
||||||
|
|
||||||
|
1. The original static API: `SchemaConverter.build(schema)` doesn't persist any reference cache between calls and doesn't require any configuration.
|
||||||
|
2. The new instance API: use a `SchemaConverter()` instance and call `build_with_cache`, which exposes and persists a reference cache and helper methods.
|
||||||
|
|
||||||
|
The instance API is useful when you want to reuse generated subtypes, inspect cached models, or share caches between converters; all leveraging namespaces via the `$id` property in JSON Schema. See the docs for full details: https://jambo.readthedocs.io/en/latest/usage.ref_cache.html
|
||||||
|
|
||||||
|
|
||||||
|
### Static (compatibility) example
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from jambo import SchemaConverter
|
from jambo import SchemaConverter
|
||||||
|
|
||||||
|
|
||||||
schema = {
|
schema = {
|
||||||
"title": "Person",
|
"title": "Person",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -70,12 +80,40 @@ schema = {
|
|||||||
"required": ["name"],
|
"required": ["name"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Old-style convenience API (kept for compatibility)
|
||||||
Person = SchemaConverter.build(schema)
|
Person = SchemaConverter.build(schema)
|
||||||
|
|
||||||
obj = Person(name="Alice", age=30)
|
obj = Person(name="Alice", age=30)
|
||||||
print(obj)
|
print(obj)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Instance API (recommended for cache control)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from jambo import SchemaConverter
|
||||||
|
|
||||||
|
converter = SchemaConverter()
|
||||||
|
|
||||||
|
schema = {
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"address": {"type": "object", "properties": {"street": {"type": "string"}}},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# build_with_cache populates the converter's instance-level ref cache
|
||||||
|
Person = converter.build_with_cache(schema)
|
||||||
|
|
||||||
|
# you can retrieve cached subtypes by name/path
|
||||||
|
cached_person = converter.get_cached_ref("Person")
|
||||||
|
# clear the instance cache when needed
|
||||||
|
converter.clear_ref_cache()
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ Example Validations
|
## ✅ Example Validations
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ extensions = [
|
|||||||
"sphinx.ext.viewcode",
|
"sphinx.ext.viewcode",
|
||||||
"sphinx.ext.autodoc",
|
"sphinx.ext.autodoc",
|
||||||
"sphinx.ext.napoleon",
|
"sphinx.ext.napoleon",
|
||||||
|
"sphinx_autodoc_typehints", # <-- needed
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -35,3 +36,6 @@ html_static_path = ["_static"]
|
|||||||
# -- Options for autodoc -----------------------------------------------------
|
# -- Options for autodoc -----------------------------------------------------
|
||||||
add_module_names = False
|
add_module_names = False
|
||||||
python_use_unqualified_type_names = True
|
python_use_unqualified_type_names = True
|
||||||
|
|
||||||
|
|
||||||
|
autodoc_typehints = "both"
|
||||||
|
|||||||
37
docs/source/jambo.exceptions.rst
Normal file
37
docs/source/jambo.exceptions.rst
Normal 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:
|
||||||
@@ -36,6 +36,22 @@ jambo.parser.boolean\_type\_parser module
|
|||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
:undoc-members:
|
: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
|
jambo.parser.float\_type\_parser module
|
||||||
---------------------------------------
|
---------------------------------------
|
||||||
|
|
||||||
@@ -52,6 +68,14 @@ jambo.parser.int\_type\_parser module
|
|||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
:undoc-members:
|
: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
|
jambo.parser.object\_type\_parser module
|
||||||
----------------------------------------
|
----------------------------------------
|
||||||
|
|
||||||
@@ -60,6 +84,14 @@ jambo.parser.object\_type\_parser module
|
|||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
:undoc-members:
|
: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
|
jambo.parser.ref\_type\_parser module
|
||||||
-------------------------------------
|
-------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Subpackages
|
|||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 4
|
:maxdepth: 4
|
||||||
|
|
||||||
|
jambo.exceptions
|
||||||
jambo.parser
|
jambo.parser
|
||||||
jambo.types
|
jambo.types
|
||||||
|
|
||||||
|
|||||||
348
docs/source/usage.ref_cache.rst
Normal file
348
docs/source/usage.ref_cache.rst
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
===============
|
||||||
|
Reference Cache
|
||||||
|
===============
|
||||||
|
|
||||||
|
The reference cache is named after the mechanism used to implement
|
||||||
|
the `$ref` keyword in the JSON Schema specification.
|
||||||
|
|
||||||
|
Internally, the cache is used by both :py:meth:`SchemaConverter.build_with_cache <jambo.SchemaConverter.build_with_cache>`
|
||||||
|
and :py:meth:`SchemaConverter.build <jambo.SchemaConverter.build>`.
|
||||||
|
However, only :py:meth:`SchemaConverter.build_with_cache <jambo.SchemaConverter.build_with_cache>` exposes the cache through a supported API;
|
||||||
|
:py:meth:`SchemaConverter.build <jambo.SchemaConverter.build>` uses the cache internally and does not provide access to it.
|
||||||
|
|
||||||
|
The reference cache accepts a mutable mapping (typically a plain Python dict)
|
||||||
|
that maps reference names (strings) to generated Pydantic model classes.
|
||||||
|
Since only the reference names are stored it can cause name collisions if
|
||||||
|
multiple schemas with overlapping names are processed using the same cache.
|
||||||
|
Therefore, it's recommended that each namespace or schema source uses its own
|
||||||
|
:class:`SchemaConverter` instance.
|
||||||
|
|
||||||
|
-----------------------------------------
|
||||||
|
Configuring and Using the Reference Cache
|
||||||
|
-----------------------------------------
|
||||||
|
|
||||||
|
The reference cache can be used in three ways:
|
||||||
|
|
||||||
|
* Without a persistent reference cache (no sharing between calls).
|
||||||
|
* Passing an explicit ``ref_cache`` dictionary to a call.
|
||||||
|
* Using the converter instance's default cache (the instance-level cache).
|
||||||
|
|
||||||
|
|
||||||
|
Usage Without Reference Cache
|
||||||
|
=============================
|
||||||
|
|
||||||
|
When you run the library without a persistent reference cache, the generated
|
||||||
|
types are not stored for reuse. Each call to a build method creates fresh
|
||||||
|
Pydantic model classes (they will have different Python object identities).
|
||||||
|
Because nothing is cached, you cannot look up generated subtypes later.
|
||||||
|
|
||||||
|
This is the default behaviour of :py:meth:`SchemaConverter.build <jambo.SchemaConverter.build>`.
|
||||||
|
You can achieve the same behaviour with :py:meth:`SchemaConverter.build_with_cache <jambo.SchemaConverter.build_with_cache>` by
|
||||||
|
passing ``without_cache=True``.
|
||||||
|
|
||||||
|
|
||||||
|
Usage: Manually Passing a Reference Cache
|
||||||
|
=========================================
|
||||||
|
|
||||||
|
You can create and pass your own mutable mapping (typically a plain dict)
|
||||||
|
as the reference cache. This gives you full control over sharing and
|
||||||
|
lifetime of cached types. When two converters share the same dict, types
|
||||||
|
created by one converter will be reused by the other.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from jambo import SchemaConverter
|
||||||
|
|
||||||
|
# a shared cache you control
|
||||||
|
shared_cache = {}
|
||||||
|
|
||||||
|
converter1 = SchemaConverter(shared_cache)
|
||||||
|
converter2 = SchemaConverter(shared_cache)
|
||||||
|
|
||||||
|
model1 = converter1.build_with_cache(schema)
|
||||||
|
model2 = converter2.build_with_cache(schema)
|
||||||
|
|
||||||
|
# Because both converters use the same cache object, the built models are the same object
|
||||||
|
assert model1 is model2
|
||||||
|
|
||||||
|
If you prefer a per-call cache (leaving the converter's instance cache unchanged), pass the ``ref_cache`` parameter to
|
||||||
|
:py:meth:`SchemaConverter.build_with_cache <jambo.SchemaConverter.build_with_cache>`:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
# pass an explicit, private cache for this call only
|
||||||
|
model_a = converter1.build_with_cache(schema, ref_cache={})
|
||||||
|
model_b = converter1.build_with_cache(schema, ref_cache={})
|
||||||
|
|
||||||
|
# because each call received a fresh dict, the resulting model classes are distinct
|
||||||
|
assert model_a is not model_b
|
||||||
|
|
||||||
|
|
||||||
|
Usage: Using the Instance Default (Instance-level) Cache
|
||||||
|
=======================================================
|
||||||
|
|
||||||
|
By default, a :class:`SchemaConverter` instance creates and keeps an internal
|
||||||
|
reference cache (a plain dict). Reusing the same converter instance across
|
||||||
|
multiple calls will reuse that cache and therefore reuse previously generated
|
||||||
|
model classes.
|
||||||
|
|
||||||
|
That cache is isolated per namespace via the `$id` property in JSON Schema, so
|
||||||
|
schemas with different `$id` values will not collide in the same cache.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from jambo import SchemaConverter
|
||||||
|
|
||||||
|
# no $id in this example, therefore a default namespace is used
|
||||||
|
schema = {
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"address": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"street": {"type": "string"},
|
||||||
|
"city": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["street", "city"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name", "address"],
|
||||||
|
}
|
||||||
|
|
||||||
|
converter = SchemaConverter() # has its own internal cache
|
||||||
|
|
||||||
|
model1 = converter.build_with_cache(schema)
|
||||||
|
model2 = converter.build_with_cache(schema)
|
||||||
|
|
||||||
|
# model1 and model2 are the same object because the instance cache persisted
|
||||||
|
assert model1 is model2
|
||||||
|
|
||||||
|
When passing a schema with a different `$id`, the instance cache keeps types
|
||||||
|
separate:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
schema_a = {
|
||||||
|
"$id": "namespace_a",
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
}
|
||||||
|
|
||||||
|
schema_b = {
|
||||||
|
"$id": "namespace_b",
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
}
|
||||||
|
|
||||||
|
converter = SchemaConverter() # has its own internal cache
|
||||||
|
|
||||||
|
model_a = converter.build_with_cache(schema_a)
|
||||||
|
model_b = converter.build_with_cache(schema_b)
|
||||||
|
|
||||||
|
# different $id values isolate the types in the same cache
|
||||||
|
assert model_a is not model_b
|
||||||
|
|
||||||
|
If you want to temporarily avoid using the instance cache for a single call,
|
||||||
|
use ``without_cache=True``. That causes :py:meth:`SchemaConverter.build_with_cache <jambo.SchemaConverter.build_with_cache>` to
|
||||||
|
use a fresh, empty cache for the duration of that call only:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
model1 = converter.build_with_cache(schema, without_cache=True)
|
||||||
|
model2 = converter.build_with_cache(schema, without_cache=True)
|
||||||
|
|
||||||
|
# each call used a fresh cache, so the models are distinct
|
||||||
|
assert model1 is not model2
|
||||||
|
|
||||||
|
|
||||||
|
Inspecting and Managing the Cache
|
||||||
|
=================================
|
||||||
|
|
||||||
|
The converter provides a small, explicit API to inspect and manage the
|
||||||
|
instance cache.
|
||||||
|
|
||||||
|
Retrieving cached types
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
:py:meth:`SchemaConverter.get_cached_ref <jambo.SchemaConverter.get_cached_ref>`(name, namespace="default") — returns a cached model class or ``None``.
|
||||||
|
|
||||||
|
Retrieving the root type of the schema
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
When retrieving the root type of a schema, pass the schema's ``title`` property as the name.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from jambo import SchemaConverter
|
||||||
|
|
||||||
|
converter = SchemaConverter()
|
||||||
|
|
||||||
|
schema = {
|
||||||
|
"title": "person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
person_model = converter.build_with_cache(schema)
|
||||||
|
cached_person_model = converter.get_cached_ref("person")
|
||||||
|
|
||||||
|
|
||||||
|
Retrieving a subtype
|
||||||
|
~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
When retrieving a subtype, pass a path string (for example, ``parent_name.field_name``) as the name.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from jambo import SchemaConverter
|
||||||
|
|
||||||
|
converter = SchemaConverter()
|
||||||
|
|
||||||
|
schema = {
|
||||||
|
"title": "person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"address": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"street": {"type": "string"},
|
||||||
|
"city": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["street", "city"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
person_model = converter.build_with_cache(schema)
|
||||||
|
cached_address_model = converter.get_cached_ref("person.address")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Retrieving a type from ``$defs``
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
When retrieving a type defined in ``$defs``, access it directly by its name.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from jambo import SchemaConverter
|
||||||
|
|
||||||
|
converter = SchemaConverter()
|
||||||
|
|
||||||
|
schema = {
|
||||||
|
"title": "person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"address": {"$ref": "#/$defs/address"},
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"address": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"street": {"type": "string"},
|
||||||
|
"city": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["street", "city"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
person_model = converter.build_with_cache(schema)
|
||||||
|
cached_address_model = converter.get_cached_ref("address")
|
||||||
|
|
||||||
|
|
||||||
|
Isolation by Namespace
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
The instance cache is isolated per namespace via the `$id` property in JSON Schema.
|
||||||
|
When retrieving a cached type, you can specify the namespace to look in
|
||||||
|
(via the ``namespace`` parameter). By default, the ``default`` namespace is used
|
||||||
|
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from jambo import SchemaConverter
|
||||||
|
|
||||||
|
converter = SchemaConverter()
|
||||||
|
|
||||||
|
schema_a = {
|
||||||
|
"$id": "namespace_a",
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
}
|
||||||
|
|
||||||
|
schema_b = {
|
||||||
|
"$id": "namespace_b",
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
}
|
||||||
|
|
||||||
|
person_a = converter.build_with_cache(schema_a)
|
||||||
|
person_b = converter.build_with_cache(schema_b)
|
||||||
|
|
||||||
|
cached_person_a = converter.get_cached_ref("Person", namespace="namespace_a")
|
||||||
|
cached_person_b = converter.get_cached_ref("Person", namespace="namespace_b")
|
||||||
|
|
||||||
|
assert cached_person_a is person_a
|
||||||
|
assert cached_person_b is person_b
|
||||||
|
|
||||||
|
|
||||||
|
Clearing the cache
|
||||||
|
------------------
|
||||||
|
|
||||||
|
:py:meth:`SchemaConverter.clear_ref_cache <jambo.SchemaConverter.clear_ref_cache>`(namespace: Optional[str]="default") — removes all entries from the instance cache.
|
||||||
|
|
||||||
|
|
||||||
|
When you want to clear the instance cache, use :py:meth:`SchemaConverter.clear_ref_cache <jambo.SchemaConverter.clear_ref_cache>`.
|
||||||
|
You can optionally specify a ``namespace`` to clear only that namespace;
|
||||||
|
otherwise, the default namespace is cleared.
|
||||||
|
|
||||||
|
If you want to clear all namespaces, call :py:meth:`SchemaConverter.clear_ref_cache <jambo.SchemaConverter.clear_ref_cache>` passing `None` as the namespace,
|
||||||
|
which removes all entries from all namespaces.
|
||||||
|
|
||||||
|
|
||||||
|
Notes and Behavioural Differences
|
||||||
|
================================
|
||||||
|
|
||||||
|
* :py:meth:`SchemaConverter.build <jambo.SchemaConverter.build>` does not expose or persist an instance cache. If you call it without
|
||||||
|
providing a ``ref_cache`` it will create and use a temporary cache for that
|
||||||
|
call only; nothing from that call will be available later via
|
||||||
|
:py:meth:`SchemaConverter.get_cached_ref <jambo.SchemaConverter.get_cached_ref>`.
|
||||||
|
|
||||||
|
* :py:meth:`SchemaConverter.build_with_cache <jambo.SchemaConverter.build_with_cache>` is the supported entry point when you want
|
||||||
|
cache control: it uses the instance cache by default, accepts an explicit
|
||||||
|
``ref_cache`` dict for per-call control, or uses ``without_cache=True`` to
|
||||||
|
run with an ephemeral cache.
|
||||||
|
|
||||||
|
|
||||||
|
References in the Test Suite
|
||||||
|
============================
|
||||||
|
|
||||||
|
These behaviours are exercised in the project's tests; see :mod:`tests.test_schema_converter`
|
||||||
|
for examples and additional usage notes.
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
|
===================
|
||||||
Using Jambo
|
Using Jambo
|
||||||
===================
|
===================
|
||||||
|
|
||||||
Jambo is designed to be easy to use, it doesn't require any complex setup or configuration.
|
Jambo is designed to be easy to use. It doesn't require complex setup or configuration when not needed, while providing more powerful instance methods when you do need control.
|
||||||
Below a example of how to use Jambo to convert a JSON Schema into a Pydantic model.
|
|
||||||
|
|
||||||
|
Below is an example of how to use Jambo to convert a JSON Schema into a Pydantic model.
|
||||||
|
|
||||||
|
|
||||||
|
-------------------------
|
||||||
|
Static Method (no config)
|
||||||
|
-------------------------
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
@@ -15,8 +21,16 @@ Below a example of how to use Jambo to convert a JSON Schema into a Pydantic mod
|
|||||||
"properties": {
|
"properties": {
|
||||||
"name": {"type": "string"},
|
"name": {"type": "string"},
|
||||||
"age": {"type": "integer"},
|
"age": {"type": "integer"},
|
||||||
|
"address": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"street": {"type": "string"},
|
||||||
|
"city": {"type": "string"},
|
||||||
},
|
},
|
||||||
"required": ["name"],
|
"required": ["street", "city"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name", "address"],
|
||||||
}
|
}
|
||||||
|
|
||||||
Person = SchemaConverter.build(schema)
|
Person = SchemaConverter.build(schema)
|
||||||
@@ -26,16 +40,81 @@ Below a example of how to use Jambo to convert a JSON Schema into a Pydantic mod
|
|||||||
# Output: Person(name='Alice', age=30)
|
# Output: Person(name='Alice', age=30)
|
||||||
|
|
||||||
|
|
||||||
The :py:meth:`SchemaConverter.build <jambo.SchemaConverter.build>` static method takes a JSON Schema dictionary and returns a Pydantic model class. You can then instantiate this class with the required fields, and it will automatically validate the data according to the schema.
|
The :py:meth:`SchemaConverter.build <jambo.SchemaConverter.build>` static method takes a JSON Schema dictionary and returns a Pydantic model class.
|
||||||
|
|
||||||
If passed a description inside the schema it will also add it to the Pydantic model using the `description` field. This is useful for AI Frameworks as: LangChain, CrewAI and others, as they use this description for passing context to LLMs.
|
Note: the static ``build`` method was the original public API of this library. It creates and returns a model class for the provided schema but does not expose or persist an instance cache.
|
||||||
|
|
||||||
|
|
||||||
For more complex schemas and types see our documentation on
|
--------------------------------
|
||||||
|
Instance Method (with ref cache)
|
||||||
|
--------------------------------
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from jambo import SchemaConverter
|
||||||
|
|
||||||
|
converter = SchemaConverter()
|
||||||
|
|
||||||
|
schema = {
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"address": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"street": {"type": "string"},
|
||||||
|
"city": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["street", "city"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name", "address"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# The instance API (build_with_cache) populates the converter's instance-level reference cache
|
||||||
|
Person = converter.build_with_cache(schema)
|
||||||
|
|
||||||
|
obj = Person(name="Alice", age=30)
|
||||||
|
print(obj)
|
||||||
|
# Output: Person(name='Alice', age=30)
|
||||||
|
|
||||||
|
# When using the converter's built-in instance cache (no ref_cache passed to the call),
|
||||||
|
# all object types parsed during the build are stored and can be retrieved via get_cached_ref.
|
||||||
|
|
||||||
|
cached_person_model = converter.get_cached_ref("Person")
|
||||||
|
assert Person is cached_person_model # the cached class is the same object that was built
|
||||||
|
|
||||||
|
# A nested/subobject type can also be retrieved from the instance cache
|
||||||
|
cached_address_model = converter.get_cached_ref("Person.address")
|
||||||
|
|
||||||
|
|
||||||
|
The :py:meth:`SchemaConverter.build_with_cache <jambo.SchemaConverter.build_with_cache>` instance method was added after the
|
||||||
|
initial static API to make it easier to access and reuse subtypes defined in a schema.
|
||||||
|
Unlike the original static :py:meth:`SchemaConverter.build <jambo.SchemaConverter.build>`,
|
||||||
|
the instance method persists and exposes the reference cache and provides helpers such as
|
||||||
|
:py:meth:`SchemaConverter.get_cached_ref <jambo.SchemaConverter.get_cached_ref>` and
|
||||||
|
:py:meth:`SchemaConverter.clear_ref_cache <jambo.SchemaConverter.clear_ref_cache>`.
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
The instance API with reference cache can lead to schema and type name collisions if not managed carefully.
|
||||||
|
It's recommended that each schema defines its own unique namespace using the `$id` property in JSON Schema,
|
||||||
|
and then access it's ref_cache by passing it explicitly when needed.
|
||||||
|
|
||||||
|
For details and examples about the reference cache and the different cache modes (instance cache, per-call cache, ephemeral cache), see:
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
usage.ref_cache
|
||||||
|
|
||||||
|
|
||||||
|
Type System
|
||||||
|
-----------
|
||||||
|
|
||||||
|
For a full explanation of the supported schemas and types see our documentation on types:
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
:caption: Contents:
|
|
||||||
|
|
||||||
usage.string
|
usage.string
|
||||||
usage.numeric
|
usage.numeric
|
||||||
|
|||||||
10
jambo/exceptions/__init__.py
Normal file
10
jambo/exceptions/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
16
jambo/exceptions/internal_assertion_exception.py
Normal file
16
jambo/exceptions/internal_assertion_exception.py
Normal 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"
|
||||||
|
)
|
||||||
27
jambo/exceptions/invalid_schema_exception.py
Normal file
27
jambo/exceptions/invalid_schema_exception.py
Normal 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
|
||||||
23
jambo/exceptions/unsupported_schema_exception.py
Normal file
23
jambo/exceptions/unsupported_schema_exception.py
Normal 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
|
||||||
@@ -1,27 +1,31 @@
|
|||||||
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 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
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T", bound=type)
|
||||||
|
|
||||||
|
|
||||||
class GenericTypeParser(ABC, Generic[T]):
|
class GenericTypeParser(ABC, Generic[T]):
|
||||||
json_schema_type: str = None
|
json_schema_type: ClassVar[str]
|
||||||
|
|
||||||
type_mappings: dict[str, str] = {}
|
type_mappings: dict[str, str] = {}
|
||||||
|
|
||||||
default_mappings = {
|
default_mappings = {
|
||||||
"default": "default",
|
"default": "default",
|
||||||
"description": "description",
|
"description": "description",
|
||||||
|
"examples": "examples",
|
||||||
|
"title": "title",
|
||||||
|
"deprecated": "deprecated",
|
||||||
}
|
}
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def from_properties_impl(
|
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]:
|
) -> tuple[T, dict]:
|
||||||
"""
|
"""
|
||||||
Abstract method to convert properties to a type and its fields properties.
|
Abstract method to convert properties to a type and its fields properties.
|
||||||
@@ -32,7 +36,7 @@ class GenericTypeParser(ABC, Generic[T]):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def from_properties(
|
def from_properties(
|
||||||
self, name: str, properties: dict[str, Any], **kwargs: Unpack[TypeParserOptions]
|
self, name: str, properties: JSONSchema, **kwargs: Unpack[TypeParserOptions]
|
||||||
) -> tuple[T, dict]:
|
) -> tuple[T, dict]:
|
||||||
"""
|
"""
|
||||||
Converts properties to a type and its fields properties.
|
Converts properties to a type and its fields properties.
|
||||||
@@ -46,15 +50,20 @@ class GenericTypeParser(ABC, Generic[T]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not self._validate_default(parsed_type, parsed_properties):
|
if not self._validate_default(parsed_type, parsed_properties):
|
||||||
raise ValueError(
|
raise InvalidSchemaException(
|
||||||
f"Default value {properties.get('default')} is not valid for type {parsed_type.__name__}"
|
"Default value is not valid", invalid_field=name
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._validate_examples(parsed_type, parsed_properties):
|
||||||
|
raise InvalidSchemaException(
|
||||||
|
"Examples values are not valid", invalid_field=name
|
||||||
)
|
)
|
||||||
|
|
||||||
return parsed_type, parsed_properties
|
return parsed_type, parsed_properties
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def type_from_properties(
|
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]:
|
) -> tuple[type, dict]:
|
||||||
"""
|
"""
|
||||||
Factory method to fetch the appropriate type parser based on properties
|
Factory method to fetch the appropriate type parser based on properties
|
||||||
@@ -64,22 +73,53 @@ class GenericTypeParser(ABC, Generic[T]):
|
|||||||
:param kwargs: Additional options for type parsing.
|
:param kwargs: Additional options for type parsing.
|
||||||
:return: A tuple containing the type and its properties.
|
:return: A tuple containing the type and its properties.
|
||||||
"""
|
"""
|
||||||
parser = cls._get_impl(properties)
|
|
||||||
|
parser = cls._get_impl(cls._normalize_properties(properties))
|
||||||
|
|
||||||
return parser().from_properties(name=name, properties=properties, **kwargs)
|
return parser().from_properties(name=name, properties=properties, **kwargs)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_properties(properties: JSONSchema) -> JSONSchema:
|
||||||
|
"""
|
||||||
|
Normalizes the properties dictionary to ensure consistent structure.
|
||||||
|
:param properties: The properties to be normalized.
|
||||||
|
"""
|
||||||
|
type_value = properties.pop("type", None)
|
||||||
|
|
||||||
|
if isinstance(type_value, str):
|
||||||
|
properties["type"] = type_value
|
||||||
|
return properties
|
||||||
|
|
||||||
|
if isinstance(type_value, list) and len(type_value) == 0:
|
||||||
|
raise InvalidSchemaException(
|
||||||
|
"Invalid schema: 'type' list cannot be empty",
|
||||||
|
invalid_field=str(properties),
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(type_value, list) and len(type_value) == 1:
|
||||||
|
properties["type"] = type_value[0]
|
||||||
|
return properties
|
||||||
|
|
||||||
|
if isinstance(type_value, list):
|
||||||
|
properties["anyOf"] = [{"type": t} for t in type_value]
|
||||||
|
return properties
|
||||||
|
|
||||||
|
return properties
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _get_impl(cls, properties: dict[str, Any]) -> type[Self]:
|
def _get_impl(cls, properties: JSONSchema) -> type[Self]:
|
||||||
for subcls in cls.__subclasses__():
|
for subcls in cls.__subclasses__():
|
||||||
schema_type, schema_value = subcls._get_schema_type()
|
schema_type, schema_value = subcls._get_schema_type()
|
||||||
|
|
||||||
if schema_type not in properties:
|
if schema_type not in properties:
|
||||||
continue
|
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
|
return subcls
|
||||||
|
|
||||||
raise ValueError("Unknown type")
|
raise InvalidSchemaException(
|
||||||
|
"No suitable type parser found", invalid_field=str(properties)
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _get_schema_type(cls) -> tuple[str, str | None]:
|
def _get_schema_type(cls) -> tuple[str, str | None]:
|
||||||
@@ -108,7 +148,7 @@ class GenericTypeParser(ABC, Generic[T]):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@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")
|
value = field_prop.get("default")
|
||||||
|
|
||||||
if value is None and field_prop.get("default_factory") is not None:
|
if value is None and field_prop.get("default_factory") is not None:
|
||||||
@@ -117,8 +157,27 @@ class GenericTypeParser(ABC, Generic[T]):
|
|||||||
if value is None:
|
if value is None:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
return GenericTypeParser._is_valid_value(field_type, field_prop, value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_examples(field_type: T, field_prop: dict) -> bool:
|
||||||
|
examples = field_prop.get("examples")
|
||||||
|
|
||||||
|
if examples is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not isinstance(examples, list):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return all(
|
||||||
|
GenericTypeParser._is_valid_value(field_type, field_prop, e)
|
||||||
|
for e in examples
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_valid_value(field_type: T, field_prop: dict, value: Any) -> bool:
|
||||||
try:
|
try:
|
||||||
field = Annotated[field_type, Field(**field_prop)]
|
field = Annotated[field_type, Field(**field_prop)] # type: ignore
|
||||||
TypeAdapter(field).validate_python(value)
|
TypeAdapter(field).validate_python(value)
|
||||||
except Exception as _:
|
except Exception as _:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
from jambo.exceptions import InvalidSchemaException
|
||||||
from jambo.parser._type_parser import GenericTypeParser
|
from jambo.parser._type_parser import GenericTypeParser
|
||||||
|
from jambo.types.json_schema_type import JSONSchema
|
||||||
from jambo.types.type_parser_options import TypeParserOptions
|
from jambo.types.type_parser_options import TypeParserOptions
|
||||||
|
|
||||||
from typing_extensions import Any, Unpack
|
from typing_extensions import Unpack
|
||||||
|
|
||||||
|
|
||||||
class AllOfTypeParser(GenericTypeParser):
|
class AllOfTypeParser(GenericTypeParser):
|
||||||
@@ -10,7 +12,7 @@ class AllOfTypeParser(GenericTypeParser):
|
|||||||
json_schema_type = "allOf"
|
json_schema_type = "allOf"
|
||||||
|
|
||||||
def from_properties_impl(
|
def from_properties_impl(
|
||||||
self, name, properties, **kwargs: Unpack[TypeParserOptions]
|
self, name: str, properties: JSONSchema, **kwargs: Unpack[TypeParserOptions]
|
||||||
):
|
):
|
||||||
sub_properties = properties.get("allOf", [])
|
sub_properties = properties.get("allOf", [])
|
||||||
|
|
||||||
@@ -25,36 +27,46 @@ class AllOfTypeParser(GenericTypeParser):
|
|||||||
sub_properties
|
sub_properties
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (examples := properties.get("examples")) is not None:
|
||||||
|
combined_properties["examples"] = examples
|
||||||
|
|
||||||
return parser().from_properties_impl(name, combined_properties, **kwargs)
|
return parser().from_properties_impl(name, combined_properties, **kwargs)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_type_parser(
|
def _get_type_parser(
|
||||||
sub_properties: list[dict[str, Any]],
|
sub_properties: list[JSONSchema],
|
||||||
) -> type[GenericTypeParser]:
|
) -> type[GenericTypeParser]:
|
||||||
if not sub_properties:
|
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
|
GenericTypeParser._get_impl(sub_property) for sub_property in sub_properties
|
||||||
)
|
)
|
||||||
if len(parsers) != 1:
|
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()
|
return parsers.pop()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _rebuild_properties_from_subproperties(
|
def _rebuild_properties_from_subproperties(
|
||||||
sub_properties: list[dict[str, Any]],
|
sub_properties: list[JSONSchema],
|
||||||
) -> dict[str, Any]:
|
) -> JSONSchema:
|
||||||
properties = {}
|
properties: JSONSchema = {}
|
||||||
for subProperty in sub_properties:
|
for subProperty in sub_properties:
|
||||||
for name, prop in subProperty.items():
|
for name, prop in subProperty.items():
|
||||||
if name not in properties:
|
if name not in properties:
|
||||||
properties[name] = prop
|
properties[name] = prop # type: ignore
|
||||||
else:
|
else:
|
||||||
# Merge properties if they exist in both sub-properties
|
# Merge properties if they exist in both sub-properties
|
||||||
properties[name] = AllOfTypeParser._validate_prop(
|
properties[name] = AllOfTypeParser._validate_prop( # type: ignore
|
||||||
name, properties[name], prop
|
name,
|
||||||
|
properties[name], # type: ignore
|
||||||
|
prop,
|
||||||
)
|
)
|
||||||
return properties
|
return properties
|
||||||
|
|
||||||
@@ -65,8 +77,8 @@ class AllOfTypeParser(GenericTypeParser):
|
|||||||
|
|
||||||
if prop_name == "default":
|
if prop_name == "default":
|
||||||
if old_value != new_value:
|
if old_value != new_value:
|
||||||
raise ValueError(
|
raise InvalidSchemaException(
|
||||||
f"Invalid JSON Schema: conflicting defaults for '{prop_name}'"
|
f"Conflicting defaults for '{prop_name}'", invalid_field=prop_name
|
||||||
)
|
)
|
||||||
return old_value
|
return old_value
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from jambo.exceptions import InvalidSchemaException
|
||||||
from jambo.parser._type_parser import GenericTypeParser
|
from jambo.parser._type_parser import GenericTypeParser
|
||||||
from jambo.types.type_parser_options import TypeParserOptions
|
from jambo.types.type_parser_options import TypeParserOptions
|
||||||
|
|
||||||
@@ -14,18 +15,25 @@ class AnyOfTypeParser(GenericTypeParser):
|
|||||||
self, name, properties, **kwargs: Unpack[TypeParserOptions]
|
self, name, properties, **kwargs: Unpack[TypeParserOptions]
|
||||||
):
|
):
|
||||||
if "anyOf" not in properties:
|
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):
|
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)
|
mapped_properties = self.mappings_properties_builder(properties, **kwargs)
|
||||||
|
|
||||||
sub_properties = properties["anyOf"]
|
sub_properties = properties["anyOf"]
|
||||||
|
|
||||||
sub_types = [
|
sub_types = [
|
||||||
GenericTypeParser.type_from_properties(name, subProperty, **kwargs)
|
GenericTypeParser.type_from_properties(
|
||||||
for subProperty in sub_properties
|
f"{name}.sub{i}", subProperty, **kwargs
|
||||||
|
)
|
||||||
|
for i, subProperty in enumerate(sub_properties)
|
||||||
]
|
]
|
||||||
|
|
||||||
if not kwargs.get("required", False):
|
if not kwargs.get("required", False):
|
||||||
@@ -34,8 +42,12 @@ class AnyOfTypeParser(GenericTypeParser):
|
|||||||
# By defining the type as Union of Annotated type we can use the Field validator
|
# By defining the type as Union of Annotated type we can use the Field validator
|
||||||
# to enforce the constraints of each union type when needed.
|
# to enforce the constraints of each union type when needed.
|
||||||
# We use Annotated to attach the Field validators to the type.
|
# We use Annotated to attach the Field validators to the type.
|
||||||
field_types = [
|
field_types = []
|
||||||
Annotated[t, Field(**v)] if v is not None else t for t, v in sub_types
|
for subType, subProp in sub_types:
|
||||||
]
|
default_value = subProp.pop("default", None)
|
||||||
|
if default_value is None:
|
||||||
|
default_value = ...
|
||||||
|
|
||||||
|
field_types.append(Annotated[subType, Field(default_value, **subProp)])
|
||||||
|
|
||||||
return Union[(*field_types,)], mapped_properties
|
return Union[(*field_types,)], mapped_properties
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
|
from jambo.exceptions import InvalidSchemaException
|
||||||
from jambo.parser._type_parser import GenericTypeParser
|
from jambo.parser._type_parser import GenericTypeParser
|
||||||
from jambo.types.type_parser_options import TypeParserOptions
|
from jambo.types.type_parser_options import TypeParserOptions
|
||||||
|
|
||||||
from typing_extensions import Iterable, TypeVar, Unpack
|
from typing_extensions import (
|
||||||
|
Iterable,
|
||||||
|
Unpack,
|
||||||
|
)
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
|
|
||||||
|
|
||||||
V = TypeVar("V")
|
|
||||||
|
|
||||||
|
|
||||||
class ArrayTypeParser(GenericTypeParser):
|
class ArrayTypeParser(GenericTypeParser):
|
||||||
mapped_type = list
|
mapped_type = list
|
||||||
|
|
||||||
json_schema_type = "type:array"
|
json_schema_type = "type:array"
|
||||||
|
|
||||||
default_mappings = {"description": "description"}
|
|
||||||
|
|
||||||
type_mappings = {
|
type_mappings = {
|
||||||
"maxItems": "max_length",
|
"maxItems": "max_length",
|
||||||
"minItems": "min_length",
|
"minItems": "min_length",
|
||||||
@@ -26,8 +25,15 @@ class ArrayTypeParser(GenericTypeParser):
|
|||||||
):
|
):
|
||||||
item_properties = kwargs.copy()
|
item_properties = kwargs.copy()
|
||||||
item_properties["required"] = True
|
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(
|
_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
|
wrapper_type = set if properties.get("uniqueItems", False) else list
|
||||||
@@ -35,11 +41,18 @@ class ArrayTypeParser(GenericTypeParser):
|
|||||||
|
|
||||||
mapped_properties = self.mappings_properties_builder(properties, **kwargs)
|
mapped_properties = self.mappings_properties_builder(properties, **kwargs)
|
||||||
|
|
||||||
if "default" in properties or not kwargs.get("required", False):
|
if (
|
||||||
|
default_value := mapped_properties.pop("default", None)
|
||||||
|
) is not None or not kwargs.get("required", False):
|
||||||
mapped_properties["default_factory"] = self._build_default_factory(
|
mapped_properties["default_factory"] = self._build_default_factory(
|
||||||
properties.get("default"), wrapper_type
|
default_value, wrapper_type
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (example_values := mapped_properties.pop("examples", None)) is not None:
|
||||||
|
mapped_properties["examples"] = [
|
||||||
|
wrapper_type(example) for example in example_values
|
||||||
|
]
|
||||||
|
|
||||||
return field_type, mapped_properties
|
return field_type, mapped_properties
|
||||||
|
|
||||||
def _build_default_factory(self, default_list, wrapper_type):
|
def _build_default_factory(self, default_list, wrapper_type):
|
||||||
@@ -47,8 +60,9 @@ class ArrayTypeParser(GenericTypeParser):
|
|||||||
return lambda: None
|
return lambda: None
|
||||||
|
|
||||||
if not isinstance(default_list, Iterable):
|
if not isinstance(default_list, Iterable):
|
||||||
raise ValueError(
|
raise InvalidSchemaException(
|
||||||
f"Default value for array must be an iterable, got {type(default_list)}"
|
f"Default value for array must be an iterable, got {type(default_list)}",
|
||||||
|
invalid_field="default",
|
||||||
)
|
)
|
||||||
|
|
||||||
return lambda: copy.deepcopy(wrapper_type(default_list))
|
return lambda: copy.deepcopy(wrapper_type(default_list))
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from jambo.exceptions import InvalidSchemaException
|
||||||
from jambo.parser._type_parser import GenericTypeParser
|
from jambo.parser._type_parser import GenericTypeParser
|
||||||
from jambo.types.type_parser_options import TypeParserOptions
|
from jambo.types.type_parser_options import TypeParserOptions
|
||||||
|
|
||||||
@@ -20,6 +21,9 @@ class BooleanTypeParser(GenericTypeParser):
|
|||||||
|
|
||||||
default_value = properties.get("default")
|
default_value = properties.get("default")
|
||||||
if default_value is not None and not isinstance(default_value, bool):
|
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
|
return bool, mapped_properties
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from jambo.exceptions import InvalidSchemaException
|
||||||
from jambo.parser._type_parser import GenericTypeParser
|
from jambo.parser._type_parser import GenericTypeParser
|
||||||
from jambo.types.json_schema_type import JSONSchemaNativeTypes
|
from jambo.types.json_schema_type import JSONSchemaNativeTypes
|
||||||
from jambo.types.type_parser_options import TypeParserOptions
|
from jambo.types.type_parser_options import TypeParserOptions
|
||||||
@@ -12,19 +13,24 @@ class ConstTypeParser(GenericTypeParser):
|
|||||||
default_mappings = {
|
default_mappings = {
|
||||||
"const": "default",
|
"const": "default",
|
||||||
"description": "description",
|
"description": "description",
|
||||||
|
"examples": "examples",
|
||||||
}
|
}
|
||||||
|
|
||||||
def from_properties_impl(
|
def from_properties_impl(
|
||||||
self, name, properties, **kwargs: Unpack[TypeParserOptions]
|
self, name, properties, **kwargs: Unpack[TypeParserOptions]
|
||||||
):
|
):
|
||||||
if "const" not in properties:
|
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"]
|
const_value = properties["const"]
|
||||||
|
|
||||||
if not isinstance(const_value, JSONSchemaNativeTypes):
|
if not isinstance(const_value, JSONSchemaNativeTypes):
|
||||||
raise ValueError(
|
raise InvalidSchemaException(
|
||||||
f"Const type {name} must have 'const' value of allowed types: {JSONSchemaNativeTypes}."
|
f"Const type {name} must have 'const' value of allowed types: {JSONSchemaNativeTypes}.",
|
||||||
|
invalid_field="const",
|
||||||
)
|
)
|
||||||
|
|
||||||
const_type = self._build_const_type(const_value)
|
const_type = self._build_const_type(const_value)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
from jambo.exceptions import InvalidSchemaException
|
||||||
from jambo.parser._type_parser import GenericTypeParser
|
from jambo.parser._type_parser import GenericTypeParser
|
||||||
from jambo.types.json_schema_type import JSONSchemaNativeTypes
|
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
|
from typing_extensions import Unpack
|
||||||
|
|
||||||
@@ -11,30 +12,38 @@ class EnumTypeParser(GenericTypeParser):
|
|||||||
json_schema_type = "enum"
|
json_schema_type = "enum"
|
||||||
|
|
||||||
def from_properties_impl(
|
def from_properties_impl(
|
||||||
self, name, properties, **kwargs: Unpack[TypeParserOptions]
|
self, name: str, properties: JSONSchema, **kwargs: Unpack[TypeParserOptions]
|
||||||
):
|
):
|
||||||
if "enum" not in properties:
|
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"]
|
enum_values = properties["enum"]
|
||||||
|
|
||||||
if not isinstance(enum_values, list):
|
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(
|
if any(not isinstance(value, JSONSchemaNativeTypes) for value in enum_values):
|
||||||
not isinstance(value, JSONSchemaNativeTypes) for value in enum_values
|
raise InvalidSchemaException(
|
||||||
):
|
f"Enum type {name} must have 'enum' values of allowed types: {JSONSchemaNativeTypes}.",
|
||||||
raise ValueError(
|
invalid_field="enum",
|
||||||
f"Enum type {name} must have 'enum' values of allowed types: {JSONSchemaNativeTypes}."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a new Enum type dynamically
|
# Create a new Enum type dynamically
|
||||||
enum_type = Enum(name, {str(value).upper(): value for value in enum_values})
|
enum_type = Enum(name, {str(value).upper(): value for value in enum_values}) # type: ignore
|
||||||
parsed_properties = self.mappings_properties_builder(properties, **kwargs)
|
parsed_properties = self.mappings_properties_builder(properties, **kwargs)
|
||||||
|
|
||||||
if (
|
if "default" in parsed_properties and parsed_properties["default"] is not None:
|
||||||
"default" in parsed_properties and parsed_properties["default"] is not None
|
|
||||||
):
|
|
||||||
parsed_properties["default"] = enum_type(parsed_properties["default"])
|
parsed_properties["default"] = enum_type(parsed_properties["default"])
|
||||||
|
|
||||||
|
if "examples" in parsed_properties:
|
||||||
|
parsed_properties["examples"] = [
|
||||||
|
enum_type(example) for example in parsed_properties["examples"]
|
||||||
|
]
|
||||||
|
|
||||||
return enum_type, parsed_properties
|
return enum_type, parsed_properties
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
from jambo.exceptions import InternalAssertionException
|
||||||
from jambo.parser._type_parser import GenericTypeParser
|
from jambo.parser._type_parser import GenericTypeParser
|
||||||
|
from jambo.types.json_schema_type import JSONSchema
|
||||||
from jambo.types.type_parser_options import TypeParserOptions
|
from jambo.types.type_parser_options import TypeParserOptions
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, create_model
|
from pydantic import BaseModel, ConfigDict, Field, create_model
|
||||||
from typing_extensions import Any, Unpack
|
from pydantic.fields import FieldInfo
|
||||||
|
from typing_extensions import Unpack
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
|
||||||
class ObjectTypeParser(GenericTypeParser):
|
class ObjectTypeParser(GenericTypeParser):
|
||||||
@@ -11,7 +16,7 @@ class ObjectTypeParser(GenericTypeParser):
|
|||||||
json_schema_type = "type:object"
|
json_schema_type = "type:object"
|
||||||
|
|
||||||
def from_properties_impl(
|
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]:
|
) -> tuple[type[BaseModel], dict]:
|
||||||
type_parsing = self.to_model(
|
type_parsing = self.to_model(
|
||||||
name,
|
name,
|
||||||
@@ -19,52 +24,81 @@ class ObjectTypeParser(GenericTypeParser):
|
|||||||
properties.get("required", []),
|
properties.get("required", []),
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
type_properties = {}
|
type_properties = self.mappings_properties_builder(properties, **kwargs)
|
||||||
|
|
||||||
if "default" in properties:
|
if (
|
||||||
type_properties["default_factory"] = lambda: type_parsing.model_validate(
|
default_value := type_properties.pop("default", None)
|
||||||
properties["default"]
|
) is not None or not kwargs.get("required", False):
|
||||||
|
type_properties["default_factory"] = (
|
||||||
|
lambda: type_parsing.model_validate(default_value)
|
||||||
|
if default_value is not None
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (example_values := type_properties.pop("examples", None)) is not None:
|
||||||
|
type_properties["examples"] = [
|
||||||
|
type_parsing.model_validate(example) for example in example_values
|
||||||
|
]
|
||||||
|
|
||||||
return type_parsing, type_properties
|
return type_parsing, type_properties
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def to_model(
|
def to_model(
|
||||||
cls,
|
cls,
|
||||||
name: str,
|
name: str,
|
||||||
schema: dict[str, Any],
|
properties: dict[str, JSONSchema],
|
||||||
required_keys: list[str],
|
required_keys: list[str],
|
||||||
**kwargs: Unpack[TypeParserOptions],
|
**kwargs: Unpack[TypeParserOptions],
|
||||||
) -> type[BaseModel]:
|
) -> type[BaseModel]:
|
||||||
"""
|
"""
|
||||||
Converts JSON Schema object properties to a Pydantic model.
|
Converts JSON Schema object properties to a Pydantic model.
|
||||||
:param name: The name of the 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.
|
:param required_keys: List of required keys in the schema.
|
||||||
:return: A Pydantic model class.
|
:return: A Pydantic model class.
|
||||||
"""
|
"""
|
||||||
model_config = ConfigDict(validate_assignment=True)
|
ref_cache = kwargs.get("ref_cache")
|
||||||
fields = cls._parse_properties(schema, required_keys, **kwargs)
|
if ref_cache is None:
|
||||||
|
raise InternalAssertionException(
|
||||||
|
"`ref_cache` must be provided in kwargs for ObjectTypeParser"
|
||||||
|
)
|
||||||
|
|
||||||
return create_model(name, __config__=model_config, **fields)
|
if (model := ref_cache.get(name)) is not None and isinstance(model, type):
|
||||||
|
warnings.warn(
|
||||||
|
f"Type '{name}' is already in the ref_cache and therefore cached value will be used."
|
||||||
|
" This may indicate a namming collision in the schema or just a normal optimization,"
|
||||||
|
" if this behavior is desired pass a clean ref_cache or use the param `without_cache`"
|
||||||
|
)
|
||||||
|
return model
|
||||||
|
|
||||||
|
model_config = ConfigDict(validate_assignment=True)
|
||||||
|
fields = cls._parse_properties(name, properties, required_keys, **kwargs)
|
||||||
|
|
||||||
|
model = create_model(name, __config__=model_config, **fields) # type: ignore
|
||||||
|
ref_cache[name] = model
|
||||||
|
|
||||||
|
return model
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _parse_properties(
|
def _parse_properties(
|
||||||
cls,
|
cls,
|
||||||
properties: dict[str, Any],
|
name: str,
|
||||||
|
properties: dict[str, JSONSchema],
|
||||||
required_keys: list[str],
|
required_keys: list[str],
|
||||||
**kwargs: Unpack[TypeParserOptions],
|
**kwargs: Unpack[TypeParserOptions],
|
||||||
) -> dict[str, tuple[type, Field]]:
|
) -> dict[str, tuple[type, FieldInfo]]:
|
||||||
required_keys = required_keys or []
|
required_keys = required_keys or []
|
||||||
|
|
||||||
fields = {}
|
fields = {}
|
||||||
for name, prop in properties.items():
|
for field_name, field_prop in properties.items():
|
||||||
sub_property: TypeParserOptions = kwargs.copy()
|
sub_property: TypeParserOptions = kwargs.copy()
|
||||||
sub_property["required"] = name in required_keys
|
sub_property["required"] = field_name in required_keys
|
||||||
|
|
||||||
parsed_type, parsed_properties = GenericTypeParser.type_from_properties(
|
parsed_type, parsed_properties = GenericTypeParser.type_from_properties(
|
||||||
name, prop, **sub_property
|
f"{name}.{field_name}",
|
||||||
|
field_prop,
|
||||||
|
**sub_property, # type: ignore
|
||||||
)
|
)
|
||||||
fields[name] = (parsed_type, Field(**parsed_properties))
|
fields[field_name] = (parsed_type, Field(**parsed_properties))
|
||||||
|
|
||||||
return fields
|
return fields
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from jambo.exceptions import InvalidSchemaException
|
||||||
from jambo.parser._type_parser import GenericTypeParser
|
from jambo.parser._type_parser import GenericTypeParser
|
||||||
from jambo.types.type_parser_options import TypeParserOptions
|
from jambo.types.type_parser_options import TypeParserOptions
|
||||||
|
|
||||||
@@ -5,6 +6,9 @@ from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, ValidationE
|
|||||||
from typing_extensions import Annotated, Any, Union, Unpack, get_args
|
from typing_extensions import Annotated, Any, Union, Unpack, get_args
|
||||||
|
|
||||||
|
|
||||||
|
Annotation = Annotated[Any, ...]
|
||||||
|
|
||||||
|
|
||||||
class OneOfTypeParser(GenericTypeParser):
|
class OneOfTypeParser(GenericTypeParser):
|
||||||
mapped_type = Union
|
mapped_type = Union
|
||||||
|
|
||||||
@@ -14,18 +18,22 @@ class OneOfTypeParser(GenericTypeParser):
|
|||||||
self, name, properties, **kwargs: Unpack[TypeParserOptions]
|
self, name, properties, **kwargs: Unpack[TypeParserOptions]
|
||||||
):
|
):
|
||||||
if "oneOf" not in properties:
|
if "oneOf" not in properties:
|
||||||
raise ValueError(f"Invalid JSON Schema: {properties}")
|
raise InvalidSchemaException(
|
||||||
|
f"Invalid JSON Schema: {properties}", invalid_field="oneOf"
|
||||||
|
)
|
||||||
|
|
||||||
if not isinstance(properties["oneOf"], list) or len(properties["oneOf"]) == 0:
|
if not isinstance(properties["oneOf"], list) or len(properties["oneOf"]) == 0:
|
||||||
raise ValueError(f"Invalid JSON Schema: {properties['oneOf']}")
|
raise InvalidSchemaException(
|
||||||
|
f"Invalid JSON Schema: {properties['oneOf']}", invalid_field="oneOf"
|
||||||
|
)
|
||||||
|
|
||||||
mapped_properties = self.mappings_properties_builder(properties, **kwargs)
|
mapped_properties = self.mappings_properties_builder(properties, **kwargs)
|
||||||
|
|
||||||
sub_properties = properties["oneOf"]
|
|
||||||
|
|
||||||
sub_types = [
|
sub_types = [
|
||||||
GenericTypeParser.type_from_properties(name, subProperty, **kwargs)
|
GenericTypeParser.type_from_properties(
|
||||||
for subProperty in sub_properties
|
f"{name}_sub{i}", subProperty, **kwargs
|
||||||
|
)
|
||||||
|
for i, subProperty in enumerate(properties["oneOf"])
|
||||||
]
|
]
|
||||||
|
|
||||||
if not kwargs.get("required", False):
|
if not kwargs.get("required", False):
|
||||||
@@ -37,8 +45,7 @@ class OneOfTypeParser(GenericTypeParser):
|
|||||||
# they were added by OpenAPI and not all implementations may support them,
|
# 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
|
# 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
|
# TL;DR: Discriminators were added by OpenAPI and not a Official JSON Schema feature
|
||||||
discriminator = properties.get("discriminator")
|
if (discriminator := properties.get("discriminator")) is not None:
|
||||||
if discriminator is not None:
|
|
||||||
validated_type = self._build_type_one_of_with_discriminator(
|
validated_type = self._build_type_one_of_with_discriminator(
|
||||||
subfield_types, discriminator
|
subfield_types, discriminator
|
||||||
)
|
)
|
||||||
@@ -49,13 +56,15 @@ class OneOfTypeParser(GenericTypeParser):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_type_one_of_with_discriminator(
|
def _build_type_one_of_with_discriminator(
|
||||||
subfield_types: list[Annotated], discriminator_prop: dict
|
subfield_types: list[Annotation], discriminator_prop: dict
|
||||||
) -> Annotated:
|
) -> Annotation:
|
||||||
"""
|
"""
|
||||||
Build a type with a discriminator.
|
Build a type with a discriminator.
|
||||||
"""
|
"""
|
||||||
if not isinstance(discriminator_prop, dict):
|
if not isinstance(discriminator_prop, dict):
|
||||||
raise ValueError("Discriminator must be a dictionary")
|
raise InvalidSchemaException(
|
||||||
|
"Discriminator must be a dictionary", invalid_field="discriminator"
|
||||||
|
)
|
||||||
|
|
||||||
for field in subfield_types:
|
for field in subfield_types:
|
||||||
field_type, field_info = get_args(field)
|
field_type, field_info = get_args(field)
|
||||||
@@ -63,18 +72,22 @@ class OneOfTypeParser(GenericTypeParser):
|
|||||||
if issubclass(field_type, BaseModel):
|
if issubclass(field_type, BaseModel):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
raise ValueError(
|
raise InvalidSchemaException(
|
||||||
"When using a discriminator, all subfield types must be of type 'object'."
|
"When using a discriminator, all subfield types must be of type 'object'.",
|
||||||
|
invalid_field="discriminator",
|
||||||
)
|
)
|
||||||
|
|
||||||
property_name = discriminator_prop.get("propertyName")
|
property_name = discriminator_prop.get("propertyName")
|
||||||
if property_name is None or not isinstance(property_name, str):
|
if property_name is None or not isinstance(property_name, str):
|
||||||
raise ValueError("Discriminator must have a 'propertyName' key")
|
raise InvalidSchemaException(
|
||||||
|
"Discriminator must have a 'propertyName' key",
|
||||||
|
invalid_field="propertyName",
|
||||||
|
)
|
||||||
|
|
||||||
return Annotated[Union[(*subfield_types,)], Field(discriminator=property_name)]
|
return Annotated[Union[(*subfield_types,)], Field(discriminator=property_name)]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_type_one_of_with_func(subfield_types: list[Annotated]) -> Annotated:
|
def _build_type_one_of_with_func(subfield_types: list[Annotation]) -> Annotation:
|
||||||
"""
|
"""
|
||||||
Build a type with a validation function for the oneOf constraint.
|
Build a type with a validation function for the oneOf constraint.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
from jambo.exceptions import InternalAssertionException, InvalidSchemaException
|
||||||
from jambo.parser import GenericTypeParser
|
from jambo.parser import GenericTypeParser
|
||||||
|
from jambo.types import RefCacheDict
|
||||||
|
from jambo.types.json_schema_type import JSONSchema
|
||||||
from jambo.types.type_parser_options import TypeParserOptions
|
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"]
|
RefStrategy = Literal["forward_ref", "def_ref"]
|
||||||
|
|
||||||
@@ -13,21 +16,22 @@ class RefTypeParser(GenericTypeParser):
|
|||||||
json_schema_type = "$ref"
|
json_schema_type = "$ref"
|
||||||
|
|
||||||
def from_properties_impl(
|
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]:
|
) -> tuple[RefType, dict]:
|
||||||
if "$ref" not in properties:
|
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 kwargs.get("context") is None:
|
||||||
if context is None:
|
raise InternalAssertionException(
|
||||||
raise RuntimeError(
|
"`context` must be provided in kwargs for RefTypeParser"
|
||||||
f"RefTypeParser: Missing `content` in properties for {name}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
ref_cache = kwargs.get("ref_cache")
|
ref_cache = kwargs.get("ref_cache")
|
||||||
if ref_cache is None:
|
if ref_cache is None:
|
||||||
raise RuntimeError(
|
raise InternalAssertionException(
|
||||||
f"RefTypeParser: Missing `ref_cache` in properties for {name}"
|
"`ref_cache` must be provided in kwargs for RefTypeParser"
|
||||||
)
|
)
|
||||||
|
|
||||||
mapped_properties = self.mappings_properties_builder(properties, **kwargs)
|
mapped_properties = self.mappings_properties_builder(properties, **kwargs)
|
||||||
@@ -41,19 +45,19 @@ class RefTypeParser(GenericTypeParser):
|
|||||||
# If the reference is either processing or already cached
|
# If the reference is either processing or already cached
|
||||||
return ref_state, mapped_properties
|
return ref_state, mapped_properties
|
||||||
|
|
||||||
ref_cache[ref_name] = self._parse_from_strategy(
|
ref = self._parse_from_strategy(ref_strategy, ref_name, ref_property, **kwargs)
|
||||||
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(
|
def _parse_from_strategy(
|
||||||
self,
|
self,
|
||||||
ref_strategy: RefStrategy,
|
ref_strategy: RefStrategy,
|
||||||
ref_name: str,
|
ref_name: str,
|
||||||
ref_property: dict[str, Any],
|
ref_property: JSONSchema,
|
||||||
**kwargs: Unpack[TypeParserOptions],
|
**kwargs: Unpack[TypeParserOptions],
|
||||||
):
|
) -> RefType:
|
||||||
|
mapped_type: RefType
|
||||||
match ref_strategy:
|
match ref_strategy:
|
||||||
case "forward_ref":
|
case "forward_ref":
|
||||||
mapped_type = ForwardRef(ref_name)
|
mapped_type = ForwardRef(ref_name)
|
||||||
@@ -62,14 +66,14 @@ class RefTypeParser(GenericTypeParser):
|
|||||||
ref_name, ref_property, **kwargs
|
ref_name, ref_property, **kwargs
|
||||||
)
|
)
|
||||||
case _:
|
case _:
|
||||||
raise ValueError(
|
raise InvalidSchemaException(
|
||||||
f"RefTypeParser: Unsupported $ref {ref_property['$ref']}"
|
f"Unsupported $ref {ref_property['$ref']}", invalid_field="$ref"
|
||||||
)
|
)
|
||||||
|
|
||||||
return mapped_type
|
return mapped_type
|
||||||
|
|
||||||
def _get_ref_from_cache(
|
def _get_ref_from_cache(
|
||||||
self, ref_name: str, ref_cache: dict[str, type]
|
self, ref_name: str, ref_cache: RefCacheDict
|
||||||
) -> RefType | type | None:
|
) -> RefType | type | None:
|
||||||
try:
|
try:
|
||||||
ref_state = ref_cache[ref_name]
|
ref_state = ref_cache[ref_name]
|
||||||
@@ -84,42 +88,48 @@ class RefTypeParser(GenericTypeParser):
|
|||||||
# If the reference is not in the cache, we will set it to None
|
# If the reference is not in the cache, we will set it to None
|
||||||
ref_cache[ref_name] = None
|
ref_cache[ref_name] = None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def _examine_ref_strategy(
|
def _examine_ref_strategy(
|
||||||
self, name: str, properties: dict[str, Any], **kwargs: Unpack[TypeParserOptions]
|
self, name: str, properties: JSONSchema, **kwargs: Unpack[TypeParserOptions]
|
||||||
) -> tuple[RefStrategy, str, dict] | None:
|
) -> tuple[RefStrategy, str, JSONSchema]:
|
||||||
if properties["$ref"] == "#":
|
if properties.get("$ref") == "#":
|
||||||
ref_name = kwargs["context"].get("title")
|
ref_name = kwargs["context"].get("title")
|
||||||
if ref_name is None:
|
if ref_name is None:
|
||||||
raise ValueError(
|
raise InvalidSchemaException(
|
||||||
"RefTypeParser: Missing title in properties for $ref of Root Reference"
|
"Missing title in properties for $ref of Root Reference",
|
||||||
|
invalid_field="title",
|
||||||
)
|
)
|
||||||
return "forward_ref", ref_name, {}
|
return "forward_ref", ref_name, {}
|
||||||
|
|
||||||
if properties["$ref"].startswith("#/$defs/"):
|
if properties.get("$ref", "").startswith("#/$defs/"):
|
||||||
target_name, target_property = self._extract_target_ref(
|
target_name, target_property = self._extract_target_ref(
|
||||||
name, properties, **kwargs
|
name, properties, **kwargs
|
||||||
)
|
)
|
||||||
return "def_ref", target_name, target_property
|
return "def_ref", target_name, target_property
|
||||||
|
|
||||||
raise ValueError(
|
raise InvalidSchemaException(
|
||||||
"RefTypeParser: Only Root and $defs references are supported at the moment"
|
"Only Root and $defs references are supported at the moment",
|
||||||
|
invalid_field="$ref",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _extract_target_ref(
|
def _extract_target_ref(
|
||||||
self, name: str, properties: dict[str, Any], **kwargs: Unpack[TypeParserOptions]
|
self, name: str, properties: JSONSchema, **kwargs: Unpack[TypeParserOptions]
|
||||||
) -> tuple[str, dict]:
|
) -> tuple[str, JSONSchema]:
|
||||||
target_name = None
|
target_name = None
|
||||||
target_property = kwargs["context"]
|
target_property = kwargs["context"]
|
||||||
for prop_name in properties["$ref"].split("/")[1:]:
|
for prop_name in properties["$ref"].split("/")[1:]:
|
||||||
if prop_name not in target_property:
|
if prop_name not in target_property:
|
||||||
raise ValueError(
|
raise InvalidSchemaException(
|
||||||
f"RefTypeParser: Missing {prop_name} in"
|
f"Missing {prop_name} in properties for $ref {properties['$ref']}",
|
||||||
" properties for $ref {properties['$ref']}"
|
invalid_field=prop_name,
|
||||||
)
|
)
|
||||||
target_name = 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:
|
if not isinstance(target_name, str) or target_property is None:
|
||||||
raise ValueError(f"RefTypeParser: Invalid $ref {properties['$ref']}")
|
raise InvalidSchemaException(
|
||||||
|
f"Invalid $ref {properties['$ref']}", invalid_field="$ref"
|
||||||
|
)
|
||||||
|
|
||||||
return target_name, target_property
|
return target_name, target_property
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
from jambo.exceptions import InvalidSchemaException
|
||||||
from jambo.parser._type_parser import GenericTypeParser
|
from jambo.parser._type_parser import GenericTypeParser
|
||||||
from jambo.types.type_parser_options import TypeParserOptions
|
from jambo.types.type_parser_options import TypeParserOptions
|
||||||
|
|
||||||
from pydantic import AnyUrl, EmailStr
|
from pydantic import AnyUrl, EmailStr, TypeAdapter, ValidationError
|
||||||
from typing_extensions import Unpack
|
from typing_extensions import Unpack
|
||||||
|
|
||||||
from datetime import date, datetime, time, timedelta
|
from datetime import date, datetime, time, timedelta
|
||||||
@@ -18,23 +19,22 @@ class StringTypeParser(GenericTypeParser):
|
|||||||
"maxLength": "max_length",
|
"maxLength": "max_length",
|
||||||
"minLength": "min_length",
|
"minLength": "min_length",
|
||||||
"pattern": "pattern",
|
"pattern": "pattern",
|
||||||
"format": "format",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
format_type_mapping = {
|
format_type_mapping = {
|
||||||
# 7.3.1. Dates, Times, and Duration
|
# [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,
|
"date": date,
|
||||||
"time": time,
|
"time": time,
|
||||||
"date-time": datetime,
|
"date-time": datetime,
|
||||||
"duration": timedelta,
|
"duration": timedelta,
|
||||||
# 7.3.2. Email Addresses
|
# [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,
|
"email": EmailStr,
|
||||||
# 7.3.3. Hostnames
|
# [7.3.3](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7.3.3). Hostnames
|
||||||
"hostname": str,
|
"hostname": str,
|
||||||
# 7.3.4. IP Addresses
|
# [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,
|
"ipv4": IPv4Address,
|
||||||
"ipv6": IPv6Address,
|
"ipv6": IPv6Address,
|
||||||
# 7.3.5. Resource Identifiers
|
# [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,
|
"uri": AnyUrl,
|
||||||
# "iri" # Not supported by pydantic and currently not supported by jambo
|
# "iri" # Not supported by pydantic and currently not supported by jambo
|
||||||
"uuid": UUID,
|
"uuid": UUID,
|
||||||
@@ -54,10 +54,27 @@ class StringTypeParser(GenericTypeParser):
|
|||||||
return str, mapped_properties
|
return str, mapped_properties
|
||||||
|
|
||||||
if format_type not in self.format_type_mapping:
|
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]
|
mapped_type = self.format_type_mapping[format_type]
|
||||||
if format_type in self.format_pattern_mapping:
|
if format_type in self.format_pattern_mapping:
|
||||||
mapped_properties["pattern"] = self.format_pattern_mapping[format_type]
|
mapped_properties["pattern"] = self.format_pattern_mapping[format_type]
|
||||||
|
|
||||||
|
try:
|
||||||
|
if "examples" in mapped_properties:
|
||||||
|
mapped_properties["examples"] = [
|
||||||
|
TypeAdapter(mapped_type).validate_python(example)
|
||||||
|
for example in mapped_properties["examples"]
|
||||||
|
]
|
||||||
|
except ValidationError as err:
|
||||||
|
raise InvalidSchemaException(
|
||||||
|
f"Invalid example type for field {name}."
|
||||||
|
) from err
|
||||||
|
|
||||||
|
if "json_schema_extra" not in mapped_properties:
|
||||||
|
mapped_properties["json_schema_extra"] = {}
|
||||||
|
mapped_properties["json_schema_extra"]["format"] = format_type
|
||||||
|
|
||||||
return mapped_type, mapped_properties
|
return mapped_type, mapped_properties
|
||||||
|
|||||||
0
jambo/py.typed
Normal file
0
jambo/py.typed
Normal file
@@ -1,9 +1,11 @@
|
|||||||
|
from jambo.exceptions import InvalidSchemaException, UnsupportedSchemaException
|
||||||
from jambo.parser import ObjectTypeParser, RefTypeParser
|
from jambo.parser import ObjectTypeParser, RefTypeParser
|
||||||
from jambo.types.json_schema_type import JSONSchema
|
from jambo.types import JSONSchema, RefCacheDict
|
||||||
|
|
||||||
from jsonschema.exceptions import SchemaError
|
from jsonschema.exceptions import SchemaError
|
||||||
from jsonschema.validators import validator_for
|
from jsonschema.validators import validator_for
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from typing_extensions import Optional
|
||||||
|
|
||||||
|
|
||||||
class SchemaConverter:
|
class SchemaConverter:
|
||||||
@@ -15,22 +17,67 @@ class SchemaConverter:
|
|||||||
fields and types. The generated model can be used for data validation and serialization.
|
fields and types. The generated model can be used for data validation and serialization.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
def __init__(
|
||||||
def build(schema: JSONSchema) -> type[BaseModel]:
|
self, namespace_registry: Optional[dict[str, RefCacheDict]] = None
|
||||||
|
) -> None:
|
||||||
|
if namespace_registry is None:
|
||||||
|
namespace_registry = dict()
|
||||||
|
self._namespace_registry = namespace_registry
|
||||||
|
|
||||||
|
def build_with_cache(
|
||||||
|
self,
|
||||||
|
schema: JSONSchema,
|
||||||
|
ref_cache: Optional[RefCacheDict] = None,
|
||||||
|
without_cache: bool = False,
|
||||||
|
) -> type[BaseModel]:
|
||||||
"""
|
"""
|
||||||
Converts a JSON Schema to a Pydantic model.
|
Converts a JSON Schema to a Pydantic model.
|
||||||
|
This is the instance method version of `build` and uses the instance's reference cache if none is provided.
|
||||||
|
Use this method if you want to utilize the instance's reference cache.
|
||||||
|
|
||||||
:param schema: The JSON Schema to convert.
|
:param schema: The JSON Schema to convert.
|
||||||
:return: A Pydantic model class.
|
:param ref_cache: An optional reference cache to use during conversion.
|
||||||
|
:param without_cache: Whether to use a clean reference cache for this conversion.
|
||||||
|
:return: The generated Pydantic model.
|
||||||
"""
|
"""
|
||||||
|
local_ref_cache: RefCacheDict
|
||||||
|
|
||||||
|
if without_cache:
|
||||||
|
local_ref_cache = dict()
|
||||||
|
elif ref_cache is None:
|
||||||
|
namespace = schema.get("$id", "default")
|
||||||
|
local_ref_cache = self._namespace_registry.setdefault(namespace, dict())
|
||||||
|
else:
|
||||||
|
local_ref_cache = ref_cache
|
||||||
|
|
||||||
|
return self.build(schema, local_ref_cache)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build(
|
||||||
|
schema: JSONSchema, ref_cache: Optional[RefCacheDict] = None
|
||||||
|
) -> type[BaseModel]:
|
||||||
|
"""
|
||||||
|
Converts a JSON Schema to a Pydantic model.
|
||||||
|
This method doesn't use a reference cache if none is provided.
|
||||||
|
:param schema: The JSON Schema to convert.
|
||||||
|
:param ref_cache: An optional reference cache to use during conversion, if provided `with_clean_cache` will be ignored.
|
||||||
|
:return: The generated Pydantic model.
|
||||||
|
"""
|
||||||
|
if ref_cache is None:
|
||||||
|
ref_cache = dict()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
validator = validator_for(schema)
|
validator = validator_for(schema)
|
||||||
validator.check_schema(schema)
|
validator.check_schema(schema) # type: ignore
|
||||||
except SchemaError as e:
|
except SchemaError as err:
|
||||||
raise ValueError(f"Invalid JSON Schema: {e}")
|
raise InvalidSchemaException(
|
||||||
|
"Validation of JSON Schema failed.", cause=err
|
||||||
|
) from err
|
||||||
|
|
||||||
if "title" not in schema:
|
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)
|
schema_type = SchemaConverter._get_schema_type(schema)
|
||||||
|
|
||||||
@@ -38,10 +85,11 @@ class SchemaConverter:
|
|||||||
case "object":
|
case "object":
|
||||||
return ObjectTypeParser.to_model(
|
return ObjectTypeParser.to_model(
|
||||||
schema["title"],
|
schema["title"],
|
||||||
schema["properties"],
|
schema.get("properties", {}),
|
||||||
schema.get("required", []),
|
schema.get("required", []),
|
||||||
context=schema,
|
context=schema,
|
||||||
ref_cache=dict(),
|
ref_cache=ref_cache,
|
||||||
|
required=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
case "$ref":
|
case "$ref":
|
||||||
@@ -49,14 +97,47 @@ class SchemaConverter:
|
|||||||
schema["title"],
|
schema["title"],
|
||||||
schema,
|
schema,
|
||||||
context=schema,
|
context=schema,
|
||||||
ref_cache=dict(),
|
ref_cache=ref_cache,
|
||||||
|
required=True,
|
||||||
)
|
)
|
||||||
return parsed_model
|
return parsed_model
|
||||||
case _:
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
def clear_ref_cache(self, namespace: Optional[str] = "default") -> None:
|
||||||
|
"""
|
||||||
|
Clears the reference cache.
|
||||||
|
"""
|
||||||
|
if namespace is None:
|
||||||
|
self._namespace_registry.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
if namespace in self._namespace_registry:
|
||||||
|
self._namespace_registry[namespace].clear()
|
||||||
|
|
||||||
|
def get_cached_ref(
|
||||||
|
self, ref_name: str, namespace: str = "default"
|
||||||
|
) -> Optional[type]:
|
||||||
|
"""
|
||||||
|
Gets a cached reference from the reference cache.
|
||||||
|
:param ref_name: The name of the reference to get.
|
||||||
|
:return: The cached reference, or None if not found.
|
||||||
|
"""
|
||||||
|
cached_type = self._namespace_registry.get(namespace, {}).get(ref_name)
|
||||||
|
|
||||||
|
if isinstance(cached_type, type):
|
||||||
|
return cached_type
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_schema_type(schema: JSONSchema) -> str:
|
def _get_schema_type(schema: JSONSchema) -> str | None:
|
||||||
"""
|
"""
|
||||||
Returns the type of the schema.
|
Returns the type of the schema.
|
||||||
:param schema: The JSON Schema to check.
|
:param schema: The JSON Schema to check.
|
||||||
@@ -65,4 +146,11 @@ class SchemaConverter:
|
|||||||
if "$ref" in schema:
|
if "$ref" in schema:
|
||||||
return "$ref"
|
return "$ref"
|
||||||
|
|
||||||
return schema.get("type", "undefined")
|
type_value = schema.get("type")
|
||||||
|
if isinstance(type_value, list):
|
||||||
|
raise InvalidSchemaException(
|
||||||
|
"Invalid schema: 'type' cannot be a list at the top level",
|
||||||
|
invalid_field=str(schema),
|
||||||
|
)
|
||||||
|
|
||||||
|
return type_value
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from .json_schema_type import (
|
||||||
|
JSONSchema,
|
||||||
|
JSONSchemaNativeTypes,
|
||||||
|
JSONSchemaType,
|
||||||
|
JSONType,
|
||||||
|
)
|
||||||
|
from .type_parser_options import RefCacheDict, TypeParserOptions
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"JSONSchemaType",
|
||||||
|
"JSONSchemaNativeTypes",
|
||||||
|
"JSONType",
|
||||||
|
"JSONSchema",
|
||||||
|
"RefCacheDict",
|
||||||
|
"TypeParserOptions",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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
|
from types import NoneType
|
||||||
|
|
||||||
|
|
||||||
|
# Primitive JSON types
|
||||||
JSONSchemaType = Literal[
|
JSONSchemaType = Literal[
|
||||||
"string", "number", "integer", "boolean", "object", "array", "null"
|
"string", "number", "integer", "boolean", "object", "array", "null"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
JSONSchemaNativeTypes: tuple[type, ...] = (
|
JSONSchemaNativeTypes: tuple[type, ...] = (
|
||||||
str,
|
str,
|
||||||
int,
|
|
||||||
float,
|
float,
|
||||||
|
int,
|
||||||
bool,
|
bool,
|
||||||
list,
|
list,
|
||||||
set,
|
set,
|
||||||
NoneType,
|
NoneType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
JSONType = Union[str, int, float, bool, None, Dict[str, "JSONType"], List["JSONType"]]
|
JSONType = Union[str, int, float, bool, None, Dict[str, "JSONType"], List["JSONType"]]
|
||||||
|
|
||||||
|
# Dynamically define TypedDict with JSON Schema keywords
|
||||||
class JSONSchema(TypedDict, total=False):
|
JSONSchema = TypedDict(
|
||||||
# Basic metadata
|
"JSONSchema",
|
||||||
title: str
|
{
|
||||||
description: str
|
"$id": str,
|
||||||
default: JSONType
|
"$schema": str,
|
||||||
examples: List[JSONType]
|
"$ref": str,
|
||||||
|
"$anchor": str,
|
||||||
# Type definitions
|
"$comment": str,
|
||||||
type: Union[JSONSchemaType, List[JSONSchemaType]]
|
"$defs": Dict[str, "JSONSchema"],
|
||||||
|
"title": str,
|
||||||
# Object-specific keywords
|
"description": str,
|
||||||
properties: Dict[str, "JSONSchema"]
|
"default": JSONType,
|
||||||
required: List[str]
|
"examples": List[JSONType],
|
||||||
additionalProperties: Union[bool, "JSONSchema"]
|
"type": JSONSchemaType | List[JSONSchemaType],
|
||||||
minProperties: int
|
"enum": List[JSONType],
|
||||||
maxProperties: int
|
"const": JSONType,
|
||||||
patternProperties: Dict[str, "JSONSchema"]
|
"properties": Dict[str, "JSONSchema"],
|
||||||
dependencies: Dict[str, Union[List[str], "JSONSchema"]]
|
"patternProperties": Dict[str, "JSONSchema"],
|
||||||
|
"additionalProperties": Union[bool, "JSONSchema"],
|
||||||
# Array-specific keywords
|
"required": List[str],
|
||||||
items: Union["JSONSchema", List["JSONSchema"]]
|
"minProperties": int,
|
||||||
additionalItems: Union[bool, "JSONSchema"]
|
"maxProperties": int,
|
||||||
minItems: int
|
"dependencies": Dict[str, Union[List[str], "JSONSchema"]],
|
||||||
maxItems: int
|
"items": "JSONSchema",
|
||||||
uniqueItems: bool
|
"prefixItems": List["JSONSchema"],
|
||||||
|
"additionalItems": Union[bool, "JSONSchema"],
|
||||||
# String-specific keywords
|
"contains": "JSONSchema",
|
||||||
minLength: int
|
"minItems": int,
|
||||||
maxLength: int
|
"maxItems": int,
|
||||||
pattern: str
|
"uniqueItems": bool,
|
||||||
format: str
|
"minLength": int,
|
||||||
|
"maxLength": int,
|
||||||
# Number-specific keywords
|
"pattern": str,
|
||||||
minimum: float
|
"format": str,
|
||||||
maximum: float
|
"minimum": float,
|
||||||
exclusiveMinimum: float
|
"maximum": float,
|
||||||
exclusiveMaximum: float
|
"exclusiveMinimum": Union[bool, float],
|
||||||
multipleOf: float
|
"exclusiveMaximum": Union[bool, float],
|
||||||
|
"multipleOf": float,
|
||||||
# Enum and const
|
"if": "JSONSchema",
|
||||||
enum: List[JSONType]
|
"then": "JSONSchema",
|
||||||
const: JSONType
|
"else": "JSONSchema",
|
||||||
|
"allOf": List["JSONSchema"],
|
||||||
# Conditionals
|
"anyOf": List["JSONSchema"],
|
||||||
if_: "JSONSchema" # 'if' is a reserved word in Python
|
"oneOf": List["JSONSchema"],
|
||||||
then: "JSONSchema"
|
"not": "JSONSchema",
|
||||||
else_: "JSONSchema" # 'else' is also a reserved word
|
},
|
||||||
|
total=False, # all fields optional
|
||||||
# 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
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
from jambo.types.json_schema_type import JSONSchema
|
from jambo.types.json_schema_type import JSONSchema
|
||||||
|
|
||||||
from typing_extensions import TypedDict
|
from typing_extensions import ForwardRef, MutableMapping, TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
RefCacheDict = MutableMapping[str, ForwardRef | type | None]
|
||||||
|
|
||||||
|
|
||||||
class TypeParserOptions(TypedDict):
|
class TypeParserOptions(TypedDict):
|
||||||
required: bool
|
required: bool
|
||||||
context: JSONSchema
|
context: JSONSchema
|
||||||
ref_cache: dict[str, type]
|
ref_cache: RefCacheDict
|
||||||
|
|||||||
@@ -18,25 +18,28 @@ classifiers = [
|
|||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
"Programming Language :: Python :: 3.13",
|
"Programming Language :: Python :: 3.13",
|
||||||
]
|
]
|
||||||
license = { file = "LICENSE" }
|
license = "MIT"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
||||||
# Project Dependencies
|
# Project Dependencies
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"email-validator>=2.2.0",
|
"email-validator>=2.2.0",
|
||||||
"jsonschema>=4.23.0",
|
"jsonschema>=4.23.0",
|
||||||
"pydantic>=2.10.6",
|
"pydantic>=2.12.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"coverage>=7.8.0",
|
"coverage>=7.8.0",
|
||||||
|
"mypy>=1.18.1",
|
||||||
"poethepoet>=0.33.1",
|
"poethepoet>=0.33.1",
|
||||||
"pre-commit>=4.2.0",
|
"pre-commit>=4.2.0",
|
||||||
"ruff>=0.11.4",
|
"ruff>=0.11.4",
|
||||||
"sphinx>=8.1.3",
|
"sphinx>=8.1.3",
|
||||||
"sphinx-autobuild>=2024.10.3",
|
"sphinx-autobuild>=2024.10.3",
|
||||||
|
"sphinx-autodoc-typehints>=3.0.1",
|
||||||
"sphinx-rtd-theme>=3.0.2",
|
"sphinx-rtd-theme>=3.0.2",
|
||||||
|
"types-jsonschema>=4.25.1.20250822",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -50,6 +53,7 @@ repository = "https://github.com/HideyoshiNakazone/jambo.git"
|
|||||||
create-hooks = "bash .githooks/set-hooks.sh"
|
create-hooks = "bash .githooks/set-hooks.sh"
|
||||||
tests = "python -m coverage run -m unittest discover -v"
|
tests = "python -m coverage run -m unittest discover -v"
|
||||||
tests-report = "python -m coverage xml"
|
tests-report = "python -m coverage xml"
|
||||||
|
type-check = "mypy jambo"
|
||||||
serve-docs = "sphinx-autobuild docs/source docs/build"
|
serve-docs = "sphinx-autobuild docs/source docs/build"
|
||||||
|
|
||||||
# Build System
|
# Build System
|
||||||
@@ -83,3 +87,8 @@ section-order=[
|
|||||||
"standard-library",
|
"standard-library",
|
||||||
]
|
]
|
||||||
lines-after-imports = 2
|
lines-after-imports = 2
|
||||||
|
|
||||||
|
|
||||||
|
[tool.pyright]
|
||||||
|
venvPath = "."
|
||||||
|
venv = ".venv"
|
||||||
|
|||||||
0
tests/exceptions/__init__.py
Normal file
0
tests/exceptions/__init__.py
Normal file
21
tests/exceptions/test_internal_assertion_exception.py
Normal file
21
tests/exceptions/test_internal_assertion_exception.py
Normal 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)
|
||||||
44
tests/exceptions/test_invalid_schema_exception.py
Normal file
44
tests/exceptions/test_invalid_schema_exception.py
Normal 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)
|
||||||
31
tests/exceptions/test_unsupported_schema_exception.py
Normal file
31
tests/exceptions/test_unsupported_schema_exception.py
Normal 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)
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
from jambo.exceptions import InvalidSchemaException
|
||||||
from jambo.parser.allof_type_parser import AllOfTypeParser
|
from jambo.parser.allof_type_parser import AllOfTypeParser
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
|
|
||||||
|
|
||||||
@@ -39,16 +42,16 @@ class TestAllOfTypeParser(TestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
type_parsing, type_validator = AllOfTypeParser().from_properties(
|
type_parsing, type_validator = AllOfTypeParser().from_properties(
|
||||||
"placeholder", properties
|
"placeholder", properties, ref_cache={}
|
||||||
)
|
)
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
type_parsing(name="John", age=101)
|
type_parsing(name="John", age=101)
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
type_parsing(name="", age=30)
|
type_parsing(name="", age=30)
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
type_parsing(name="John Invalid", age=30)
|
type_parsing(name="John Invalid", age=30)
|
||||||
|
|
||||||
obj = type_parsing(name="John", age=30)
|
obj = type_parsing(name="John", age=30)
|
||||||
@@ -84,13 +87,13 @@ class TestAllOfTypeParser(TestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
type_parsing, type_validator = AllOfTypeParser().from_properties(
|
type_parsing, type_validator = AllOfTypeParser().from_properties(
|
||||||
"placeholder", properties
|
"placeholder", properties, ref_cache={}
|
||||||
)
|
)
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
type_parsing(name="John")
|
type_parsing(name="John")
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
type_parsing(age=30)
|
type_parsing(age=30)
|
||||||
|
|
||||||
obj = type_parsing(name="John", age=30)
|
obj = type_parsing(name="John", age=30)
|
||||||
@@ -113,7 +116,7 @@ class TestAllOfTypeParser(TestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
type_parsing, type_validator = AllOfTypeParser().from_properties(
|
type_parsing, type_validator = AllOfTypeParser().from_properties(
|
||||||
"placeholder", properties
|
"placeholder", properties, ref_cache={}
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(type_parsing, str)
|
self.assertEqual(type_parsing, str)
|
||||||
@@ -134,7 +137,7 @@ class TestAllOfTypeParser(TestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
type_parsing, type_validator = AllOfTypeParser().from_properties(
|
type_parsing, type_validator = AllOfTypeParser().from_properties(
|
||||||
"placeholder", properties
|
"placeholder", properties, ref_cache={}
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(type_parsing, str)
|
self.assertEqual(type_parsing, str)
|
||||||
@@ -154,8 +157,8 @@ class TestAllOfTypeParser(TestCase):
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
AllOfTypeParser().from_properties("placeholder", properties)
|
AllOfTypeParser().from_properties("placeholder", properties, ref_cache={})
|
||||||
|
|
||||||
def test_all_of_invalid_type_not_present(self):
|
def test_all_of_invalid_type_not_present(self):
|
||||||
properties = {
|
properties = {
|
||||||
@@ -167,8 +170,8 @@ class TestAllOfTypeParser(TestCase):
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
AllOfTypeParser().from_properties("placeholder", properties)
|
AllOfTypeParser().from_properties("placeholder", properties, ref_cache={})
|
||||||
|
|
||||||
def test_all_of_invalid_type_in_fields(self):
|
def test_all_of_invalid_type_in_fields(self):
|
||||||
properties = {
|
properties = {
|
||||||
@@ -180,8 +183,8 @@ class TestAllOfTypeParser(TestCase):
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
AllOfTypeParser().from_properties("placeholder", properties)
|
AllOfTypeParser().from_properties("placeholder", properties, ref_cache={})
|
||||||
|
|
||||||
def test_all_of_invalid_type_not_all_equal(self):
|
def test_all_of_invalid_type_not_all_equal(self):
|
||||||
"""
|
"""
|
||||||
@@ -196,8 +199,8 @@ class TestAllOfTypeParser(TestCase):
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
AllOfTypeParser().from_properties("placeholder", properties)
|
AllOfTypeParser().from_properties("placeholder", properties, ref_cache={})
|
||||||
|
|
||||||
def test_all_of_description_field(self):
|
def test_all_of_description_field(self):
|
||||||
"""
|
"""
|
||||||
@@ -234,7 +237,9 @@ class TestAllOfTypeParser(TestCase):
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
type_parsing, _ = AllOfTypeParser().from_properties("placeholder", properties)
|
type_parsing, _ = AllOfTypeParser().from_properties(
|
||||||
|
"placeholder", properties, ref_cache={}
|
||||||
|
)
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
type_parsing.model_json_schema()["properties"]["name"]["description"],
|
type_parsing.model_json_schema()["properties"]["name"]["description"],
|
||||||
@@ -272,7 +277,9 @@ class TestAllOfTypeParser(TestCase):
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
type_parsing, _ = AllOfTypeParser().from_properties("placeholder", properties)
|
type_parsing, _ = AllOfTypeParser().from_properties(
|
||||||
|
"placeholder", properties, ref_cache={}
|
||||||
|
)
|
||||||
obj = type_parsing()
|
obj = type_parsing()
|
||||||
self.assertEqual(obj.name, "John")
|
self.assertEqual(obj.name, "John")
|
||||||
self.assertEqual(obj.age, 30)
|
self.assertEqual(obj.age, 30)
|
||||||
@@ -304,5 +311,52 @@ class TestAllOfTypeParser(TestCase):
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
AllOfTypeParser().from_properties("placeholder", properties)
|
AllOfTypeParser().from_properties("placeholder", properties, ref_cache={})
|
||||||
|
|
||||||
|
def test_all_of_with_root_examples(self):
|
||||||
|
"""
|
||||||
|
Tests the AllOfTypeParser with examples.
|
||||||
|
"""
|
||||||
|
|
||||||
|
properties = {
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 4,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"examples": [
|
||||||
|
{"name": "John"},
|
||||||
|
{"name": "Jane"},
|
||||||
|
{"name": "Doe"},
|
||||||
|
{"name": "Jack"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
type_parsed, type_properties = AllOfTypeParser().from_properties(
|
||||||
|
"placeholder", properties, ref_cache={}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
type_properties["examples"],
|
||||||
|
[
|
||||||
|
type_parsed(name="John"),
|
||||||
|
type_parsed(name="Jane"),
|
||||||
|
type_parsed(name="Doe"),
|
||||||
|
type_parsed(name="Jack"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from jambo.exceptions import InvalidSchemaException
|
||||||
from jambo.parser.anyof_type_parser import AnyOfTypeParser
|
from jambo.parser.anyof_type_parser import AnyOfTypeParser
|
||||||
|
|
||||||
from typing_extensions import Annotated, Union, get_args, get_origin
|
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)
|
AnyOfTypeParser().from_properties("placeholder", properties)
|
||||||
|
|
||||||
def test_any_of_with_invalid_properties(self):
|
def test_any_of_with_invalid_properties(self):
|
||||||
@@ -22,7 +23,7 @@ class TestAnyOfTypeParser(TestCase):
|
|||||||
"anyOf": None,
|
"anyOf": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
AnyOfTypeParser().from_properties("placeholder", properties)
|
AnyOfTypeParser().from_properties("placeholder", properties)
|
||||||
|
|
||||||
def test_any_of_string_or_int(self):
|
def test_any_of_string_or_int(self):
|
||||||
@@ -95,5 +96,48 @@ class TestAnyOfTypeParser(TestCase):
|
|||||||
"default": 3.14,
|
"default": 3.14,
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
AnyOfTypeParser().from_properties("placeholder", properties)
|
AnyOfTypeParser().from_properties("placeholder", properties)
|
||||||
|
|
||||||
|
def test_anyof_with_examples(self):
|
||||||
|
"""
|
||||||
|
Tests the AnyOfTypeParser with a string or int type and examples.
|
||||||
|
"""
|
||||||
|
|
||||||
|
properties = {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"examples": ["example string"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"examples": [123],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed_type, _ = AnyOfTypeParser().from_properties("placeholder", properties)
|
||||||
|
|
||||||
|
type_1, type_2 = get_args(parsed_type)
|
||||||
|
|
||||||
|
self.assertEqual(get_args(type_1)[1].examples, ["example string"])
|
||||||
|
|
||||||
|
self.assertEqual(get_args(type_2)[1].examples, [123])
|
||||||
|
|
||||||
|
def test_any_of_with_root_examples(self):
|
||||||
|
"""
|
||||||
|
Tests the AnyOfTypeParser with a string or int type and examples.
|
||||||
|
"""
|
||||||
|
|
||||||
|
properties = {
|
||||||
|
"anyOf": [
|
||||||
|
{"type": "string"},
|
||||||
|
{"type": "integer"},
|
||||||
|
],
|
||||||
|
"examples": ["100", 100],
|
||||||
|
}
|
||||||
|
|
||||||
|
_, type_validator = AnyOfTypeParser().from_properties("placeholder", properties)
|
||||||
|
|
||||||
|
self.assertEqual(type_validator["examples"], ["100", 100])
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from jambo.exceptions import InvalidSchemaException
|
||||||
from jambo.parser import ArrayTypeParser
|
from jambo.parser import ArrayTypeParser
|
||||||
|
|
||||||
from typing_extensions import get_args
|
from typing_extensions import get_args
|
||||||
@@ -18,6 +19,17 @@ class TestArrayTypeParser(TestCase):
|
|||||||
self.assertEqual(type_parsing.__origin__, list)
|
self.assertEqual(type_parsing.__origin__, list)
|
||||||
self.assertEqual(element_type, str)
|
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):
|
def test_array_parser_with_options_unique(self):
|
||||||
parser = ArrayTypeParser()
|
parser = ArrayTypeParser()
|
||||||
|
|
||||||
@@ -67,7 +79,7 @@ class TestArrayTypeParser(TestCase):
|
|||||||
|
|
||||||
properties = {"items": {"type": "string"}, "default": ["a", 1, "c"]}
|
properties = {"items": {"type": "string"}, "default": ["a", 1, "c"]}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parser.from_properties("placeholder", properties)
|
parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
def test_array_parser_with_invalid_default_type(self):
|
def test_array_parser_with_invalid_default_type(self):
|
||||||
@@ -75,15 +87,15 @@ class TestArrayTypeParser(TestCase):
|
|||||||
|
|
||||||
properties = {"items": {"type": "string"}, "default": 000}
|
properties = {"items": {"type": "string"}, "default": 000}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parser.from_properties("placeholder", properties)
|
parser.from_properties("placeholder", properties=properties)
|
||||||
|
|
||||||
def test_array_parser_with_invalid_default_min(self):
|
def test_array_parser_with_invalid_default_min(self):
|
||||||
parser = ArrayTypeParser()
|
parser = ArrayTypeParser()
|
||||||
|
|
||||||
properties = {"items": {"type": "string"}, "default": ["a"], "minItems": 2}
|
properties = {"items": {"type": "string"}, "default": ["a"], "minItems": 2}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parser.from_properties("placeholder", properties)
|
parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
def test_array_parser_with_invalid_default_max(self):
|
def test_array_parser_with_invalid_default_max(self):
|
||||||
@@ -95,5 +107,21 @@ class TestArrayTypeParser(TestCase):
|
|||||||
"maxItems": 3,
|
"maxItems": 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parser.from_properties("placeholder", properties)
|
parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
|
def test_array_parser_with_examples(self):
|
||||||
|
parser = ArrayTypeParser()
|
||||||
|
|
||||||
|
properties = {
|
||||||
|
"items": {"type": "integer"},
|
||||||
|
"examples": [
|
||||||
|
[1, 2, 3],
|
||||||
|
[4, 5, 6],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
|
self.assertEqual(type_parsing.__origin__, list)
|
||||||
|
self.assertEqual(type_validator["examples"], [[1, 2, 3], [4, 5, 6]])
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from jambo.exceptions import InvalidSchemaException
|
||||||
from jambo.parser import BooleanTypeParser
|
from jambo.parser import BooleanTypeParser
|
||||||
|
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
@@ -39,5 +40,21 @@ class TestBoolTypeParser(TestCase):
|
|||||||
"default": "invalid",
|
"default": "invalid",
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parser.from_properties_impl("placeholder", properties)
|
parser.from_properties_impl("placeholder", properties)
|
||||||
|
|
||||||
|
def test_bool_parser_with_examples(self):
|
||||||
|
parser = BooleanTypeParser()
|
||||||
|
|
||||||
|
properties = {
|
||||||
|
"type": "boolean",
|
||||||
|
"examples": [True, False],
|
||||||
|
}
|
||||||
|
|
||||||
|
type_parsing, type_validator = parser.from_properties_impl(
|
||||||
|
"placeholder", properties
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(type_parsing, bool)
|
||||||
|
self.assertEqual(type_validator["default"], None)
|
||||||
|
self.assertEqual(type_validator["examples"], [True, False])
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from jambo.exceptions import InvalidSchemaException
|
||||||
from jambo.parser import ConstTypeParser
|
from jambo.parser import ConstTypeParser
|
||||||
|
|
||||||
from typing_extensions import Annotated, Literal, get_args, get_origin
|
from typing_extensions import Annotated, Literal, get_args, get_origin
|
||||||
@@ -11,7 +12,7 @@ class TestConstTypeParser(TestCase):
|
|||||||
parser = ConstTypeParser()
|
parser = ConstTypeParser()
|
||||||
|
|
||||||
expected_const_value = "United States of America"
|
expected_const_value = "United States of America"
|
||||||
properties = {"const": expected_const_value}
|
properties = {"const": expected_const_value, "examples": [expected_const_value]}
|
||||||
|
|
||||||
parsed_type, parsed_properties = parser.from_properties_impl(
|
parsed_type, parsed_properties = parser.from_properties_impl(
|
||||||
"country", properties
|
"country", properties
|
||||||
@@ -22,13 +23,14 @@ class TestConstTypeParser(TestCase):
|
|||||||
self.assertEqual(get_args(parsed_type), (expected_const_value,))
|
self.assertEqual(get_args(parsed_type), (expected_const_value,))
|
||||||
|
|
||||||
self.assertEqual(parsed_properties["default"], expected_const_value)
|
self.assertEqual(parsed_properties["default"], expected_const_value)
|
||||||
|
self.assertEqual(parsed_properties["examples"], [expected_const_value])
|
||||||
|
|
||||||
def test_const_type_parser_non_hashable_value(self):
|
def test_const_type_parser_non_hashable_value(self):
|
||||||
"""Test const parser with non-hashable values (uses Annotated with validator)"""
|
"""Test const parser with non-hashable values (uses Annotated with validator)"""
|
||||||
parser = ConstTypeParser()
|
parser = ConstTypeParser()
|
||||||
|
|
||||||
expected_const_value = [1, 2, 3] # Lists are not hashable
|
expected_const_value = [1, 2, 3] # Lists are not hashable
|
||||||
properties = {"const": expected_const_value}
|
properties = {"const": expected_const_value, "examples": [expected_const_value]}
|
||||||
|
|
||||||
parsed_type, parsed_properties = parser.from_properties_impl(
|
parsed_type, parsed_properties = parser.from_properties_impl(
|
||||||
"list_const", properties
|
"list_const", properties
|
||||||
@@ -39,13 +41,14 @@ class TestConstTypeParser(TestCase):
|
|||||||
self.assertIn(list, get_args(parsed_type))
|
self.assertIn(list, get_args(parsed_type))
|
||||||
|
|
||||||
self.assertEqual(parsed_properties["default"], expected_const_value)
|
self.assertEqual(parsed_properties["default"], expected_const_value)
|
||||||
|
self.assertEqual(parsed_properties["examples"], [expected_const_value])
|
||||||
|
|
||||||
def test_const_type_parser_integer_value(self):
|
def test_const_type_parser_integer_value(self):
|
||||||
"""Test const parser with integer values (uses Literal)"""
|
"""Test const parser with integer values (uses Literal)"""
|
||||||
parser = ConstTypeParser()
|
parser = ConstTypeParser()
|
||||||
|
|
||||||
expected_const_value = 42
|
expected_const_value = 42
|
||||||
properties = {"const": expected_const_value}
|
properties = {"const": expected_const_value, "examples": [expected_const_value]}
|
||||||
|
|
||||||
parsed_type, parsed_properties = parser.from_properties_impl(
|
parsed_type, parsed_properties = parser.from_properties_impl(
|
||||||
"int_const", properties
|
"int_const", properties
|
||||||
@@ -56,13 +59,14 @@ class TestConstTypeParser(TestCase):
|
|||||||
self.assertEqual(get_args(parsed_type), (expected_const_value,))
|
self.assertEqual(get_args(parsed_type), (expected_const_value,))
|
||||||
|
|
||||||
self.assertEqual(parsed_properties["default"], expected_const_value)
|
self.assertEqual(parsed_properties["default"], expected_const_value)
|
||||||
|
self.assertEqual(parsed_properties["examples"], [expected_const_value])
|
||||||
|
|
||||||
def test_const_type_parser_boolean_value(self):
|
def test_const_type_parser_boolean_value(self):
|
||||||
"""Test const parser with boolean values (uses Literal)"""
|
"""Test const parser with boolean values (uses Literal)"""
|
||||||
parser = ConstTypeParser()
|
parser = ConstTypeParser()
|
||||||
|
|
||||||
expected_const_value = True
|
expected_const_value = True
|
||||||
properties = {"const": expected_const_value}
|
properties = {"const": expected_const_value, "examples": [expected_const_value]}
|
||||||
|
|
||||||
parsed_type, parsed_properties = parser.from_properties_impl(
|
parsed_type, parsed_properties = parser.from_properties_impl(
|
||||||
"bool_const", properties
|
"bool_const", properties
|
||||||
@@ -73,6 +77,7 @@ class TestConstTypeParser(TestCase):
|
|||||||
self.assertEqual(get_args(parsed_type), (expected_const_value,))
|
self.assertEqual(get_args(parsed_type), (expected_const_value,))
|
||||||
|
|
||||||
self.assertEqual(parsed_properties["default"], expected_const_value)
|
self.assertEqual(parsed_properties["default"], expected_const_value)
|
||||||
|
self.assertEqual(parsed_properties["examples"], [expected_const_value])
|
||||||
|
|
||||||
def test_const_type_parser_invalid_properties(self):
|
def test_const_type_parser_invalid_properties(self):
|
||||||
parser = ConstTypeParser()
|
parser = ConstTypeParser()
|
||||||
@@ -80,7 +85,7 @@ class TestConstTypeParser(TestCase):
|
|||||||
expected_const_value = "United States of America"
|
expected_const_value = "United States of America"
|
||||||
properties = {"notConst": expected_const_value}
|
properties = {"notConst": expected_const_value}
|
||||||
|
|
||||||
with self.assertRaises(ValueError) as context:
|
with self.assertRaises(InvalidSchemaException) as context:
|
||||||
parser.from_properties_impl("invalid_country", properties)
|
parser.from_properties_impl("invalid_country", properties)
|
||||||
|
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
@@ -93,7 +98,7 @@ class TestConstTypeParser(TestCase):
|
|||||||
|
|
||||||
properties = {"const": {}}
|
properties = {"const": {}}
|
||||||
|
|
||||||
with self.assertRaises(ValueError) as context:
|
with self.assertRaises(InvalidSchemaException) as context:
|
||||||
parser.from_properties_impl("invalid_country", properties)
|
parser.from_properties_impl("invalid_country", properties)
|
||||||
|
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from jambo.exceptions import InvalidSchemaException
|
||||||
from jambo.parser import EnumTypeParser
|
from jambo.parser import EnumTypeParser
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@@ -10,7 +11,7 @@ class TestEnumTypeParser(TestCase):
|
|||||||
|
|
||||||
schema = {}
|
schema = {}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parsed_type, parsed_properties = parser.from_properties_impl(
|
parsed_type, parsed_properties = parser.from_properties_impl(
|
||||||
"TestEnum",
|
"TestEnum",
|
||||||
schema,
|
schema,
|
||||||
@@ -23,7 +24,7 @@ class TestEnumTypeParser(TestCase):
|
|||||||
"enum": "not_a_list",
|
"enum": "not_a_list",
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parsed_type, parsed_properties = parser.from_properties_impl(
|
parsed_type, parsed_properties = parser.from_properties_impl(
|
||||||
"TestEnum",
|
"TestEnum",
|
||||||
schema,
|
schema,
|
||||||
@@ -86,5 +87,29 @@ class TestEnumTypeParser(TestCase):
|
|||||||
"enum": ["value1", 42, dict()],
|
"enum": ["value1", 42, dict()],
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parser.from_properties_impl("TestEnum", schema)
|
parser.from_properties_impl("TestEnum", schema)
|
||||||
|
|
||||||
|
def test_enum_type_parser_creates_enum_with_examples(self):
|
||||||
|
parser = EnumTypeParser()
|
||||||
|
|
||||||
|
schema = {
|
||||||
|
"enum": ["value1", "value2", "value3"],
|
||||||
|
"examples": ["value1", "value3"],
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed_type, parsed_properties = parser.from_properties_impl(
|
||||||
|
"TestEnum",
|
||||||
|
schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsInstance(parsed_type, type)
|
||||||
|
self.assertTrue(issubclass(parsed_type, Enum))
|
||||||
|
self.assertEqual(
|
||||||
|
set(parsed_type.__members__.keys()), {"VALUE1", "VALUE2", "VALUE3"}
|
||||||
|
)
|
||||||
|
self.assertEqual(parsed_properties["default"], None)
|
||||||
|
self.assertEqual(
|
||||||
|
parsed_properties["examples"],
|
||||||
|
[getattr(parsed_type, "VALUE1"), getattr(parsed_type, "VALUE3")],
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from jambo.exceptions import InvalidSchemaException
|
||||||
from jambo.parser import FloatTypeParser
|
from jambo.parser import FloatTypeParser
|
||||||
|
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
@@ -22,6 +23,7 @@ class TestFloatTypeParser(TestCase):
|
|||||||
"maximum": 10.5,
|
"maximum": 10.5,
|
||||||
"minimum": 1.0,
|
"minimum": 1.0,
|
||||||
"multipleOf": 0.5,
|
"multipleOf": 0.5,
|
||||||
|
"examples": [1.5, 2.5],
|
||||||
}
|
}
|
||||||
|
|
||||||
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
||||||
@@ -30,6 +32,7 @@ class TestFloatTypeParser(TestCase):
|
|||||||
self.assertEqual(type_validator["le"], 10.5)
|
self.assertEqual(type_validator["le"], 10.5)
|
||||||
self.assertEqual(type_validator["ge"], 1.0)
|
self.assertEqual(type_validator["ge"], 1.0)
|
||||||
self.assertEqual(type_validator["multiple_of"], 0.5)
|
self.assertEqual(type_validator["multiple_of"], 0.5)
|
||||||
|
self.assertEqual(type_validator["examples"], [1.5, 2.5])
|
||||||
|
|
||||||
def test_float_parser_with_default(self):
|
def test_float_parser_with_default(self):
|
||||||
parser = FloatTypeParser()
|
parser = FloatTypeParser()
|
||||||
@@ -61,7 +64,7 @@ class TestFloatTypeParser(TestCase):
|
|||||||
"multipleOf": 0.5,
|
"multipleOf": 0.5,
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parser.from_properties("placeholder", properties)
|
parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
def test_float_parser_with_default_invalid_maximum(self):
|
def test_float_parser_with_default_invalid_maximum(self):
|
||||||
@@ -75,7 +78,7 @@ class TestFloatTypeParser(TestCase):
|
|||||||
"multipleOf": 0.5,
|
"multipleOf": 0.5,
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parser.from_properties("placeholder", properties)
|
parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
def test_float_parser_with_default_invalid_minimum(self):
|
def test_float_parser_with_default_invalid_minimum(self):
|
||||||
@@ -89,7 +92,7 @@ class TestFloatTypeParser(TestCase):
|
|||||||
"multipleOf": 0.5,
|
"multipleOf": 0.5,
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parser.from_properties("placeholder", properties)
|
parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
def test_float_parser_with_default_invalid_exclusive_maximum(self):
|
def test_float_parser_with_default_invalid_exclusive_maximum(self):
|
||||||
@@ -103,7 +106,7 @@ class TestFloatTypeParser(TestCase):
|
|||||||
"multipleOf": 0.5,
|
"multipleOf": 0.5,
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parser.from_properties("placeholder", properties)
|
parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
def test_float_parser_with_default_invalid_exclusive_minimum(self):
|
def test_float_parser_with_default_invalid_exclusive_minimum(self):
|
||||||
@@ -117,7 +120,7 @@ class TestFloatTypeParser(TestCase):
|
|||||||
"multipleOf": 0.5,
|
"multipleOf": 0.5,
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parser.from_properties("placeholder", properties)
|
parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
def test_float_parser_with_default_invalid_multiple(self):
|
def test_float_parser_with_default_invalid_multiple(self):
|
||||||
@@ -131,5 +134,5 @@ class TestFloatTypeParser(TestCase):
|
|||||||
"multipleOf": 2.0,
|
"multipleOf": 2.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parser.from_properties("placeholder", properties)
|
parser.from_properties("placeholder", properties)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from jambo.exceptions import InvalidSchemaException
|
||||||
from jambo.parser import IntTypeParser
|
from jambo.parser import IntTypeParser
|
||||||
|
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
@@ -22,6 +23,7 @@ class TestIntTypeParser(TestCase):
|
|||||||
"maximum": 10,
|
"maximum": 10,
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"multipleOf": 2,
|
"multipleOf": 2,
|
||||||
|
"examples": [2, 4],
|
||||||
}
|
}
|
||||||
|
|
||||||
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
||||||
@@ -30,6 +32,7 @@ class TestIntTypeParser(TestCase):
|
|||||||
self.assertEqual(type_validator["le"], 10)
|
self.assertEqual(type_validator["le"], 10)
|
||||||
self.assertEqual(type_validator["ge"], 1)
|
self.assertEqual(type_validator["ge"], 1)
|
||||||
self.assertEqual(type_validator["multiple_of"], 2)
|
self.assertEqual(type_validator["multiple_of"], 2)
|
||||||
|
self.assertEqual(type_validator["examples"], [2, 4])
|
||||||
|
|
||||||
def test_int_parser_with_default(self):
|
def test_int_parser_with_default(self):
|
||||||
parser = IntTypeParser()
|
parser = IntTypeParser()
|
||||||
@@ -61,7 +64,7 @@ class TestIntTypeParser(TestCase):
|
|||||||
"multipleOf": 2,
|
"multipleOf": 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parser.from_properties("placeholder", properties)
|
parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
def test_int_parser_with_default_invalid_maximum(self):
|
def test_int_parser_with_default_invalid_maximum(self):
|
||||||
@@ -75,7 +78,7 @@ class TestIntTypeParser(TestCase):
|
|||||||
"multipleOf": 2,
|
"multipleOf": 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parser.from_properties("placeholder", properties)
|
parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
def test_int_parser_with_default_invalid_minimum(self):
|
def test_int_parser_with_default_invalid_minimum(self):
|
||||||
@@ -89,7 +92,7 @@ class TestIntTypeParser(TestCase):
|
|||||||
"multipleOf": 2,
|
"multipleOf": 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parser.from_properties("placeholder", properties)
|
parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
def test_int_parser_with_default_invalid_exclusive_maximum(self):
|
def test_int_parser_with_default_invalid_exclusive_maximum(self):
|
||||||
@@ -103,7 +106,7 @@ class TestIntTypeParser(TestCase):
|
|||||||
"multipleOf": 2,
|
"multipleOf": 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parser.from_properties("placeholder", properties)
|
parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
def test_int_parser_with_default_invalid_exclusive_minimum(self):
|
def test_int_parser_with_default_invalid_exclusive_minimum(self):
|
||||||
@@ -117,7 +120,7 @@ class TestIntTypeParser(TestCase):
|
|||||||
"multipleOf": 2,
|
"multipleOf": 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parser.from_properties("placeholder", properties)
|
parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
def test_int_parser_with_default_invalid_multipleOf(self):
|
def test_int_parser_with_default_invalid_multipleOf(self):
|
||||||
@@ -131,5 +134,5 @@ class TestIntTypeParser(TestCase):
|
|||||||
"multipleOf": 2,
|
"multipleOf": 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parser.from_properties("placeholder", properties)
|
parser.from_properties("placeholder", properties)
|
||||||
|
|||||||
@@ -16,6 +16,22 @@ class TestNullTypeParser(TestCase):
|
|||||||
self.assertEqual(type_parsing, type(None))
|
self.assertEqual(type_parsing, type(None))
|
||||||
self.assertEqual(type_validator, {"default": None})
|
self.assertEqual(type_validator, {"default": None})
|
||||||
|
|
||||||
|
def test_null_parser_with_examples(self):
|
||||||
|
parser = NullTypeParser()
|
||||||
|
|
||||||
|
properties = {
|
||||||
|
"type": "null",
|
||||||
|
"examples": [None],
|
||||||
|
}
|
||||||
|
|
||||||
|
type_parsing, type_validator = parser.from_properties_impl(
|
||||||
|
"placeholder", properties
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(type_parsing, type(None))
|
||||||
|
self.assertEqual(type_validator["default"], None)
|
||||||
|
self.assertEqual(type_validator["examples"], [None])
|
||||||
|
|
||||||
def test_null_parser_with_invalid_default(self):
|
def test_null_parser_with_invalid_default(self):
|
||||||
parser = NullTypeParser()
|
parser = NullTypeParser()
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
|
from jambo.exceptions import InternalAssertionException
|
||||||
from jambo.parser import ObjectTypeParser
|
from jambo.parser import ObjectTypeParser
|
||||||
|
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
|
|
||||||
|
|
||||||
class TestObjectTypeParser(TestCase):
|
class TestObjectTypeParser(TestCase):
|
||||||
|
def test_object_type_parser_throws_without_ref_cache(self):
|
||||||
|
parser = ObjectTypeParser()
|
||||||
|
|
||||||
|
properties = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
with self.assertRaises(InternalAssertionException):
|
||||||
|
parser.from_properties_impl("placeholder", properties)
|
||||||
|
|
||||||
def test_object_type_parser(self):
|
def test_object_type_parser(self):
|
||||||
parser = ObjectTypeParser()
|
parser = ObjectTypeParser()
|
||||||
|
|
||||||
@@ -15,13 +30,41 @@ class TestObjectTypeParser(TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
Model, _args = parser.from_properties_impl("placeholder", properties)
|
Model, _args = parser.from_properties_impl(
|
||||||
|
"placeholder", properties, ref_cache={}
|
||||||
|
)
|
||||||
|
|
||||||
obj = Model(name="name", age=10)
|
obj = Model(name="name", age=10)
|
||||||
|
|
||||||
self.assertEqual(obj.name, "name")
|
self.assertEqual(obj.name, "name")
|
||||||
self.assertEqual(obj.age, 10)
|
self.assertEqual(obj.age, 10)
|
||||||
|
|
||||||
|
def test_object_type_parser_with_object_example(self):
|
||||||
|
parser = ObjectTypeParser()
|
||||||
|
|
||||||
|
properties = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
},
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"name": "example_name",
|
||||||
|
"age": 30,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
_, type_validator = parser.from_properties_impl(
|
||||||
|
"placeholder", properties, ref_cache={}
|
||||||
|
)
|
||||||
|
|
||||||
|
test_example = type_validator["examples"][0]
|
||||||
|
|
||||||
|
self.assertEqual(test_example.name, "example_name")
|
||||||
|
self.assertEqual(test_example.age, 30)
|
||||||
|
|
||||||
def test_object_type_parser_with_default(self):
|
def test_object_type_parser_with_default(self):
|
||||||
parser = ObjectTypeParser()
|
parser = ObjectTypeParser()
|
||||||
|
|
||||||
@@ -37,7 +80,9 @@ class TestObjectTypeParser(TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, type_validator = parser.from_properties_impl("placeholder", properties)
|
_, type_validator = parser.from_properties_impl(
|
||||||
|
"placeholder", properties, ref_cache={}
|
||||||
|
)
|
||||||
|
|
||||||
# Check default value
|
# Check default value
|
||||||
default_obj = type_validator["default_factory"]()
|
default_obj = type_validator["default_factory"]()
|
||||||
@@ -47,3 +92,18 @@ class TestObjectTypeParser(TestCase):
|
|||||||
# Chekc default factory new object id
|
# Chekc default factory new object id
|
||||||
new_obj = type_validator["default_factory"]()
|
new_obj = type_validator["default_factory"]()
|
||||||
self.assertNotEqual(id(default_obj), id(new_obj))
|
self.assertNotEqual(id(default_obj), id(new_obj))
|
||||||
|
|
||||||
|
def test_object_type_parser_warns_if_object_override_in_cache(self):
|
||||||
|
ref_cache = {}
|
||||||
|
|
||||||
|
parser = ObjectTypeParser()
|
||||||
|
|
||||||
|
properties = {"type": "object", "properties": {}}
|
||||||
|
|
||||||
|
with self.assertWarns(UserWarning):
|
||||||
|
_, type_validator = parser.from_properties_impl(
|
||||||
|
"placeholder", properties, ref_cache=ref_cache
|
||||||
|
)
|
||||||
|
_, type_validator = parser.from_properties_impl(
|
||||||
|
"placeholder", properties, ref_cache=ref_cache
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
from jambo import SchemaConverter
|
from jambo import SchemaConverter
|
||||||
|
from jambo.exceptions import InvalidSchemaException
|
||||||
from jambo.parser.oneof_type_parser import OneOfTypeParser
|
from jambo.parser.oneof_type_parser import OneOfTypeParser
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
|
|
||||||
|
|
||||||
class TestOneOfTypeParser(TestCase):
|
class TestOneOfTypeParser(TestCase):
|
||||||
def test_oneof_raises_on_invalid_property(self):
|
def test_oneof_raises_on_invalid_property(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
OneOfTypeParser().from_properties_impl(
|
OneOfTypeParser().from_properties_impl(
|
||||||
"test_field",
|
"test_field",
|
||||||
{
|
{
|
||||||
@@ -17,7 +20,18 @@ class TestOneOfTypeParser(TestCase):
|
|||||||
ref_cache={},
|
ref_cache={},
|
||||||
)
|
)
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
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(
|
SchemaConverter.build(
|
||||||
{
|
{
|
||||||
"title": "Test",
|
"title": "Test",
|
||||||
@@ -71,13 +85,13 @@ class TestOneOfTypeParser(TestCase):
|
|||||||
|
|
||||||
Model = SchemaConverter.build(schema)
|
Model = SchemaConverter.build(schema)
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
Model(id=-5)
|
Model(id=-5)
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
Model(id="invalid")
|
Model(id="invalid")
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
Model(id=123.45)
|
Model(id=123.45)
|
||||||
|
|
||||||
def test_oneof_with_conflicting_schemas(self):
|
def test_oneof_with_conflicting_schemas(self):
|
||||||
@@ -103,11 +117,11 @@ class TestOneOfTypeParser(TestCase):
|
|||||||
obj2 = Model(data=9)
|
obj2 = Model(data=9)
|
||||||
self.assertEqual(obj2.data, 9)
|
self.assertEqual(obj2.data, 9)
|
||||||
|
|
||||||
with self.assertRaises(ValueError) as cm:
|
with self.assertRaises(ValidationError) as cm:
|
||||||
Model(data=6)
|
Model(data=6)
|
||||||
self.assertIn("matches multiple oneOf schemas", str(cm.exception))
|
self.assertIn("matches multiple oneOf schemas", str(cm.exception))
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
Model(data=5)
|
Model(data=5)
|
||||||
|
|
||||||
def test_oneof_with_objects(self):
|
def test_oneof_with_objects(self):
|
||||||
@@ -147,7 +161,7 @@ class TestOneOfTypeParser(TestCase):
|
|||||||
obj2 = Model(contact_info={"phone": "123-456-7890"})
|
obj2 = Model(contact_info={"phone": "123-456-7890"})
|
||||||
self.assertEqual(obj2.contact_info.phone, "123-456-7890")
|
self.assertEqual(obj2.contact_info.phone, "123-456-7890")
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
Model(contact_info={"email": "user@example.com", "phone": "123-456-7890"})
|
Model(contact_info={"email": "user@example.com", "phone": "123-456-7890"})
|
||||||
|
|
||||||
def test_oneof_with_discriminator_basic(self):
|
def test_oneof_with_discriminator_basic(self):
|
||||||
@@ -190,14 +204,14 @@ class TestOneOfTypeParser(TestCase):
|
|||||||
self.assertEqual(dog.pet.type, "dog")
|
self.assertEqual(dog.pet.type, "dog")
|
||||||
self.assertEqual(dog.pet.barks, False)
|
self.assertEqual(dog.pet.barks, False)
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
Model(pet={"type": "cat", "barks": True})
|
Model(pet={"type": "cat", "barks": True})
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
Model(pet={"type": "bird", "flies": True})
|
Model(pet={"type": "bird", "flies": True})
|
||||||
|
|
||||||
def test_oneof_with_invalid_types(self):
|
def test_oneof_with_invalid_types(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
SchemaConverter.build(
|
SchemaConverter.build(
|
||||||
{
|
{
|
||||||
"title": "Pet",
|
"title": "Pet",
|
||||||
@@ -301,13 +315,13 @@ class TestOneOfTypeParser(TestCase):
|
|||||||
|
|
||||||
Model = SchemaConverter.build(schema)
|
Model = SchemaConverter.build(schema)
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
Model(shape={"type": "triangle", "base": 5, "height": 3})
|
Model(shape={"type": "triangle", "base": 5, "height": 3})
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
Model(shape={"type": "circle", "side": 5})
|
Model(shape={"type": "circle", "side": 5})
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
Model(shape={"radius": 5})
|
Model(shape={"radius": 5})
|
||||||
|
|
||||||
def test_oneof_missing_properties(self):
|
def test_oneof_missing_properties(self):
|
||||||
@@ -324,7 +338,7 @@ class TestOneOfTypeParser(TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
SchemaConverter.build(schema)
|
SchemaConverter.build(schema)
|
||||||
|
|
||||||
def test_oneof_invalid_properties(self):
|
def test_oneof_invalid_properties(self):
|
||||||
@@ -336,7 +350,7 @@ class TestOneOfTypeParser(TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
SchemaConverter.build(schema)
|
SchemaConverter.build(schema)
|
||||||
|
|
||||||
def test_oneof_with_default_value(self):
|
def test_oneof_with_default_value(self):
|
||||||
@@ -373,12 +387,12 @@ class TestOneOfTypeParser(TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
SchemaConverter.build(schema)
|
SchemaConverter.build(schema)
|
||||||
|
|
||||||
def test_oneof_discriminator_without_property_name(self):
|
def test_oneof_discriminator_without_property_name(self):
|
||||||
# Should throw because the spec determines propertyName is required for discriminator
|
# Should throw because the spec determines propertyName is required for discriminator
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
SchemaConverter.build(
|
SchemaConverter.build(
|
||||||
{
|
{
|
||||||
"title": "Test",
|
"title": "Test",
|
||||||
@@ -409,7 +423,7 @@ class TestOneOfTypeParser(TestCase):
|
|||||||
|
|
||||||
def test_oneof_discriminator_with_invalid_discriminator(self):
|
def test_oneof_discriminator_with_invalid_discriminator(self):
|
||||||
# Should throw because a valid discriminator is required
|
# Should throw because a valid discriminator is required
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
SchemaConverter.build(
|
SchemaConverter.build(
|
||||||
{
|
{
|
||||||
"title": "Test",
|
"title": "Test",
|
||||||
@@ -465,8 +479,9 @@ class TestOneOfTypeParser(TestCase):
|
|||||||
self.assertEqual(obj2.value, "very long string")
|
self.assertEqual(obj2.value, "very long string")
|
||||||
|
|
||||||
# Invalid: Medium string (matches BOTH schemas - violates oneOf)
|
# Invalid: Medium string (matches BOTH schemas - violates oneOf)
|
||||||
with self.assertRaises(ValueError) as cm:
|
with self.assertRaises(ValidationError) as cm:
|
||||||
Model(value="hello") # 5 chars: matches maxLength=6 AND minLength=4
|
Model(value="hello") # 5 chars: matches maxLength=6 AND minLength=4
|
||||||
|
|
||||||
self.assertIn("matches multiple oneOf schemas", str(cm.exception))
|
self.assertIn("matches multiple oneOf schemas", str(cm.exception))
|
||||||
|
|
||||||
def test_oneof_shapes_discriminator_from_docs(self):
|
def test_oneof_shapes_discriminator_from_docs(self):
|
||||||
@@ -515,5 +530,73 @@ class TestOneOfTypeParser(TestCase):
|
|||||||
self.assertEqual(rectangle.shape.height, 20)
|
self.assertEqual(rectangle.shape.height, 20)
|
||||||
|
|
||||||
# Invalid: Wrong properties for the type
|
# Invalid: Wrong properties for the type
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
Model(shape={"type": "circle", "width": 10})
|
Model(shape={"type": "circle", "width": 10})
|
||||||
|
|
||||||
|
def test_oneof_with_examples(self):
|
||||||
|
schema = {
|
||||||
|
"title": "ExampleTest",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"value": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"examples": ["example1", "example2"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"examples": [1, 2, 3],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["value"],
|
||||||
|
}
|
||||||
|
|
||||||
|
Model = SchemaConverter.build(schema)
|
||||||
|
|
||||||
|
# Since Pydantic does not natively support oneOf and the validation
|
||||||
|
# is done via a custom validator, the `value` is represented using `anyOf`
|
||||||
|
model_schema = Model.model_json_schema()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
model_schema["properties"]["value"]["anyOf"][0]["examples"],
|
||||||
|
["example1", "example2"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
model_schema["properties"]["value"]["anyOf"][1]["examples"],
|
||||||
|
[1, 2, 3],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_oneof_with_root_examples(self):
|
||||||
|
schema = {
|
||||||
|
"title": "ExampleTest",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"value": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"examples": ["example1", 2],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["value"],
|
||||||
|
}
|
||||||
|
|
||||||
|
Model = SchemaConverter.build(schema)
|
||||||
|
|
||||||
|
# Since Pydantic does not natively support oneOf and the validation
|
||||||
|
# is done via a custom validator, the `value` is represented using `anyOf`
|
||||||
|
model_schema = Model.model_json_schema()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
model_schema["properties"]["value"]["examples"],
|
||||||
|
["example1", 2],
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
from jambo.exceptions import InternalAssertionException, InvalidSchemaException
|
||||||
from jambo.parser import ObjectTypeParser, RefTypeParser
|
from jambo.parser import ObjectTypeParser, RefTypeParser
|
||||||
|
|
||||||
from typing import ForwardRef
|
from pydantic import ValidationError
|
||||||
|
from typing_extensions import ForwardRef
|
||||||
|
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
|
|
||||||
|
|
||||||
@@ -16,7 +19,7 @@ class TestRefTypeParser(TestCase):
|
|||||||
"required": ["name", "age"],
|
"required": ["name", "age"],
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
RefTypeParser().from_properties(
|
RefTypeParser().from_properties(
|
||||||
"person",
|
"person",
|
||||||
properties,
|
properties,
|
||||||
@@ -40,7 +43,7 @@ class TestRefTypeParser(TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(InternalAssertionException):
|
||||||
RefTypeParser().from_properties(
|
RefTypeParser().from_properties(
|
||||||
"person",
|
"person",
|
||||||
properties,
|
properties,
|
||||||
@@ -63,7 +66,7 @@ class TestRefTypeParser(TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(InternalAssertionException):
|
||||||
RefTypeParser().from_properties(
|
RefTypeParser().from_properties(
|
||||||
"person",
|
"person",
|
||||||
properties,
|
properties,
|
||||||
@@ -77,7 +80,7 @@ class TestRefTypeParser(TestCase):
|
|||||||
"$ref": "https://example.com/schemas/person.json",
|
"$ref": "https://example.com/schemas/person.json",
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
RefTypeParser().from_properties(
|
RefTypeParser().from_properties(
|
||||||
"person",
|
"person",
|
||||||
properties,
|
properties,
|
||||||
@@ -110,7 +113,7 @@ class TestRefTypeParser(TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
ObjectTypeParser().from_properties(
|
ObjectTypeParser().from_properties(
|
||||||
"person",
|
"person",
|
||||||
properties,
|
properties,
|
||||||
@@ -126,7 +129,7 @@ class TestRefTypeParser(TestCase):
|
|||||||
"$defs": {},
|
"$defs": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
RefTypeParser().from_properties(
|
RefTypeParser().from_properties(
|
||||||
"person",
|
"person",
|
||||||
properties,
|
properties,
|
||||||
@@ -142,7 +145,7 @@ class TestRefTypeParser(TestCase):
|
|||||||
"$defs": {"person": None},
|
"$defs": {"person": None},
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
RefTypeParser().from_properties(
|
RefTypeParser().from_properties(
|
||||||
"person",
|
"person",
|
||||||
properties,
|
properties,
|
||||||
@@ -232,7 +235,7 @@ class TestRefTypeParser(TestCase):
|
|||||||
"required": ["name", "age"],
|
"required": ["name", "age"],
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
ObjectTypeParser().from_properties(
|
ObjectTypeParser().from_properties(
|
||||||
"person",
|
"person",
|
||||||
properties,
|
properties,
|
||||||
@@ -264,7 +267,7 @@ class TestRefTypeParser(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# checks if when created via FowardRef the model is validated correctly.
|
# checks if when created via FowardRef the model is validated correctly.
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
model(
|
model(
|
||||||
name="John",
|
name="John",
|
||||||
age=30,
|
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(
|
ref_strategy, ref_name, ref_property = RefTypeParser()._parse_from_strategy(
|
||||||
"invalid_strategy",
|
"invalid_strategy",
|
||||||
"person",
|
"person",
|
||||||
@@ -482,3 +485,38 @@ class TestRefTypeParser(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(obj.name, "John")
|
self.assertEqual(obj.name, "John")
|
||||||
self.assertEqual(obj.age, 30)
|
self.assertEqual(obj.age, 30)
|
||||||
|
|
||||||
|
def test_ref_type_parser_with_def_with_examples(self):
|
||||||
|
properties = {
|
||||||
|
"title": "person",
|
||||||
|
"$ref": "#/$defs/person",
|
||||||
|
"$defs": {
|
||||||
|
"person": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"examples": [
|
||||||
|
{"name": "John", "age": 30},
|
||||||
|
{"name": "Jane", "age": 25},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
_, type_validator = RefTypeParser().from_properties(
|
||||||
|
"person",
|
||||||
|
properties,
|
||||||
|
context=properties,
|
||||||
|
ref_cache={},
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
type_validator.get("examples"),
|
||||||
|
[
|
||||||
|
{"name": "John", "age": 30},
|
||||||
|
{"name": "Jane", "age": 25},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
from jambo.exceptions import InvalidSchemaException
|
||||||
from jambo.parser import StringTypeParser
|
from jambo.parser import StringTypeParser
|
||||||
|
|
||||||
from pydantic import AnyUrl, EmailStr
|
from pydantic import AnyUrl, EmailStr
|
||||||
|
|
||||||
from datetime import date, datetime, time, timedelta
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
from ipaddress import IPv4Address, IPv6Address
|
from ipaddress import IPv4Address, IPv6Address, ip_address
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ class TestStringTypeParser(TestCase):
|
|||||||
"maxLength": 10,
|
"maxLength": 10,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"pattern": "^[a-zA-Z]+$",
|
"pattern": "^[a-zA-Z]+$",
|
||||||
|
"examples": ["test", "TEST"],
|
||||||
}
|
}
|
||||||
|
|
||||||
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
||||||
@@ -34,6 +36,7 @@ class TestStringTypeParser(TestCase):
|
|||||||
self.assertEqual(type_validator["max_length"], 10)
|
self.assertEqual(type_validator["max_length"], 10)
|
||||||
self.assertEqual(type_validator["min_length"], 1)
|
self.assertEqual(type_validator["min_length"], 1)
|
||||||
self.assertEqual(type_validator["pattern"], "^[a-zA-Z]+$")
|
self.assertEqual(type_validator["pattern"], "^[a-zA-Z]+$")
|
||||||
|
self.assertEqual(type_validator["examples"], ["test", "TEST"])
|
||||||
|
|
||||||
def test_string_parser_with_default_value(self):
|
def test_string_parser_with_default_value(self):
|
||||||
parser = StringTypeParser()
|
parser = StringTypeParser()
|
||||||
@@ -62,7 +65,7 @@ class TestStringTypeParser(TestCase):
|
|||||||
"minLength": 5,
|
"minLength": 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parser.from_properties("placeholder", properties)
|
parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
def test_string_parser_with_default_invalid_maxlength(self):
|
def test_string_parser_with_default_invalid_maxlength(self):
|
||||||
@@ -75,7 +78,7 @@ class TestStringTypeParser(TestCase):
|
|||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parser.from_properties("placeholder", properties)
|
parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
def test_string_parser_with_default_invalid_minlength(self):
|
def test_string_parser_with_default_invalid_minlength(self):
|
||||||
@@ -88,7 +91,7 @@ class TestStringTypeParser(TestCase):
|
|||||||
"minLength": 2,
|
"minLength": 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
parser.from_properties("placeholder", properties)
|
parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
def test_string_parser_with_email_format(self):
|
def test_string_parser_with_email_format(self):
|
||||||
@@ -97,11 +100,13 @@ class TestStringTypeParser(TestCase):
|
|||||||
properties = {
|
properties = {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "email",
|
"format": "email",
|
||||||
|
"examples": ["test@example.com"],
|
||||||
}
|
}
|
||||||
|
|
||||||
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
self.assertEqual(type_parsing, EmailStr)
|
self.assertEqual(type_parsing, EmailStr)
|
||||||
|
self.assertEqual(type_validator["examples"], ["test@example.com"])
|
||||||
|
|
||||||
def test_string_parser_with_uri_format(self):
|
def test_string_parser_with_uri_format(self):
|
||||||
parser = StringTypeParser()
|
parser = StringTypeParser()
|
||||||
@@ -109,21 +114,27 @@ class TestStringTypeParser(TestCase):
|
|||||||
properties = {
|
properties = {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uri",
|
"format": "uri",
|
||||||
|
"examples": ["test://domain/resource"],
|
||||||
}
|
}
|
||||||
|
|
||||||
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
self.assertEqual(type_parsing, AnyUrl)
|
self.assertEqual(type_parsing, AnyUrl)
|
||||||
|
self.assertEqual(type_validator["examples"], [AnyUrl("test://domain/resource")])
|
||||||
|
|
||||||
def test_string_parser_with_ip_formats(self):
|
def test_string_parser_with_ip_formats(self):
|
||||||
parser = StringTypeParser()
|
parser = StringTypeParser()
|
||||||
|
|
||||||
formats = {"ipv4": IPv4Address, "ipv6": IPv6Address}
|
formats = {"ipv4": IPv4Address, "ipv6": IPv6Address}
|
||||||
|
examples = {"ipv4": ["192.168.1.1"], "ipv6": ["::1"]}
|
||||||
|
|
||||||
for ip_format, expected_type in formats.items():
|
for ip_format, expected_type in formats.items():
|
||||||
|
example = examples[ip_format]
|
||||||
|
|
||||||
properties = {
|
properties = {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": ip_format,
|
"format": ip_format,
|
||||||
|
"examples": example,
|
||||||
}
|
}
|
||||||
|
|
||||||
type_parsing, type_validator = parser.from_properties(
|
type_parsing, type_validator = parser.from_properties(
|
||||||
@@ -131,6 +142,9 @@ class TestStringTypeParser(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(type_parsing, expected_type)
|
self.assertEqual(type_parsing, expected_type)
|
||||||
|
self.assertEqual(
|
||||||
|
type_validator["examples"], [ip_address(e) for e in example]
|
||||||
|
)
|
||||||
|
|
||||||
def test_string_parser_with_uuid_format(self):
|
def test_string_parser_with_uuid_format(self):
|
||||||
parser = StringTypeParser()
|
parser = StringTypeParser()
|
||||||
@@ -138,11 +152,15 @@ class TestStringTypeParser(TestCase):
|
|||||||
properties = {
|
properties = {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
|
"examples": ["ab71aaf4-ab6e-43cd-a369-cebdd9f7a4c6"],
|
||||||
}
|
}
|
||||||
|
|
||||||
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
self.assertEqual(type_parsing, UUID)
|
self.assertEqual(type_parsing, UUID)
|
||||||
|
self.assertEqual(
|
||||||
|
type_validator["examples"], [UUID("ab71aaf4-ab6e-43cd-a369-cebdd9f7a4c6")]
|
||||||
|
)
|
||||||
|
|
||||||
def test_string_parser_with_time_format(self):
|
def test_string_parser_with_time_format(self):
|
||||||
parser = StringTypeParser()
|
parser = StringTypeParser()
|
||||||
@@ -150,19 +168,33 @@ class TestStringTypeParser(TestCase):
|
|||||||
properties = {
|
properties = {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "time",
|
"format": "time",
|
||||||
|
"examples": ["14:30:00", "09:15:30.500", "10:00:00+02:00"],
|
||||||
}
|
}
|
||||||
|
|
||||||
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
self.assertEqual(type_parsing, time)
|
self.assertEqual(type_parsing, time)
|
||||||
|
self.assertEqual(
|
||||||
|
type_validator["examples"],
|
||||||
|
[
|
||||||
|
time(hour=14, minute=30, second=0),
|
||||||
|
time(hour=9, minute=15, second=30, microsecond=500_000),
|
||||||
|
time(hour=10, minute=0, second=0, tzinfo=timezone(timedelta(hours=2))),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def test_string_parser_with_pattern_based_formats(self):
|
def test_string_parser_with_pattern_based_formats(self):
|
||||||
parser = StringTypeParser()
|
parser = StringTypeParser()
|
||||||
|
|
||||||
for format_type in ["hostname"]:
|
format_types = {
|
||||||
|
"hostname": "example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
for format_type, example_type in format_types.items():
|
||||||
properties = {
|
properties = {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": format_type,
|
"format": format_type,
|
||||||
|
"examples": [example_type],
|
||||||
}
|
}
|
||||||
|
|
||||||
type_parsing, type_validator = parser.from_properties(
|
type_parsing, type_validator = parser.from_properties(
|
||||||
@@ -174,6 +206,7 @@ class TestStringTypeParser(TestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
type_validator["pattern"], parser.format_pattern_mapping[format_type]
|
type_validator["pattern"], parser.format_pattern_mapping[format_type]
|
||||||
)
|
)
|
||||||
|
self.assertEqual(type_validator["examples"], [example_type])
|
||||||
|
|
||||||
def test_string_parser_with_unsupported_format(self):
|
def test_string_parser_with_unsupported_format(self):
|
||||||
parser = StringTypeParser()
|
parser = StringTypeParser()
|
||||||
@@ -183,11 +216,12 @@ class TestStringTypeParser(TestCase):
|
|||||||
"format": "unsupported-format",
|
"format": "unsupported-format",
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError) as context:
|
with self.assertRaises(InvalidSchemaException) as context:
|
||||||
parser.from_properties("placeholder", properties)
|
parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
self.assertEqual(
|
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):
|
def test_string_parser_with_date_format(self):
|
||||||
@@ -196,11 +230,20 @@ class TestStringTypeParser(TestCase):
|
|||||||
properties = {
|
properties = {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date",
|
"format": "date",
|
||||||
|
"examples": ["2025-11-17", "1999-12-31", "2000-01-01"],
|
||||||
}
|
}
|
||||||
|
|
||||||
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
self.assertEqual(type_parsing, date)
|
self.assertEqual(type_parsing, date)
|
||||||
|
self.assertEqual(
|
||||||
|
type_validator["examples"],
|
||||||
|
[
|
||||||
|
date(year=2025, month=11, day=17),
|
||||||
|
date(year=1999, month=12, day=31),
|
||||||
|
date(year=2000, month=1, day=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def test_string_parser_with_datetime_format(self):
|
def test_string_parser_with_datetime_format(self):
|
||||||
parser = StringTypeParser()
|
parser = StringTypeParser()
|
||||||
@@ -208,11 +251,52 @@ class TestStringTypeParser(TestCase):
|
|||||||
properties = {
|
properties = {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
|
"examples": [
|
||||||
|
"2025-11-17T11:15:00",
|
||||||
|
"2025-11-17T11:15:00+01:00",
|
||||||
|
"2025-11-17T11:15:00.123456-05:00",
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
self.assertEqual(type_parsing, datetime)
|
self.assertEqual(type_parsing, datetime)
|
||||||
|
self.assertEqual(
|
||||||
|
type_validator["examples"],
|
||||||
|
[
|
||||||
|
datetime(year=2025, month=11, day=17, hour=11, minute=15, second=0),
|
||||||
|
datetime(
|
||||||
|
year=2025,
|
||||||
|
month=11,
|
||||||
|
day=17,
|
||||||
|
hour=11,
|
||||||
|
minute=15,
|
||||||
|
second=0,
|
||||||
|
tzinfo=timezone(timedelta(hours=1)),
|
||||||
|
),
|
||||||
|
datetime(
|
||||||
|
year=2025,
|
||||||
|
month=11,
|
||||||
|
day=17,
|
||||||
|
hour=11,
|
||||||
|
minute=15,
|
||||||
|
second=0,
|
||||||
|
microsecond=123456,
|
||||||
|
tzinfo=timezone(timedelta(hours=-5)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_string_parser_with_invalid_example_value(self):
|
||||||
|
with self.assertRaises(InvalidSchemaException):
|
||||||
|
StringTypeParser().from_properties(
|
||||||
|
"placeholder",
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "email",
|
||||||
|
"examples": ["invalid-email"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def test_string_parser_with_timedelta_format(self):
|
def test_string_parser_with_timedelta_format(self):
|
||||||
parser = StringTypeParser()
|
parser = StringTypeParser()
|
||||||
@@ -220,8 +304,18 @@ class TestStringTypeParser(TestCase):
|
|||||||
properties = {
|
properties = {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "duration",
|
"format": "duration",
|
||||||
|
"examples": ["P1Y2M3DT4H5M6S", "PT30M", "P7D", "PT0.5S"],
|
||||||
}
|
}
|
||||||
|
|
||||||
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
type_parsing, type_validator = parser.from_properties("placeholder", properties)
|
||||||
|
|
||||||
self.assertEqual(type_parsing, timedelta)
|
self.assertEqual(type_parsing, timedelta)
|
||||||
|
self.assertEqual(
|
||||||
|
type_validator["examples"],
|
||||||
|
[
|
||||||
|
timedelta(days=428, hours=4, minutes=5, seconds=6),
|
||||||
|
timedelta(minutes=30),
|
||||||
|
timedelta(days=7),
|
||||||
|
timedelta(seconds=0.5),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from jambo.exceptions import InvalidSchemaException
|
||||||
from jambo.parser import StringTypeParser
|
from jambo.parser import StringTypeParser
|
||||||
from jambo.parser._type_parser import GenericTypeParser
|
from jambo.parser._type_parser import GenericTypeParser
|
||||||
|
|
||||||
@@ -17,5 +18,16 @@ class TestGenericTypeParser(TestCase):
|
|||||||
StringTypeParser.json_schema_type = "type:string"
|
StringTypeParser.json_schema_type = "type:string"
|
||||||
|
|
||||||
def test_get_impl_invalid_type(self):
|
def test_get_impl_invalid_type(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
GenericTypeParser._get_impl({"type": "invalid_type"})
|
GenericTypeParser._get_impl({"type": "invalid_type"})
|
||||||
|
|
||||||
|
def test_invalid_examples_not_list(self):
|
||||||
|
parser = StringTypeParser()
|
||||||
|
|
||||||
|
properties = {
|
||||||
|
"type": "integer",
|
||||||
|
"examples": "this should be a list",
|
||||||
|
}
|
||||||
|
|
||||||
|
with self.assertRaises(InvalidSchemaException):
|
||||||
|
parser.from_properties("placeholder", properties)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
from jambo import SchemaConverter
|
from jambo import SchemaConverter
|
||||||
|
from jambo.exceptions import InvalidSchemaException, UnsupportedSchemaException
|
||||||
|
from jambo.types import JSONSchema
|
||||||
|
|
||||||
from pydantic import AnyUrl, BaseModel
|
from pydantic import AnyUrl, BaseModel, ValidationError
|
||||||
|
from typing_extensions import get_args
|
||||||
|
|
||||||
from ipaddress import IPv4Address, IPv6Address
|
from ipaddress import IPv4Address, IPv6Address
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
@@ -12,6 +15,12 @@ def is_pydantic_model(cls):
|
|||||||
|
|
||||||
|
|
||||||
class TestSchemaConverter(TestCase):
|
class TestSchemaConverter(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.converter = SchemaConverter()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.converter.clear_ref_cache(namespace=None)
|
||||||
|
|
||||||
def test_invalid_schema(self):
|
def test_invalid_schema(self):
|
||||||
schema = {
|
schema = {
|
||||||
"title": 1,
|
"title": 1,
|
||||||
@@ -23,8 +32,22 @@ class TestSchemaConverter(TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
SchemaConverter.build(schema)
|
self.converter.build_with_cache(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):
|
||||||
|
self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
def test_build_expects_title(self):
|
def test_build_expects_title(self):
|
||||||
schema = {
|
schema = {
|
||||||
@@ -36,8 +59,8 @@ class TestSchemaConverter(TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
SchemaConverter.build(schema)
|
self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
def test_build_expects_object(self):
|
def test_build_expects_object(self):
|
||||||
schema = {
|
schema = {
|
||||||
@@ -46,8 +69,8 @@ class TestSchemaConverter(TestCase):
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(TypeError):
|
with self.assertRaises(UnsupportedSchemaException):
|
||||||
SchemaConverter.build(schema)
|
self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
def test_is_invalid_field(self):
|
def test_is_invalid_field(self):
|
||||||
schema = {
|
schema = {
|
||||||
@@ -62,8 +85,8 @@ class TestSchemaConverter(TestCase):
|
|||||||
# 'required': ['name', 'age', 'is_active', 'friends', 'address'],
|
# 'required': ['name', 'age', 'is_active', 'friends', 'address'],
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError) as context:
|
with self.assertRaises(InvalidSchemaException) as context:
|
||||||
SchemaConverter.build(schema)
|
self.converter.build_with_cache(schema)
|
||||||
self.assertTrue("Unknown type" in str(context.exception))
|
self.assertTrue("Unknown type" in str(context.exception))
|
||||||
|
|
||||||
def test_jsonschema_to_pydantic(self):
|
def test_jsonschema_to_pydantic(self):
|
||||||
@@ -78,7 +101,7 @@ class TestSchemaConverter(TestCase):
|
|||||||
"required": ["name"],
|
"required": ["name"],
|
||||||
}
|
}
|
||||||
|
|
||||||
model = SchemaConverter.build(schema)
|
model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
self.assertTrue(is_pydantic_model(model))
|
self.assertTrue(is_pydantic_model(model))
|
||||||
|
|
||||||
@@ -99,20 +122,20 @@ class TestSchemaConverter(TestCase):
|
|||||||
"required": ["name"],
|
"required": ["name"],
|
||||||
}
|
}
|
||||||
|
|
||||||
model = SchemaConverter.build(schema)
|
model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
self.assertEqual(model(name="John", age=30).name, "John")
|
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")
|
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")
|
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")
|
model(name="", age=45, email="teste@hideyoshi.com")
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
model(name="John", age=45, email="hideyoshi.com")
|
model(name="John", age=45, email="hideyoshi.com")
|
||||||
|
|
||||||
def test_validation_integer(self):
|
def test_validation_integer(self):
|
||||||
@@ -130,14 +153,14 @@ class TestSchemaConverter(TestCase):
|
|||||||
"required": ["age"],
|
"required": ["age"],
|
||||||
}
|
}
|
||||||
|
|
||||||
model = SchemaConverter.build(schema)
|
model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
self.assertEqual(model(age=30).age, 30)
|
self.assertEqual(model(age=30).age, 30)
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
model(age=-1)
|
model(age=-1)
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
model(age=121)
|
model(age=121)
|
||||||
|
|
||||||
def test_validation_float(self):
|
def test_validation_float(self):
|
||||||
@@ -155,14 +178,14 @@ class TestSchemaConverter(TestCase):
|
|||||||
"required": ["age"],
|
"required": ["age"],
|
||||||
}
|
}
|
||||||
|
|
||||||
model = SchemaConverter.build(schema)
|
model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
self.assertEqual(model(age=30).age, 30.0)
|
self.assertEqual(model(age=30).age, 30.0)
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
model(age=-1.0)
|
model(age=-1.0)
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
model(age=121.0)
|
model(age=121.0)
|
||||||
|
|
||||||
def test_validation_boolean(self):
|
def test_validation_boolean(self):
|
||||||
@@ -176,7 +199,7 @@ class TestSchemaConverter(TestCase):
|
|||||||
"required": ["is_active"],
|
"required": ["is_active"],
|
||||||
}
|
}
|
||||||
|
|
||||||
model = SchemaConverter.build(schema)
|
model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
self.assertEqual(model(is_active=True).is_active, True)
|
self.assertEqual(model(is_active=True).is_active, True)
|
||||||
|
|
||||||
@@ -199,39 +222,20 @@ class TestSchemaConverter(TestCase):
|
|||||||
"required": ["friends"],
|
"required": ["friends"],
|
||||||
}
|
}
|
||||||
|
|
||||||
model = SchemaConverter.build(schema)
|
model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
model(friends=["John", "Jane", "John"]).friends, {"John", "Jane"}
|
model(friends=["John", "Jane", "John"]).friends, {"John", "Jane"}
|
||||||
)
|
)
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
model(friends=[])
|
model(friends=[])
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
model(friends=["John", "Jane", "Invalid"])
|
model(friends=["John", "Jane", "Invalid"])
|
||||||
|
|
||||||
def test_validation_list_with_missing_items(self):
|
def test_validation_list_with_missing_items(self):
|
||||||
model = SchemaConverter.build(
|
model = self.converter.build_with_cache(
|
||||||
{
|
|
||||||
"title": "Person",
|
|
||||||
"description": "A person",
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"friends": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {"type": "string"},
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 2,
|
|
||||||
"default": ["John", "Jane"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(model().friends, ["John", "Jane"])
|
|
||||||
|
|
||||||
model = SchemaConverter.build(
|
|
||||||
{
|
{
|
||||||
"title": "Person",
|
"title": "Person",
|
||||||
"description": "A person",
|
"description": "A person",
|
||||||
@@ -248,7 +252,7 @@ class TestSchemaConverter(TestCase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
model()
|
model()
|
||||||
|
|
||||||
def test_validation_object(self):
|
def test_validation_object(self):
|
||||||
@@ -269,14 +273,14 @@ class TestSchemaConverter(TestCase):
|
|||||||
"required": ["address"],
|
"required": ["address"],
|
||||||
}
|
}
|
||||||
|
|
||||||
model = SchemaConverter.build(schema)
|
model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
obj = model(address={"street": "123 Main St", "city": "Springfield"})
|
obj = model(address={"street": "123 Main St", "city": "Springfield"})
|
||||||
|
|
||||||
self.assertEqual(obj.address.street, "123 Main St")
|
self.assertEqual(obj.address.street, "123 Main St")
|
||||||
self.assertEqual(obj.address.city, "Springfield")
|
self.assertEqual(obj.address.city, "Springfield")
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
model()
|
model()
|
||||||
|
|
||||||
def test_default_for_string(self):
|
def test_default_for_string(self):
|
||||||
@@ -293,7 +297,7 @@ class TestSchemaConverter(TestCase):
|
|||||||
"required": ["name"],
|
"required": ["name"],
|
||||||
}
|
}
|
||||||
|
|
||||||
model = SchemaConverter.build(schema)
|
model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
obj = model(name="John")
|
obj = model(name="John")
|
||||||
|
|
||||||
@@ -315,8 +319,8 @@ class TestSchemaConverter(TestCase):
|
|||||||
"required": ["name"],
|
"required": ["name"],
|
||||||
}
|
}
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSchemaException):
|
||||||
SchemaConverter.build(schema_max_length)
|
self.converter.build_with_cache(schema_max_length)
|
||||||
|
|
||||||
def test_default_for_list(self):
|
def test_default_for_list(self):
|
||||||
schema_list = {
|
schema_list = {
|
||||||
@@ -333,10 +337,11 @@ class TestSchemaConverter(TestCase):
|
|||||||
"required": ["friends"],
|
"required": ["friends"],
|
||||||
}
|
}
|
||||||
|
|
||||||
model_list = SchemaConverter.build(schema_list)
|
model_list = self.converter.build_with_cache(schema_list)
|
||||||
|
|
||||||
self.assertEqual(model_list().friends, ["John", "Jane"])
|
self.assertEqual(model_list().friends, ["John", "Jane"])
|
||||||
|
|
||||||
|
def test_default_for_list_with_unique_items(self):
|
||||||
# Test for default with uniqueItems
|
# Test for default with uniqueItems
|
||||||
schema_set = {
|
schema_set = {
|
||||||
"title": "Person",
|
"title": "Person",
|
||||||
@@ -353,7 +358,7 @@ class TestSchemaConverter(TestCase):
|
|||||||
"required": ["friends"],
|
"required": ["friends"],
|
||||||
}
|
}
|
||||||
|
|
||||||
model_set = SchemaConverter.build(schema_set)
|
model_set = self.converter.build_with_cache(schema_set)
|
||||||
|
|
||||||
self.assertEqual(model_set().friends, {"John", "Jane"})
|
self.assertEqual(model_set().friends, {"John", "Jane"})
|
||||||
|
|
||||||
@@ -375,7 +380,7 @@ class TestSchemaConverter(TestCase):
|
|||||||
"required": ["address"],
|
"required": ["address"],
|
||||||
}
|
}
|
||||||
|
|
||||||
model = SchemaConverter.build(schema)
|
model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
obj = model(address={"street": "123 Main St", "city": "Springfield"})
|
obj = model(address={"street": "123 Main St", "city": "Springfield"})
|
||||||
|
|
||||||
@@ -399,7 +404,7 @@ class TestSchemaConverter(TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
Model = SchemaConverter.build(schema)
|
Model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
obj = Model(
|
obj = Model(
|
||||||
name="J",
|
name="J",
|
||||||
@@ -407,10 +412,10 @@ class TestSchemaConverter(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(obj.name, "J")
|
self.assertEqual(obj.name, "J")
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
Model(name="John Invalid")
|
Model(name="John Invalid")
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
Model(name="")
|
Model(name="")
|
||||||
|
|
||||||
def test_any_of(self):
|
def test_any_of(self):
|
||||||
@@ -428,7 +433,7 @@ class TestSchemaConverter(TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
Model = SchemaConverter.build(schema)
|
Model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
obj = Model(id=1)
|
obj = Model(id=1)
|
||||||
self.assertEqual(obj.id, 1)
|
self.assertEqual(obj.id, 1)
|
||||||
@@ -436,13 +441,13 @@ class TestSchemaConverter(TestCase):
|
|||||||
obj = Model(id="12345678901")
|
obj = Model(id="12345678901")
|
||||||
self.assertEqual(obj.id, "12345678901")
|
self.assertEqual(obj.id, "12345678901")
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
Model(id="")
|
Model(id="")
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
Model(id="12345678901234567890")
|
Model(id="12345678901234567890")
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
Model(id=11)
|
Model(id=11)
|
||||||
|
|
||||||
def test_string_format_email(self):
|
def test_string_format_email(self):
|
||||||
@@ -451,9 +456,11 @@ class TestSchemaConverter(TestCase):
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {"email": {"type": "string", "format": "email"}},
|
"properties": {"email": {"type": "string", "format": "email"}},
|
||||||
}
|
}
|
||||||
model = SchemaConverter.build(schema)
|
|
||||||
|
model = self.converter.build_with_cache(schema)
|
||||||
self.assertEqual(model(email="test@example.com").email, "test@example.com")
|
self.assertEqual(model(email="test@example.com").email, "test@example.com")
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
model(email="invalid-email")
|
model(email="invalid-email")
|
||||||
|
|
||||||
def test_string_format_uri(self):
|
def test_string_format_uri(self):
|
||||||
@@ -462,11 +469,13 @@ class TestSchemaConverter(TestCase):
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {"website": {"type": "string", "format": "uri"}},
|
"properties": {"website": {"type": "string", "format": "uri"}},
|
||||||
}
|
}
|
||||||
model = SchemaConverter.build(schema)
|
|
||||||
|
model = self.converter.build_with_cache(schema)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
model(website="https://example.com").website, AnyUrl("https://example.com")
|
model(website="https://example.com").website, AnyUrl("https://example.com")
|
||||||
)
|
)
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
model(website="invalid-uri")
|
model(website="invalid-uri")
|
||||||
|
|
||||||
def test_string_format_ipv4(self):
|
def test_string_format_ipv4(self):
|
||||||
@@ -475,9 +484,11 @@ class TestSchemaConverter(TestCase):
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {"ip": {"type": "string", "format": "ipv4"}},
|
"properties": {"ip": {"type": "string", "format": "ipv4"}},
|
||||||
}
|
}
|
||||||
model = SchemaConverter.build(schema)
|
|
||||||
|
model = self.converter.build_with_cache(schema)
|
||||||
self.assertEqual(model(ip="192.168.1.1").ip, IPv4Address("192.168.1.1"))
|
self.assertEqual(model(ip="192.168.1.1").ip, IPv4Address("192.168.1.1"))
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
model(ip="256.256.256.256")
|
model(ip="256.256.256.256")
|
||||||
|
|
||||||
def test_string_format_ipv6(self):
|
def test_string_format_ipv6(self):
|
||||||
@@ -486,12 +497,14 @@ class TestSchemaConverter(TestCase):
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {"ip": {"type": "string", "format": "ipv6"}},
|
"properties": {"ip": {"type": "string", "format": "ipv6"}},
|
||||||
}
|
}
|
||||||
model = SchemaConverter.build(schema)
|
|
||||||
|
model = self.converter.build_with_cache(schema)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
model(ip="2001:0db8:85a3:0000:0000:8a2e:0370:7334").ip,
|
model(ip="2001:0db8:85a3:0000:0000:8a2e:0370:7334").ip,
|
||||||
IPv6Address("2001:0db8:85a3:0000:0000:8a2e:0370:7334"),
|
IPv6Address("2001:0db8:85a3:0000:0000:8a2e:0370:7334"),
|
||||||
)
|
)
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
model(ip="invalid-ipv6")
|
model(ip="invalid-ipv6")
|
||||||
|
|
||||||
def test_string_format_uuid(self):
|
def test_string_format_uuid(self):
|
||||||
@@ -500,14 +513,15 @@ class TestSchemaConverter(TestCase):
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {"id": {"type": "string", "format": "uuid"}},
|
"properties": {"id": {"type": "string", "format": "uuid"}},
|
||||||
}
|
}
|
||||||
model = SchemaConverter.build(schema)
|
|
||||||
|
model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
model(id="123e4567-e89b-12d3-a456-426614174000").id,
|
model(id="123e4567-e89b-12d3-a456-426614174000").id,
|
||||||
UUID("123e4567-e89b-12d3-a456-426614174000"),
|
UUID("123e4567-e89b-12d3-a456-426614174000"),
|
||||||
)
|
)
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
model(id="invalid-uuid")
|
model(id="invalid-uuid")
|
||||||
|
|
||||||
def test_string_format_hostname(self):
|
def test_string_format_hostname(self):
|
||||||
@@ -516,9 +530,11 @@ class TestSchemaConverter(TestCase):
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {"hostname": {"type": "string", "format": "hostname"}},
|
"properties": {"hostname": {"type": "string", "format": "hostname"}},
|
||||||
}
|
}
|
||||||
model = SchemaConverter.build(schema)
|
|
||||||
|
model = self.converter.build_with_cache(schema)
|
||||||
self.assertEqual(model(hostname="example.com").hostname, "example.com")
|
self.assertEqual(model(hostname="example.com").hostname, "example.com")
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
model(hostname="invalid..hostname")
|
model(hostname="invalid..hostname")
|
||||||
|
|
||||||
def test_string_format_datetime(self):
|
def test_string_format_datetime(self):
|
||||||
@@ -527,12 +543,14 @@ class TestSchemaConverter(TestCase):
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {"timestamp": {"type": "string", "format": "date-time"}},
|
"properties": {"timestamp": {"type": "string", "format": "date-time"}},
|
||||||
}
|
}
|
||||||
model = SchemaConverter.build(schema)
|
|
||||||
|
model = self.converter.build_with_cache(schema)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
model(timestamp="2024-01-01T12:00:00Z").timestamp.isoformat(),
|
model(timestamp="2024-01-01T12:00:00Z").timestamp.isoformat(),
|
||||||
"2024-01-01T12:00:00+00:00",
|
"2024-01-01T12:00:00+00:00",
|
||||||
)
|
)
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
model(timestamp="invalid-datetime")
|
model(timestamp="invalid-datetime")
|
||||||
|
|
||||||
def test_string_format_time(self):
|
def test_string_format_time(self):
|
||||||
@@ -541,11 +559,13 @@ class TestSchemaConverter(TestCase):
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {"time": {"type": "string", "format": "time"}},
|
"properties": {"time": {"type": "string", "format": "time"}},
|
||||||
}
|
}
|
||||||
model = SchemaConverter.build(schema)
|
|
||||||
|
model = self.converter.build_with_cache(schema)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
model(time="20:20:39+00:00").time.isoformat(), "20:20:39+00:00"
|
model(time="20:20:39+00:00").time.isoformat(), "20:20:39+00:00"
|
||||||
)
|
)
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
model(time="25:00:00")
|
model(time="25:00:00")
|
||||||
|
|
||||||
def test_string_format_unsupported(self):
|
def test_string_format_unsupported(self):
|
||||||
@@ -554,8 +574,9 @@ class TestSchemaConverter(TestCase):
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {"field": {"type": "string", "format": "unsupported"}},
|
"properties": {"field": {"type": "string", "format": "unsupported"}},
|
||||||
}
|
}
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
SchemaConverter.build(schema)
|
with self.assertRaises(InvalidSchemaException):
|
||||||
|
self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
def test_ref_with_root_ref(self):
|
def test_ref_with_root_ref(self):
|
||||||
schema = {
|
schema = {
|
||||||
@@ -571,7 +592,7 @@ class TestSchemaConverter(TestCase):
|
|||||||
"required": ["name", "age"],
|
"required": ["name", "age"],
|
||||||
}
|
}
|
||||||
|
|
||||||
model = SchemaConverter.build(schema)
|
model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
obj = model(
|
obj = model(
|
||||||
name="John",
|
name="John",
|
||||||
@@ -606,7 +627,7 @@ class TestSchemaConverter(TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
model = SchemaConverter.build(schema)
|
model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
obj = model(
|
obj = model(
|
||||||
name="John",
|
name="John",
|
||||||
@@ -645,7 +666,7 @@ class TestSchemaConverter(TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
Model = SchemaConverter.build(schema)
|
Model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
obj = Model(
|
obj = Model(
|
||||||
name="John",
|
name="John",
|
||||||
@@ -671,7 +692,7 @@ class TestSchemaConverter(TestCase):
|
|||||||
"required": ["status"],
|
"required": ["status"],
|
||||||
}
|
}
|
||||||
|
|
||||||
Model = SchemaConverter.build(schema)
|
Model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
obj = Model(status="active")
|
obj = Model(status="active")
|
||||||
self.assertEqual(obj.status.value, "active")
|
self.assertEqual(obj.status.value, "active")
|
||||||
@@ -690,7 +711,7 @@ class TestSchemaConverter(TestCase):
|
|||||||
"required": ["status"],
|
"required": ["status"],
|
||||||
}
|
}
|
||||||
|
|
||||||
Model = SchemaConverter.build(schema)
|
Model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
obj = Model()
|
obj = Model()
|
||||||
self.assertEqual(obj.status.value, "active")
|
self.assertEqual(obj.status.value, "active")
|
||||||
@@ -707,15 +728,15 @@ class TestSchemaConverter(TestCase):
|
|||||||
"required": ["name"],
|
"required": ["name"],
|
||||||
}
|
}
|
||||||
|
|
||||||
Model = SchemaConverter.build(schema)
|
Model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
obj = Model()
|
obj = Model()
|
||||||
self.assertEqual(obj.name, "United States of America")
|
self.assertEqual(obj.name, "United States of America")
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
obj.name = "Canada"
|
obj.name = "Canada"
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
Model(name="Canada")
|
Model(name="Canada")
|
||||||
|
|
||||||
def test_const_type_parser_with_non_hashable_value(self):
|
def test_const_type_parser_with_non_hashable_value(self):
|
||||||
@@ -730,15 +751,15 @@ class TestSchemaConverter(TestCase):
|
|||||||
"required": ["name"],
|
"required": ["name"],
|
||||||
}
|
}
|
||||||
|
|
||||||
Model = SchemaConverter.build(schema)
|
Model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
obj = Model()
|
obj = Model()
|
||||||
self.assertEqual(obj.name, ["Brazil"])
|
self.assertEqual(obj.name, ["Brazil"])
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
obj.name = ["Argentina"]
|
obj.name = ["Argentina"]
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
Model(name=["Argentina"])
|
Model(name=["Argentina"])
|
||||||
|
|
||||||
def test_null_type_parser(self):
|
def test_null_type_parser(self):
|
||||||
@@ -750,7 +771,7 @@ class TestSchemaConverter(TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
Model = SchemaConverter.build(schema)
|
Model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
obj = Model()
|
obj = Model()
|
||||||
self.assertIsNone(obj.a_thing)
|
self.assertIsNone(obj.a_thing)
|
||||||
@@ -758,5 +779,394 @@ class TestSchemaConverter(TestCase):
|
|||||||
obj = Model(a_thing=None)
|
obj = Model(a_thing=None)
|
||||||
self.assertIsNone(obj.a_thing)
|
self.assertIsNone(obj.a_thing)
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValidationError):
|
||||||
Model(a_thing="not none")
|
Model(a_thing="not none")
|
||||||
|
|
||||||
|
def test_scoped_ref_schema(self):
|
||||||
|
schema: JSONSchema = {
|
||||||
|
"title": "Example Schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"operating_system": {
|
||||||
|
"oneOf": [
|
||||||
|
{"$ref": "#/$defs/operating_system"},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"creation": {"$ref": "#/$defs/operating_system"},
|
||||||
|
"reinstallation": {"$ref": "#/$defs/operating_system"},
|
||||||
|
},
|
||||||
|
"required": ["creation", "reinstallation"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"operating_system": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"version": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["name", "version"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
schema_type = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
|
# check for me that the types generated by the oneOf in the typing.Annotated have different names
|
||||||
|
operating_system_field = schema_type.model_fields["operating_system"]
|
||||||
|
|
||||||
|
arg1, arg2 = get_args(operating_system_field.annotation)
|
||||||
|
|
||||||
|
first_type = get_args(arg1)[0]
|
||||||
|
second_type = get_args(arg2)[0]
|
||||||
|
|
||||||
|
self.assertNotEqual(first_type.__name__, second_type.__name__)
|
||||||
|
|
||||||
|
def test_object_invalid_require(self):
|
||||||
|
# https://github.com/HideyoshiNakazone/jambo/issues/60
|
||||||
|
object_ = self.converter.build_with_cache(
|
||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "TEST",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["title"],
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The title of the object",
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"summary": {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(object_.model_fields["description"].is_required()) # FAIL
|
||||||
|
|
||||||
|
def test_instance_level_ref_cache(self):
|
||||||
|
ref_cache = {}
|
||||||
|
|
||||||
|
schema = {
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"emergency_contact": {
|
||||||
|
"$ref": "#",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name", "age"],
|
||||||
|
}
|
||||||
|
|
||||||
|
converter1 = SchemaConverter(ref_cache)
|
||||||
|
model1 = converter1.build_with_cache(schema)
|
||||||
|
|
||||||
|
converter2 = SchemaConverter(ref_cache)
|
||||||
|
model2 = converter2.build_with_cache(schema)
|
||||||
|
|
||||||
|
self.assertIs(model1, model2)
|
||||||
|
|
||||||
|
def test_instance_level_ref_cache_isolation_via_without_cache_param(self):
|
||||||
|
schema = {
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"emergency_contact": {
|
||||||
|
"$ref": "#",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name", "age"],
|
||||||
|
}
|
||||||
|
|
||||||
|
model1 = self.converter.build_with_cache(schema, without_cache=True)
|
||||||
|
model2 = self.converter.build_with_cache(schema, without_cache=True)
|
||||||
|
|
||||||
|
self.assertIsNot(model1, model2)
|
||||||
|
|
||||||
|
def test_instance_level_ref_cache_isolation_via_provided_cache(self):
|
||||||
|
schema = {
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"emergency_contact": {
|
||||||
|
"$ref": "#",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name", "age"],
|
||||||
|
}
|
||||||
|
|
||||||
|
model1 = self.converter.build_with_cache(schema, ref_cache={})
|
||||||
|
model2 = self.converter.build_with_cache(schema, ref_cache={})
|
||||||
|
|
||||||
|
self.assertIsNot(model1, model2)
|
||||||
|
|
||||||
|
def test_get_type_from_cache(self):
|
||||||
|
schema = {
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"emergency_contact": {
|
||||||
|
"$ref": "#",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name", "age"],
|
||||||
|
}
|
||||||
|
|
||||||
|
model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
|
cached_model = self.converter.get_cached_ref("Person")
|
||||||
|
|
||||||
|
self.assertIs(model, cached_model)
|
||||||
|
|
||||||
|
def test_get_type_from_cache_not_found(self):
|
||||||
|
cached_model = self.converter.get_cached_ref("NonExistentModel")
|
||||||
|
|
||||||
|
self.assertIsNone(cached_model)
|
||||||
|
|
||||||
|
def test_get_type_from_cache_nested_type(self):
|
||||||
|
schema = {
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"address": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"street": {"type": "string"},
|
||||||
|
"city": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["street", "city"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name", "age", "address"],
|
||||||
|
}
|
||||||
|
|
||||||
|
model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
|
cached_model = self.converter.get_cached_ref("Person.address")
|
||||||
|
|
||||||
|
self.assertIsNotNone(cached_model)
|
||||||
|
self.assertIs(model.model_fields["address"].annotation, cached_model)
|
||||||
|
|
||||||
|
def test_get_type_from_cache_with_def(self):
|
||||||
|
schema = {
|
||||||
|
"title": "person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"address": {"$ref": "#/$defs/address"},
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"address": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"street": {"type": "string"},
|
||||||
|
"city": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["street", "city"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
person_model = self.converter.build_with_cache(schema)
|
||||||
|
cached_person_model = self.converter.get_cached_ref("person")
|
||||||
|
|
||||||
|
self.assertIs(person_model, cached_person_model)
|
||||||
|
|
||||||
|
cached_address_model = self.converter.get_cached_ref("address")
|
||||||
|
|
||||||
|
self.assertIsNotNone(cached_address_model)
|
||||||
|
|
||||||
|
def test_parse_list_type_multiple_values(self):
|
||||||
|
schema = {
|
||||||
|
"title": "TestListType",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"values": {"type": ["string", "number"]}},
|
||||||
|
}
|
||||||
|
|
||||||
|
Model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
|
obj1 = Model(values="a string")
|
||||||
|
self.assertEqual(obj1.values, "a string")
|
||||||
|
|
||||||
|
obj2 = Model(values=42)
|
||||||
|
self.assertEqual(obj2.values, 42)
|
||||||
|
|
||||||
|
def test_parse_list_type_one_value(self):
|
||||||
|
schema = {
|
||||||
|
"title": "TestListType",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"values": {"type": ["string"]}},
|
||||||
|
}
|
||||||
|
|
||||||
|
Model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
|
obj1 = Model(values="a string")
|
||||||
|
self.assertEqual(obj1.values, "a string")
|
||||||
|
|
||||||
|
def test_parse_list_type_empty(self):
|
||||||
|
schema = {
|
||||||
|
"title": "TestListType",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"values": {"type": []}},
|
||||||
|
}
|
||||||
|
|
||||||
|
with self.assertRaises(InvalidSchemaException):
|
||||||
|
self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
|
def test_parse_list_type_root_level_throws(self):
|
||||||
|
schema = {"title": "TestListType", "type": ["string", "number"]}
|
||||||
|
|
||||||
|
with self.assertRaises(InvalidSchemaException):
|
||||||
|
self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
|
def tests_instance_level_ref_cache_isolation_via_property_id(self):
|
||||||
|
schema1: JSONSchema = {
|
||||||
|
"$id": "http://example.com/schemas/person1.json",
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"emergency_contact": {
|
||||||
|
"$ref": "#",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name", "age"],
|
||||||
|
}
|
||||||
|
|
||||||
|
model1 = self.converter.build_with_cache(schema1)
|
||||||
|
|
||||||
|
schema2: JSONSchema = {
|
||||||
|
"$id": "http://example.com/schemas/person2.json",
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"address": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["name", "age", "address"],
|
||||||
|
}
|
||||||
|
|
||||||
|
model2 = self.converter.build_with_cache(schema2)
|
||||||
|
|
||||||
|
self.assertIsNot(model1, model2)
|
||||||
|
|
||||||
|
def tests_instance_level_ref_cache_colision_when_same_property_id(self):
|
||||||
|
schema1: JSONSchema = {
|
||||||
|
"$id": "http://example.com/schemas/person.json",
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"emergency_contact": {
|
||||||
|
"$ref": "#",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name", "age"],
|
||||||
|
}
|
||||||
|
|
||||||
|
model1 = self.converter.build_with_cache(schema1)
|
||||||
|
|
||||||
|
schema2: JSONSchema = {
|
||||||
|
"$id": "http://example.com/schemas/person.json",
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"address": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["name", "age", "address"],
|
||||||
|
}
|
||||||
|
|
||||||
|
model2 = self.converter.build_with_cache(schema2)
|
||||||
|
|
||||||
|
self.assertIs(model1, model2)
|
||||||
|
|
||||||
|
def test_namespace_isolation_via_on_call_config(self):
|
||||||
|
namespace = "namespace1"
|
||||||
|
|
||||||
|
schema: JSONSchema = {
|
||||||
|
"$id": namespace,
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"address": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"street": {"type": "string"},
|
||||||
|
"city": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["street", "city"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name", "age", "address"],
|
||||||
|
}
|
||||||
|
|
||||||
|
model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
|
invalid_cached_model = self.converter.get_cached_ref("Person")
|
||||||
|
self.assertIsNone(invalid_cached_model)
|
||||||
|
|
||||||
|
cached_model = self.converter.get_cached_ref("Person", namespace=namespace)
|
||||||
|
self.assertIs(model, cached_model)
|
||||||
|
|
||||||
|
def test_clear_namespace_registry(self):
|
||||||
|
namespace = "namespace_to_clear"
|
||||||
|
|
||||||
|
schema: JSONSchema = {
|
||||||
|
"$id": namespace,
|
||||||
|
"title": "Person",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"age": {"type": "integer"},
|
||||||
|
"address": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"street": {"type": "string"},
|
||||||
|
"city": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["street", "city"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name", "age", "address"],
|
||||||
|
}
|
||||||
|
|
||||||
|
model = self.converter.build_with_cache(schema)
|
||||||
|
|
||||||
|
cached_model = self.converter.get_cached_ref("Person", namespace=namespace)
|
||||||
|
self.assertIs(model, cached_model)
|
||||||
|
|
||||||
|
self.converter.clear_ref_cache(namespace=namespace)
|
||||||
|
|
||||||
|
cleared_cached_model = self.converter.get_cached_ref(
|
||||||
|
"Person", namespace=namespace
|
||||||
|
)
|
||||||
|
self.assertIsNone(cleared_cached_model)
|
||||||
|
|||||||
Reference in New Issue
Block a user