Skip to content

Commit 2fa2097

Browse files
authored
Consolidate test infrastructure helpers (#3316)
1 parent 2f63e1b commit 2fa2097

6 files changed

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

0 commit comments

Comments
 (0)