Skip to content

Commit fe7dbe1

Browse files
committed
Consolidate test infrastructure helpers
1 parent 425b974 commit fe7dbe1

6 files changed

Lines changed: 520 additions & 274 deletions

File tree

tests/conftest.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,15 @@ def _format_diff(expected: str, actual: str, expected_path: Path) -> str: # pra
565565
return output.getvalue()
566566

567567

568+
def _infer_expected_file(caller_function_name: str) -> str:
569+
name = caller_function_name
570+
for prefix in ("test_main_", "test_"): # pragma: no branch
571+
if name.startswith(prefix):
572+
name = name[len(prefix) :]
573+
break
574+
return f"{name}.py"
575+
576+
568577
def _assert_with_external_file(content: str, expected_path: Path) -> None:
569578
"""Assert content matches external file, handling line endings."""
570579
__tracebackhide__ = True
@@ -658,14 +667,8 @@ def _assert_file_content(
658667
frame = inspect.currentframe()
659668
assert frame is not None
660669
assert frame.f_back is not None
661-
func_name = frame.f_back.f_code.co_name
670+
expected_name = _infer_expected_file(frame.f_back.f_code.co_name)
662671
del frame
663-
name = func_name
664-
for prefix in ("test_main_", "test_"): # pragma: no branch
665-
if name.startswith(prefix):
666-
name = name[len(prefix) :]
667-
break
668-
expected_name = f"{name}.py"
669672

670673
expected_path = base_path / expected_name
671674
content = output_file.read_text(encoding=encoding)

tests/main/_builtin_parity.py

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
"""Built-in formatter parity helpers for main integration tests."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import re
7+
import shutil
8+
import sys
9+
import warnings
10+
from contextlib import contextmanager
11+
from typing import TYPE_CHECKING, Any
12+
13+
import httpx
14+
import pytest
15+
16+
from datamodel_code_generator import generate
17+
from datamodel_code_generator.__main__ import Exit, main
18+
from datamodel_code_generator.format import Formatter
19+
from tests.conftest import _format_diff, _normalize_line_endings, assert_warnings_contain
20+
21+
if TYPE_CHECKING:
22+
from collections.abc import Generator, Sequence
23+
from pathlib import Path
24+
25+
_BUILTIN_FORMATTER_PARITY_ENV = "DATAMODEL_CODE_GENERATOR_CHECK_BUILTIN_FORMATTER_PARITY"
26+
_DEFAULT_CLI_FORMATTERS = {"black", "isort"}
27+
_DEFAULT_API_FORMATTERS = {Formatter.BLACK, Formatter.ISORT}
28+
29+
30+
@contextmanager
31+
def _preserve_mock_calls(mocked_callables: Sequence[Any]) -> Generator[None, None, None]:
32+
snapshots: list[tuple[Any, Any, list[Any], list[Any], list[Any], int]] = []
33+
for mocked_callable in mocked_callables:
34+
if hasattr(mocked_callable, "mock_calls") and hasattr(mocked_callable, "reset_mock"):
35+
snapshots.append(( # noqa: PERF401
36+
mocked_callable,
37+
mocked_callable.call_args,
38+
list(mocked_callable.call_args_list),
39+
list(mocked_callable.mock_calls),
40+
list(mocked_callable.method_calls),
41+
mocked_callable.call_count,
42+
))
43+
try:
44+
yield
45+
finally:
46+
for mocked_callable, call_args, call_args_list, mock_calls, method_calls, call_count in snapshots:
47+
mocked_callable.reset_mock()
48+
mocked_callable._mock_call_args = call_args
49+
mocked_callable._mock_call_args_list = call_args_list
50+
mocked_callable._mock_mock_calls = mock_calls
51+
mocked_callable._mock_method_calls = method_calls
52+
mocked_callable._mock_call_count = call_count
53+
54+
55+
def _parity_mocked_callables_to_preserve() -> list[Any]:
56+
prance = sys.modules.get("prance")
57+
if prance is None:
58+
return []
59+
return [getattr(prance, "BaseParser", None)]
60+
61+
62+
def _extract_cli_formatters(extra_args: Sequence[str] | None) -> list[str] | None:
63+
if extra_args is None or "--formatters" not in extra_args:
64+
return None
65+
extra_args_list = list(extra_args)
66+
formatter_index = extra_args_list.index("--formatters")
67+
formatters: list[str] = []
68+
for item in extra_args_list[formatter_index + 1 :]:
69+
if item.startswith("-"):
70+
break
71+
formatters.append(item)
72+
return formatters
73+
74+
75+
def _uses_default_cli_formatters(extra_args: Sequence[str] | None) -> bool:
76+
if extra_args is None:
77+
return True
78+
if "--custom-formatters" in extra_args or "--custom-formatters-kwargs" in extra_args:
79+
return False
80+
return (formatters := _extract_cli_formatters(extra_args)) is None or set(formatters) == _DEFAULT_CLI_FORMATTERS
81+
82+
83+
def _uses_check_mode(extra_args: Sequence[str] | None) -> bool:
84+
return extra_args is not None and "--check" in extra_args
85+
86+
87+
def _uses_default_api_formatters(generate_options: dict[str, Any]) -> bool:
88+
if generate_options.get("custom_formatters") or generate_options.get(
89+
"custom_formatters_kwargs"
90+
): # pragma: no cover
91+
return False
92+
return (formatters := generate_options.get("formatters")) is None or set(formatters) == _DEFAULT_API_FORMATTERS
93+
94+
95+
def _builtin_formatter_extra_args(extra_args: Sequence[str] | None) -> list[str]:
96+
if extra_args is None:
97+
return ["--formatters", "builtin"]
98+
extra_args_list = list(extra_args)
99+
if "--formatters" not in extra_args_list:
100+
return [*extra_args_list, "--formatters", "builtin"]
101+
formatter_index = extra_args_list.index("--formatters") # pragma: no cover
102+
end_index = formatter_index + 1 # pragma: no cover
103+
while end_index < len(extra_args_list) and not extra_args_list[end_index].startswith("-"): # pragma: no cover
104+
end_index += 1 # pragma: no cover
105+
return [*extra_args_list[: formatter_index + 1], "builtin", *extra_args_list[end_index:]] # pragma: no cover
106+
107+
108+
def _builtin_formatter_parity_output_path(output_path: Path) -> Path:
109+
if output_path.is_dir():
110+
return output_path.with_name(f"{output_path.name}_builtin_parity")
111+
if output_path.suffix:
112+
return output_path.with_name(f"{output_path.stem}.builtin-parity{output_path.suffix}")
113+
return output_path.with_name(f"{output_path.name}.builtin-parity") # pragma: no cover
114+
115+
116+
def _clear_builtin_formatter_parity_output(output_path: Path) -> None:
117+
if output_path.is_dir():
118+
shutil.rmtree(output_path)
119+
elif output_path.exists():
120+
output_path.unlink()
121+
122+
123+
def _normalize_builtin_parity_content(content: str) -> str:
124+
return re.sub(
125+
r"^# command: datamodel-codegen .*$",
126+
"# command: datamodel-codegen [COMMAND]",
127+
content,
128+
flags=re.MULTILINE,
129+
)
130+
131+
132+
def _assert_same_generated_python(expected_path: Path, actual_path: Path) -> None:
133+
if expected_path.is_file():
134+
expected = _normalize_builtin_parity_content(_normalize_line_endings(expected_path.read_text(encoding="utf-8")))
135+
actual = _normalize_builtin_parity_content(_normalize_line_endings(actual_path.read_text(encoding="utf-8")))
136+
if expected != actual: # pragma: no cover
137+
diff = _format_diff(expected, actual, expected_path)
138+
pytest.fail(f"Built-in formatter output differs from black+isort for {expected_path}\n{diff}")
139+
return
140+
141+
expected_files = {path.relative_to(expected_path) for path in expected_path.rglob("*.py")}
142+
actual_files = {path.relative_to(actual_path) for path in actual_path.rglob("*.py")}
143+
if expected_files != actual_files: # pragma: no cover
144+
pytest.fail(
145+
"Built-in formatter output file set differs from black+isort\n"
146+
f"Missing: {sorted(expected_files - actual_files)}\n"
147+
f"Extra: {sorted(actual_files - expected_files)}"
148+
)
149+
for relative_path in sorted(expected_files):
150+
expected_file = expected_path / relative_path
151+
actual_file = actual_path / relative_path
152+
expected = _normalize_builtin_parity_content(_normalize_line_endings(expected_file.read_text(encoding="utf-8")))
153+
actual = _normalize_builtin_parity_content(_normalize_line_endings(actual_file.read_text(encoding="utf-8")))
154+
if expected != actual: # pragma: no cover
155+
diff = _format_diff(expected, actual, expected_file)
156+
pytest.fail(f"Built-in formatter output differs from black+isort for {relative_path}\n{diff}")
157+
158+
159+
def _assert_builtin_cli_formatter_parity(
160+
*,
161+
input_path: Path | None,
162+
output_path: Path | None,
163+
input_file_type: str | None,
164+
extra_args: Sequence[str] | None,
165+
copy_files: Sequence[tuple[Path, Path]] | None,
166+
stdin_path: Path | None,
167+
monkeypatch: pytest.MonkeyPatch | None,
168+
) -> None:
169+
if os.environ.get(_BUILTIN_FORMATTER_PARITY_ENV) != "1":
170+
return
171+
if (
172+
output_path is None
173+
or not output_path.exists()
174+
or _uses_check_mode(extra_args)
175+
or not _uses_default_cli_formatters(extra_args)
176+
or hasattr(httpx.get, "mock_calls")
177+
):
178+
return
179+
180+
from tests.main.conftest import _assert_exit_code, _copy_files, _extend_args, _run_main
181+
182+
builtin_output_path = _builtin_formatter_parity_output_path(output_path)
183+
_clear_builtin_formatter_parity_output(builtin_output_path)
184+
builtin_extra_args = _builtin_formatter_extra_args(extra_args)
185+
186+
with _preserve_mock_calls(_parity_mocked_callables_to_preserve()):
187+
if stdin_path is not None:
188+
if monkeypatch is None: # pragma: no cover
189+
pytest.fail("monkeypatch is required when using stdin_path")
190+
_copy_files(copy_files)
191+
with stdin_path.open(encoding="utf-8") as stdin:
192+
monkeypatch.setattr("sys.stdin", stdin)
193+
args: list[str] = []
194+
_extend_args(
195+
args,
196+
output_path=builtin_output_path,
197+
input_file_type=input_file_type,
198+
extra_args=builtin_extra_args,
199+
)
200+
return_code = main(args)
201+
else:
202+
if input_path is None: # pragma: no cover
203+
pytest.fail("input_path is required")
204+
return_code = _run_main(
205+
input_path,
206+
builtin_output_path,
207+
input_file_type,
208+
extra_args=builtin_extra_args,
209+
copy_files=copy_files,
210+
)
211+
212+
_assert_exit_code(return_code, Exit.OK, f"Built-in formatter parity input: {input_path}")
213+
_assert_same_generated_python(output_path, builtin_output_path)
214+
_clear_builtin_formatter_parity_output(builtin_output_path)
215+
216+
217+
def _assert_builtin_generate_formatter_parity(
218+
*,
219+
input_: Path,
220+
output_path: Path,
221+
generate_options: dict[str, Any],
222+
expected_warnings: Sequence[str] | None = None,
223+
) -> None:
224+
if os.environ.get(_BUILTIN_FORMATTER_PARITY_ENV) != "1":
225+
return
226+
if not output_path.exists() or not _uses_default_api_formatters(generate_options): # pragma: no cover
227+
return
228+
229+
builtin_output_path = _builtin_formatter_parity_output_path(output_path)
230+
_clear_builtin_formatter_parity_output(builtin_output_path)
231+
builtin_options = {
232+
**generate_options,
233+
"output": builtin_output_path,
234+
"formatters": [Formatter.BUILTIN],
235+
}
236+
if expected_warnings is None:
237+
generate(input_=input_, **builtin_options)
238+
else:
239+
with warnings.catch_warnings(record=True) as warning_records:
240+
warnings.simplefilter("always")
241+
generate(input_=input_, **builtin_options)
242+
assert_warnings_contain(warning_records, *expected_warnings)
243+
_assert_same_generated_python(output_path, builtin_output_path)
244+
_clear_builtin_formatter_parity_output(builtin_output_path)

0 commit comments

Comments
 (0)