diff --git a/pyproject.toml b/pyproject.toml index f1ab92987..956135256 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -285,6 +285,7 @@ markers = [ "perf: marks tests as performance tests (excluded from CI benchmarks)", "benchmark: marks tests as benchmark tests", "allow_direct_assert: marks guarded tests where direct assert statements are intentionally allowed", + "isolate_builtin_formatter_config: marks tests that must not inherit repository formatter config", ] [tool.coverage] diff --git a/src/datamodel_code_generator/_builtin_formatter.py b/src/datamodel_code_generator/_builtin_formatter.py index 8aa7184d4..88ceb1cb9 100644 --- a/src/datamodel_code_generator/_builtin_formatter.py +++ b/src/datamodel_code_generator/_builtin_formatter.py @@ -45,6 +45,8 @@ class _ImportCategory(IntEnum): LONG_TARGET_PREFIX_LENGTH = 30 TYPE_ALIAS_INLINE_ARGUMENT_COUNT = 2 STRING_PREFIX_PATTERN = re.compile(r"(?i)^([rubf]*)(\"\"\"|'''|\"|')") +PEP695_TYPE_ALIAS_START_PATTERN = re.compile(r"^(?P\s*)type\s+(?P[A-Za-z_]\w*(?:\[.*?\])?)\s*=") +PEP695_TYPE_ALIAS_PLACEHOLDER = "__datamodel_codegen_builtin_type_alias__" def _is_valid_builtin_line_length(line_length: Any) -> TypeGuard[int]: @@ -183,7 +185,76 @@ def _import_category(module: str, level: int, known_first_party: frozenset[str]) def _has_inline_comment(lines: list[str], node: ast.AST) -> bool: lineno = getattr(node, "lineno", 1) end_lineno = getattr(node, "end_lineno", None) or lineno - return any("#" in line for line in lines[lineno - 1 : end_lineno]) + return any(_has_comment_token(line) for line in lines[lineno - 1 : end_lineno]) + + +def _has_comment_token(line: str) -> bool: + try: + tokens = tokenize.generate_tokens(StringIO(line).readline) + return any(token.type == tokenize.COMMENT for token in tokens) + except tokenize.TokenError: + return "#" in line + + +def _needs_pep695_type_alias_placeholders(python_version: PythonVersion | None) -> bool: + return ( + python_version is not None + and python_version.version_key >= (3, 12) + and (3, 10) <= sys.version_info[:2] < (3, 12) + ) + + +def _pep695_type_alias_statement_end(lines: list[str], start_index: int) -> int: + bracket_depth = 0 + try: + tokens = tokenize.generate_tokens(StringIO("\n".join(lines[start_index:])).readline) + for token in tokens: + if token.type == tokenize.OP: + if token.string in {"(", "[", "{"}: + bracket_depth += 1 + elif token.string in {")", "]", "}"}: + bracket_depth -= 1 + if token.type == tokenize.NEWLINE and bracket_depth <= 0: + return start_index + token.end[0] - 1 + except tokenize.TokenError: + return start_index + return start_index + + +def _replace_pep695_type_aliases_with_placeholders(code: str) -> str: + lines = code.splitlines() + placeholder_lines = list(lines) + line_index = 0 + while line_index < len(lines): + line = lines[line_index] + if not PEP695_TYPE_ALIAS_START_PATTERN.match(line): + line_index += 1 + continue + + indent = _line_indent(line) + end_index = _pep695_type_alias_statement_end(lines, line_index) + placeholder_lines[line_index] = f"{indent}{PEP695_TYPE_ALIAS_PLACEHOLDER} = None" + for continuation_index in range(line_index + 1, end_index + 1): + placeholder_lines[continuation_index] = "" + line_index = end_index + 1 + return "\n".join(placeholder_lines) + + +def _parse_builtin_code(code: str, python_version: PythonVersion | None) -> ast.Module | None: + feature_version = python_version.version_key if python_version is not None else None + try: + return ast.parse(code, feature_version=feature_version) + except SyntaxError: + if not _needs_pep695_type_alias_placeholders(python_version): + return None + + placeholder_code = _replace_pep695_type_aliases_with_placeholders(code) + if placeholder_code == code: + return None + try: + return ast.parse(placeholder_code, feature_version=feature_version) + except SyntaxError: + return None def _format_import_node_without_reordering( @@ -1031,7 +1102,7 @@ def _format_generated_class_statement( for keyword in config_dict[1].keywords ) ) - if (len(line) <= line_length and not config_dict_needs_formatting) or "#" in line: + if (len(line) <= line_length and not config_dict_needs_formatting) or _has_comment_token(line): return None if isinstance(statement, ast.FunctionDef): @@ -1289,7 +1360,7 @@ def _format_generated_class_definition( line_length: int, source: str, ) -> str | None: - if len(line) <= line_length or "#" in line: + if len(line) <= line_length or _has_comment_token(line): return None indent = _line_indent(line) @@ -1321,7 +1392,7 @@ def _format_generated_class_definition( def _format_generated_module_statement( # noqa: PLR0911 statement: ast.stmt, line: str, line_length: int, source: str ) -> str | None: - if "#" in line: + if _has_comment_token(line): return None if ( isinstance(statement, ast.AnnAssign) @@ -1386,6 +1457,43 @@ def _format_type_checking_block( _LineReplacement = tuple[int, int, list[str]] +def _pep695_type_alias_replacement(lines: list[str], start_index: int, line_length: int) -> _LineReplacement | None: + line = lines[start_index] + if (match := PEP695_TYPE_ALIAS_START_PATTERN.match(line)) is None: + return None + + end_index = _pep695_type_alias_statement_end(lines, start_index) + statement_lines = lines[start_index : end_index + 1] + if any(_has_comment_token(statement_line) for statement_line in statement_lines): + return None + + rhs_first_line = line[match.end() :].lstrip() + rhs_source = "\n".join([rhs_first_line, *statement_lines[1:]]) + try: + value = ast.parse(rhs_source, mode="eval").body + except SyntaxError: + return None + if not _is_annotated(value) or len(line) <= line_length: + return None + + indent = match.group("indent") + target = match.group("target") + formatted_value = _format_annotated(value, indent, line_length, rhs_source) + return start_index + 1, end_index + 1, f"{indent}type {target} = {formatted_value}".splitlines() + + +def _collect_pep695_type_alias_replacements(lines: list[str], line_length: int) -> list[_LineReplacement]: + replacements: list[_LineReplacement] = [] + line_index = 0 + while line_index < len(lines): + if (replacement := _pep695_type_alias_replacement(lines, line_index, line_length)) is None: + line_index += 1 + continue + replacements.append(replacement) + line_index = replacement[1] + return replacements + + def _collect_builtin_replacements( # noqa: PLR0912, PLR0913 tree: ast.Module, lines: list[str], @@ -1615,9 +1723,8 @@ def apply_builtin_formatter( # noqa: PLR0913 if not code: return "" - try: - tree = ast.parse(code, feature_version=python_version.version_key if python_version is not None else None) - except SyntaxError: + tree = _parse_builtin_code(code, python_version) + if tree is None: return f"{code}\n" replacements = _collect_builtin_replacements( @@ -1628,6 +1735,8 @@ def apply_builtin_formatter( # noqa: PLR0913 known_first_party, wrap_string_literal=wrap_string_literal, ) + if _needs_pep695_type_alias_placeholders(python_version): + replacements.extend(_collect_pep695_type_alias_replacements(lines, line_length)) import_nodes = _iter_module_import_nodes(tree) if not import_nodes: diff --git a/tests/conftest.py b/tests/conftest.py index aa05f7d9a..b4653413d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ import importlib import inspect import json +import os import re import socket import sys @@ -34,6 +35,8 @@ CLI_DOC_COLLECTION_OUTPUT = Path(__file__).parent / "cli_doc" / ".cli_doc_collection.json" CLI_DOC_SCHEMA_VERSION = 1 +TEST_DEFAULT_FORMATTER_ENV = "DATAMODEL_CODE_GENERATOR_TEST_DEFAULT_FORMATTER" +BUILTIN_FORMATTER_VALUE = "builtin" _VERSION_PATTERN = re.compile(r"^\d+\.\d+$") _MOCK_PUBLIC_IP = "93.184.216.34" @@ -1127,6 +1130,24 @@ def assert_generated_file_matches_output(result: object, output_file: Path) -> N raise AssertionError(msg) +@pytest.fixture(autouse=True) +def _isolate_builtin_formatter_config( + request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory +) -> None: + """Keep marked tests from inheriting the repository Ruff formatter config.""" + if request.node.get_closest_marker("isolate_builtin_formatter_config") is None: + return + if os.environ.get(TEST_DEFAULT_FORMATTER_ENV) != BUILTIN_FORMATTER_VALUE: + return + + settings_path = tmp_path_factory.mktemp("builtin_formatter_config") + (settings_path / "pyproject.toml").write_text( + "[tool.datamodel-codegen]\nbuiltin-format-line-length = 88\n", + encoding="utf-8", + ) + monkeypatch.chdir(settings_path) + + @pytest.fixture(autouse=True) def _inline_snapshot_file_formats() -> None: register_format_alias(".py", ".txt") diff --git a/tests/main/conftest.py b/tests/main/conftest.py index 1556586da..46887e41c 100644 --- a/tests/main/conftest.py +++ b/tests/main/conftest.py @@ -4,6 +4,7 @@ import importlib.util import inspect +import os import shutil import sys import textwrap @@ -24,7 +25,7 @@ from datamodel_code_generator import InputFileType, generate from datamodel_code_generator.__main__ import Exit, main from datamodel_code_generator.arguments import arg_parser -from datamodel_code_generator.format import PythonVersion, is_supported_in_black +from datamodel_code_generator.format import Formatter, PythonVersion, is_supported_in_black from tests.conftest import ( AssertFileContent, _infer_expected_file, @@ -58,23 +59,72 @@ ] CopyFilesMapping = Sequence[tuple[Path, Path]] +_TEST_DEFAULT_FORMATTER_ENV = "DATAMODEL_CODE_GENERATOR_TEST_DEFAULT_FORMATTER" +_BUILTIN_FORMATTER_VALUE = "builtin" +_BUILTIN_FORMATTER_LINE_LENGTH = 88 +_BUILTIN_FORMATTER_CONFIG = "[tool.datamodel-codegen]\nbuiltin-format-line-length = 88\n" +_CLI_FORMATTER_RELATED_OPTIONS = frozenset({ + "--custom-formatters", + "--custom-formatters-kwargs", + "--formatters", + "--profile", + "--skip-string-normalization", + "--use-double-quotes", + "--wrap-string-literal", +}) +_NON_GENERATION_CLI_OPTIONS = frozenset({ + "--debug", + "--generate-cli-command", + "--generate-prompt", + "--generate-pyproject-config", + "--help", + "--list-deprecations", + "--list-experimental", + "--output-format", + "--output-format-json-schema", + "--version", + "--watch", +}) +_API_FORMATTER_RELATED_OPTIONS = frozenset({ + "builtin_format_line_length", + "config", + "custom_formatters", + "custom_formatters_kwargs", + "formatters", + "settings_path", + "use_double_quotes", + "use_type_checking_imports", + "wrap_string_literal", +}) + + +def _uses_builtin_test_default_formatter() -> bool: + return os.environ.get(_TEST_DEFAULT_FORMATTER_ENV) == _BUILTIN_FORMATTER_VALUE + + +def _uses_external_test_default_formatter() -> bool: + return not _uses_builtin_test_default_formatter() + + MSGSPEC_LEGACY_BLACK_SKIP = pytest.mark.skipif( - sys.version_info[:2] == (3, 12) and version.parse(black.__version__) < version.parse("24.0.0"), + _uses_external_test_default_formatter() + and sys.version_info[:2] == (3, 12) + and version.parse(black.__version__) < version.parse("24.0.0"), reason="msgspec.Struct formatting differs with python3.12 + black < 24", ) LEGACY_BLACK_SKIP = pytest.mark.skipif( - version.parse(black.__version__) < version.parse("24.0.0"), + _uses_external_test_default_formatter() and version.parse(black.__version__) < version.parse("24.0.0"), reason="Type annotation formatting differs with black < 24", ) BLACK_PY313_SKIP = pytest.mark.skipif( - not is_supported_in_black(PythonVersion.PY_313), + _uses_external_test_default_formatter() and not is_supported_in_black(PythonVersion.PY_313), reason=f"Installed black ({black.__version__}) doesn't support Python 3.13", ) BLACK_PY314_SKIP = pytest.mark.skipif( - not is_supported_in_black(PythonVersion.PY_314), + _uses_external_test_default_formatter() and not is_supported_in_black(PythonVersion.PY_314), reason=f"Installed black ({black.__version__}) doesn't support Python 3.14", ) @@ -248,6 +298,115 @@ def _validate_extra_args(extra_args: Sequence[str] | None) -> None: pytest.fail(f"Invalid CLI options in extra_args: {invalid_args}. Valid options: {sorted(valid_cli_options)}") +def _has_formatter_related_cli_options(args: Sequence[str]) -> bool: + return any(arg.split("=", maxsplit=1)[0] in _CLI_FORMATTER_RELATED_OPTIONS for arg in args) + + +def _has_formatter_related_copy_files(copy_files: CopyFilesMapping | None) -> bool: + return copy_files is not None and any(dst.name == "pyproject.toml" for _, dst in copy_files) + + +def _has_formatter_related_settings_path(output_path: Path | None) -> bool: + if output_path is None: + return False + settings_path = output_path if output_path.is_dir() else output_path.parent + for path in (settings_path, *settings_path.parents): + pyproject_toml = path / "pyproject.toml" + if pyproject_toml.is_file(): + return True + return False + + +def _builtin_default_formatter_config_path(output_path: Path) -> Path: + if output_path.is_dir(): + return output_path / "pyproject.toml" + return output_path.parent / "pyproject.toml" + + +@contextmanager +def _builtin_default_formatter_config(output_path: Path | None, *, enabled: bool) -> Generator[None]: + if not enabled or output_path is None: + yield + return + pyproject_toml = _builtin_default_formatter_config_path(output_path) + if pyproject_toml.is_file(): + yield + return + pyproject_toml.write_text(_BUILTIN_FORMATTER_CONFIG, encoding="utf-8") + try: + yield + finally: + pyproject_toml.unlink(missing_ok=True) + + +def _should_use_builtin_default_cli_formatter( + args: Sequence[str], + *, + copy_files: CopyFilesMapping | None = None, + output_path: Path | None = None, + is_generation_command: bool = True, +) -> bool: + args_list = list(args) + return not ( + not is_generation_command + or not _uses_builtin_test_default_formatter() + or _has_formatter_related_cli_options(args_list) + or _has_formatter_related_copy_files(copy_files) + or _has_formatter_related_settings_path(output_path) + ) + + +def _is_main_generation_command(args: Sequence[str]) -> bool: + return any(arg.split("=", maxsplit=1)[0] in {"--input", "--url"} for arg in args) and not any( + arg.split("=", maxsplit=1)[0] in _NON_GENERATION_CLI_OPTIONS for arg in args + ) + + +def _get_cli_output_path(args: Sequence[str]) -> Path | None: + args_list = list(args) + for index, arg in enumerate(args_list): + if arg == "--output" and index + 1 < len(args_list): + return Path(args_list[index + 1]) + if arg.startswith("--output="): + return Path(arg.split("=", maxsplit=1)[1]) + return None + + +def _default_formatter_cli_args( + args: Sequence[str], + *, + copy_files: CopyFilesMapping | None = None, + output_path: Path | None = None, + is_generation_command: bool = True, +) -> list[str]: + args_list = list(args) + if not _should_use_builtin_default_cli_formatter( + args_list, + copy_files=copy_files, + output_path=output_path, + is_generation_command=is_generation_command, + ): + return args_list + return [*args_list, "--formatters", _BUILTIN_FORMATTER_VALUE] + + +def _default_formatter_generate_options( + generate_kwargs: dict[str, Any], *, output_path: Path | None = None +) -> dict[str, Any]: + output = output_path or generate_kwargs.get("output") + if ( + not _uses_builtin_test_default_formatter() + or any(key in generate_kwargs for key in _API_FORMATTER_RELATED_OPTIONS) + or (isinstance(output, Path) and _has_formatter_related_settings_path(output)) + ): + return generate_kwargs + return { + **generate_kwargs, + "formatters": [Formatter.BUILTIN], + "builtin_format_line_length": _BUILTIN_FORMATTER_LINE_LENGTH, + } + + def _extend_args( args: list[str], *, @@ -255,7 +414,8 @@ def _extend_args( output_path: Path | None = None, input_file_type: InputFileTypeLiteral | None = None, extra_args: Sequence[str] | None = None, -) -> None: + copy_files: CopyFilesMapping | None = None, +) -> bool: """Extend args with optional input_path, output_path, input_file_type and extra_args.""" if input_path is not None: args.extend(["--input", str(input_path)]) @@ -266,6 +426,14 @@ def _extend_args( _validate_extra_args(extra_args) if extra_args is not None: args.extend(extra_args) + use_builtin_default = _should_use_builtin_default_cli_formatter( + args, + copy_files=copy_files, + output_path=output_path, + ) + if use_builtin_default: + args.extend(("--formatters", _BUILTIN_FORMATTER_VALUE)) + return use_builtin_default def _run_main( @@ -279,10 +447,16 @@ def _run_main( """Execute main() with standard arguments (internal use).""" _copy_files(copy_files) args: list[str] = [] - _extend_args( - args, input_path=input_path, output_path=output_path, input_file_type=input_file_type, extra_args=extra_args + use_builtin_default = _extend_args( + args, + input_path=input_path, + output_path=output_path, + input_file_type=input_file_type, + extra_args=extra_args, + copy_files=copy_files, ) - return main(args) + with _builtin_default_formatter_config(output_path, enabled=use_builtin_default): + return main(args) def _builtin_cli_formatter_parity_context() -> _BuiltinCliFormatterParityContext: @@ -303,8 +477,11 @@ def _run_main_url( ) -> Exit: """Execute main() with URL input (internal use).""" args = ["--url", url] - _extend_args(args, output_path=output_path, input_file_type=input_file_type, extra_args=extra_args) - return main(args) + use_builtin_default = _extend_args( + args, output_path=output_path, input_file_type=input_file_type, extra_args=extra_args + ) + with _builtin_default_formatter_config(output_path, enabled=use_builtin_default): + return main(args) def run_main_with_args( @@ -332,7 +509,16 @@ def run_main_with_args( Exit code from main() """ __tracebackhide__ = True - return_code = main(list(args)) + output_path = _get_cli_output_path(args) + is_generation_command = _is_main_generation_command(args) + use_builtin_default = _should_use_builtin_default_cli_formatter( + args, + output_path=output_path, + is_generation_command=is_generation_command, + ) + main_args = [*args, "--formatters", _BUILTIN_FORMATTER_VALUE] if use_builtin_default else list(args) + with _builtin_default_formatter_config(output_path, enabled=use_builtin_default): + return_code = main(main_args) _assert_exit_code(return_code, expected_exit, f"Args: {args}") _assert_captured_output( capsys, @@ -356,8 +542,19 @@ def run_main_with_system_exit( ) -> None: """Execute main() expecting argparse/SystemExit and assert captured output.""" __tracebackhide__ = True - with pytest.raises(SystemExit) as exc_info: - main(list(args)) + output_path = _get_cli_output_path(args) + is_generation_command = _is_main_generation_command(args) + use_builtin_default = _should_use_builtin_default_cli_formatter( + args, + output_path=output_path, + is_generation_command=is_generation_command, + ) + main_args = [*args, "--formatters", _BUILTIN_FORMATTER_VALUE] if use_builtin_default else list(args) + with ( + pytest.raises(SystemExit) as exc_info, + _builtin_default_formatter_config(output_path, enabled=use_builtin_default), + ): + main(main_args) if exc_info.value.code != expected_code: # pragma: no cover pytest.fail(f"Expected SystemExit code {expected_code!r}, got {exc_info.value.code!r}\nArgs: {args}") _assert_captured_output( @@ -433,7 +630,7 @@ def run_generate_file_and_assert( generate_options: dict[str, Any] = { "output": output_path, - **generate_kwargs, + **_default_formatter_generate_options(generate_kwargs, output_path=output_path), } if input_file_type is not None: generate_options["input_file_type"] = input_file_type @@ -477,7 +674,7 @@ def run_generate_and_assert( """Execute generate(output=None) and assert the returned text output.""" __tracebackhide__ = True - result = generate(input_=input_, **generate_kwargs) + result = generate(input_=input_, **_default_formatter_generate_options(generate_kwargs)) if not isinstance(result, str): # pragma: no cover pytest.fail(f"Expected generate() to return str, got {type(result).__name__}") assert_output(result, expected_file) @@ -575,15 +772,25 @@ def run_main_and_assert( # noqa: PLR0912 _copy_files(copy_files) monkeypatch.setattr("sys.stdin", stdin_path.open(encoding="utf-8")) args: list[str] = [] - _extend_args(args, output_path=output_path, input_file_type=input_file_type, extra_args=extra_args) - return_code = main(args) + use_builtin_default = _extend_args( + args, + output_path=output_path, + input_file_type=input_file_type, + extra_args=extra_args, + copy_files=copy_files, + ) + with _builtin_default_formatter_config(output_path, enabled=use_builtin_default): + return_code = main(args) # Handle stdout-only output (no output_path) elif output_path is None: if input_path is None: # pragma: no cover pytest.fail("input_path is required when output_path is None") args = [] - _extend_args(args, input_path=input_path, input_file_type=input_file_type, extra_args=extra_args) - return_code = main(args) + use_builtin_default = _extend_args( + args, input_path=input_path, input_file_type=input_file_type, extra_args=extra_args + ) + with _builtin_default_formatter_config(output_path, enabled=use_builtin_default): + return_code = main(args) # Standard file input else: if input_path is None: # pragma: no cover diff --git a/tests/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index 074f95521..74a2e5f5a 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -10784,6 +10784,7 @@ def test_main_use_root_model_type_alias(output_file: Path) -> None: ) +@pytest.mark.isolate_builtin_formatter_config def test_main_jsonschema_schema_id( capsys: pytest.CaptureFixture, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -11714,6 +11715,7 @@ def test_jsonschema_classvar_extra_pydantic_v2(output_file: Path) -> None: ) +@pytest.mark.isolate_builtin_formatter_config def test_jsonschema_classvar_field_str_custom_template( capsys: pytest.CaptureFixture, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/main/openapi/test_main_openapi.py b/tests/main/openapi/test_main_openapi.py index 20f6f2f7b..6fff6ab47 100644 --- a/tests/main/openapi/test_main_openapi.py +++ b/tests/main/openapi/test_main_openapi.py @@ -681,6 +681,7 @@ def test_main_modular_filename(output_file: Path) -> None: ) +@pytest.mark.isolate_builtin_formatter_config def test_main_openapi_no_file( capsys: pytest.CaptureFixture[str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -701,6 +702,7 @@ def test_main_openapi_no_file( black.__version__.split(".")[0] == "19", reason="Installed black doesn't support the old style", ) +@pytest.mark.isolate_builtin_formatter_config @pytest.mark.cli_doc( options=["--extra-template-data"], option_description="""Pass custom template variables from JSON file for code generation. @@ -743,6 +745,7 @@ def test_main_openapi_extra_template_data_config( ) +@pytest.mark.isolate_builtin_formatter_config def test_main_custom_template_dir_old_style( capsys: pytest.CaptureFixture, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -777,6 +780,7 @@ def test_main_custom_template_dir_old_style( cli_args=["--custom-template-dir", "templates", "--extra-template-data", "openapi/extra_data.json"], golden_output="openapi/custom_template_dir.py", ) +@pytest.mark.isolate_builtin_formatter_config def test_main_openapi_custom_template_dir( capsys: pytest.CaptureFixture, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -805,6 +809,7 @@ def test_main_openapi_custom_template_dir( ) +@pytest.mark.isolate_builtin_formatter_config def test_main_openapi_custom_template_dir_include_override( capsys: pytest.CaptureFixture, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -831,6 +836,7 @@ def test_main_openapi_custom_template_dir_include_override( ) +@pytest.mark.isolate_builtin_formatter_config def test_main_openapi_schema_extensions( capsys: pytest.CaptureFixture, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_conftest_helpers.py b/tests/test_conftest_helpers.py index 57858d49c..54ea4508b 100644 --- a/tests/test_conftest_helpers.py +++ b/tests/test_conftest_helpers.py @@ -164,6 +164,100 @@ def test_builtin_parity_clear_output_path(tmp_path: Path) -> None: assert not stale_dir.exists() +def test_default_formatter_cli_args(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Builtin formatter defaults apply only to generation commands without formatter settings.""" + monkeypatch.delenv("DATAMODEL_CODE_GENERATOR_TEST_DEFAULT_FORMATTER", raising=False) + assert main_conftest._default_formatter_cli_args(["--input", "schema.json"]) == ["--input", "schema.json"] + + monkeypatch.setenv("DATAMODEL_CODE_GENERATOR_TEST_DEFAULT_FORMATTER", "builtin") + assert main_conftest._default_formatter_cli_args(["--input", "schema.json"]) == [ + "--input", + "schema.json", + "--formatters", + "builtin", + ] + assert main_conftest._default_formatter_cli_args( + ["--input", "schema.json"], + output_path=tmp_path / "output.py", + ) == [ + "--input", + "schema.json", + "--formatters", + "builtin", + ] + assert not (tmp_path / "pyproject.toml").exists() + assert main_conftest._default_formatter_cli_args(["--input", "schema.json", "--formatters=isort"]) == [ + "--input", + "schema.json", + "--formatters=isort", + ] + assert main_conftest._default_formatter_cli_args(["--input", "schema.json"], is_generation_command=False) == [ + "--input", + "schema.json", + ] + assert main_conftest._default_formatter_cli_args( + ["--input", "schema.json"], + copy_files=[(tmp_path / "source.toml", tmp_path / "pyproject.toml")], + output_path=tmp_path / "output.py", + ) == ["--input", "schema.json"] + (tmp_path / "pyproject.toml").write_text("[tool.black]\nline-length = 60\n", encoding="utf-8") + assert main_conftest._default_formatter_cli_args( + ["--input", "schema.json"], + output_path=tmp_path / "output.py", + ) == ["--input", "schema.json"] + + +def test_builtin_default_formatter_config(tmp_path: Path) -> None: + """Builtin formatter config is present only while the generation helper runs.""" + output_file = tmp_path / "output.py" + with main_conftest._builtin_default_formatter_config(output_file, enabled=True): + assert (tmp_path / "pyproject.toml").read_text(encoding="utf-8") == ( + "[tool.datamodel-codegen]\nbuiltin-format-line-length = 88\n" + ) + assert not (tmp_path / "pyproject.toml").exists() + + with main_conftest._builtin_default_formatter_config(tmp_path, enabled=True): + assert (tmp_path / "pyproject.toml").is_file() + assert not (tmp_path / "pyproject.toml").exists() + + existing_config = "[tool.black]\nline-length = 60\n" + (tmp_path / "pyproject.toml").write_text(existing_config, encoding="utf-8") + with main_conftest._builtin_default_formatter_config(output_file, enabled=True): + assert (tmp_path / "pyproject.toml").read_text(encoding="utf-8") == existing_config + assert (tmp_path / "pyproject.toml").read_text(encoding="utf-8") == existing_config + + +def test_default_formatter_main_generation_detection() -> None: + """Raw main helpers identify only generation commands for builtin defaults.""" + assert main_conftest._is_main_generation_command(["--input", "schema.json"]) + assert main_conftest._is_main_generation_command(["--url=https://example.com/schema.json"]) + assert not main_conftest._is_main_generation_command(["--input", "schema.json", "--generate-prompt"]) + assert not main_conftest._is_main_generation_command(["--input", "schema.json", "--output-format=json"]) + assert not main_conftest._is_main_generation_command(["--version"]) + assert main_conftest._get_cli_output_path(["--output", "model.py"]) == Path("model.py") + assert main_conftest._get_cli_output_path(["--output=model.py"]) == Path("model.py") + assert main_conftest._get_cli_output_path(["--input", "schema.json"]) is None + + +def test_default_formatter_generate_options(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """API generation helpers add builtin only when formatter settings are absent.""" + generate_kwargs: dict[str, object] = {} + monkeypatch.delenv("DATAMODEL_CODE_GENERATOR_TEST_DEFAULT_FORMATTER", raising=False) + assert main_conftest._default_formatter_generate_options(generate_kwargs) == generate_kwargs + + monkeypatch.setenv("DATAMODEL_CODE_GENERATOR_TEST_DEFAULT_FORMATTER", "builtin") + assert main_conftest._default_formatter_generate_options(generate_kwargs) == { + "formatters": [Formatter.BUILTIN], + "builtin_format_line_length": 88, + } + + explicit_kwargs = {"formatters": [Formatter.BLACK]} + assert main_conftest._default_formatter_generate_options(explicit_kwargs) == explicit_kwargs + + (tmp_path / "pyproject.toml").write_text("[tool.black]\nline-length = 60\n", encoding="utf-8") + assert main_conftest._default_formatter_generate_options({}, output_path=tmp_path / "output.py") == {} + + def test_builtin_parity_generated_python_comparison(tmp_path: Path) -> None: """Generated Python comparison ignores command header differences.""" expected_file = tmp_path / "expected.py" diff --git a/tests/test_format.py b/tests/test_format.py index fc4669d67..0f6eb5cfa 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -807,7 +807,6 @@ def test_apply_builtin_formatter_normalizes_type_alias_blank_lines_without_impor assert apply_builtin_formatter(code) == "type Foo = str\n\n\ntype Bar = Foo\n" -@pytest.mark.skipif(sys.version_info < (3, 12), reason="type statements require Python 3.12") def test_builtin_formatter_respects_target_python_version_for_ast_parse() -> None: """Test built-in formatter parses code using the configured target Python version.""" code = "type Foo = str\n\n\n\ntype Bar = Foo\n" @@ -819,6 +818,109 @@ def test_builtin_formatter_respects_target_python_version_for_ast_parse() -> Non assert py312_formatter.format_code(code) == "type Foo = str\n\n\ntype Bar = Foo\n" +def test_builtin_formatter_formats_type_alias_modules_on_older_runtimes() -> None: + """Test built-in formatter handles target Python 3.12 type aliases on older runtimes.""" + code = ( + "from typing import Annotated\n" + "from pydantic import Field\n" + "\n" + "\n" + "type Alias = Annotated[str | bool, Field(..., " + "description='An annotated union type', title='MyAnnotatedType')]\n" + "\n" + "\n" + "\n" + "class Model:\n" + " x_value: Annotated[Alias | None, Field(validate_default=True)] = {'type': 'b', 'value': 1}\n" + ) + + assert apply_builtin_formatter(code, line_length=88, python_version=PythonVersion.PY_312) == ( + "from typing import Annotated\n" + "\n" + "from pydantic import Field\n" + "\n" + "type Alias = Annotated[\n" + " str | bool,\n" + " Field(..., description='An annotated union type', title='MyAnnotatedType'),\n" + "]\n" + "\n" + "\n" + "class Model:\n" + " x_value: Annotated[Alias | None, Field(validate_default=True)] = {\n" + " 'type': 'b',\n" + " 'value': 1,\n" + " }\n" + ) + + +def test_builtin_formatter_pep695_placeholder_helpers(monkeypatch: pytest.MonkeyPatch) -> None: + """Test helper branches for parsing Python 3.12 type aliases on older runtimes.""" + monkeypatch.setattr(builtin_formatter.sys, "version_info", (3, 11, 0, "final", 0)) + + assert builtin_formatter._needs_pep695_type_alias_placeholders(PythonVersion.PY_312) + assert not builtin_formatter._needs_pep695_type_alias_placeholders(PythonVersion.PY_311) + assert not builtin_formatter._needs_pep695_type_alias_placeholders(None) + + alias_lines = [ + "x = 1", + "type Alias = Annotated[", + " str | bool,", + " Field(...),", + "]", + "y = 2", + ] + assert builtin_formatter._pep695_type_alias_statement_end(alias_lines, 1) == 4 + assert builtin_formatter._pep695_type_alias_statement_end([], 0) == 0 + assert builtin_formatter._pep695_type_alias_statement_end(["type Alias = Annotated["], 0) == 0 + assert builtin_formatter._replace_pep695_type_aliases_with_placeholders("\n".join(alias_lines)) == ( + f"x = 1\n{builtin_formatter.PEP695_TYPE_ALIAS_PLACEHOLDER} = None\n\n\n\ny = 2" + ) + + assert builtin_formatter._parse_builtin_code("type Alias =\n", PythonVersion.PY_312) is not None + assert builtin_formatter._parse_builtin_code("if", PythonVersion.PY_312) is None + assert builtin_formatter._parse_builtin_code(" type Alias =\n", PythonVersion.PY_312) is None + + +def test_builtin_formatter_pep695_type_alias_replacement_edges() -> None: + """Test PEP 695 alias replacement skips unsupported forms and rewrites generated Annotated aliases.""" + assert builtin_formatter._has_comment_token("value = 'unterminated #") + assert not builtin_formatter._has_comment_token("value = 'https://example.com/#fragment'") + assert ( + builtin_formatter._pep695_type_alias_replacement( + ["type Alias = Annotated[str, Field(..., description='kept')] # noqa: F401"], + 0, + 40, + ) + is None + ) + assert builtin_formatter._pep695_type_alias_replacement(["type Alias = ["], 0, 40) is None + assert builtin_formatter._pep695_type_alias_replacement(["type Alias = str"], 0, 40) is None + + replacements = builtin_formatter._collect_pep695_type_alias_replacements( + [ + "value = 1", + "type Alias = Annotated[str, Field(..., description='long generated alias description')]", + ], + 40, + ) + + assert replacements == [ + ( + 2, + 2, + [ + "type Alias = Annotated[", + " str,", + " Field(", + " ...,", + " description='long generated alias description',", + " ),", + "]", + ], + ) + ] + + def test_apply_builtin_formatter_parenthesizes_short_annotated_default() -> None: """Test built-in formatter matches black for short overflowing Annotated defaults.""" code = ( @@ -924,6 +1026,26 @@ def test_apply_builtin_formatter_wraps_string_default_with_single_quote() -> Non ) +def test_apply_builtin_formatter_formats_hash_inside_field_string() -> None: + """Test URL fragments inside generated strings are not treated as comments.""" + code = ( + "class ErrorResponse:\n" + " type: AnyUrl | None = Field('about:blank', description='An absolute URI that identifies the problem " + "type. When dereferenced,\\nit SHOULD provide human-readable documentation for the problem type\\n(e.g., " + "using HTML).\\n', examples=['https://tools.ietf.org/html/rfc7231#section-6.6.4'])\n" + ) + + assert apply_builtin_formatter(code, line_length=88) == ( + "class ErrorResponse:\n" + " type: AnyUrl | None = Field(\n" + " 'about:blank',\n" + " description='An absolute URI that identifies the problem type. When dereferenced,\\nit SHOULD " + "provide human-readable documentation for the problem type\\n(e.g., using HTML).\\n',\n" + " examples=['https://tools.ietf.org/html/rfc7231#section-6.6.4'],\n" + " )\n" + ) + + def test_apply_builtin_formatter_wraps_union_subscript_annotation() -> None: """Test built-in formatter matches black for generated Union annotations.""" code = ( diff --git a/tests/test_main_kr.py b/tests/test_main_kr.py index 6afb0e0c0..7aaa57c74 100644 --- a/tests/test_main_kr.py +++ b/tests/test_main_kr.py @@ -147,6 +147,7 @@ def test_main_modular(output_dir: Path) -> None: ) +@pytest.mark.isolate_builtin_formatter_config @freeze_time(TIMESTAMP) def test_main_modular_no_file(capsys: pytest.CaptureFixture[str]) -> None: """Test main function on modular file with no output name outputs to stdout.""" @@ -167,6 +168,7 @@ def test_main_modular_filename(output_file: Path) -> None: ) +@pytest.mark.isolate_builtin_formatter_config def test_main_no_file(capsys: pytest.CaptureFixture[str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Test main function on non-modular file with no output name.""" monkeypatch.chdir(tmp_path) @@ -181,6 +183,7 @@ def test_main_no_file(capsys: pytest.CaptureFixture[str], tmp_path: Path, monkey ) +@pytest.mark.isolate_builtin_formatter_config def test_main_custom_template_dir( capsys: pytest.CaptureFixture[str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tox.ini b/tox.ini index b3f64715e..ff505d614 100644 --- a/tox.ini +++ b/tox.ini @@ -111,6 +111,11 @@ commands = check-wheel-contents --no-config {env_tmp_dir} dependency_groups = pkg-meta +[testenv:py{310,311,312,313,314}-parallel] +set_env = + {[testenv]set_env} + DATAMODEL_CODE_GENERATOR_TEST_DEFAULT_FORMATTER = builtin + [testenv:w3c-xmlschema-e2e] description = run XML Schema generation against the W3C XML Schema Test Suite commands =