|
| 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