Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
12 changes: 11 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,14 @@ 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
raise TypeLoadError(expected_type=str, input_value=data)


_global_resolver = BuiltinJSONSchemaResolver(
ref_generator=BuiltinRefGenerator(),
ref_mangler=CompoundRefMangler(QualnameRefMangler(), IndexRefMangler()),
Expand All @@ -74,6 +83,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 @@ -17,7 +17,7 @@ def __init__(self) -> None:

def _append_with_patcher(self: S, patcher: Callable[[JSONSchema], JSONSchema]) -> S:
self_copy = copy(self)
self_copy._patchers.append(patcher)
self_copy._patchers = [*self._patchers, patcher]

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 was this change necessary for?

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.

It fixes a shared-state bug in JSONSchemaPatch. copy(self) is a shallow copy, so self_copy._patchers and self._patchers pointed to the same list. The old .append(patcher) therefore also added the patcher to the original object, and to every other copy made from it.

Example of the bug:

  base = JSONSchemaPatch()
  a = base.mutate_copy(...)   # base._patchers now contains a's patcher too
  b = base.mutate_copy(...)   # b gets a's patcher as well

The patch methods are meant to be immutable and return a new object. With the old code, using a patch as a base for several variants produced wrong schemas. Building a new list for the copy fixes this, and the original is never mutated.

return self_copy

def mutate_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
187 changes: 187 additions & 0 deletions tests/integration/morphing/test_json_schema_facade.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
from dataclasses import dataclass

import pytest
from tests_helpers import raises_exc
from tests_helpers.morphing import JSONSchemaOptItem, assert_morphing

from adaptix import Retort, name_mapping
from adaptix._internal.definitions import Direction
from adaptix._internal.morphing.facade.func import (
DIALECT_2020_12,
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, RefGenerator
from adaptix._internal.morphing.json_schema.schema_model import JSONSchemaBuiltinFormat
from adaptix.load_error import AggregateLoadError, ExtraFieldsLoadError


@pytest.mark.parametrize(
["raw", "expected"],
[
("date-time", JSONSchemaBuiltinFormat.DATE_TIME),
("my-custom-format", "my-custom-format"),
],
)
def test_load_json_schema_format_uses_builtin_enum_or_falls_back_to_str(raw, expected):
schema = load_json_schema({"type": "string", "format": raw})

assert schema.format == expected


def test_load_json_schema_strict_unknown_field_raises():
data = {"type": "string", "x-custom": "value"}

raises_exc(
AggregateLoadError(
f"while loading model {JSONSchema}",
[ExtraFieldsLoadError({"x-custom"}, data)],
),
lambda: load_json_schema(data, 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"}


@dataclass
class SimpleModel:
name: str
value: int


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

assert ("$schema" in schema) == with_dialect_uri


def test_generate_json_schema_matches_full_expected_shape():
assert_morphing(
retort=Retort(),
tp=SimpleModel,
data={"name": "foo", "value": 1},
loaded=SimpleModel(name="foo", value=1),
json_schema={
"$ref": "#/$defs/SimpleModel",
"$schema": DIALECT_2020_12,
"$defs": {
"SimpleModel": {
"title": "SimpleModel",
"type": "object",
"required": ["name", "value"],
"properties": {
"name": {"type": "string"},
"value": {"type": "integer"},
},
"additionalProperties": JSONSchemaOptItem(input=True),
},
},
},
)


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

assert schema["$ref"] == "#/components/schemas/SimpleModel"


class _PrefixRefGenerator(RefGenerator):
def generate_ref(self, json_schema, loc_stack):
return "Custom" + BuiltinRefGenerator().generate_ref(json_schema, loc_stack)


@pytest.mark.parametrize(
["resolver", "occupied_refs", "expected_ref"],
[
pytest.param(
BuiltinJSONSchemaResolver(BuiltinRefGenerator(), IndexRefMangler()),
("SimpleModel",),
"SimpleModel-1",
id="mangling_of_occupied_ref",
),
pytest.param(
BuiltinJSONSchemaResolver(_PrefixRefGenerator(), IndexRefMangler()),
(),
"CustomSimpleModel",
id="custom_ref_generator",
),
],
)
def test_generate_json_schema_custom_resolver(resolver, occupied_refs, expected_ref):
schema = generate_json_schema(
Retort(), SimpleModel, Direction.INPUT,
resolver=resolver,
occupied_refs=occupied_refs,
)

assert schema["$ref"] == f"#/$defs/{expected_ref}"


@dataclass
class ListItem:
x: int
y: int


@dataclass
class ListModel:
a: ListItem
b: ListItem


def test_generate_json_schema_prefix_items_dedupes_shared_ref():
retort = Retort(recipe=[name_mapping(ListModel, as_list=True)])

schema = generate_json_schema(retort, ListModel, Direction.INPUT)

assert schema["$defs"]["ListModel"]["prefixItems"] == [
{"$ref": "#/$defs/ListItem"},
{"$ref": "#/$defs/ListItem"},
]
assert "ListItem" in schema["$defs"]


@dataclass
class Tag:
label: str


@dataclass
class Article:
title: str
tag: Tag


@dataclass
class Post:
body: str
tag: Tag


def test_generate_schemas_namespace_deduplicates_shared_types():
retort = Retort()

defs, schemas = generate_json_schemas_namespace([
(retort, Direction.INPUT, Article),
(retort, Direction.INPUT, Post),
])

assert len(schemas) == 2
assert defs.keys() == {"Article", "Post", "Tag"}
Loading
Loading