Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,4 @@ dmypy.json
/docs/build

.venv/
uv.lock
11 changes: 10 additions & 1 deletion src/adaptix/_internal/morphing/facade/func.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from collections.abc import Container, Iterable, Mapping, Sequence
from contextlib import suppress
from dataclasses import fields
from typing import Any, TypeVar, overload

Expand All @@ -12,7 +13,7 @@
from ..json_schema.ref_generator import BuiltinRefGenerator
from ..json_schema.request_cls import JSONSchemaContext
from ..json_schema.resolver import BuiltinJSONSchemaResolver, JSONSchemaResolver
from ..json_schema.schema_model import JSONObject, _JSONSchemaCore
from ..json_schema.schema_model import JSONObject, JSONSchemaBuiltinFormat, _JSONSchemaCore
from ..load_error import TypeLoadError
from ..provider_template import ABCProxy
from .provider import loader, name_mapping
Expand Down Expand Up @@ -56,6 +57,13 @@ def _ref_loader(data):
raise TypeLoadError(expected_type=str, input_value=data)


def _format_loader(data):
if isinstance(data, str):
with suppress(ValueError):
return JSONSchemaBuiltinFormat(data)
return data

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why custom loader is required here? Why data for unkown types are directly returned?

@Niccolum Niccolum Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Custom loader needed because format: JSONSchemaBuiltinFormat | str is ambiguous for the default union loader (both sides are string-shaped, so plain str would always win and the enum would never be produced) - enum is tried first, falling back to the raw string for custom formats (see test at test_json_schema_facade.py, test test_load_json_schema_format_uses_builtin_enum_or_falls_back_to_str with "format": "date-time"). Good catch on the fallthrough for non-str data - fixed to raise TypeLoadError.



_global_resolver = BuiltinJSONSchemaResolver(
ref_generator=BuiltinRefGenerator(),
ref_mangler=CompoundRefMangler(QualnameRefMangler(), IndexRefMangler()),
Expand All @@ -74,6 +82,7 @@ def _ref_loader(data):
extra_out="extra_keywords",
),
loader(P[JSONSchema].ref, _ref_loader),
loader(P[JSONSchema].format, _format_loader),
],
)

Expand Down
2 changes: 1 addition & 1 deletion src/adaptix/_internal/morphing/json_schema/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def __init__(self) -> None:
self._patchers: list[Patcher] = []

def _append_with_patcher(self: S, patcher: Callable[[JSONSchema], JSONSchema]) -> S:
self_copy = copy(self)
self_copy = deepcopy(self)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why copy patcher objects? They should be immutable

@Niccolum Niccolum Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The actual issue was list aliasing on _patchers after the shallow copy(). It was same for previous and new item. Use list for fix it, not append

self_copy._patchers.append(patcher)
return self_copy

Expand Down
4 changes: 2 additions & 2 deletions src/adaptix/_internal/morphing/json_schema/schema_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
**_base_json_schema_templates,
Omittable[RefT]: dedent( # type: ignore[misc, valid-type]
"""
if __value__ != Omitted():
if __value__ != Omitted() and isinstance(__value__, LocalRefSource):
yield from __traverser__(__value__.json_schema)
""",
),
Expand Down Expand Up @@ -88,7 +88,7 @@ def {function_name}(obj, /):

""",
) + "\n\n".join(indent(item, " " * 4) for item in result)
namespace: dict[str, Any] = {"Omitted": Omitted}
namespace: dict[str, Any] = {"Omitted": Omitted, "LocalRefSource": LocalRefSource}
exec(compile(module_code, file_name, "exec"), namespace, namespace) # noqa: S102
return namespace[function_name]

Expand Down
276 changes: 276 additions & 0 deletions tests/integration/morphing/test_json_schema_facade.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
from dataclasses import dataclass
from typing import Any, Optional

import pytest
from tests_helpers.structure_tools import EXISTS, NOT_EXISTS, assert_structure

from adaptix import Retort
from adaptix._internal.definitions import Direction
from adaptix._internal.morphing.facade.func import (
generate_json_schema,
generate_json_schemas_namespace,
load_json_schema,
)
from adaptix._internal.morphing.json_schema.definitions import JSONSchema
from adaptix._internal.morphing.json_schema.mangling import IndexRefMangler
from adaptix._internal.morphing.json_schema.ref_generator import BuiltinRefGenerator
from adaptix._internal.morphing.json_schema.resolver import BuiltinJSONSchemaResolver
from adaptix._internal.morphing.json_schema.schema_model import (
JSONSchemaBuiltinFormat,
JSONSchemaType,
)
from adaptix.load_error import AggregateLoadError

DIALECT_URI = "https://json-schema.org/draft/2020-12/schema"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This constant exists in main library code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! fixed



@pytest.mark.parametrize(
["raw", "expected"],
[
("null", JSONSchemaType.NULL),
("boolean", JSONSchemaType.BOOLEAN),
("object", JSONSchemaType.OBJECT),
("array", JSONSchemaType.ARRAY),
("number", JSONSchemaType.NUMBER),
("integer", JSONSchemaType.INTEGER),
("string", JSONSchemaType.STRING),
],
)
def test_load_json_schema_type_enum_values(raw: str, expected: JSONSchemaType):
schema = load_json_schema({"type": raw})

assert schema.type == expected


@pytest.mark.parametrize(
["schema_data", "expected"],
[
(
{"type": "string", "title": "MyStr", "description": "A string"},
JSONSchema(type=JSONSchemaType.STRING, title="MyStr", description="A string"),
),
(
{"type": "string", "format": "date-time"},
JSONSchema(type=JSONSchemaType.STRING, format=JSONSchemaBuiltinFormat.DATE_TIME),
),
(
{"type": "string", "format": "my-custom-format"},
JSONSchema(type=JSONSchemaType.STRING, format="my-custom-format"),
),
(
{
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
},
"required": ["name"],
},
JSONSchema(
type=JSONSchemaType.OBJECT,
properties={
"name": JSONSchema(type=JSONSchemaType.STRING),
"age": JSONSchema(type=JSONSchemaType.INTEGER),
},
required=["name"],
),
),
(
{"anyOf": [{"type": "string"}, {"type": "integer"}]},
JSONSchema(any_of=[
JSONSchema(type=JSONSchemaType.STRING),
JSONSchema(type=JSONSchemaType.INTEGER),
]),
),
],
)
def test_load_json_schema_parses_dict_to_object(schema_data, expected):
schema = load_json_schema(schema_data)

assert schema == expected


def test_load_json_schema_strict_unknown_field_raises():
with pytest.raises(AggregateLoadError):
load_json_schema({"type": "string", "x-custom": "value"}, error_on_extra=True)


def test_load_json_schema_lax_unknown_field_goes_to_extra_keywords():
schema = load_json_schema({"type": "string", "x-custom": "value"}, error_on_extra=False)

assert schema.extra_keywords == {"x-custom": "value"}


def test_load_json_schema_empty_dict():
schema = load_json_schema({})

assert schema == JSONSchema()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are these tests for? They check Adaptix's basic model loading functionality

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point - trimmed the tests that only exercised generic Adaptix dataclass/enum loading, since that's already covered by core Adaptix tests.

Kept only what's actually specific to this facade's retort config in func.py: the custom _format_loader and the error_on_extra behavior (ExtraForbid vs extra_out="extra_keywords") - that logic is unique to load_json_schema and isn't tested elsewhere.



@dataclass
class SimpleModel:
name: str
value: int


@dataclass
class ModelWithOptional:
required_field: str
optional_field: Optional[str] = None


@pytest.mark.parametrize(
["with_dialect_uri", "expected_schema"],
[
(True, DIALECT_URI),
(False, NOT_EXISTS),
],
ids=["with_uri", "without_uri"],
)
def test_generate_json_schema_dialect_uri(with_dialect_uri: bool, expected_schema: Any): # noqa: FBT001
schema = generate_json_schema(Retort(), SimpleModel, Direction.INPUT, with_dialect_uri=with_dialect_uri)

assert_structure(schema, {"$schema": expected_schema})


def test_generate_json_schema_top_level_keys():
schema = generate_json_schema(Retort(), SimpleModel, Direction.INPUT)

assert_structure(schema, {
"$schema": EXISTS,
"$ref": EXISTS,
"$defs": {
"SimpleModel": EXISTS,
},
}, strict=True)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why these tests checks only keys?

@Niccolum Niccolum Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched this one to assert_morphing. test_generate_json_schema_dialect_uri just check $schema - remove assert_structure from file



def test_generate_json_schema_custom_ref_prefix():
schema = generate_json_schema(
Retort(), SimpleModel, Direction.INPUT,
local_ref_prefix="#/components/schemas/",
)

assert_structure(schema, {"$ref": "#/components/schemas/SimpleModel"})


def test_generate_json_schema_occupied_refs_triggers_mangling():
schema = generate_json_schema(
Retort(), SimpleModel, Direction.INPUT,
occupied_refs=("SimpleModel",),
resolver=BuiltinJSONSchemaResolver(BuiltinRefGenerator(), IndexRefMangler()),
)

assert_structure(schema, {
"$defs": {
"SimpleModel": NOT_EXISTS,
"SimpleModel-1": EXISTS,
},
})


def test_generate_json_schema_custom_resolver():
schema = generate_json_schema(
Retort(), SimpleModel, Direction.INPUT,
resolver=BuiltinJSONSchemaResolver(BuiltinRefGenerator(), IndexRefMangler()),
)

assert_structure(schema, {"$defs": {"SimpleModel": EXISTS}})


def test_generate_json_schema_optional_field_not_required_on_input():
input_schema = generate_json_schema(Retort(), ModelWithOptional, Direction.INPUT)
output_schema = generate_json_schema(Retort(), ModelWithOptional, Direction.OUTPUT)

# Input schema: only required_field is required (optional_field has default)
assert_structure(input_schema, {
"$defs": {
"ModelWithOptional": {
"required": ["required_field"],
},
},
})

# Output schema: both fields are required (dumping doesn't care about defaults)
assert_structure(output_schema, {
"$defs": {
"ModelWithOptional": {
"required": ["required_field", "optional_field"],
},
},
})


@dataclass
class Tag:
label: str


@dataclass
class Article:
title: str
tag: Tag


@dataclass
class Post:
body: str
tag: Tag


def _namespace_query():
retort = Retort()
return [(retort, Direction.INPUT, Article), (retort, Direction.INPUT, Post)]


@pytest.mark.parametrize(
"resolver",
[
pytest.param(None, id="default"),
pytest.param(
BuiltinJSONSchemaResolver(BuiltinRefGenerator(), IndexRefMangler()),
id="custom_resolver",
),
],
)
def test_generate_schemas_namespace_deduplicates_shared_types(resolver):
query = _namespace_query()
kwargs = {"resolver": resolver} if resolver else {}

defs, schemas = generate_json_schemas_namespace(query, **kwargs)

assert len(schemas) == 2

assert_structure(defs, {
"Article": EXISTS,
"Post": EXISTS,
"Tag": EXISTS,
}, strict=True)


@pytest.mark.parametrize(
"with_dialect_uri",
[
pytest.param(True, id="with_uri"),
pytest.param(False, id="without_uri"),
],
)
def test_generate_schemas_namespace_dialect_uri(with_dialect_uri: bool): # noqa: FBT001
query = _namespace_query()
_, schemas = generate_json_schemas_namespace(query, with_dialect_uri=with_dialect_uri)

expected_schema = DIALECT_URI if with_dialect_uri else NOT_EXISTS
for schema in schemas:
assert_structure(schema, {"$schema": expected_schema, "$ref": EXISTS})


def test_generate_schemas_namespace_custom_ref_prefix():
query = _namespace_query()
_, schemas = generate_json_schemas_namespace(
query, local_ref_prefix="#/components/schemas/",
)

assert_structure(schemas, [
{"$ref": "#/components/schemas/Article"},
{"$ref": "#/components/schemas/Post"},
])
Loading