15 Commits

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

Fixes https://github.com/HideyoshiNakazone/jambo/issues/77
2026-01-14 13:14:48 -05:00
6440c30a91 Merge pull request #76 from HideyoshiNakazone/chore/adds-python3.14-metadata
chore: adds python3.14 metadata
2025-12-08 19:07:19 -03:00
19d1f72951 fix: minor fix in internal typing 2025-12-08 19:04:17 -03:00
02a28c9586 chore: updates uv version in ci 2025-12-08 18:56:55 -03:00
eee32a02ae chore: adds python3.14 to metadata 2025-12-08 18:56:01 -03:00
00802744dd Merge pull request #74 from HideyoshiNakazone/chore/run-tests-on-python3.14
chore: updates project to be python3.14 compatible
2025-12-06 17:09:21 -03:00
dd2e7d221c chore: updates uv version in github action file 2025-12-06 17:05:42 -03:00
558abf5d40 chore: updates project to be python3.14 compatible 2025-12-06 17:01:24 -03:00
70afa80ccf Merge pull request #73 from JCHacking/string-duration
feat: duration string parser
2025-12-06 16:53:27 -03:00
422cc2efe0 feat: captures any str validation exception and converts it into ValidationError
converts any exception thrown in the str parser example validation into ValidationError so that the user knows that this is a error in the schema and not a parsing validation exception
2025-12-05 20:15:08 -03:00
JCHacking
dd31f62ef2 feat: duration string parser 2025-12-01 17:38:31 +01:00
e8bda6bc07 Merge pull request #71 from HideyoshiNakazone/fix/fixes-annotation-definition-anyof
fix: fixes annotation definition in anyof parser
2025-11-28 18:30:38 -03:00
d8fe98639a fix: fixes annotation definition in anyof parser 2025-11-28 18:28:49 -03:00
14 changed files with 801 additions and 578 deletions

View File

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

View File

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

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.14

View File

@@ -42,8 +42,12 @@ class AnyOfTypeParser(GenericTypeParser):
# By defining the type as Union of Annotated type we can use the Field validator
# to enforce the constraints of each union type when needed.
# We use Annotated to attach the Field validators to the type.
field_types = [
Annotated[t, Field(**v)] if v is not None else t for t, v in sub_types
]
field_types = []
for subType, subProp in sub_types:
default_value = subProp.pop("default", None)
if default_value is None:
default_value = ...
field_types.append(Annotated[subType, Field(default_value, **subProp)])
return Union[(*field_types,)], mapped_properties

View File

@@ -36,6 +36,8 @@ class EnumTypeParser(GenericTypeParser):
# Create a new Enum type dynamically
enum_type = Enum(name, {str(value).upper(): value for value in enum_values}) # type: ignore
enum_type.__doc__ = properties.get("description")
parsed_properties = self.mappings_properties_builder(properties, **kwargs)
if "default" in parsed_properties and parsed_properties["default"] is not None:

View File

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

View File

@@ -2,8 +2,8 @@ from jambo.exceptions import InvalidSchemaException
from jambo.parser._type_parser import GenericTypeParser
from jambo.types.type_parser_options import TypeParserOptions
from pydantic import AnyUrl, EmailStr
from typing_extensions import Any, Unpack
from pydantic import AnyUrl, EmailStr, TypeAdapter, ValidationError
from typing_extensions import Unpack
from datetime import date, datetime, time, timedelta
from ipaddress import IPv4Address, IPv6Address
@@ -62,37 +62,19 @@ class StringTypeParser(GenericTypeParser):
if format_type in self.format_pattern_mapping:
mapped_properties["pattern"] = self.format_pattern_mapping[format_type]
try:
if "examples" in mapped_properties:
mapped_properties["examples"] = [
self._parse_example(example, format_type, mapped_type)
TypeAdapter(mapped_type).validate_python(example)
for example in mapped_properties["examples"]
]
except ValidationError as err:
raise InvalidSchemaException(
f"Invalid example type for field {name}."
) from err
if "json_schema_extra" not in mapped_properties:
mapped_properties["json_schema_extra"] = {}
mapped_properties["json_schema_extra"]["format"] = format_type
return mapped_type, mapped_properties
def _parse_example(
self, example: Any, format_type: str, mapped_type: type[Any]
) -> Any:
"""
Parse example from JSON Schema format to python format
:param example: Example Value
:param format_type: Format Type
:param mapped_type: Type to parse
:return: Example parsed
"""
match format_type:
case "date" | "time" | "date-time":
return mapped_type.fromisoformat(example)
case "duration":
# TODO: Implement duration parser
raise NotImplementedError
case "ipv4" | "ipv6":
return mapped_type(example)
case "uuid":
return mapped_type(example)
case _:
return example

View File

@@ -5,7 +5,7 @@ from jambo.types import JSONSchema, RefCacheDict
from jsonschema.exceptions import SchemaError
from jsonschema.validators import validator_for
from pydantic import BaseModel
from typing_extensions import Optional
from typing_extensions import MutableMapping, Optional
class SchemaConverter:
@@ -17,8 +17,10 @@ class SchemaConverter:
fields and types. The generated model can be used for data validation and serialization.
"""
_namespace_registry: MutableMapping[str, RefCacheDict]
def __init__(
self, namespace_registry: Optional[dict[str, RefCacheDict]] = None
self, namespace_registry: Optional[MutableMapping[str, RefCacheDict]] = None
) -> None:
if namespace_registry is None:
namespace_registry = dict()
@@ -87,6 +89,7 @@ class SchemaConverter:
schema["title"],
schema.get("properties", {}),
schema.get("required", []),
description=schema.get("description"),
context=schema,
ref_cache=ref_cache,
required=True,
@@ -129,9 +132,7 @@ class SchemaConverter:
: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)
cached_type = self._namespace_registry.get(namespace, {}).get(ref_name)
if isinstance(cached_type, type):
return cached_type

View File

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

View File

@@ -49,6 +49,20 @@ class TestEnumTypeParser(TestCase):
)
self.assertEqual(parsed_properties, {"default": None})
def test_enum_type_parser_creates_enum_with_description(self):
parser = EnumTypeParser()
schema = {
"description": "an enum",
"enum": ["value1"],
}
parsed_type, parsed_properties = parser.from_properties_impl(
"TestEnum",
schema,
)
self.assertEqual(parsed_type.__doc__, "an enum")
def test_enum_type_parser_creates_enum_with_default(self):
parser = EnumTypeParser()

View File

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

View File

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

View File

@@ -274,6 +274,7 @@ class TestSchemaConverter(TestCase):
}
model = self.converter.build_with_cache(schema)
self.assertEqual(model.__doc__, "A person")
obj = model(address={"street": "123 Main St", "city": "Springfield"})
@@ -1166,5 +1167,7 @@ class TestSchemaConverter(TestCase):
self.converter.clear_ref_cache(namespace=namespace)
cleared_cached_model = self.converter.get_cached_ref("Person", namespace=namespace)
cleared_cached_model = self.converter.get_cached_ref(
"Person", namespace=namespace
)
self.assertIsNone(cleared_cached_model)

1254
uv.lock generated

File diff suppressed because it is too large Load Diff