Skip to content

Commit c22cf41

Browse files
committed
Guard mutable generation inputs
1 parent bcd0b3c commit c22cf41

7 files changed

Lines changed: 208 additions & 24 deletions

File tree

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2153,6 +2153,13 @@ def _deep_merge(self, dict1: dict[Any, Any], dict2: dict[Any, Any]) -> dict[Any,
21532153
result[key] = value
21542154
return result
21552155

2156+
def _without_self_metadata(self, raw: dict[str, Any]) -> dict[str, Any]: # noqa: PLR6301
2157+
if "self" not in raw:
2158+
return raw
2159+
raw = raw.copy()
2160+
raw.pop("self", None)
2161+
return raw
2162+
21562163
def _load_ref_schema_object(self, ref: str) -> JsonSchemaObject:
21572164
"""Load a JsonSchemaObject from a $ref using standard resolve/load pipeline."""
21582165
resolved_ref = self.model_resolver.resolve_ref(ref)
@@ -2163,6 +2170,8 @@ def _load_ref_schema_object(self, ref: str) -> JsonSchemaObject:
21632170
if fragment:
21642171
pointer = split_json_pointer(raw_doc, fragment)
21652172
target_schema = get_model_by_path(raw_doc, pointer)
2173+
if isinstance(target_schema, dict):
2174+
target_schema = self._without_self_metadata(target_schema)
21662175

21672176
return self._validate_schema_object(target_schema, [resolved_ref])
21682177

@@ -5458,9 +5467,10 @@ def _parse_file( # noqa: PLR0912, PLR0913, PLR0914, PLR0915
54585467
class_name=True,
54595468
preserve_class_name=preserve_root_class_name,
54605469
).name
5470+
# Some jsonschema docs include attribute self to have include version details.
5471+
# Keep parser-owned and cached raw data unchanged for later reuse.
5472+
raw = self._without_self_metadata(raw)
54615473
with self.root_id_context(raw):
5462-
# Some jsonschema docs include attribute self to have include version details
5463-
raw.pop("self", None)
54645474
# parse $id before parsing $ref
54655475
root_obj = self._validate_schema_object(raw, path_parts or ["#"])
54665476
self.parse_id(root_obj, [*path_parts, "#"] if path_parts else ["#"])

tests/conftest.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
import sys
1313
import time
1414
from collections import Counter
15-
from collections.abc import Callable, Mapping, Sequence
15+
from collections.abc import Callable, Generator, Mapping, Sequence
16+
from contextlib import contextmanager
17+
from copy import deepcopy
1618
from dataclasses import dataclass
1719
from datetime import datetime, timezone
1820
from itertools import starmap
@@ -702,6 +704,28 @@ def assert_output(
702704
_assert_with_external_file(output, expected_path)
703705

704706

707+
def _tracks_mutation(value: object) -> bool:
708+
match value:
709+
case dict() | list():
710+
return True
711+
return False
712+
713+
714+
@contextmanager
715+
def assert_inputs_not_mutated(inputs: Mapping[str, object] | None) -> Generator[None, None, None]:
716+
"""Assert guarded mutable inputs are unchanged after the wrapped operation."""
717+
__tracebackhide__ = True
718+
if inputs is None:
719+
yield
720+
return
721+
722+
snapshots = {label: deepcopy(value) for label, value in inputs.items() if _tracks_mutation(value)}
723+
yield
724+
for label, before in snapshots.items():
725+
if (current := inputs[label]) != before: # pragma: no cover - exercised by helper tests
726+
pytest.fail(f"{label} was mutated: before={before!r}, after={current!r}")
727+
728+
705729
def assert_directory_content(
706730
output_dir: Path,
707731
expected_dir: Path,

tests/main/conftest.py

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import time
1212
import warnings
1313
from argparse import Namespace
14-
from collections.abc import Callable, Generator, Sequence
14+
from collections.abc import Callable, Generator, Mapping, Sequence
1515
from contextlib import contextmanager
1616
from functools import cache
1717
from pathlib import Path
@@ -31,6 +31,7 @@
3131
_infer_expected_file,
3232
_validation_stats,
3333
assert_directory_content,
34+
assert_inputs_not_mutated,
3435
assert_output,
3536
assert_warnings_contain,
3637
freeze_time,
@@ -611,6 +612,7 @@ def run_generate_file_and_assert(
611612
expected_file: str | Path | None = None,
612613
transform: Callable[[str], str] | None = None,
613614
expected_warnings: Sequence[str] | None = None,
615+
unchanged_inputs: Mapping[str, object] | None = None,
614616
**generate_kwargs: Any,
615617
) -> None:
616618
"""Execute generate() for a file input and assert the generated output."""
@@ -632,19 +634,20 @@ def run_generate_file_and_assert(
632634
if input_file_type is not None:
633635
generate_options["input_file_type"] = input_file_type
634636

635-
if expected_warnings is None:
636-
generate(
637-
input_=input_,
638-
**generate_options,
639-
)
640-
else:
641-
with warnings.catch_warnings(record=True) as warning_records:
642-
warnings.simplefilter("always")
637+
with assert_inputs_not_mutated(unchanged_inputs):
638+
if expected_warnings is None:
643639
generate(
644640
input_=input_,
645641
**generate_options,
646642
)
647-
assert_warnings_contain(warning_records, *expected_warnings)
643+
else:
644+
with warnings.catch_warnings(record=True) as warning_records:
645+
warnings.simplefilter("always")
646+
generate(
647+
input_=input_,
648+
**generate_options,
649+
)
650+
assert_warnings_contain(warning_records, *expected_warnings)
648651

649652
if expected_file is None:
650653
frame = inspect.currentframe()
@@ -654,24 +657,32 @@ def run_generate_file_and_assert(
654657
del frame
655658

656659
assert_func(output_path, expected_file, transform=transform)
657-
_assert_builtin_generate_formatter_parity(
658-
input_=input_,
659-
output_path=output_path,
660-
generate_options=generate_options,
661-
expected_warnings=expected_warnings,
662-
)
660+
with assert_inputs_not_mutated(unchanged_inputs):
661+
_assert_builtin_generate_formatter_parity(
662+
input_=input_,
663+
output_path=output_path,
664+
generate_options=generate_options,
665+
expected_warnings=expected_warnings,
666+
)
663667

664668

665669
def run_generate_and_assert(
666670
*,
667671
input_: Any,
668672
expected_file: Path,
673+
assert_input_unchanged: bool = False,
674+
unchanged_inputs: Mapping[str, object] | None = None,
669675
**generate_kwargs: Any,
670676
) -> None:
671677
"""Execute generate(output=None) and assert the returned text output."""
672678
__tracebackhide__ = True
673679

674-
result = generate(input_=input_, **_default_formatter_generate_options(generate_kwargs))
680+
guarded_inputs = dict(unchanged_inputs or {})
681+
if assert_input_unchanged:
682+
guarded_inputs["input_"] = input_
683+
684+
with assert_inputs_not_mutated(guarded_inputs or None):
685+
result = generate(input_=input_, **_default_formatter_generate_options(generate_kwargs))
675686
if not isinstance(result, str): # pragma: no cover
676687
pytest.fail(f"Expected generate() to return str, got {type(result).__name__}")
677688
assert_output(result, expected_file)

tests/main/jsonschema/test_main_jsonschema.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
TargetPydanticVersion,
2424
chdir,
2525
generate,
26+
load_data_from_path,
2627
)
2728
from datamodel_code_generator.__main__ import Exit
2829
from datamodel_code_generator.format import is_supported_in_black
@@ -2247,6 +2248,57 @@ def test_main_generate_without_input_file_type(output_file: Path) -> None:
22472248
)
22482249

22492250

2251+
def test_generate_keeps_cached_yaml_ref_original_unchanged(tmp_path: Path) -> None:
2252+
"""Keep cached parsed YAML references unchanged while resolving JSON Schema refs."""
2253+
schema_path = tmp_path / "schema.yaml"
2254+
child_path = tmp_path / "child.yaml"
2255+
output_path = tmp_path / "output.py"
2256+
schema_path.write_text(
2257+
"""
2258+
title: Root
2259+
type: object
2260+
properties:
2261+
child:
2262+
$ref: child.yaml#
2263+
""".lstrip(),
2264+
encoding="utf-8",
2265+
)
2266+
child_path.write_text(
2267+
"""
2268+
self:
2269+
version: metadata
2270+
title: Child
2271+
type: object
2272+
properties:
2273+
name:
2274+
type: string
2275+
""".lstrip(),
2276+
encoding="utf-8",
2277+
)
2278+
cached_child_schema = load_data_from_path(child_path.resolve(), "utf-8")
2279+
2280+
def assert_ref_output(
2281+
output_file: Path,
2282+
expected_file: str | Path | None = None, # noqa: ARG001
2283+
transform: object = None,
2284+
) -> None:
2285+
output = output_file.read_text(encoding="utf-8")
2286+
if callable(transform):
2287+
output = transform(output)
2288+
assert "class Child(BaseModel):" in output
2289+
assert "class Root(BaseModel):" in output
2290+
2291+
run_generate_file_and_assert(
2292+
input_path=schema_path,
2293+
output_path=output_path,
2294+
input_file_type=InputFileType.JsonSchema,
2295+
assert_func=assert_ref_output,
2296+
expected_file="unused.py",
2297+
disable_timestamp=True,
2298+
unchanged_inputs={"cached child.yaml": cached_child_schema},
2299+
)
2300+
2301+
22502302
def test_main_generate_relative_input_path(output_file: Path) -> None:
22512303
"""Test helper with a relative input path."""
22522304
run_generate_file_and_assert(

tests/main/protobuf/test_main_protobuf.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
EXPECTED_PROTOBUF_PATH,
1515
PROTOBUF_DATA_PATH,
1616
assert_input_file_type,
17+
run_generate_and_assert,
1718
run_generate_file_and_assert,
1819
run_main_and_assert,
1920
)
@@ -163,14 +164,14 @@ def test_generate_api_protobuf_definition_key_collision() -> None:
163164

164165
def test_generate_api_protobuf_from_path_list() -> None:
165166
"""Generate Protocol Buffers models from a list of .proto file paths."""
166-
result = generate(
167-
cast(Any, [(PROTOBUF_DATA_PATH / "spec_proto3.proto").resolve()]), # noqa: TC006
167+
run_generate_and_assert(
168+
input_=cast(Any, [(PROTOBUF_DATA_PATH / "spec_proto3.proto").resolve()]), # noqa: TC006
168169
input_file_type=InputFileType.Protobuf,
169170
disable_timestamp=True,
171+
expected_file=EXPECTED_PROTOBUF_PATH / "spec_proto3_list_input.py",
172+
assert_input_unchanged=True,
170173
)
171174

172-
assert_output(result, EXPECTED_PROTOBUF_PATH / "spec_proto3_list_input.py")
173-
174175

175176
def test_generate_api_protobuf_rejects_dict_input() -> None:
176177
"""Reject mapping input because Protocol Buffers requires .proto text."""

tests/main/test_main_general.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2711,6 +2711,7 @@ def test_generate_with_dict_jsonschema() -> None:
27112711
input_file_type=InputFileType.JsonSchema,
27122712
disable_timestamp=True,
27132713
expected_file=EXPECTED_MAIN_PATH / "dict_input" / "jsonschema.py",
2714+
assert_input_unchanged=True,
27142715
)
27152716

27162717

@@ -2723,6 +2724,7 @@ def test_generate_with_dict_openapi() -> None:
27232724
input_file_type=InputFileType.OpenAPI,
27242725
disable_timestamp=True,
27252726
expected_file=EXPECTED_MAIN_PATH / "dict_input" / "openapi.py",
2727+
assert_input_unchanged=True,
27262728
)
27272729

27282730

tests/test_conftest_helpers.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from tests.conftest import (
1616
_infer_expected_file,
1717
assert_exact_directory_content,
18+
assert_inputs_not_mutated,
1819
assert_parser_modules,
1920
assert_parser_results,
2021
)
@@ -94,6 +95,89 @@ def test_assert_parser_modules_rejects_unexpected_module(tmp_path: Path) -> None
9495
assert_parser_modules({("sample.py",): "value = 1\n"}, expected_dir)
9596

9697

98+
def test_assert_inputs_not_mutated_allows_unchanged_nested_values() -> None:
99+
"""Mutation guard accepts unchanged dict/list inputs and ignores immutable labels."""
100+
schema = {"properties": {"name": {"type": "string"}}, "required": ["name"]}
101+
102+
with assert_inputs_not_mutated({"schema": schema, "description": "ignored"}):
103+
assert schema["properties"]["name"]["type"] == "string"
104+
105+
106+
def test_assert_inputs_not_mutated_reports_nested_mutation() -> None:
107+
"""Mutation guard reports the label for nested dict/list mutations."""
108+
schema = {"properties": {"name": {"type": "string"}}, "required": ["name"]}
109+
110+
with (
111+
pytest.raises(pytest.fail.Exception, match="schema was mutated"),
112+
assert_inputs_not_mutated({"schema": schema}),
113+
):
114+
schema["required"].append("age")
115+
116+
117+
def test_run_generate_and_assert_detects_mutated_input(
118+
monkeypatch: pytest.MonkeyPatch,
119+
tmp_path: Path,
120+
) -> None:
121+
"""run_generate_and_assert can guard caller-provided dict/list inputs."""
122+
schema = {"properties": {"name": {"type": "string"}}, "required": ["name"]}
123+
124+
def fake_generate(*, input_: object, **_: object) -> str:
125+
assert isinstance(input_, dict)
126+
input_["required"].append("age")
127+
return "value = 1\n"
128+
129+
def assert_output_noop(output: object, expected_file: Path) -> None:
130+
assert output == "value = 1\n"
131+
assert expected_file == tmp_path / "expected.py"
132+
133+
monkeypatch.setattr(main_conftest, "generate", fake_generate)
134+
monkeypatch.setattr(main_conftest, "assert_output", assert_output_noop)
135+
136+
with pytest.raises(pytest.fail.Exception, match="input_ was mutated"):
137+
main_conftest.run_generate_and_assert(
138+
input_=schema,
139+
expected_file=tmp_path / "expected.py",
140+
assert_input_unchanged=True,
141+
)
142+
143+
144+
def test_run_generate_file_and_assert_detects_mutated_unchanged_input(
145+
monkeypatch: pytest.MonkeyPatch,
146+
tmp_path: Path,
147+
) -> None:
148+
"""run_generate_file_and_assert can guard cached parsed original objects."""
149+
cached_schema = {"properties": {"name": {"type": "string"}}, "required": ["name"]}
150+
input_path = tmp_path / "schema.json"
151+
output_path = tmp_path / "output.py"
152+
153+
def fake_generate(*, input_: object, **options: object) -> None:
154+
assert input_ == input_path
155+
cached_schema["required"].append("age")
156+
output = options["output"]
157+
assert isinstance(output, Path)
158+
output.write_text("value = 1\n", encoding="utf-8")
159+
160+
def assert_file_noop(
161+
output_file: Path,
162+
expected_file: str | Path | None = None,
163+
transform: object = None,
164+
) -> None:
165+
assert output_file == output_path
166+
assert expected_file == "unused.py"
167+
assert transform is None
168+
169+
monkeypatch.setattr(main_conftest, "generate", fake_generate)
170+
171+
with pytest.raises(pytest.fail.Exception, match="cached schema was mutated"):
172+
main_conftest.run_generate_file_and_assert(
173+
input_path=input_path,
174+
output_path=output_path,
175+
assert_func=assert_file_noop,
176+
expected_file="unused.py",
177+
unchanged_inputs={"cached schema": cached_schema},
178+
)
179+
180+
97181
def test_builtin_parity_mock_call_preservation(mocker: MockerFixture) -> None:
98182
"""Mock call history is restored after parity-only calls."""
99183
mocked_callable = mocker.Mock()

0 commit comments

Comments
 (0)