From bbb69581daaf6d19e732f47393fd27e494f8b1cc Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 10 Mar 2026 01:46:03 +0000 Subject: [PATCH 01/13] Avoid TYPE_CHECKING imports for Ruff modular output --- docs/cli-reference/index.md | 3 +- docs/cli-reference/quick-reference.md | 2 + docs/cli-reference/template-customization.md | 382 ++++++++++++++++++ src/datamodel_code_generator/__init__.py | 2 + src/datamodel_code_generator/__main__.py | 3 + .../_types/generate_config_dict.py | 1 + .../_types/parser_config_dicts.py | 1 + src/datamodel_code_generator/arguments.py | 7 + src/datamodel_code_generator/cli_options.py | 4 + src/datamodel_code_generator/config.py | 2 + src/datamodel_code_generator/format.py | 19 +- src/datamodel_code_generator/parser/base.py | 2 + src/datamodel_code_generator/prompt_data.py | 1 + .../expected/main/input_model/config_class.py | 1 + .../no_use_type_checking_imports_internal.py | 62 +++ tests/main/test_main_general.py | 54 +++ .../test_public_api_signature_baseline.py | 2 + tests/test_format.py | 24 ++ 18 files changed, 566 insertions(+), 6 deletions(-) create mode 100644 tests/data/expected/main/openapi/no_use_type_checking_imports_internal.py diff --git a/docs/cli-reference/index.md b/docs/cli-reference/index.md index 20baaf6d4..f7b102166 100644 --- a/docs/cli-reference/index.md +++ b/docs/cli-reference/index.md @@ -12,7 +12,7 @@ This documentation is auto-generated from test cases. | 🔧 [Typing Customization](typing-customization.md) | 29 | Type annotation and import behavior | | 🏷️ [Field Customization](field-customization.md) | 24 | Field naming and docstring behavior | | 🏗️ [Model Customization](model-customization.md) | 39 | Model generation behavior | -| 🎨 [Template Customization](template-customization.md) | 19 | Output formatting and custom rendering | +| 🎨 [Template Customization](template-customization.md) | 20 | Output formatting and custom rendering | | 📘 [OpenAPI-only Options](openapi-only-options.md) | 7 | OpenAPI-specific features | | 📋 [GraphQL-only Options](graphql-only-options.md) | 1 | | | ⚙️ [General Options](general-options.md) | 15 | Utilities and meta options | @@ -137,6 +137,7 @@ This documentation is auto-generated from test cases. - [`--no-use-closed-typed-dict`](typing-customization.md#no-use-closed-typed-dict) - [`--no-use-specialized-enum`](typing-customization.md#no-use-specialized-enum) - [`--no-use-standard-collections`](typing-customization.md#no-use-standard-collections) +- [`--no-use-type-checking-imports`](template-customization.md#no-use-type-checking-imports) - [`--no-use-union-operator`](typing-customization.md#no-use-union-operator) ### O {#o} diff --git a/docs/cli-reference/quick-reference.md b/docs/cli-reference/quick-reference.md index ef8892f83..6c0067bf9 100644 --- a/docs/cli-reference/quick-reference.md +++ b/docs/cli-reference/quick-reference.md @@ -152,6 +152,7 @@ datamodel-codegen [OPTIONS] | [`--extra-template-data`](template-customization.md#extra-template-data) | Pass custom template variables from JSON file for code generation. | | [`--formatters`](template-customization.md#formatters) | Specify code formatters to apply to generated output. | | [`--no-treat-dot-as-module`](template-customization.md#no-treat-dot-as-module) | Keep dots in schema names as underscores for flat output. | +| [`--no-use-type-checking-imports`](template-customization.md#no-use-type-checking-imports) | Keep generated model imports available at runtime when using Ruff fixes. | | [`--treat-dot-as-module`](template-customization.md#treat-dot-as-module) | Treat dots in schema names as module separators. | | [`--use-double-quotes`](template-customization.md#use-double-quotes) | Use double quotes for string literals in generated code. | | [`--use-exact-imports`](template-customization.md#use-exact-imports) | Import exact types instead of modules. | @@ -292,6 +293,7 @@ All options sorted alphabetically: - [`--no-use-closed-typed-dict`](typing-customization.md#no-use-closed-typed-dict) - Disable PEP 728 TypedDict closed/extra_items generation. - [`--no-use-specialized-enum`](typing-customization.md#no-use-specialized-enum) - Disable specialized Enum classes for Python 3.11+ code gener... - [`--no-use-standard-collections`](typing-customization.md#no-use-standard-collections) - Use typing.Dict/List instead of built-in dict/list for conta... +- [`--no-use-type-checking-imports`](template-customization.md#no-use-type-checking-imports) - Keep generated model imports available at runtime when using... - [`--no-use-union-operator`](typing-customization.md#no-use-union-operator) - Use Union[X, Y] / Optional[X] instead of X | Y union operato... - [`--openapi-include-paths`](openapi-only-options.md#openapi-include-paths) - Filter OpenAPI paths to include in model generation. - [`--openapi-scopes`](openapi-only-options.md#openapi-scopes) - Specify OpenAPI scopes to generate (schemas, paths, paramete... diff --git a/docs/cli-reference/template-customization.md b/docs/cli-reference/template-customization.md index 05fa4d41e..3d290c897 100644 --- a/docs/cli-reference/template-customization.md +++ b/docs/cli-reference/template-customization.md @@ -18,6 +18,7 @@ | [`--extra-template-data`](#extra-template-data) | Pass custom template variables from JSON file for code gener... | | [`--formatters`](#formatters) | Specify code formatters to apply to generated output. | | [`--no-treat-dot-as-module`](#no-treat-dot-as-module) | Keep dots in schema names as underscores for flat output. | +| [`--no-use-type-checking-imports`](#no-use-type-checking-imports) | Keep generated model imports available at runtime when using... | | [`--treat-dot-as-module`](#treat-dot-as-module) | Treat dots in schema names as module separators. | | [`--use-double-quotes`](#use-double-quotes) | Use double quotes for string literals in generated code. | | [`--use-exact-imports`](#use-exact-imports) | Import exact types instead of modules. | @@ -2339,6 +2340,387 @@ The `--no-treat-dot-as-module` flag prevents splitting dotted schema names. --- +## `--no-use-type-checking-imports` {#no-use-type-checking-imports} + +Keep generated model imports available at runtime when using Ruff fixes. + +The `--no-use-type-checking-imports` flag prevents Ruff from moving generated model imports +into `TYPE_CHECKING` blocks. This is useful for modular Pydantic output where referenced +models need to be importable at runtime without calling `model_rebuild()` manually. + +**Related:** [`--formatters`](template-customization.md#formatters), [`--use-exact-imports`](template-customization.md#use-exact-imports) + +!!! tip "Usage" + + ```bash + datamodel-codegen --input schema.json --formatters ruff-check ruff-format --no-use-type-checking-imports --disable-timestamp # (1)! + ``` + + 1. :material-arrow-left: `--no-use-type-checking-imports` - the option documented here + +??? example "Examples" + + **Input Schema:** + + ```yaml + openapi: "3.0.0" + info: + version: 1.0.0 + title: Modular Swagger Petstore + license: + name: MIT + servers: + - url: http://petstore.swagger.io/v1 + paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: A paged array of pets + headers: + x-next: + description: A link to the next page of responses + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/collections.Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + x-amazon-apigateway-integration: + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${PythonVersionFunction.Arn}/invocations + passthroughBehavior: when_no_templates + httpMethod: POST + type: aws_proxy + post: + summary: Create a pet + operationId: createPets + tags: + - pets + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + x-amazon-apigateway-integration: + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${PythonVersionFunction.Arn}/invocations + passthroughBehavior: when_no_templates + httpMethod: POST + type: aws_proxy + /pets/{petId}: + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/collections.Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + x-amazon-apigateway-integration: + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${PythonVersionFunction.Arn}/invocations + passthroughBehavior: when_no_templates + httpMethod: POST + type: aws_proxy + components: + schemas: + models.Species: + type: string + enum: + - dog + - cat + - snake + models.Pet: + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + species: + $ref: '#/components/schemas/models.Species' + models.User: + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + collections.Pets: + type: array + items: + $ref: "#/components/schemas/models.Pet" + collections.Users: + type: array + items: + $ref: "#/components/schemas/models.User" + optional: + type: string + Id: + type: string + collections.Rules: + type: array + items: + type: string + Error: + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string + collections.apis: + type: array + items: + type: object + properties: + apiKey: + type: string + description: To be used as a dataset parameter value + apiVersionNumber: + type: string + description: To be used as a version parameter value + apiUrl: + type: string + format: uri + description: "The URL describing the dataset's fields" + apiDocumentationUrl: + type: string + format: uri + description: A URL to the API console for each API + stage: + type: string + enum: [ + "test", + "dev", + "stg", + "prod" + ] + models.Event: + type: object + properties: + name: + anyOf: + - type: string + - type: number + - type: integer + - type: boolean + - type: object + - type: array + items: + type: string + Result: + type: object + properties: + event: + $ref: '#/components/schemas/models.Event' + foo.bar.Thing: + properties: + attributes: + type: object + foo.bar.Thang: + properties: + attributes: + type: array + items: + type: object + foo.bar.Clone: + allOf: + - $ref: '#/components/schemas/foo.bar.Thing' + - type: object + properties: + others: + type: object + properties: + name: + type: string + + foo.Tea: + properties: + flavour: + type: string + id: + $ref: '#/components/schemas/Id' + Source: + properties: + country: + type: string + foo.Cocoa: + properties: + quality: + type: integer + bar.Field: + type: string + example: green + woo.boo.Chocolate: + properties: + flavour: + type: string + source: + $ref: '#/components/schemas/Source' + cocoa: + $ref: '#/components/schemas/foo.Cocoa' + field: + $ref: '#/components/schemas/bar.Field' + differentTea: + type: object + properties: + foo: + $ref: '#/components/schemas/foo.Tea' + nested: + $ref: '#/components/schemas/nested.foo.Tea' + nested.foo.Tea: + properties: + flavour: + type: string + id: + $ref: '#/components/schemas/Id' + self: + $ref: '#/components/schemas/nested.foo.Tea' + optional: + type: array + items: + $ref: '#/components/schemas/optional' + nested.foo.TeaClone: + properties: + flavour: + type: string + id: + $ref: '#/components/schemas/Id' + self: + $ref: '#/components/schemas/nested.foo.Tea' + optional: + type: array + items: + $ref: '#/components/schemas/optional' + nested.foo.List: + type: array + items: + $ref: '#/components/schemas/nested.foo.Tea' + ``` + + **Output:** + + ```python + # generated by datamodel-codegen: + # filename: _internal + + from __future__ import annotations + from pydantic import BaseModel, RootModel + from . import models + + + class Optional(RootModel[str]): + root: str + + + class Id(RootModel[str]): + root: str + + + class Error(BaseModel): + code: int + message: str + + + class Result(BaseModel): + event: models.Event | None = None + + + class Source(BaseModel): + country: str | None = None + + + class DifferentTea(BaseModel): + foo: Tea | None = None + nested: Tea_1 | None = None + + + class Tea(BaseModel): + flavour: str | None = None + id: Id | None = None + + + class Cocoa(BaseModel): + quality: int | None = None + + + class Tea_1(BaseModel): + flavour: str | None = None + id: Id | None = None + self: Tea_1 | None = None + optional: list[Optional] | None = None + + + class TeaClone(BaseModel): + flavour: str | None = None + id: Id | None = None + self: Tea_1 | None = None + optional: list[Optional] | None = None + + + class List(RootModel[list[Tea_1]]): + root: list[Tea_1] + + + Tea_1.model_rebuild() + ``` + +--- + ## `--treat-dot-as-module` {#treat-dot-as-module} Treat dots in schema names as module separators. diff --git a/src/datamodel_code_generator/__init__.py b/src/datamodel_code_generator/__init__.py index d7138dda6..2b6795d74 100644 --- a/src/datamodel_code_generator/__init__.py +++ b/src/datamodel_code_generator/__init__.py @@ -713,6 +713,7 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]: "target_date_class": config.output_date_class, "dataclass_arguments": dataclass_arguments, "defer_formatting": defer_formatting, + "use_type_checking_imports": config.use_type_checking_imports, "enum_field_as_literal": ( config.enum_field_as_literal if config.enum_field_as_literal is not None @@ -913,6 +914,7 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]: custom_formatters_kwargs=config.custom_formatters_kwargs, encoding=config.encoding, formatters=config.formatters, + use_type_checking_imports=config.use_type_checking_imports, ) code_formatter.format_directory(output) diff --git a/src/datamodel_code_generator/__main__.py b/src/datamodel_code_generator/__main__.py index b23746119..f897ecc92 100644 --- a/src/datamodel_code_generator/__main__.py +++ b/src/datamodel_code_generator/__main__.py @@ -116,6 +116,7 @@ }) BOOLEAN_OPTIONAL_OPTIONS: frozenset[str] = frozenset({ + "use_type_checking_imports", "use_specialized_enum", "use_standard_collections", }) @@ -518,6 +519,7 @@ def validate_class_name_affix_scope(cls, v: str | ClassNameAffixScope | None) -> http_query_parameters: Optional[Sequence[tuple[str, str]]] = None # noqa: UP045 treat_dot_as_module: Optional[bool] = None # noqa: UP045 use_exact_imports: bool = False + use_type_checking_imports: bool = True union_mode: Optional[UnionMode] = None # noqa: UP045 output_datetime_class: Optional[DatetimeClassType] = None # noqa: UP045 output_date_class: Optional[DateClassType] = None # noqa: UP045 @@ -961,6 +963,7 @@ def run_generate_from_config( # noqa: PLR0913, PLR0917 http_query_parameters=config.http_query_parameters, treat_dot_as_module=config.treat_dot_as_module, use_exact_imports=config.use_exact_imports, + use_type_checking_imports=config.use_type_checking_imports, union_mode=config.union_mode, output_datetime_class=config.output_datetime_class, output_date_class=config.output_date_class, diff --git a/src/datamodel_code_generator/_types/generate_config_dict.py b/src/datamodel_code_generator/_types/generate_config_dict.py index 7c73f5a02..8a7d1a45d 100644 --- a/src/datamodel_code_generator/_types/generate_config_dict.py +++ b/src/datamodel_code_generator/_types/generate_config_dict.py @@ -145,6 +145,7 @@ class GenerateConfigDict(TypedDict, closed=True): http_query_parameters: NotRequired[Sequence[tuple[str, str]] | None] treat_dot_as_module: NotRequired[bool | None] use_exact_imports: NotRequired[bool] + use_type_checking_imports: NotRequired[bool] union_mode: NotRequired[UnionMode | None] output_datetime_class: NotRequired[DatetimeClassType | None] output_date_class: NotRequired[DateClassType | None] diff --git a/src/datamodel_code_generator/_types/parser_config_dicts.py b/src/datamodel_code_generator/_types/parser_config_dicts.py index d274d0460..5ab9edd21 100644 --- a/src/datamodel_code_generator/_types/parser_config_dicts.py +++ b/src/datamodel_code_generator/_types/parser_config_dicts.py @@ -141,6 +141,7 @@ class ParserConfigDict(TypedDict): http_query_parameters: NotRequired[Sequence[tuple[str, str]] | None] treat_dot_as_module: NotRequired[bool | None] use_exact_imports: NotRequired[bool] + use_type_checking_imports: NotRequired[bool] default_field_extras: NotRequired[dict[str, Any] | None] target_datetime_class: NotRequired[DatetimeClassType | None] target_date_class: NotRequired[DateClassType | None] diff --git a/src/datamodel_code_generator/arguments.py b/src/datamodel_code_generator/arguments.py index ec30584e0..a9c26a04f 100644 --- a/src/datamodel_code_generator/arguments.py +++ b/src/datamodel_code_generator/arguments.py @@ -418,6 +418,13 @@ def start_section(self, heading: str | None) -> None: action="store_true", default=None, ) +model_options.add_argument( + "--use-type-checking-imports", + help="Allow Ruff to move typing-only imports into TYPE_CHECKING blocks. Default: enabled. " + "Use --no-use-type-checking-imports to keep referenced models imported at runtime.", + action=BooleanOptionalAction, + default=None, +) model_options.add_argument( "--output-datetime-class", help="Choose Datetime class between AwareDatetime, NaiveDatetime, PastDatetime, FutureDatetime or datetime. " diff --git a/src/datamodel_code_generator/cli_options.py b/src/datamodel_code_generator/cli_options.py index 8af0c3ea7..c15abd2d7 100644 --- a/src/datamodel_code_generator/cli_options.py +++ b/src/datamodel_code_generator/cli_options.py @@ -194,6 +194,10 @@ class CLIOptionMeta: "--use-standard-primitive-types": CLIOptionMeta( name="--use-standard-primitive-types", category=OptionCategory.TYPING ), + "--use-type-checking-imports": CLIOptionMeta(name="--use-type-checking-imports", category=OptionCategory.TEMPLATE), + "--no-use-type-checking-imports": CLIOptionMeta( + name="--no-use-type-checking-imports", category=OptionCategory.TEMPLATE + ), "--output-datetime-class": CLIOptionMeta(name="--output-datetime-class", category=OptionCategory.TYPING), "--output-date-class": CLIOptionMeta(name="--output-date-class", category=OptionCategory.TYPING), "--use-decimal-for-multiple-of": CLIOptionMeta( diff --git a/src/datamodel_code_generator/config.py b/src/datamodel_code_generator/config.py index 67fc19c21..b43bd4bb0 100644 --- a/src/datamodel_code_generator/config.py +++ b/src/datamodel_code_generator/config.py @@ -170,6 +170,7 @@ class GenerateConfig(BaseModel): http_query_parameters: Sequence[tuple[str, str]] | None = None treat_dot_as_module: bool | None = None use_exact_imports: bool = False + use_type_checking_imports: bool = True union_mode: UnionMode | None = None output_datetime_class: DatetimeClassType | None = None output_date_class: DateClassType | None = None @@ -302,6 +303,7 @@ class ParserConfig(BaseModel): http_query_parameters: Sequence[tuple[str, str]] | None = None treat_dot_as_module: bool | None = None use_exact_imports: bool = False + use_type_checking_imports: bool = True default_field_extras: dict[str, Any] | None = None target_datetime_class: DatetimeClassType | None = None target_date_class: DateClassType | None = None diff --git a/src/datamodel_code_generator/format.py b/src/datamodel_code_generator/format.py index 4338af841..d0afc4168 100644 --- a/src/datamodel_code_generator/format.py +++ b/src/datamodel_code_generator/format.py @@ -217,6 +217,7 @@ def __init__( # noqa: PLR0912, PLR0913, PLR0915, PLR0917 custom_formatters_kwargs: dict[str, Any] | None = None, encoding: str = "utf-8", formatters: list[Formatter] | None = None, + use_type_checking_imports: bool = True, # noqa: FBT001, FBT002 defer_formatting: bool = False, # noqa: FBT001, FBT002 ) -> None: """Initialize code formatter with configuration for black, isort, ruff, and custom formatters.""" @@ -247,6 +248,7 @@ def __init__( # noqa: PLR0912, PLR0913, PLR0915, PLR0917 self.formatters = formatters self.defer_formatting = defer_formatting self.encoding = encoding + self.use_type_checking_imports = use_type_checking_imports use_black = Formatter.BLACK in formatters use_isort = Formatter.ISORT in formatters @@ -371,8 +373,8 @@ def apply_black(self, code: str) -> str: def apply_ruff_lint(self, code: str) -> str: """Run ruff check with auto-fix on code.""" - result = subprocess.run( - ("ruff", "check", "--fix", "--unsafe-fixes", "-"), + result = subprocess.run( # noqa: S603 + self._ruff_check_command("-"), input=code.encode(self.encoding), capture_output=True, check=False, @@ -393,14 +395,14 @@ def apply_ruff_formatter(self, code: str) -> str: def apply_ruff_check_and_format(self, code: str) -> str: """Run ruff check and format sequentially for reliable processing.""" - ruff_path = self._find_ruff_path() check_result = subprocess.run( # noqa: S603 - (ruff_path, "check", "--fix", "--unsafe-fixes", "-"), + self._ruff_check_command("-"), input=code.encode(self.encoding), capture_output=True, check=False, cwd=self.settings_path, ) + ruff_path = self._find_ruff_path() format_result = subprocess.run( # noqa: S603 (ruff_path, "format", "-"), input=check_result.stdout, @@ -410,6 +412,13 @@ def apply_ruff_check_and_format(self, code: str) -> str: ) return format_result.stdout.decode(self.encoding) + def _ruff_check_command(self, *paths: str) -> tuple[str, ...]: + """Build the Ruff check command for the current formatter settings.""" + command: tuple[str, ...] = ("ruff", "check", "--fix", "--unsafe-fixes") + if not self.use_type_checking_imports: + command += ("--unfixable", "TC001,TC002,TC003") + return (*command, *paths) + @staticmethod def _find_ruff_path() -> str: """Find ruff executable path, checking virtual environment first.""" @@ -435,7 +444,7 @@ def format_directory(self, directory: Path) -> None: """Apply ruff formatting to all Python files in a directory.""" if Formatter.RUFF_CHECK in self.formatters: subprocess.run( # noqa: S603 - ("ruff", "check", "--fix", "--unsafe-fixes", str(directory)), + self._ruff_check_command(str(directory)), capture_output=True, check=False, cwd=self.settings_path, diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index 4438fbc7a..cb4f4e784 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -922,6 +922,7 @@ def __init__( # noqa: PLR0912, PLR0915 self.imports: Imports = Imports(config.use_exact_imports) self.use_exact_imports: bool = config.use_exact_imports + self.use_type_checking_imports: bool = config.use_type_checking_imports self._append_additional_imports(additional_imports=config.additional_imports) self.class_decorators: list[str] = config.class_decorators or [] @@ -3045,6 +3046,7 @@ def _prepare_parse_config( # noqa: PLR0913, PLR0917 custom_formatters_kwargs=self.custom_formatters_kwargs, encoding=self.encoding, formatters=self.formatters, + use_type_checking_imports=self.use_type_checking_imports, defer_formatting=self.defer_formatting, ) diff --git a/src/datamodel_code_generator/prompt_data.py b/src/datamodel_code_generator/prompt_data.py index 298c47211..68c716e8f 100644 --- a/src/datamodel_code_generator/prompt_data.py +++ b/src/datamodel_code_generator/prompt_data.py @@ -82,6 +82,7 @@ "--no-use-closed-typed-dict": "Disable PEP 728 TypedDict closed/extra_items generation.", "--no-use-specialized-enum": "Disable specialized Enum classes for Python 3.11+ code generation.", "--no-use-standard-collections": "Use typing.Dict/List instead of built-in dict/list for container types.", + "--no-use-type-checking-imports": "Keep generated model imports available at runtime when using Ruff fixes.", "--no-use-union-operator": "Use Union[X, Y] / Optional[X] instead of X | Y union operator.", "--openapi-include-paths": "Filter OpenAPI paths to include in model generation.", "--openapi-scopes": "Specify OpenAPI scopes to generate (schemas, paths, parameters).", diff --git a/tests/data/expected/main/input_model/config_class.py b/tests/data/expected/main/input_model/config_class.py index d32d2756d..9d896fb2b 100644 --- a/tests/data/expected/main/input_model/config_class.py +++ b/tests/data/expected/main/input_model/config_class.py @@ -224,6 +224,7 @@ class GenerateConfig(TypedDict, closed=True): http_query_parameters: NotRequired[Sequence[tuple[str, str]] | None] treat_dot_as_module: NotRequired[bool | None] use_exact_imports: NotRequired[bool] + use_type_checking_imports: NotRequired[bool] union_mode: NotRequired[UnionMode | None] output_datetime_class: NotRequired[DatetimeClassType | None] output_date_class: NotRequired[DateClassType | None] diff --git a/tests/data/expected/main/openapi/no_use_type_checking_imports_internal.py b/tests/data/expected/main/openapi/no_use_type_checking_imports_internal.py new file mode 100644 index 000000000..0e26ffa34 --- /dev/null +++ b/tests/data/expected/main/openapi/no_use_type_checking_imports_internal.py @@ -0,0 +1,62 @@ +# generated by datamodel-codegen: +# filename: _internal + +from __future__ import annotations +from pydantic import BaseModel, RootModel +from . import models + + +class Optional(RootModel[str]): + root: str + + +class Id(RootModel[str]): + root: str + + +class Error(BaseModel): + code: int + message: str + + +class Result(BaseModel): + event: models.Event | None = None + + +class Source(BaseModel): + country: str | None = None + + +class DifferentTea(BaseModel): + foo: Tea | None = None + nested: Tea_1 | None = None + + +class Tea(BaseModel): + flavour: str | None = None + id: Id | None = None + + +class Cocoa(BaseModel): + quality: int | None = None + + +class Tea_1(BaseModel): + flavour: str | None = None + id: Id | None = None + self: Tea_1 | None = None + optional: list[Optional] | None = None + + +class TeaClone(BaseModel): + flavour: str | None = None + id: Id | None = None + self: Tea_1 | None = None + optional: list[Optional] | None = None + + +class List(RootModel[list[Tea_1]]): + root: list[Tea_1] + + +Tea_1.model_rebuild() diff --git a/tests/main/test_main_general.py b/tests/main/test_main_general.py index a6bfafebc..6f80f6b3a 100644 --- a/tests/main/test_main_general.py +++ b/tests/main/test_main_general.py @@ -1685,6 +1685,60 @@ def test_ruff_batch_formatting_directory(output_dir: Path) -> None: assert "class Order" in content +@pytest.mark.cli_doc( + options=["--no-use-type-checking-imports"], + option_description="""Keep generated model imports available at runtime when using Ruff fixes. + +The `--no-use-type-checking-imports` flag prevents Ruff from moving generated model imports +into `TYPE_CHECKING` blocks. This is useful for modular Pydantic output where referenced +models need to be importable at runtime without calling `model_rebuild()` manually.""", + input_schema="openapi/modular.yaml", + cli_args=["--formatters", "ruff-check", "ruff-format", "--no-use-type-checking-imports", "--disable-timestamp"], + golden_output="openapi/no_use_type_checking_imports_internal.py", + related_options=["--use-type-checking-imports", "--formatters", "--use-exact-imports"], +) +def test_no_use_type_checking_imports(output_dir: Path) -> None: + """Keep generated model imports available at runtime when using Ruff fixes. + + The `--no-use-type-checking-imports` flag prevents Ruff from moving generated model imports + into `TYPE_CHECKING` blocks. This is useful for modular Pydantic output where referenced + models need to be importable at runtime without calling `model_rebuild()` manually. + """ + import importlib + import sys + + run_main_and_assert( + input_path=OPEN_API_DATA_PATH / "modular.yaml", + output_path=output_dir, + input_file_type="openapi", + extra_args=[ + "--formatters", + "ruff-check", + "ruff-format", + "--no-use-type-checking-imports", + "--disable-timestamp", + ], + ) + + internal_path = output_dir / "_internal.py" + content = internal_path.read_text() + assert "TYPE_CHECKING" not in content + assert content == (EXPECTED_MAIN_PATH / "openapi" / "no_use_type_checking_imports_internal.py").read_text() + + sys.path.insert(0, str(output_dir.parent)) + importlib.invalidate_caches() + try: + from model._internal import Result + + result = Result.model_validate({"event": {"id": "abc"}}) + assert result.event is not None + assert result.event.__class__.__name__ == "Event" + finally: + sys.path.pop(0) + for name in [module for module in sys.modules if module == "model" or module.startswith("model.")]: + del sys.modules[name] + + def test_generate_returns_string_when_output_none() -> None: """Test that generate() returns str when output=None for single file.""" json_schema = '{"type": "object", "properties": {"name": {"type": "string"}}}' diff --git a/tests/main/test_public_api_signature_baseline.py b/tests/main/test_public_api_signature_baseline.py index c5d303bf2..a772d911c 100644 --- a/tests/main/test_public_api_signature_baseline.py +++ b/tests/main/test_public_api_signature_baseline.py @@ -160,6 +160,7 @@ def _baseline_generate( http_query_parameters: Sequence[tuple[str, str]] | None = None, treat_dot_as_module: bool | None = None, use_exact_imports: bool = False, + use_type_checking_imports: bool = True, union_mode: UnionMode | None = None, output_datetime_class: DatetimeClassType | None = None, output_date_class: DateClassType | None = None, @@ -294,6 +295,7 @@ def __init__( http_query_parameters: Sequence[tuple[str, str]] | None = None, treat_dot_as_module: bool | None = None, use_exact_imports: bool = False, + use_type_checking_imports: bool = True, default_field_extras: dict[str, Any] | None = None, target_datetime_class: DatetimeClassType | None = None, target_date_class: DateClassType | None = None, diff --git a/tests/test_format.py b/tests/test_format.py index c790b0190..c949ed6af 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -194,6 +194,30 @@ def test_format_code_ruff_check_formatter(tmp_path: Path, monkeypatch: pytest.Mo ) +def test_format_code_ruff_check_formatter_without_type_checking_imports( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test ruff check formatter keeps runtime imports when requested.""" + monkeypatch.chdir(tmp_path) + formatter = CodeFormatter( + PythonVersionMin, + formatters=[Formatter.RUFF_CHECK], + use_type_checking_imports=False, + ) + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value.stdout = b"output" + formatted_code = formatter.format_code("input") + + assert formatted_code == "output" + mock_run.assert_called_once_with( + ("ruff", "check", "--fix", "--unsafe-fixes", "--unfixable", "TC001,TC002,TC003", "-"), + input=b"input", + capture_output=True, + check=False, + cwd=str(tmp_path), + ) + + def test_settings_path_with_existing_file(tmp_path: Path) -> None: """Test settings_path with existing file uses parent directory.""" pyproject = tmp_path / "pyproject.toml" From 33a2f00a4248e400661ee57d7371fb04574380a4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Mar 2026 01:46:34 +0000 Subject: [PATCH 02/13] docs: update llms.txt files Generated by GitHub Actions --- docs/llms-full.txt | 387 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 386 insertions(+), 1 deletion(-) diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 88eee0e97..4d21dc05e 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -244,7 +244,7 @@ This documentation is auto-generated from test cases. | 🔧 [Typing Customization](typing-customization.md) | 29 | Type annotation and import behavior | | 🏷️ [Field Customization](field-customization.md) | 24 | Field naming and docstring behavior | | 🏗️ [Model Customization](model-customization.md) | 39 | Model generation behavior | -| 🎨 [Template Customization](template-customization.md) | 19 | Output formatting and custom rendering | +| 🎨 [Template Customization](template-customization.md) | 20 | Output formatting and custom rendering | | 📘 [OpenAPI-only Options](openapi-only-options.md) | 7 | OpenAPI-specific features | | 📋 [GraphQL-only Options](graphql-only-options.md) | 1 | | | ⚙️ [General Options](general-options.md) | 15 | Utilities and meta options | @@ -369,6 +369,7 @@ This documentation is auto-generated from test cases. - [`--no-use-closed-typed-dict`](typing-customization.md#no-use-closed-typed-dict) - [`--no-use-specialized-enum`](typing-customization.md#no-use-specialized-enum) - [`--no-use-standard-collections`](typing-customization.md#no-use-standard-collections) +- [`--no-use-type-checking-imports`](template-customization.md#no-use-type-checking-imports) - [`--no-use-union-operator`](typing-customization.md#no-use-union-operator) ### O {#o} @@ -16206,6 +16207,7 @@ Source: https://datamodel-code-generator.koxudaxi.dev/cli-reference/template-cus | [`--extra-template-data`](#extra-template-data) | Pass custom template variables from JSON file for code gener... | | [`--formatters`](#formatters) | Specify code formatters to apply to generated output. | | [`--no-treat-dot-as-module`](#no-treat-dot-as-module) | Keep dots in schema names as underscores for flat output. | +| [`--no-use-type-checking-imports`](#no-use-type-checking-imports) | Keep generated model imports available at runtime when using... | | [`--treat-dot-as-module`](#treat-dot-as-module) | Treat dots in schema names as module separators. | | [`--use-double-quotes`](#use-double-quotes) | Use double quotes for string literals in generated code. | | [`--use-exact-imports`](#use-exact-imports) | Import exact types instead of modules. | @@ -18527,6 +18529,387 @@ The `--no-treat-dot-as-module` flag prevents splitting dotted schema names. --- +## `--no-use-type-checking-imports` {#no-use-type-checking-imports} + +Keep generated model imports available at runtime when using Ruff fixes. + +The `--no-use-type-checking-imports` flag prevents Ruff from moving generated model imports +into `TYPE_CHECKING` blocks. This is useful for modular Pydantic output where referenced +models need to be importable at runtime without calling `model_rebuild()` manually. + +**Related:** [`--formatters`](template-customization.md#formatters), [`--use-exact-imports`](template-customization.md#use-exact-imports) + +!!! tip "Usage" + + ```bash + datamodel-codegen --input schema.json --formatters ruff-check ruff-format --no-use-type-checking-imports --disable-timestamp # (1)! + ``` + + 1. :material-arrow-left: `--no-use-type-checking-imports` - the option documented here + +??? example "Examples" + + **Input Schema:** + + ```yaml + openapi: "3.0.0" + info: + version: 1.0.0 + title: Modular Swagger Petstore + license: + name: MIT + servers: + - url: http://petstore.swagger.io/v1 + paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: A paged array of pets + headers: + x-next: + description: A link to the next page of responses + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/collections.Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + x-amazon-apigateway-integration: + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${PythonVersionFunction.Arn}/invocations + passthroughBehavior: when_no_templates + httpMethod: POST + type: aws_proxy + post: + summary: Create a pet + operationId: createPets + tags: + - pets + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + x-amazon-apigateway-integration: + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${PythonVersionFunction.Arn}/invocations + passthroughBehavior: when_no_templates + httpMethod: POST + type: aws_proxy + /pets/{petId}: + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/collections.Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + x-amazon-apigateway-integration: + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${PythonVersionFunction.Arn}/invocations + passthroughBehavior: when_no_templates + httpMethod: POST + type: aws_proxy + components: + schemas: + models.Species: + type: string + enum: + - dog + - cat + - snake + models.Pet: + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + species: + $ref: '#/components/schemas/models.Species' + models.User: + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + collections.Pets: + type: array + items: + $ref: "#/components/schemas/models.Pet" + collections.Users: + type: array + items: + $ref: "#/components/schemas/models.User" + optional: + type: string + Id: + type: string + collections.Rules: + type: array + items: + type: string + Error: + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string + collections.apis: + type: array + items: + type: object + properties: + apiKey: + type: string + description: To be used as a dataset parameter value + apiVersionNumber: + type: string + description: To be used as a version parameter value + apiUrl: + type: string + format: uri + description: "The URL describing the dataset's fields" + apiDocumentationUrl: + type: string + format: uri + description: A URL to the API console for each API + stage: + type: string + enum: [ + "test", + "dev", + "stg", + "prod" + ] + models.Event: + type: object + properties: + name: + anyOf: + - type: string + - type: number + - type: integer + - type: boolean + - type: object + - type: array + items: + type: string + Result: + type: object + properties: + event: + $ref: '#/components/schemas/models.Event' + foo.bar.Thing: + properties: + attributes: + type: object + foo.bar.Thang: + properties: + attributes: + type: array + items: + type: object + foo.bar.Clone: + allOf: + - $ref: '#/components/schemas/foo.bar.Thing' + - type: object + properties: + others: + type: object + properties: + name: + type: string + + foo.Tea: + properties: + flavour: + type: string + id: + $ref: '#/components/schemas/Id' + Source: + properties: + country: + type: string + foo.Cocoa: + properties: + quality: + type: integer + bar.Field: + type: string + example: green + woo.boo.Chocolate: + properties: + flavour: + type: string + source: + $ref: '#/components/schemas/Source' + cocoa: + $ref: '#/components/schemas/foo.Cocoa' + field: + $ref: '#/components/schemas/bar.Field' + differentTea: + type: object + properties: + foo: + $ref: '#/components/schemas/foo.Tea' + nested: + $ref: '#/components/schemas/nested.foo.Tea' + nested.foo.Tea: + properties: + flavour: + type: string + id: + $ref: '#/components/schemas/Id' + self: + $ref: '#/components/schemas/nested.foo.Tea' + optional: + type: array + items: + $ref: '#/components/schemas/optional' + nested.foo.TeaClone: + properties: + flavour: + type: string + id: + $ref: '#/components/schemas/Id' + self: + $ref: '#/components/schemas/nested.foo.Tea' + optional: + type: array + items: + $ref: '#/components/schemas/optional' + nested.foo.List: + type: array + items: + $ref: '#/components/schemas/nested.foo.Tea' + ``` + + **Output:** + + ```python + # generated by datamodel-codegen: + # filename: _internal + + from __future__ import annotations + from pydantic import BaseModel, RootModel + from . import models + + + class Optional(RootModel[str]): + root: str + + + class Id(RootModel[str]): + root: str + + + class Error(BaseModel): + code: int + message: str + + + class Result(BaseModel): + event: models.Event | None = None + + + class Source(BaseModel): + country: str | None = None + + + class DifferentTea(BaseModel): + foo: Tea | None = None + nested: Tea_1 | None = None + + + class Tea(BaseModel): + flavour: str | None = None + id: Id | None = None + + + class Cocoa(BaseModel): + quality: int | None = None + + + class Tea_1(BaseModel): + flavour: str | None = None + id: Id | None = None + self: Tea_1 | None = None + optional: list[Optional] | None = None + + + class TeaClone(BaseModel): + flavour: str | None = None + id: Id | None = None + self: Tea_1 | None = None + optional: list[Optional] | None = None + + + class List(RootModel[list[Tea_1]]): + root: list[Tea_1] + + + Tea_1.model_rebuild() + ``` + +--- + ## `--treat-dot-as-module` {#treat-dot-as-module} Treat dots in schema names as module separators. @@ -23195,6 +23578,7 @@ datamodel-codegen [OPTIONS] | [`--extra-template-data`](template-customization.md#extra-template-data) | Pass custom template variables from JSON file for code generation. | | [`--formatters`](template-customization.md#formatters) | Specify code formatters to apply to generated output. | | [`--no-treat-dot-as-module`](template-customization.md#no-treat-dot-as-module) | Keep dots in schema names as underscores for flat output. | +| [`--no-use-type-checking-imports`](template-customization.md#no-use-type-checking-imports) | Keep generated model imports available at runtime when using Ruff fixes. | | [`--treat-dot-as-module`](template-customization.md#treat-dot-as-module) | Treat dots in schema names as module separators. | | [`--use-double-quotes`](template-customization.md#use-double-quotes) | Use double quotes for string literals in generated code. | | [`--use-exact-imports`](template-customization.md#use-exact-imports) | Import exact types instead of modules. | @@ -23335,6 +23719,7 @@ All options sorted alphabetically: - [`--no-use-closed-typed-dict`](typing-customization.md#no-use-closed-typed-dict) - Disable PEP 728 TypedDict closed/extra_items generation. - [`--no-use-specialized-enum`](typing-customization.md#no-use-specialized-enum) - Disable specialized Enum classes for Python 3.11+ code gener... - [`--no-use-standard-collections`](typing-customization.md#no-use-standard-collections) - Use typing.Dict/List instead of built-in dict/list for conta... +- [`--no-use-type-checking-imports`](template-customization.md#no-use-type-checking-imports) - Keep generated model imports available at runtime when using... - [`--no-use-union-operator`](typing-customization.md#no-use-union-operator) - Use Union[X, Y] / Optional[X] instead of X | Y union operato... - [`--openapi-include-paths`](openapi-only-options.md#openapi-include-paths) - Filter OpenAPI paths to include in model generation. - [`--openapi-scopes`](openapi-only-options.md#openapi-scopes) - Specify OpenAPI scopes to generate (schemas, paths, paramete... From 7bc82e11520b1cc96f846b87f3bd893a5254b240 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 10 Mar 2026 01:57:18 +0000 Subject: [PATCH 03/13] Fix Ruff path resolution for combined formatter --- src/datamodel_code_generator/format.py | 8 +++--- tests/test_format.py | 39 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/datamodel_code_generator/format.py b/src/datamodel_code_generator/format.py index d0afc4168..69b2027bc 100644 --- a/src/datamodel_code_generator/format.py +++ b/src/datamodel_code_generator/format.py @@ -395,14 +395,14 @@ def apply_ruff_formatter(self, code: str) -> str: def apply_ruff_check_and_format(self, code: str) -> str: """Run ruff check and format sequentially for reliable processing.""" + ruff_path = self._find_ruff_path() check_result = subprocess.run( # noqa: S603 - self._ruff_check_command("-"), + self._ruff_check_command("-", ruff_path=ruff_path), input=code.encode(self.encoding), capture_output=True, check=False, cwd=self.settings_path, ) - ruff_path = self._find_ruff_path() format_result = subprocess.run( # noqa: S603 (ruff_path, "format", "-"), input=check_result.stdout, @@ -412,9 +412,9 @@ def apply_ruff_check_and_format(self, code: str) -> str: ) return format_result.stdout.decode(self.encoding) - def _ruff_check_command(self, *paths: str) -> tuple[str, ...]: + def _ruff_check_command(self, *paths: str, ruff_path: str = "ruff") -> tuple[str, ...]: """Build the Ruff check command for the current formatter settings.""" - command: tuple[str, ...] = ("ruff", "check", "--fix", "--unsafe-fixes") + command: tuple[str, ...] = (ruff_path, "check", "--fix", "--unsafe-fixes") if not self.use_type_checking_imports: command += ("--unfixable", "TC001,TC002,TC003") return (*command, *paths) diff --git a/tests/test_format.py b/tests/test_format.py index c949ed6af..52b84e451 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -218,6 +218,45 @@ def test_format_code_ruff_check_formatter_without_type_checking_imports( ) +def test_format_code_ruff_check_and_format_uses_resolved_ruff_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test combined Ruff formatting reuses the resolved Ruff executable.""" + monkeypatch.chdir(tmp_path) + formatter = CodeFormatter( + PythonVersionMin, + formatters=[Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT], + ) + with ( + mock.patch.object(formatter, "_find_ruff_path", return_value="/tmp/venv/bin/ruff") as mock_find_ruff_path, + mock.patch("subprocess.run") as mock_run, + ): + mock_run.side_effect = [ + mock.Mock(stdout=b"checked"), + mock.Mock(stdout=b"formatted"), + ] + formatted_code = formatter.format_code("input") + + assert formatted_code == "formatted" + mock_find_ruff_path.assert_called_once_with() + assert mock_run.call_args_list == [ + mock.call( + ("/tmp/venv/bin/ruff", "check", "--fix", "--unsafe-fixes", "-"), + input=b"input", + capture_output=True, + check=False, + cwd=str(tmp_path), + ), + mock.call( + ("/tmp/venv/bin/ruff", "format", "-"), + input=b"checked", + capture_output=True, + check=False, + cwd=str(tmp_path), + ), + ] + + def test_settings_path_with_existing_file(tmp_path: Path) -> None: """Test settings_path with existing file uses parent directory.""" pyproject = tmp_path / "pyproject.toml" From 2a6044bad4bd36d91a9b4bce3bb7c036cfbb0f85 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 10 Mar 2026 02:27:29 +0000 Subject: [PATCH 04/13] Default to runtime imports for modular Ruff output --- src/datamodel_code_generator/__init__.py | 13 +- src/datamodel_code_generator/__main__.py | 2 +- .../_types/generate_config_dict.py | 2 +- .../_types/parser_config_dicts.py | 2 +- src/datamodel_code_generator/arguments.py | 6 +- src/datamodel_code_generator/config.py | 4 +- src/datamodel_code_generator/format.py | 20 ++- src/datamodel_code_generator/parser/base.py | 13 +- .../expected/main/input_model/config_class.py | 2 +- tests/main/test_main_general.py | 31 +++++ .../test_public_api_signature_baseline.py | 4 +- tests/test_format.py | 129 ++++++++++++++++-- 12 files changed, 201 insertions(+), 27 deletions(-) diff --git a/src/datamodel_code_generator/__init__.py b/src/datamodel_code_generator/__init__.py index 2b6795d74..4147804a7 100644 --- a/src/datamodel_code_generator/__init__.py +++ b/src/datamodel_code_generator/__init__.py @@ -61,6 +61,7 @@ Formatter, PythonVersion, PythonVersionMin, + resolve_use_type_checking_imports, ) from datamodel_code_generator.parser import DefaultPutDict, LiteralType @@ -690,6 +691,14 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]: OpenAPIParserConfig, ) + effective_use_type_checking_imports = resolve_use_type_checking_imports( + config.use_type_checking_imports, + defer_formatting=defer_formatting, + formatters=config.formatters, + is_pydantic_output=config.output_model_type + in {DataModelType.PydanticV2BaseModel, DataModelType.PydanticV2Dataclass}, + ) + additional_options: ParserConfigDict = { "data_model_type": data_model_types.data_model, "data_model_root_type": data_model_types.root_model, @@ -713,7 +722,7 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]: "target_date_class": config.output_date_class, "dataclass_arguments": dataclass_arguments, "defer_formatting": defer_formatting, - "use_type_checking_imports": config.use_type_checking_imports, + "use_type_checking_imports": effective_use_type_checking_imports, "enum_field_as_literal": ( config.enum_field_as_literal if config.enum_field_as_literal is not None @@ -914,7 +923,7 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]: custom_formatters_kwargs=config.custom_formatters_kwargs, encoding=config.encoding, formatters=config.formatters, - use_type_checking_imports=config.use_type_checking_imports, + use_type_checking_imports=effective_use_type_checking_imports, ) code_formatter.format_directory(output) diff --git a/src/datamodel_code_generator/__main__.py b/src/datamodel_code_generator/__main__.py index f897ecc92..feab8e070 100644 --- a/src/datamodel_code_generator/__main__.py +++ b/src/datamodel_code_generator/__main__.py @@ -519,7 +519,7 @@ def validate_class_name_affix_scope(cls, v: str | ClassNameAffixScope | None) -> http_query_parameters: Optional[Sequence[tuple[str, str]]] = None # noqa: UP045 treat_dot_as_module: Optional[bool] = None # noqa: UP045 use_exact_imports: bool = False - use_type_checking_imports: bool = True + use_type_checking_imports: Optional[bool] = None # noqa: UP045 union_mode: Optional[UnionMode] = None # noqa: UP045 output_datetime_class: Optional[DatetimeClassType] = None # noqa: UP045 output_date_class: Optional[DateClassType] = None # noqa: UP045 diff --git a/src/datamodel_code_generator/_types/generate_config_dict.py b/src/datamodel_code_generator/_types/generate_config_dict.py index 8a7d1a45d..17aa9bd0c 100644 --- a/src/datamodel_code_generator/_types/generate_config_dict.py +++ b/src/datamodel_code_generator/_types/generate_config_dict.py @@ -145,7 +145,7 @@ class GenerateConfigDict(TypedDict, closed=True): http_query_parameters: NotRequired[Sequence[tuple[str, str]] | None] treat_dot_as_module: NotRequired[bool | None] use_exact_imports: NotRequired[bool] - use_type_checking_imports: NotRequired[bool] + use_type_checking_imports: NotRequired[bool | None] union_mode: NotRequired[UnionMode | None] output_datetime_class: NotRequired[DatetimeClassType | None] output_date_class: NotRequired[DateClassType | None] diff --git a/src/datamodel_code_generator/_types/parser_config_dicts.py b/src/datamodel_code_generator/_types/parser_config_dicts.py index 5ab9edd21..9402aefc5 100644 --- a/src/datamodel_code_generator/_types/parser_config_dicts.py +++ b/src/datamodel_code_generator/_types/parser_config_dicts.py @@ -141,7 +141,7 @@ class ParserConfigDict(TypedDict): http_query_parameters: NotRequired[Sequence[tuple[str, str]] | None] treat_dot_as_module: NotRequired[bool | None] use_exact_imports: NotRequired[bool] - use_type_checking_imports: NotRequired[bool] + use_type_checking_imports: NotRequired[bool | None] default_field_extras: NotRequired[dict[str, Any] | None] target_datetime_class: NotRequired[DatetimeClassType | None] target_date_class: NotRequired[DateClassType | None] diff --git a/src/datamodel_code_generator/arguments.py b/src/datamodel_code_generator/arguments.py index a9c26a04f..bc810d5bc 100644 --- a/src/datamodel_code_generator/arguments.py +++ b/src/datamodel_code_generator/arguments.py @@ -420,8 +420,10 @@ def start_section(self, heading: str | None) -> None: ) model_options.add_argument( "--use-type-checking-imports", - help="Allow Ruff to move typing-only imports into TYPE_CHECKING blocks. Default: enabled. " - "Use --no-use-type-checking-imports to keep referenced models imported at runtime.", + help="Allow Ruff to move typing-only imports into TYPE_CHECKING blocks. " + "By default this stays enabled, except for deferred Ruff formatting of modular Pydantic output " + "where referenced models stay imported at runtime. " + "Use --no-use-type-checking-imports to force runtime imports.", action=BooleanOptionalAction, default=None, ) diff --git a/src/datamodel_code_generator/config.py b/src/datamodel_code_generator/config.py index b43bd4bb0..e6354409c 100644 --- a/src/datamodel_code_generator/config.py +++ b/src/datamodel_code_generator/config.py @@ -170,7 +170,7 @@ class GenerateConfig(BaseModel): http_query_parameters: Sequence[tuple[str, str]] | None = None treat_dot_as_module: bool | None = None use_exact_imports: bool = False - use_type_checking_imports: bool = True + use_type_checking_imports: bool | None = None union_mode: UnionMode | None = None output_datetime_class: DatetimeClassType | None = None output_date_class: DateClassType | None = None @@ -303,7 +303,7 @@ class ParserConfig(BaseModel): http_query_parameters: Sequence[tuple[str, str]] | None = None treat_dot_as_module: bool | None = None use_exact_imports: bool = False - use_type_checking_imports: bool = True + use_type_checking_imports: bool | None = None default_field_extras: dict[str, Any] | None = None target_datetime_class: DatetimeClassType | None = None target_date_class: DateClassType | None = None diff --git a/src/datamodel_code_generator/format.py b/src/datamodel_code_generator/format.py index 69b2027bc..0a0954c73 100644 --- a/src/datamodel_code_generator/format.py +++ b/src/datamodel_code_generator/format.py @@ -203,6 +203,21 @@ class Formatter(Enum): DEFAULT_FORMATTERS = [Formatter.BLACK, Formatter.ISORT] +def resolve_use_type_checking_imports( + use_type_checking_imports: bool | None, # noqa: FBT001 + *, + defer_formatting: bool, + formatters: list[Formatter] | None, + is_pydantic_output: bool, +) -> bool: + """Resolve the effective TYPE_CHECKING import behavior.""" + if use_type_checking_imports is not None: + return use_type_checking_imports + + has_ruff = bool(formatters) and (Formatter.RUFF_CHECK in formatters or Formatter.RUFF_FORMAT in formatters) + return not (defer_formatting and has_ruff and is_pydantic_output) + + class CodeFormatter: """Formats generated code using black, isort, ruff, and custom formatters.""" @@ -442,16 +457,17 @@ def apply_isort(self, code: str) -> str: def format_directory(self, directory: Path) -> None: """Apply ruff formatting to all Python files in a directory.""" + ruff_path = self._find_ruff_path() if Formatter.RUFF_CHECK in self.formatters: subprocess.run( # noqa: S603 - self._ruff_check_command(str(directory)), + self._ruff_check_command(str(directory), ruff_path=ruff_path), capture_output=True, check=False, cwd=self.settings_path, ) if Formatter.RUFF_FORMAT in self.formatters: subprocess.run( # noqa: S603 - ("ruff", "format", str(directory)), + (ruff_path, "format", str(directory)), capture_output=True, check=False, cwd=self.settings_path, diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index cb4f4e784..ba4e0540d 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -53,6 +53,7 @@ CodeFormatter, Formatter, PythonVersion, + resolve_use_type_checking_imports, ) from datamodel_code_generator.imports import ( IMPORT_ANNOTATIONS, @@ -922,7 +923,7 @@ def __init__( # noqa: PLR0912, PLR0915 self.imports: Imports = Imports(config.use_exact_imports) self.use_exact_imports: bool = config.use_exact_imports - self.use_type_checking_imports: bool = config.use_type_checking_imports + self.use_type_checking_imports: bool | None = config.use_type_checking_imports self._append_additional_imports(additional_imports=config.additional_imports) self.class_decorators: list[str] = config.class_decorators or [] @@ -3036,6 +3037,14 @@ def _prepare_parse_config( # noqa: PLR0913, PLR0917 code_formatter: CodeFormatter | None = None if format_: + effective_use_type_checking_imports = resolve_use_type_checking_imports( + self.use_type_checking_imports, + defer_formatting=self.defer_formatting, + formatters=self.formatters, + is_pydantic_output=self.data_model_type.__module__.startswith( + "datamodel_code_generator.model.pydantic_v2" + ), + ) code_formatter = CodeFormatter( self.target_python_version, settings_path, @@ -3046,7 +3055,7 @@ def _prepare_parse_config( # noqa: PLR0913, PLR0917 custom_formatters_kwargs=self.custom_formatters_kwargs, encoding=self.encoding, formatters=self.formatters, - use_type_checking_imports=self.use_type_checking_imports, + use_type_checking_imports=effective_use_type_checking_imports, defer_formatting=self.defer_formatting, ) diff --git a/tests/data/expected/main/input_model/config_class.py b/tests/data/expected/main/input_model/config_class.py index 9d896fb2b..913e47756 100644 --- a/tests/data/expected/main/input_model/config_class.py +++ b/tests/data/expected/main/input_model/config_class.py @@ -224,7 +224,7 @@ class GenerateConfig(TypedDict, closed=True): http_query_parameters: NotRequired[Sequence[tuple[str, str]] | None] treat_dot_as_module: NotRequired[bool | None] use_exact_imports: NotRequired[bool] - use_type_checking_imports: NotRequired[bool] + use_type_checking_imports: NotRequired[bool | None] union_mode: NotRequired[UnionMode | None] output_datetime_class: NotRequired[DatetimeClassType | None] output_date_class: NotRequired[DateClassType | None] diff --git a/tests/main/test_main_general.py b/tests/main/test_main_general.py index 6f80f6b3a..291011ffa 100644 --- a/tests/main/test_main_general.py +++ b/tests/main/test_main_general.py @@ -1685,6 +1685,37 @@ def test_ruff_batch_formatting_directory(output_dir: Path) -> None: assert "class Order" in content +def test_type_checking_imports_default_to_runtime_imports_for_modular_pydantic_ruff(output_dir: Path) -> None: + """Test modular Pydantic output keeps runtime imports by default when Ruff formats a directory.""" + import importlib + import sys + + run_main_and_assert( + input_path=OPEN_API_DATA_PATH / "modular.yaml", + output_path=output_dir, + input_file_type="openapi", + extra_args=["--formatters", "ruff-check", "ruff-format", "--disable-timestamp"], + ) + + internal_path = output_dir / "_internal.py" + content = internal_path.read_text() + assert "TYPE_CHECKING" not in content + assert content == (EXPECTED_MAIN_PATH / "openapi" / "no_use_type_checking_imports_internal.py").read_text() + + sys.path.insert(0, str(output_dir.parent)) + importlib.invalidate_caches() + try: + from model._internal import Result + + result = Result.model_validate({"event": {"id": "abc"}}) + assert result.event is not None + assert result.event.__class__.__name__ == "Event" + finally: + sys.path.pop(0) + for name in [module for module in sys.modules if module == "model" or module.startswith("model.")]: + del sys.modules[name] + + @pytest.mark.cli_doc( options=["--no-use-type-checking-imports"], option_description="""Keep generated model imports available at runtime when using Ruff fixes. diff --git a/tests/main/test_public_api_signature_baseline.py b/tests/main/test_public_api_signature_baseline.py index a772d911c..fa447c084 100644 --- a/tests/main/test_public_api_signature_baseline.py +++ b/tests/main/test_public_api_signature_baseline.py @@ -160,7 +160,7 @@ def _baseline_generate( http_query_parameters: Sequence[tuple[str, str]] | None = None, treat_dot_as_module: bool | None = None, use_exact_imports: bool = False, - use_type_checking_imports: bool = True, + use_type_checking_imports: bool | None = None, union_mode: UnionMode | None = None, output_datetime_class: DatetimeClassType | None = None, output_date_class: DateClassType | None = None, @@ -295,7 +295,7 @@ def __init__( http_query_parameters: Sequence[tuple[str, str]] | None = None, treat_dot_as_module: bool | None = None, use_exact_imports: bool = False, - use_type_checking_imports: bool = True, + use_type_checking_imports: bool | None = None, default_field_extras: dict[str, Any] | None = None, target_datetime_class: DatetimeClassType | None = None, target_date_class: DateClassType | None = None, diff --git a/tests/test_format.py b/tests/test_format.py index 52b84e451..3b900f378 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -9,7 +9,13 @@ import pytest -from datamodel_code_generator.format import CodeFormatter, Formatter, PythonVersion, PythonVersionMin +from datamodel_code_generator.format import ( + CodeFormatter, + Formatter, + PythonVersion, + PythonVersionMin, + resolve_use_type_checking_imports, +) EXAMPLE_LICENSE_FILE = str(Path(__file__).parent / "data/python/custom_formatters/license_example.txt") @@ -218,6 +224,46 @@ def test_format_code_ruff_check_formatter_without_type_checking_imports( ) +@pytest.mark.parametrize("explicit_value", [True, False]) +def test_resolve_use_type_checking_imports_respects_explicit_value(explicit_value: bool) -> None: + """Test explicit TYPE_CHECKING import settings are preserved.""" + assert ( + resolve_use_type_checking_imports( + explicit_value, + defer_formatting=True, + formatters=[Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT], + is_pydantic_output=True, + ) + is explicit_value + ) + + +def test_resolve_use_type_checking_imports_defaults_to_runtime_imports_for_deferred_pydantic_ruff() -> None: + """Test deferred Ruff formatting keeps runtime imports for modular Pydantic output by default.""" + assert not resolve_use_type_checking_imports( + None, + defer_formatting=True, + formatters=[Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT], + is_pydantic_output=True, + ) + + +def test_resolve_use_type_checking_imports_keeps_existing_default_outside_deferred_pydantic_ruff() -> None: + """Test non-modular or non-Pydantic output keeps TYPE_CHECKING imports enabled by default.""" + assert resolve_use_type_checking_imports( + None, + defer_formatting=False, + formatters=[Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT], + is_pydantic_output=True, + ) + assert resolve_use_type_checking_imports( + None, + defer_formatting=True, + formatters=[Formatter.RUFF_CHECK], + is_pydantic_output=False, + ) + + def test_format_code_ruff_check_and_format_uses_resolved_ruff_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -307,11 +353,14 @@ def test_format_directory_ruff_check(tmp_path: Path, monkeypatch: pytest.MonkeyP output_dir = tmp_path / "output" output_dir.mkdir() - with mock.patch("subprocess.run") as mock_run: + with ( + mock.patch.object(formatter, "_find_ruff_path", return_value="/tmp/venv/bin/ruff"), + mock.patch("subprocess.run") as mock_run, + ): formatter.format_directory(output_dir) mock_run.assert_called_once_with( - ("ruff", "check", "--fix", "--unsafe-fixes", str(output_dir)), + ("/tmp/venv/bin/ruff", "check", "--fix", "--unsafe-fixes", str(output_dir)), capture_output=True, check=False, cwd=str(tmp_path), @@ -328,11 +377,14 @@ def test_format_directory_ruff_format(tmp_path: Path, monkeypatch: pytest.Monkey output_dir = tmp_path / "output" output_dir.mkdir() - with mock.patch("subprocess.run") as mock_run: + with ( + mock.patch.object(formatter, "_find_ruff_path", return_value="/tmp/venv/bin/ruff"), + mock.patch("subprocess.run") as mock_run, + ): formatter.format_directory(output_dir) mock_run.assert_called_once_with( - ("ruff", "format", str(output_dir)), + ("/tmp/venv/bin/ruff", "format", str(output_dir)), capture_output=True, check=False, cwd=str(tmp_path), @@ -349,18 +401,21 @@ def test_format_directory_both_ruff_formatters(tmp_path: Path, monkeypatch: pyte output_dir = tmp_path / "output" output_dir.mkdir() - with mock.patch("subprocess.run") as mock_run: + with ( + mock.patch.object(formatter, "_find_ruff_path", return_value="/tmp/venv/bin/ruff"), + mock.patch("subprocess.run") as mock_run, + ): formatter.format_directory(output_dir) assert mock_run.call_count == 2 mock_run.assert_any_call( - ("ruff", "check", "--fix", "--unsafe-fixes", str(output_dir)), + ("/tmp/venv/bin/ruff", "check", "--fix", "--unsafe-fixes", str(output_dir)), capture_output=True, check=False, cwd=str(tmp_path), ) mock_run.assert_any_call( - ("ruff", "format", str(output_dir)), + ("/tmp/venv/bin/ruff", "format", str(output_dir)), capture_output=True, check=False, cwd=str(tmp_path), @@ -397,23 +452,75 @@ def test_generate_with_ruff_batch_formatting(tmp_path: Path) -> None: """ output_dir = tmp_path / "output" - with mock.patch("datamodel_code_generator.format.subprocess.run") as mock_run: + with ( + mock.patch("datamodel_code_generator.format.CodeFormatter._find_ruff_path", return_value="/tmp/venv/bin/ruff"), + mock.patch("datamodel_code_generator.format.subprocess.run") as mock_run, + ): + generate( + input_=schema, + output=output_dir, + formatters=[Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT], + module_split_mode=ModuleSplitMode.Single, + ) + + assert mock_run.call_count == 2 + mock_run.assert_any_call( + ( + "/tmp/venv/bin/ruff", + "check", + "--fix", + "--unsafe-fixes", + "--unfixable", + "TC001,TC002,TC003", + str(output_dir), + ), + capture_output=True, + check=False, + cwd=mock.ANY, + ) + mock_run.assert_any_call( + ("/tmp/venv/bin/ruff", "format", str(output_dir)), + capture_output=True, + check=False, + cwd=mock.ANY, + ) + + +def test_generate_with_ruff_batch_formatting_and_explicit_type_checking_imports(tmp_path: Path) -> None: + """Test explicit TYPE_CHECKING imports override the modular Pydantic Ruff default.""" + from datamodel_code_generator import ModuleSplitMode, generate + + schema = """ + { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + """ + output_dir = tmp_path / "output" + + with ( + mock.patch("datamodel_code_generator.format.CodeFormatter._find_ruff_path", return_value="/tmp/venv/bin/ruff"), + mock.patch("datamodel_code_generator.format.subprocess.run") as mock_run, + ): generate( input_=schema, output=output_dir, formatters=[Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT], module_split_mode=ModuleSplitMode.Single, + use_type_checking_imports=True, ) assert mock_run.call_count == 2 mock_run.assert_any_call( - ("ruff", "check", "--fix", "--unsafe-fixes", str(output_dir)), + ("/tmp/venv/bin/ruff", "check", "--fix", "--unsafe-fixes", str(output_dir)), capture_output=True, check=False, cwd=mock.ANY, ) mock_run.assert_any_call( - ("ruff", "format", str(output_dir)), + ("/tmp/venv/bin/ruff", "format", str(output_dir)), capture_output=True, check=False, cwd=mock.ANY, From 6605f6250c1a72ca00be28b888f86c1888d7980c Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 10 Mar 2026 02:31:59 +0000 Subject: [PATCH 05/13] Resolve Ruff executable path consistently --- src/datamodel_code_generator/format.py | 9 ++++++--- tests/test_format.py | 25 +++++++++++++++++++------ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/datamodel_code_generator/format.py b/src/datamodel_code_generator/format.py index 0a0954c73..7c6c582ac 100644 --- a/src/datamodel_code_generator/format.py +++ b/src/datamodel_code_generator/format.py @@ -399,8 +399,9 @@ def apply_ruff_lint(self, code: str) -> str: def apply_ruff_formatter(self, code: str) -> str: """Format code using ruff format.""" - result = subprocess.run( - ("ruff", "format", "-"), + ruff_path = self._find_ruff_path() + result = subprocess.run( # noqa: S603 + (ruff_path, "format", "-"), input=code.encode(self.encoding), capture_output=True, check=False, @@ -427,8 +428,10 @@ def apply_ruff_check_and_format(self, code: str) -> str: ) return format_result.stdout.decode(self.encoding) - def _ruff_check_command(self, *paths: str, ruff_path: str = "ruff") -> tuple[str, ...]: + def _ruff_check_command(self, *paths: str, ruff_path: str | None = None) -> tuple[str, ...]: """Build the Ruff check command for the current formatter settings.""" + if ruff_path is None: + ruff_path = self._find_ruff_path() command: tuple[str, ...] = (ruff_path, "check", "--fix", "--unsafe-fixes") if not self.use_type_checking_imports: command += ("--unfixable", "TC001,TC002,TC003") diff --git a/tests/test_format.py b/tests/test_format.py index 3b900f378..6f8c2e987 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -169,13 +169,20 @@ def test_format_code_ruff_format_formatter(tmp_path: Path, monkeypatch: pytest.M PythonVersionMin, formatters=[Formatter.RUFF_FORMAT], ) - with mock.patch("subprocess.run") as mock_run: + with ( + mock.patch.object(formatter, "_find_ruff_path", return_value="/tmp/venv/bin/ruff"), + mock.patch("subprocess.run") as mock_run, + ): mock_run.return_value.stdout = b"output" formatted_code = formatter.format_code("input") assert formatted_code == "output" mock_run.assert_called_once_with( - ("ruff", "format", "-"), input=b"input", capture_output=True, check=False, cwd=str(tmp_path) + ("/tmp/venv/bin/ruff", "format", "-"), + input=b"input", + capture_output=True, + check=False, + cwd=str(tmp_path), ) @@ -186,13 +193,16 @@ def test_format_code_ruff_check_formatter(tmp_path: Path, monkeypatch: pytest.Mo PythonVersionMin, formatters=[Formatter.RUFF_CHECK], ) - with mock.patch("subprocess.run") as mock_run: + with ( + mock.patch.object(formatter, "_find_ruff_path", return_value="/tmp/venv/bin/ruff"), + mock.patch("subprocess.run") as mock_run, + ): mock_run.return_value.stdout = b"output" formatted_code = formatter.format_code("input") assert formatted_code == "output" mock_run.assert_called_once_with( - ("ruff", "check", "--fix", "--unsafe-fixes", "-"), + ("/tmp/venv/bin/ruff", "check", "--fix", "--unsafe-fixes", "-"), input=b"input", capture_output=True, check=False, @@ -210,13 +220,16 @@ def test_format_code_ruff_check_formatter_without_type_checking_imports( formatters=[Formatter.RUFF_CHECK], use_type_checking_imports=False, ) - with mock.patch("subprocess.run") as mock_run: + with ( + mock.patch.object(formatter, "_find_ruff_path", return_value="/tmp/venv/bin/ruff"), + mock.patch("subprocess.run") as mock_run, + ): mock_run.return_value.stdout = b"output" formatted_code = formatter.format_code("input") assert formatted_code == "output" mock_run.assert_called_once_with( - ("ruff", "check", "--fix", "--unsafe-fixes", "--unfixable", "TC001,TC002,TC003", "-"), + ("/tmp/venv/bin/ruff", "check", "--fix", "--unsafe-fixes", "--unfixable", "TC001,TC002,TC003", "-"), input=b"input", capture_output=True, check=False, From b0fd2d150ab51bf269dc49021a044fe818c95b37 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 10 Mar 2026 05:24:37 +0000 Subject: [PATCH 06/13] Refine multi-module Ruff import defaults --- docs/cli-reference/index.md | 3 +- docs/cli-reference/quick-reference.md | 2 + docs/cli-reference/template-customization.md | 390 +++++++++++++++++- src/datamodel_code_generator/__init__.py | 19 +- src/datamodel_code_generator/arguments.py | 18 +- src/datamodel_code_generator/format.py | 6 +- src/datamodel_code_generator/model/base.py | 1 + .../model/pydantic_base.py | 2 + .../model/pydantic_v2/dataclass.py | 1 + src/datamodel_code_generator/parser/base.py | 68 +-- src/datamodel_code_generator/prompt_data.py | 1 + .../use_type_checking_imports_internal.py | 67 +++ tests/main/test_main_general.py | 88 +++- tests/test_format.py | 122 ++++-- 14 files changed, 701 insertions(+), 87 deletions(-) create mode 100644 tests/data/expected/main/openapi/use_type_checking_imports_internal.py diff --git a/docs/cli-reference/index.md b/docs/cli-reference/index.md index f7b102166..7f598d525 100644 --- a/docs/cli-reference/index.md +++ b/docs/cli-reference/index.md @@ -12,7 +12,7 @@ This documentation is auto-generated from test cases. | 🔧 [Typing Customization](typing-customization.md) | 29 | Type annotation and import behavior | | 🏷️ [Field Customization](field-customization.md) | 24 | Field naming and docstring behavior | | 🏗️ [Model Customization](model-customization.md) | 39 | Model generation behavior | -| 🎨 [Template Customization](template-customization.md) | 20 | Output formatting and custom rendering | +| 🎨 [Template Customization](template-customization.md) | 21 | Output formatting and custom rendering | | 📘 [OpenAPI-only Options](openapi-only-options.md) | 7 | OpenAPI-specific features | | 📋 [GraphQL-only Options](graphql-only-options.md) | 1 | | | ⚙️ [General Options](general-options.md) | 15 | Utilities and meta options | @@ -219,6 +219,7 @@ This documentation is auto-generated from test cases. - [`--use-title-as-name`](field-customization.md#use-title-as-name) - [`--use-tuple-for-fixed-items`](typing-customization.md#use-tuple-for-fixed-items) - [`--use-type-alias`](typing-customization.md#use-type-alias) +- [`--use-type-checking-imports`](template-customization.md#use-type-checking-imports) - [`--use-union-operator`](typing-customization.md#use-union-operator) - [`--use-unique-items-as-set`](typing-customization.md#use-unique-items-as-set) diff --git a/docs/cli-reference/quick-reference.md b/docs/cli-reference/quick-reference.md index 6c0067bf9..3d78642ed 100644 --- a/docs/cli-reference/quick-reference.md +++ b/docs/cli-reference/quick-reference.md @@ -156,6 +156,7 @@ datamodel-codegen [OPTIONS] | [`--treat-dot-as-module`](template-customization.md#treat-dot-as-module) | Treat dots in schema names as module separators. | | [`--use-double-quotes`](template-customization.md#use-double-quotes) | Use double quotes for string literals in generated code. | | [`--use-exact-imports`](template-customization.md#use-exact-imports) | Import exact types instead of modules. | +| [`--use-type-checking-imports`](template-customization.md#use-type-checking-imports) | Allow Ruff to move typing-only imports into TYPE_CHECKING blocks. | | [`--validators`](template-customization.md#validators) | Add custom field validators to generated Pydantic v2 models. | | [`--wrap-string-literal`](template-customization.md#wrap-string-literal) | Wrap long string literals across multiple lines. | @@ -357,6 +358,7 @@ All options sorted alphabetically: - [`--use-title-as-name`](field-customization.md#use-title-as-name) - Use schema title as the generated class name. - [`--use-tuple-for-fixed-items`](typing-customization.md#use-tuple-for-fixed-items) - Generate tuple types for arrays with items array syntax. - [`--use-type-alias`](typing-customization.md#use-type-alias) - Use TypeAlias instead of root models for type definitions (e... +- [`--use-type-checking-imports`](template-customization.md#use-type-checking-imports) - Allow Ruff to move typing-only imports into TYPE_CHECKING bl... - [`--use-union-operator`](typing-customization.md#use-union-operator) - Use | operator for Union types (PEP 604). - [`--use-unique-items-as-set`](typing-customization.md#use-unique-items-as-set) - Generate set types for arrays with uniqueItems constraint. - [`--validation`](openapi-only-options.md#validation) - Enable validation constraints (deprecated, use --field-const... diff --git a/docs/cli-reference/template-customization.md b/docs/cli-reference/template-customization.md index 3d290c897..b3924c0c7 100644 --- a/docs/cli-reference/template-customization.md +++ b/docs/cli-reference/template-customization.md @@ -22,6 +22,7 @@ | [`--treat-dot-as-module`](#treat-dot-as-module) | Treat dots in schema names as module separators. | | [`--use-double-quotes`](#use-double-quotes) | Use double quotes for string literals in generated code. | | [`--use-exact-imports`](#use-exact-imports) | Import exact types instead of modules. | +| [`--use-type-checking-imports`](#use-type-checking-imports) | Allow Ruff to move typing-only imports into TYPE_CHECKING bl... | | [`--validators`](#validators) | Add custom field validators to generated Pydantic v2 models. | | [`--wrap-string-literal`](#wrap-string-literal) | Wrap long string literals across multiple lines. | @@ -2347,13 +2348,15 @@ Keep generated model imports available at runtime when using Ruff fixes. The `--no-use-type-checking-imports` flag prevents Ruff from moving generated model imports into `TYPE_CHECKING` blocks. This is useful for modular Pydantic output where referenced models need to be importable at runtime without calling `model_rebuild()` manually. +By default, TYPE_CHECKING imports stay enabled; `--use-type-checking-imports` explicitly +re-enables that default behavior when runtime imports were otherwise preserved. **Related:** [`--formatters`](template-customization.md#formatters), [`--use-exact-imports`](template-customization.md#use-exact-imports) !!! tip "Usage" ```bash - datamodel-codegen --input schema.json --formatters ruff-check ruff-format --no-use-type-checking-imports --disable-timestamp # (1)! + datamodel-codegen --input schema.json --output-model-type pydantic_v2.BaseModel --formatters ruff-check ruff-format --no-use-type-checking-imports --disable-timestamp # (1)! ``` 1. :material-arrow-left: `--no-use-type-checking-imports` - the option documented here @@ -3009,6 +3012,391 @@ modules are generated. For single-file output, the difference is minimal. --- +## `--use-type-checking-imports` {#use-type-checking-imports} + +Allow Ruff to move typing-only imports into TYPE_CHECKING blocks. + +The `--use-type-checking-imports` flag explicitly re-enables Ruff's TYPE_CHECKING import moves +for multi-module Pydantic output where runtime imports might otherwise be preserved by default. + +**Related:** [`--formatters`](template-customization.md#formatters), [`--no-use-type-checking-imports`](template-customization.md#no-use-type-checking-imports), [`--use-exact-imports`](template-customization.md#use-exact-imports) + +!!! tip "Usage" + + ```bash + datamodel-codegen --input schema.json --output-model-type pydantic_v2.BaseModel --formatters ruff-check ruff-format --use-type-checking-imports --disable-timestamp # (1)! + ``` + + 1. :material-arrow-left: `--use-type-checking-imports` - the option documented here + +??? example "Examples" + + **Input Schema:** + + ```yaml + openapi: "3.0.0" + info: + version: 1.0.0 + title: Modular Swagger Petstore + license: + name: MIT + servers: + - url: http://petstore.swagger.io/v1 + paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: A paged array of pets + headers: + x-next: + description: A link to the next page of responses + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/collections.Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + x-amazon-apigateway-integration: + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${PythonVersionFunction.Arn}/invocations + passthroughBehavior: when_no_templates + httpMethod: POST + type: aws_proxy + post: + summary: Create a pet + operationId: createPets + tags: + - pets + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + x-amazon-apigateway-integration: + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${PythonVersionFunction.Arn}/invocations + passthroughBehavior: when_no_templates + httpMethod: POST + type: aws_proxy + /pets/{petId}: + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/collections.Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + x-amazon-apigateway-integration: + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${PythonVersionFunction.Arn}/invocations + passthroughBehavior: when_no_templates + httpMethod: POST + type: aws_proxy + components: + schemas: + models.Species: + type: string + enum: + - dog + - cat + - snake + models.Pet: + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + species: + $ref: '#/components/schemas/models.Species' + models.User: + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + collections.Pets: + type: array + items: + $ref: "#/components/schemas/models.Pet" + collections.Users: + type: array + items: + $ref: "#/components/schemas/models.User" + optional: + type: string + Id: + type: string + collections.Rules: + type: array + items: + type: string + Error: + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string + collections.apis: + type: array + items: + type: object + properties: + apiKey: + type: string + description: To be used as a dataset parameter value + apiVersionNumber: + type: string + description: To be used as a version parameter value + apiUrl: + type: string + format: uri + description: "The URL describing the dataset's fields" + apiDocumentationUrl: + type: string + format: uri + description: A URL to the API console for each API + stage: + type: string + enum: [ + "test", + "dev", + "stg", + "prod" + ] + models.Event: + type: object + properties: + name: + anyOf: + - type: string + - type: number + - type: integer + - type: boolean + - type: object + - type: array + items: + type: string + Result: + type: object + properties: + event: + $ref: '#/components/schemas/models.Event' + foo.bar.Thing: + properties: + attributes: + type: object + foo.bar.Thang: + properties: + attributes: + type: array + items: + type: object + foo.bar.Clone: + allOf: + - $ref: '#/components/schemas/foo.bar.Thing' + - type: object + properties: + others: + type: object + properties: + name: + type: string + + foo.Tea: + properties: + flavour: + type: string + id: + $ref: '#/components/schemas/Id' + Source: + properties: + country: + type: string + foo.Cocoa: + properties: + quality: + type: integer + bar.Field: + type: string + example: green + woo.boo.Chocolate: + properties: + flavour: + type: string + source: + $ref: '#/components/schemas/Source' + cocoa: + $ref: '#/components/schemas/foo.Cocoa' + field: + $ref: '#/components/schemas/bar.Field' + differentTea: + type: object + properties: + foo: + $ref: '#/components/schemas/foo.Tea' + nested: + $ref: '#/components/schemas/nested.foo.Tea' + nested.foo.Tea: + properties: + flavour: + type: string + id: + $ref: '#/components/schemas/Id' + self: + $ref: '#/components/schemas/nested.foo.Tea' + optional: + type: array + items: + $ref: '#/components/schemas/optional' + nested.foo.TeaClone: + properties: + flavour: + type: string + id: + $ref: '#/components/schemas/Id' + self: + $ref: '#/components/schemas/nested.foo.Tea' + optional: + type: array + items: + $ref: '#/components/schemas/optional' + nested.foo.List: + type: array + items: + $ref: '#/components/schemas/nested.foo.Tea' + ``` + + **Output:** + + ```python + # generated by datamodel-codegen: + # filename: _internal + + from __future__ import annotations + + from typing import TYPE_CHECKING + + from pydantic import BaseModel, RootModel + + if TYPE_CHECKING: + from . import models + + + class Optional(RootModel[str]): + root: str + + + class Id(RootModel[str]): + root: str + + + class Error(BaseModel): + code: int + message: str + + + class Result(BaseModel): + event: models.Event | None = None + + + class Source(BaseModel): + country: str | None = None + + + class DifferentTea(BaseModel): + foo: Tea | None = None + nested: Tea_1 | None = None + + + class Tea(BaseModel): + flavour: str | None = None + id: Id | None = None + + + class Cocoa(BaseModel): + quality: int | None = None + + + class Tea_1(BaseModel): + flavour: str | None = None + id: Id | None = None + self: Tea_1 | None = None + optional: list[Optional] | None = None + + + class TeaClone(BaseModel): + flavour: str | None = None + id: Id | None = None + self: Tea_1 | None = None + optional: list[Optional] | None = None + + + class List(RootModel[list[Tea_1]]): + root: list[Tea_1] + + + Tea_1.model_rebuild() + ``` + +--- + ## `--validators` {#validators} Add custom field validators to generated Pydantic v2 models. diff --git a/src/datamodel_code_generator/__init__.py b/src/datamodel_code_generator/__init__.py index 4147804a7..b49b9693a 100644 --- a/src/datamodel_code_generator/__init__.py +++ b/src/datamodel_code_generator/__init__.py @@ -691,14 +691,6 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]: OpenAPIParserConfig, ) - effective_use_type_checking_imports = resolve_use_type_checking_imports( - config.use_type_checking_imports, - defer_formatting=defer_formatting, - formatters=config.formatters, - is_pydantic_output=config.output_model_type - in {DataModelType.PydanticV2BaseModel, DataModelType.PydanticV2Dataclass}, - ) - additional_options: ParserConfigDict = { "data_model_type": data_model_types.data_model, "data_model_root_type": data_model_types.root_model, @@ -722,7 +714,7 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]: "target_date_class": config.output_date_class, "dataclass_arguments": dataclass_arguments, "defer_formatting": defer_formatting, - "use_type_checking_imports": effective_use_type_checking_imports, + "use_type_checking_imports": config.use_type_checking_imports, "enum_field_as_literal": ( config.enum_field_as_literal if config.enum_field_as_literal is not None @@ -913,6 +905,14 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]: and config.formatters and (Formatter.RUFF_CHECK in config.formatters or Formatter.RUFF_FORMAT in config.formatters) ): + effective_use_type_checking_imports = resolve_use_type_checking_imports( + config.use_type_checking_imports, + is_multi_module_output=True, + formatters=config.formatters, + preserve_runtime_imports_for_multi_module_ruff=( + data_model_types.data_model.PRESERVE_RUNTIME_IMPORTS_FOR_MULTI_MODULE_RUFF + ), + ) code_formatter = CodeFormatter( config.target_python_version, config.settings_path, @@ -924,6 +924,7 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]: encoding=config.encoding, formatters=config.formatters, use_type_checking_imports=effective_use_type_checking_imports, + defer_formatting=True, ) code_formatter.format_directory(output) diff --git a/src/datamodel_code_generator/arguments.py b/src/datamodel_code_generator/arguments.py index bc810d5bc..92c654a7c 100644 --- a/src/datamodel_code_generator/arguments.py +++ b/src/datamodel_code_generator/arguments.py @@ -418,15 +418,6 @@ def start_section(self, heading: str | None) -> None: action="store_true", default=None, ) -model_options.add_argument( - "--use-type-checking-imports", - help="Allow Ruff to move typing-only imports into TYPE_CHECKING blocks. " - "By default this stays enabled, except for deferred Ruff formatting of modular Pydantic output " - "where referenced models stay imported at runtime. " - "Use --no-use-type-checking-imports to force runtime imports.", - action=BooleanOptionalAction, - default=None, -) model_options.add_argument( "--output-datetime-class", help="Choose Datetime class between AwareDatetime, NaiveDatetime, PastDatetime, FutureDatetime or datetime. " @@ -924,6 +915,15 @@ def start_section(self, heading: str | None) -> None: "Keys are model names, values contain validator definitions with field, function, and mode.", type=Path, ) +template_options.add_argument( + "--use-type-checking-imports", + help="Allow Ruff to move typing-only imports into TYPE_CHECKING blocks. " + "By default this stays enabled, except for multi-module Ruff formatting of modular Pydantic output " + "where referenced models stay imported at runtime. " + "Use --no-use-type-checking-imports to force runtime imports.", + action=BooleanOptionalAction, + default=None, +) template_options.add_argument( "--use-double-quotes", action="store_true", diff --git a/src/datamodel_code_generator/format.py b/src/datamodel_code_generator/format.py index 7c6c582ac..9b6a7dc99 100644 --- a/src/datamodel_code_generator/format.py +++ b/src/datamodel_code_generator/format.py @@ -206,16 +206,16 @@ class Formatter(Enum): def resolve_use_type_checking_imports( use_type_checking_imports: bool | None, # noqa: FBT001 *, - defer_formatting: bool, + is_multi_module_output: bool, formatters: list[Formatter] | None, - is_pydantic_output: bool, + preserve_runtime_imports_for_multi_module_ruff: bool, ) -> bool: """Resolve the effective TYPE_CHECKING import behavior.""" if use_type_checking_imports is not None: return use_type_checking_imports has_ruff = bool(formatters) and (Formatter.RUFF_CHECK in formatters or Formatter.RUFF_FORMAT in formatters) - return not (defer_formatting and has_ruff and is_pydantic_output) + return not (is_multi_module_output and has_ruff and preserve_runtime_imports_for_multi_module_ruff) class CodeFormatter: diff --git a/src/datamodel_code_generator/model/base.py b/src/datamodel_code_generator/model/base.py index 905842de2..fd8329a6d 100644 --- a/src/datamodel_code_generator/model/base.py +++ b/src/datamodel_code_generator/model/base.py @@ -600,6 +600,7 @@ class DataModel(TemplateBase, Nullable, ABC): # noqa: PLR0904 SUPPORTS_FIELD_RENAMING: ClassVar[bool] = False SUPPORTS_WRAPPED_DEFAULT: ClassVar[bool] = False SUPPORTS_KW_ONLY: ClassVar[bool] = False + PRESERVE_RUNTIME_IMPORTS_FOR_MULTI_MODULE_RUFF: ClassVar[bool] = False has_forward_reference: bool = False def __init__( # noqa: PLR0913 diff --git a/src/datamodel_code_generator/model/pydantic_base.py b/src/datamodel_code_generator/model/pydantic_base.py index a34c95f7d..04f8a70ac 100644 --- a/src/datamodel_code_generator/model/pydantic_base.py +++ b/src/datamodel_code_generator/model/pydantic_base.py @@ -304,6 +304,8 @@ def imports(self) -> tuple[Import, ...]: class BaseModelBase(DataModel, ABC): """Abstract base class for Pydantic BaseModel implementations.""" + PRESERVE_RUNTIME_IMPORTS_FOR_MULTI_MODULE_RUFF: ClassVar[bool] = True + def __init__( # noqa: PLR0913 self, *, diff --git a/src/datamodel_code_generator/model/pydantic_v2/dataclass.py b/src/datamodel_code_generator/model/pydantic_v2/dataclass.py index ad5151ead..3d8544c84 100644 --- a/src/datamodel_code_generator/model/pydantic_v2/dataclass.py +++ b/src/datamodel_code_generator/model/pydantic_v2/dataclass.py @@ -33,6 +33,7 @@ class DataClass(DataModel): TEMPLATE_FILE_PATH: ClassVar[str] = "pydantic_v2/dataclass.jinja2" DEFAULT_IMPORTS: ClassVar[tuple[Import, ...]] = (IMPORT_PYDANTIC_DATACLASS,) + PRESERVE_RUNTIME_IMPORTS_FOR_MULTI_MODULE_RUFF: ClassVar[bool] = True SUPPORTS_DISCRIMINATOR: ClassVar[bool] = True SUPPORTS_KW_ONLY: ClassVar[bool] = True # frozen/allow_mutation are handled as dataclass decorator arguments, not ConfigDict diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index ba4e0540d..e4ee7c6d0 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -3013,11 +3013,9 @@ def __get_resolve_reference_action_parts( ), ] - def _prepare_parse_config( # noqa: PLR0913, PLR0917 + def _prepare_parse_config( self, with_import: bool | None, # noqa: FBT001 - format_: bool | None, # noqa: FBT001 - settings_path: Path | None, disable_future_imports: bool, # noqa: FBT001 all_exports_scope: AllExportsScope | None, all_exports_collision_strategy: AllExportsCollisionStrategy | None, @@ -3035,39 +3033,43 @@ def _prepare_parse_config( # noqa: PLR0913, PLR0917 ): self.imports.append(IMPORT_ANNOTATIONS) - code_formatter: CodeFormatter | None = None - if format_: - effective_use_type_checking_imports = resolve_use_type_checking_imports( - self.use_type_checking_imports, - defer_formatting=self.defer_formatting, - formatters=self.formatters, - is_pydantic_output=self.data_model_type.__module__.startswith( - "datamodel_code_generator.model.pydantic_v2" - ), - ) - code_formatter = CodeFormatter( - self.target_python_version, - settings_path, - self.wrap_string_literal, - skip_string_normalization=not self.use_double_quotes, - known_third_party=self.known_third_party, - custom_formatters=self.custom_formatter, - custom_formatters_kwargs=self.custom_formatters_kwargs, - encoding=self.encoding, - formatters=self.formatters, - use_type_checking_imports=effective_use_type_checking_imports, - defer_formatting=self.defer_formatting, - ) - return ParseConfig( with_import=bool(with_import), use_deferred_annotations=use_deferred_annotations, - code_formatter=code_formatter, + code_formatter=None, module_split_mode=module_split_mode, all_exports_scope=all_exports_scope, all_exports_collision_strategy=all_exports_collision_strategy, ) + def _build_code_formatter( + self, + settings_path: Path | None, + *, + is_multi_module_output: bool, + ) -> CodeFormatter: + effective_use_type_checking_imports = resolve_use_type_checking_imports( + self.use_type_checking_imports, + is_multi_module_output=is_multi_module_output, + formatters=self.formatters, + preserve_runtime_imports_for_multi_module_ruff=( + self.data_model_type.PRESERVE_RUNTIME_IMPORTS_FOR_MULTI_MODULE_RUFF + ), + ) + return CodeFormatter( + self.target_python_version, + settings_path, + self.wrap_string_literal, + skip_string_normalization=not self.use_double_quotes, + known_third_party=self.known_third_party, + custom_formatters=self.custom_formatter, + custom_formatters_kwargs=self.custom_formatters_kwargs, + encoding=self.encoding, + formatters=self.formatters, + use_type_checking_imports=effective_use_type_checking_imports, + defer_formatting=self.defer_formatting, + ) + def _build_module_structure( self, sorted_data_models: SortedDataModels, @@ -3352,8 +3354,6 @@ def parse( # noqa: PLR0913, PLR0914, PLR0917 config = self._prepare_parse_config( with_import, - format_, - settings_path, disable_future_imports, all_exports_scope, all_exports_collision_strategy, @@ -3372,6 +3372,14 @@ def parse( # noqa: PLR0913, PLR0914, PLR0917 model_path_to_module_name, ) = self._build_module_structure(sorted_data_models, require_update_action_models, module_split_mode) + if format_: + config = config._replace( + code_formatter=self._build_code_formatter( + settings_path, + is_multi_module_output=self.defer_formatting or len(module_models) > 1, + ) + ) + results: dict[ModulePath, Result] = {} unused_models: list[DataModel] = [] module_to_import: dict[ModulePath, Imports] = {} diff --git a/src/datamodel_code_generator/prompt_data.py b/src/datamodel_code_generator/prompt_data.py index 68c716e8f..6247e1d81 100644 --- a/src/datamodel_code_generator/prompt_data.py +++ b/src/datamodel_code_generator/prompt_data.py @@ -145,6 +145,7 @@ "--use-title-as-name": "Use schema title as the generated class name.", "--use-tuple-for-fixed-items": "Generate tuple types for arrays with items array syntax.", "--use-type-alias": "Use TypeAlias instead of root models for type definitions (experimental).", + "--use-type-checking-imports": "Allow Ruff to move typing-only imports into TYPE_CHECKING blocks.", "--use-union-operator": "Use | operator for Union types (PEP 604).", "--use-unique-items-as-set": "Generate set types for arrays with uniqueItems constraint.", "--validation": "Enable validation constraints (deprecated, use --field-constraints).", diff --git a/tests/data/expected/main/openapi/use_type_checking_imports_internal.py b/tests/data/expected/main/openapi/use_type_checking_imports_internal.py new file mode 100644 index 000000000..0fe8e0360 --- /dev/null +++ b/tests/data/expected/main/openapi/use_type_checking_imports_internal.py @@ -0,0 +1,67 @@ +# generated by datamodel-codegen: +# filename: _internal + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import BaseModel, RootModel + +if TYPE_CHECKING: + from . import models + + +class Optional(RootModel[str]): + root: str + + +class Id(RootModel[str]): + root: str + + +class Error(BaseModel): + code: int + message: str + + +class Result(BaseModel): + event: models.Event | None = None + + +class Source(BaseModel): + country: str | None = None + + +class DifferentTea(BaseModel): + foo: Tea | None = None + nested: Tea_1 | None = None + + +class Tea(BaseModel): + flavour: str | None = None + id: Id | None = None + + +class Cocoa(BaseModel): + quality: int | None = None + + +class Tea_1(BaseModel): + flavour: str | None = None + id: Id | None = None + self: Tea_1 | None = None + optional: list[Optional] | None = None + + +class TeaClone(BaseModel): + flavour: str | None = None + id: Id | None = None + self: Tea_1 | None = None + optional: list[Optional] | None = None + + +class List(RootModel[list[Tea_1]]): + root: list[Tea_1] + + +Tea_1.model_rebuild() diff --git a/tests/main/test_main_general.py b/tests/main/test_main_general.py index 291011ffa..f61defe94 100644 --- a/tests/main/test_main_general.py +++ b/tests/main/test_main_general.py @@ -25,7 +25,7 @@ from datamodel_code_generator.__main__ import Config, Exit from datamodel_code_generator.arguments import _dataclass_arguments from datamodel_code_generator.config import GenerateConfig -from datamodel_code_generator.format import CodeFormatter, PythonVersion +from datamodel_code_generator.format import CodeFormatter, Formatter, PythonVersion from datamodel_code_generator.model.pydantic_v2 import UnionMode from datamodel_code_generator.parser.openapi import OpenAPIParser from tests.conftest import assert_output, create_assert_file_content, freeze_time @@ -1694,7 +1694,14 @@ def test_type_checking_imports_default_to_runtime_imports_for_modular_pydantic_r input_path=OPEN_API_DATA_PATH / "modular.yaml", output_path=output_dir, input_file_type="openapi", - extra_args=["--formatters", "ruff-check", "ruff-format", "--disable-timestamp"], + extra_args=[ + "--output-model-type", + "pydantic_v2.BaseModel", + "--formatters", + "ruff-check", + "ruff-format", + "--disable-timestamp", + ], ) internal_path = output_dir / "_internal.py" @@ -1722,9 +1729,19 @@ def test_type_checking_imports_default_to_runtime_imports_for_modular_pydantic_r The `--no-use-type-checking-imports` flag prevents Ruff from moving generated model imports into `TYPE_CHECKING` blocks. This is useful for modular Pydantic output where referenced -models need to be importable at runtime without calling `model_rebuild()` manually.""", +models need to be importable at runtime without calling `model_rebuild()` manually. +By default, TYPE_CHECKING imports stay enabled; `--use-type-checking-imports` explicitly +re-enables that default behavior when runtime imports were otherwise preserved.""", input_schema="openapi/modular.yaml", - cli_args=["--formatters", "ruff-check", "ruff-format", "--no-use-type-checking-imports", "--disable-timestamp"], + cli_args=[ + "--output-model-type", + "pydantic_v2.BaseModel", + "--formatters", + "ruff-check", + "ruff-format", + "--no-use-type-checking-imports", + "--disable-timestamp", + ], golden_output="openapi/no_use_type_checking_imports_internal.py", related_options=["--use-type-checking-imports", "--formatters", "--use-exact-imports"], ) @@ -1734,6 +1751,8 @@ def test_no_use_type_checking_imports(output_dir: Path) -> None: The `--no-use-type-checking-imports` flag prevents Ruff from moving generated model imports into `TYPE_CHECKING` blocks. This is useful for modular Pydantic output where referenced models need to be importable at runtime without calling `model_rebuild()` manually. + By default, TYPE_CHECKING imports stay enabled; `--use-type-checking-imports` explicitly + re-enables that default behavior when runtime imports were otherwise preserved. """ import importlib import sys @@ -1743,6 +1762,8 @@ def test_no_use_type_checking_imports(output_dir: Path) -> None: output_path=output_dir, input_file_type="openapi", extra_args=[ + "--output-model-type", + "pydantic_v2.BaseModel", "--formatters", "ruff-check", "ruff-format", @@ -1770,6 +1791,65 @@ def test_no_use_type_checking_imports(output_dir: Path) -> None: del sys.modules[name] +def test_generate_multi_module_pydantic_ruff_defaults_to_runtime_imports() -> None: + """Test generate() keeps runtime imports for multi-module Pydantic Ruff output.""" + result = generate( + OPEN_API_DATA_PATH / "modular.yaml", + input_file_type=InputFileType.OpenAPI, + output=None, + output_model_type=DataModelType.PydanticV2BaseModel, + formatters=[Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT], + disable_timestamp=True, + ) + + assert isinstance(result, dict) + internal = result["_internal.py",] + assert "TYPE_CHECKING" not in internal + assert "from . import models" in internal + assert "Tea_1.model_rebuild()" in internal + + +@pytest.mark.cli_doc( + options=["--use-type-checking-imports"], + option_description="""Allow Ruff to move typing-only imports into TYPE_CHECKING blocks. + +The `--use-type-checking-imports` flag explicitly re-enables Ruff's TYPE_CHECKING import moves +for multi-module Pydantic output where runtime imports might otherwise be preserved by default.""", + input_schema="openapi/modular.yaml", + cli_args=[ + "--output-model-type", + "pydantic_v2.BaseModel", + "--formatters", + "ruff-check", + "ruff-format", + "--use-type-checking-imports", + "--disable-timestamp", + ], + golden_output="openapi/use_type_checking_imports_internal.py", + related_options=["--no-use-type-checking-imports", "--formatters", "--use-exact-imports"], +) +def test_use_type_checking_imports_for_multi_module_pydantic_ruff() -> None: + """Allow Ruff to move typing-only imports into TYPE_CHECKING blocks. + + The `--use-type-checking-imports` flag explicitly re-enables Ruff's TYPE_CHECKING import moves + for multi-module Pydantic output where runtime imports might otherwise be preserved by default. + """ + result = generate( + OPEN_API_DATA_PATH / "modular.yaml", + input_file_type=InputFileType.OpenAPI, + output=None, + output_model_type=DataModelType.PydanticV2BaseModel, + formatters=[Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT], + use_type_checking_imports=True, + disable_timestamp=True, + ) + + assert isinstance(result, dict) + internal = result["_internal.py",] + assert "TYPE_CHECKING" in internal + assert internal == (EXPECTED_MAIN_PATH / "openapi" / "use_type_checking_imports_internal.py").read_text().rstrip() + + def test_generate_returns_string_when_output_none() -> None: """Test that generate() returns str when output=None for single file.""" json_schema = '{"type": "object", "properties": {"name": {"type": "string"}}}' diff --git a/tests/test_format.py b/tests/test_format.py index 6f8c2e987..35e65f9bf 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -24,6 +24,7 @@ NOT_SUBCLASS_FORMATTER = "tests.data.python.custom_formatters.not_subclass" ADD_COMMENT_FORMATTER = "tests.data.python.custom_formatters.add_comment" ADD_LICENSE_FORMATTER = "tests.data.python.custom_formatters.add_license" +FAKE_RUFF_PATH = "/opt/fake-ruff/bin/ruff" def test_python_version() -> None: @@ -170,7 +171,7 @@ def test_format_code_ruff_format_formatter(tmp_path: Path, monkeypatch: pytest.M formatters=[Formatter.RUFF_FORMAT], ) with ( - mock.patch.object(formatter, "_find_ruff_path", return_value="/tmp/venv/bin/ruff"), + mock.patch.object(formatter, "_find_ruff_path", return_value=FAKE_RUFF_PATH), mock.patch("subprocess.run") as mock_run, ): mock_run.return_value.stdout = b"output" @@ -178,7 +179,7 @@ def test_format_code_ruff_format_formatter(tmp_path: Path, monkeypatch: pytest.M assert formatted_code == "output" mock_run.assert_called_once_with( - ("/tmp/venv/bin/ruff", "format", "-"), + (FAKE_RUFF_PATH, "format", "-"), input=b"input", capture_output=True, check=False, @@ -194,7 +195,7 @@ def test_format_code_ruff_check_formatter(tmp_path: Path, monkeypatch: pytest.Mo formatters=[Formatter.RUFF_CHECK], ) with ( - mock.patch.object(formatter, "_find_ruff_path", return_value="/tmp/venv/bin/ruff"), + mock.patch.object(formatter, "_find_ruff_path", return_value=FAKE_RUFF_PATH), mock.patch("subprocess.run") as mock_run, ): mock_run.return_value.stdout = b"output" @@ -202,7 +203,7 @@ def test_format_code_ruff_check_formatter(tmp_path: Path, monkeypatch: pytest.Mo assert formatted_code == "output" mock_run.assert_called_once_with( - ("/tmp/venv/bin/ruff", "check", "--fix", "--unsafe-fixes", "-"), + (FAKE_RUFF_PATH, "check", "--fix", "--unsafe-fixes", "-"), input=b"input", capture_output=True, check=False, @@ -221,7 +222,7 @@ def test_format_code_ruff_check_formatter_without_type_checking_imports( use_type_checking_imports=False, ) with ( - mock.patch.object(formatter, "_find_ruff_path", return_value="/tmp/venv/bin/ruff"), + mock.patch.object(formatter, "_find_ruff_path", return_value=FAKE_RUFF_PATH), mock.patch("subprocess.run") as mock_run, ): mock_run.return_value.stdout = b"output" @@ -229,7 +230,7 @@ def test_format_code_ruff_check_formatter_without_type_checking_imports( assert formatted_code == "output" mock_run.assert_called_once_with( - ("/tmp/venv/bin/ruff", "check", "--fix", "--unsafe-fixes", "--unfixable", "TC001,TC002,TC003", "-"), + (FAKE_RUFF_PATH, "check", "--fix", "--unsafe-fixes", "--unfixable", "TC001,TC002,TC003", "-"), input=b"input", capture_output=True, check=False, @@ -243,9 +244,9 @@ def test_resolve_use_type_checking_imports_respects_explicit_value(explicit_valu assert ( resolve_use_type_checking_imports( explicit_value, - defer_formatting=True, + is_multi_module_output=True, formatters=[Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT], - is_pydantic_output=True, + preserve_runtime_imports_for_multi_module_ruff=True, ) is explicit_value ) @@ -255,9 +256,9 @@ def test_resolve_use_type_checking_imports_defaults_to_runtime_imports_for_defer """Test deferred Ruff formatting keeps runtime imports for modular Pydantic output by default.""" assert not resolve_use_type_checking_imports( None, - defer_formatting=True, + is_multi_module_output=True, formatters=[Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT], - is_pydantic_output=True, + preserve_runtime_imports_for_multi_module_ruff=True, ) @@ -265,15 +266,15 @@ def test_resolve_use_type_checking_imports_keeps_existing_default_outside_deferr """Test non-modular or non-Pydantic output keeps TYPE_CHECKING imports enabled by default.""" assert resolve_use_type_checking_imports( None, - defer_formatting=False, + is_multi_module_output=False, formatters=[Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT], - is_pydantic_output=True, + preserve_runtime_imports_for_multi_module_ruff=True, ) assert resolve_use_type_checking_imports( None, - defer_formatting=True, + is_multi_module_output=True, formatters=[Formatter.RUFF_CHECK], - is_pydantic_output=False, + preserve_runtime_imports_for_multi_module_ruff=False, ) @@ -287,7 +288,7 @@ def test_format_code_ruff_check_and_format_uses_resolved_ruff_path( formatters=[Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT], ) with ( - mock.patch.object(formatter, "_find_ruff_path", return_value="/tmp/venv/bin/ruff") as mock_find_ruff_path, + mock.patch.object(formatter, "_find_ruff_path", return_value=FAKE_RUFF_PATH) as mock_find_ruff_path, mock.patch("subprocess.run") as mock_run, ): mock_run.side_effect = [ @@ -300,14 +301,14 @@ def test_format_code_ruff_check_and_format_uses_resolved_ruff_path( mock_find_ruff_path.assert_called_once_with() assert mock_run.call_args_list == [ mock.call( - ("/tmp/venv/bin/ruff", "check", "--fix", "--unsafe-fixes", "-"), + (FAKE_RUFF_PATH, "check", "--fix", "--unsafe-fixes", "-"), input=b"input", capture_output=True, check=False, cwd=str(tmp_path), ), mock.call( - ("/tmp/venv/bin/ruff", "format", "-"), + (FAKE_RUFF_PATH, "format", "-"), input=b"checked", capture_output=True, check=False, @@ -367,13 +368,13 @@ def test_format_directory_ruff_check(tmp_path: Path, monkeypatch: pytest.MonkeyP output_dir.mkdir() with ( - mock.patch.object(formatter, "_find_ruff_path", return_value="/tmp/venv/bin/ruff"), + mock.patch.object(formatter, "_find_ruff_path", return_value=FAKE_RUFF_PATH), mock.patch("subprocess.run") as mock_run, ): formatter.format_directory(output_dir) mock_run.assert_called_once_with( - ("/tmp/venv/bin/ruff", "check", "--fix", "--unsafe-fixes", str(output_dir)), + (FAKE_RUFF_PATH, "check", "--fix", "--unsafe-fixes", str(output_dir)), capture_output=True, check=False, cwd=str(tmp_path), @@ -391,13 +392,13 @@ def test_format_directory_ruff_format(tmp_path: Path, monkeypatch: pytest.Monkey output_dir.mkdir() with ( - mock.patch.object(formatter, "_find_ruff_path", return_value="/tmp/venv/bin/ruff"), + mock.patch.object(formatter, "_find_ruff_path", return_value=FAKE_RUFF_PATH), mock.patch("subprocess.run") as mock_run, ): formatter.format_directory(output_dir) mock_run.assert_called_once_with( - ("/tmp/venv/bin/ruff", "format", str(output_dir)), + (FAKE_RUFF_PATH, "format", str(output_dir)), capture_output=True, check=False, cwd=str(tmp_path), @@ -415,20 +416,81 @@ def test_format_directory_both_ruff_formatters(tmp_path: Path, monkeypatch: pyte output_dir.mkdir() with ( - mock.patch.object(formatter, "_find_ruff_path", return_value="/tmp/venv/bin/ruff"), + mock.patch.object(formatter, "_find_ruff_path", return_value=FAKE_RUFF_PATH), + mock.patch("subprocess.run") as mock_run, + ): + formatter.format_directory(output_dir) + + assert mock_run.call_count == 2 + mock_run.assert_any_call( + (FAKE_RUFF_PATH, "check", "--fix", "--unsafe-fixes", str(output_dir)), + capture_output=True, + check=False, + cwd=str(tmp_path), + ) + mock_run.assert_any_call( + (FAKE_RUFF_PATH, "format", str(output_dir)), + capture_output=True, + check=False, + cwd=str(tmp_path), + ) + + +def test_format_directory_ruff_check_without_type_checking_imports( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test format_directory keeps runtime imports when requested.""" + monkeypatch.chdir(tmp_path) + formatter = CodeFormatter( + PythonVersionMin, + formatters=[Formatter.RUFF_CHECK], + use_type_checking_imports=False, + ) + output_dir = tmp_path / "output" + output_dir.mkdir() + + with ( + mock.patch.object(formatter, "_find_ruff_path", return_value=FAKE_RUFF_PATH), + mock.patch("subprocess.run") as mock_run, + ): + formatter.format_directory(output_dir) + + mock_run.assert_called_once_with( + (FAKE_RUFF_PATH, "check", "--fix", "--unsafe-fixes", "--unfixable", "TC001,TC002,TC003", str(output_dir)), + capture_output=True, + check=False, + cwd=str(tmp_path), + ) + + +def test_format_directory_both_ruff_formatters_without_type_checking_imports( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test format_directory keeps runtime imports with both Ruff formatters.""" + monkeypatch.chdir(tmp_path) + formatter = CodeFormatter( + PythonVersionMin, + formatters=[Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT], + use_type_checking_imports=False, + ) + output_dir = tmp_path / "output" + output_dir.mkdir() + + with ( + mock.patch.object(formatter, "_find_ruff_path", return_value=FAKE_RUFF_PATH), mock.patch("subprocess.run") as mock_run, ): formatter.format_directory(output_dir) assert mock_run.call_count == 2 mock_run.assert_any_call( - ("/tmp/venv/bin/ruff", "check", "--fix", "--unsafe-fixes", str(output_dir)), + (FAKE_RUFF_PATH, "check", "--fix", "--unsafe-fixes", "--unfixable", "TC001,TC002,TC003", str(output_dir)), capture_output=True, check=False, cwd=str(tmp_path), ) mock_run.assert_any_call( - ("/tmp/venv/bin/ruff", "format", str(output_dir)), + (FAKE_RUFF_PATH, "format", str(output_dir)), capture_output=True, check=False, cwd=str(tmp_path), @@ -466,7 +528,7 @@ def test_generate_with_ruff_batch_formatting(tmp_path: Path) -> None: output_dir = tmp_path / "output" with ( - mock.patch("datamodel_code_generator.format.CodeFormatter._find_ruff_path", return_value="/tmp/venv/bin/ruff"), + mock.patch("datamodel_code_generator.format.CodeFormatter._find_ruff_path", return_value=FAKE_RUFF_PATH), mock.patch("datamodel_code_generator.format.subprocess.run") as mock_run, ): generate( @@ -479,7 +541,7 @@ def test_generate_with_ruff_batch_formatting(tmp_path: Path) -> None: assert mock_run.call_count == 2 mock_run.assert_any_call( ( - "/tmp/venv/bin/ruff", + FAKE_RUFF_PATH, "check", "--fix", "--unsafe-fixes", @@ -492,7 +554,7 @@ def test_generate_with_ruff_batch_formatting(tmp_path: Path) -> None: cwd=mock.ANY, ) mock_run.assert_any_call( - ("/tmp/venv/bin/ruff", "format", str(output_dir)), + (FAKE_RUFF_PATH, "format", str(output_dir)), capture_output=True, check=False, cwd=mock.ANY, @@ -514,7 +576,7 @@ def test_generate_with_ruff_batch_formatting_and_explicit_type_checking_imports( output_dir = tmp_path / "output" with ( - mock.patch("datamodel_code_generator.format.CodeFormatter._find_ruff_path", return_value="/tmp/venv/bin/ruff"), + mock.patch("datamodel_code_generator.format.CodeFormatter._find_ruff_path", return_value=FAKE_RUFF_PATH), mock.patch("datamodel_code_generator.format.subprocess.run") as mock_run, ): generate( @@ -527,13 +589,13 @@ def test_generate_with_ruff_batch_formatting_and_explicit_type_checking_imports( assert mock_run.call_count == 2 mock_run.assert_any_call( - ("/tmp/venv/bin/ruff", "check", "--fix", "--unsafe-fixes", str(output_dir)), + (FAKE_RUFF_PATH, "check", "--fix", "--unsafe-fixes", str(output_dir)), capture_output=True, check=False, cwd=mock.ANY, ) mock_run.assert_any_call( - ("/tmp/venv/bin/ruff", "format", str(output_dir)), + (FAKE_RUFF_PATH, "format", str(output_dir)), capture_output=True, check=False, cwd=mock.ANY, From af9bc8593e8822d80a54405e5b1c72fe3e89b1a8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Mar 2026 05:24:56 +0000 Subject: [PATCH 07/13] docs: update llms.txt files Generated by GitHub Actions --- docs/llms-full.txt | 395 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 393 insertions(+), 2 deletions(-) diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 4d21dc05e..8afb8d2f7 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -244,7 +244,7 @@ This documentation is auto-generated from test cases. | 🔧 [Typing Customization](typing-customization.md) | 29 | Type annotation and import behavior | | 🏷️ [Field Customization](field-customization.md) | 24 | Field naming and docstring behavior | | 🏗️ [Model Customization](model-customization.md) | 39 | Model generation behavior | -| 🎨 [Template Customization](template-customization.md) | 20 | Output formatting and custom rendering | +| 🎨 [Template Customization](template-customization.md) | 21 | Output formatting and custom rendering | | 📘 [OpenAPI-only Options](openapi-only-options.md) | 7 | OpenAPI-specific features | | 📋 [GraphQL-only Options](graphql-only-options.md) | 1 | | | ⚙️ [General Options](general-options.md) | 15 | Utilities and meta options | @@ -451,6 +451,7 @@ This documentation is auto-generated from test cases. - [`--use-title-as-name`](field-customization.md#use-title-as-name) - [`--use-tuple-for-fixed-items`](typing-customization.md#use-tuple-for-fixed-items) - [`--use-type-alias`](typing-customization.md#use-type-alias) +- [`--use-type-checking-imports`](template-customization.md#use-type-checking-imports) - [`--use-union-operator`](typing-customization.md#use-union-operator) - [`--use-unique-items-as-set`](typing-customization.md#use-unique-items-as-set) @@ -16211,6 +16212,7 @@ Source: https://datamodel-code-generator.koxudaxi.dev/cli-reference/template-cus | [`--treat-dot-as-module`](#treat-dot-as-module) | Treat dots in schema names as module separators. | | [`--use-double-quotes`](#use-double-quotes) | Use double quotes for string literals in generated code. | | [`--use-exact-imports`](#use-exact-imports) | Import exact types instead of modules. | +| [`--use-type-checking-imports`](#use-type-checking-imports) | Allow Ruff to move typing-only imports into TYPE_CHECKING bl... | | [`--validators`](#validators) | Add custom field validators to generated Pydantic v2 models. | | [`--wrap-string-literal`](#wrap-string-literal) | Wrap long string literals across multiple lines. | @@ -18536,13 +18538,15 @@ Keep generated model imports available at runtime when using Ruff fixes. The `--no-use-type-checking-imports` flag prevents Ruff from moving generated model imports into `TYPE_CHECKING` blocks. This is useful for modular Pydantic output where referenced models need to be importable at runtime without calling `model_rebuild()` manually. +By default, TYPE_CHECKING imports stay enabled; `--use-type-checking-imports` explicitly +re-enables that default behavior when runtime imports were otherwise preserved. **Related:** [`--formatters`](template-customization.md#formatters), [`--use-exact-imports`](template-customization.md#use-exact-imports) !!! tip "Usage" ```bash - datamodel-codegen --input schema.json --formatters ruff-check ruff-format --no-use-type-checking-imports --disable-timestamp # (1)! + datamodel-codegen --input schema.json --output-model-type pydantic_v2.BaseModel --formatters ruff-check ruff-format --no-use-type-checking-imports --disable-timestamp # (1)! ``` 1. :material-arrow-left: `--no-use-type-checking-imports` - the option documented here @@ -19198,6 +19202,391 @@ modules are generated. For single-file output, the difference is minimal. --- +## `--use-type-checking-imports` {#use-type-checking-imports} + +Allow Ruff to move typing-only imports into TYPE_CHECKING blocks. + +The `--use-type-checking-imports` flag explicitly re-enables Ruff's TYPE_CHECKING import moves +for multi-module Pydantic output where runtime imports might otherwise be preserved by default. + +**Related:** [`--formatters`](template-customization.md#formatters), [`--no-use-type-checking-imports`](template-customization.md#no-use-type-checking-imports), [`--use-exact-imports`](template-customization.md#use-exact-imports) + +!!! tip "Usage" + + ```bash + datamodel-codegen --input schema.json --output-model-type pydantic_v2.BaseModel --formatters ruff-check ruff-format --use-type-checking-imports --disable-timestamp # (1)! + ``` + + 1. :material-arrow-left: `--use-type-checking-imports` - the option documented here + +??? example "Examples" + + **Input Schema:** + + ```yaml + openapi: "3.0.0" + info: + version: 1.0.0 + title: Modular Swagger Petstore + license: + name: MIT + servers: + - url: http://petstore.swagger.io/v1 + paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: A paged array of pets + headers: + x-next: + description: A link to the next page of responses + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/collections.Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + x-amazon-apigateway-integration: + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${PythonVersionFunction.Arn}/invocations + passthroughBehavior: when_no_templates + httpMethod: POST + type: aws_proxy + post: + summary: Create a pet + operationId: createPets + tags: + - pets + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + x-amazon-apigateway-integration: + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${PythonVersionFunction.Arn}/invocations + passthroughBehavior: when_no_templates + httpMethod: POST + type: aws_proxy + /pets/{petId}: + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/collections.Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + x-amazon-apigateway-integration: + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${PythonVersionFunction.Arn}/invocations + passthroughBehavior: when_no_templates + httpMethod: POST + type: aws_proxy + components: + schemas: + models.Species: + type: string + enum: + - dog + - cat + - snake + models.Pet: + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + species: + $ref: '#/components/schemas/models.Species' + models.User: + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + collections.Pets: + type: array + items: + $ref: "#/components/schemas/models.Pet" + collections.Users: + type: array + items: + $ref: "#/components/schemas/models.User" + optional: + type: string + Id: + type: string + collections.Rules: + type: array + items: + type: string + Error: + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string + collections.apis: + type: array + items: + type: object + properties: + apiKey: + type: string + description: To be used as a dataset parameter value + apiVersionNumber: + type: string + description: To be used as a version parameter value + apiUrl: + type: string + format: uri + description: "The URL describing the dataset's fields" + apiDocumentationUrl: + type: string + format: uri + description: A URL to the API console for each API + stage: + type: string + enum: [ + "test", + "dev", + "stg", + "prod" + ] + models.Event: + type: object + properties: + name: + anyOf: + - type: string + - type: number + - type: integer + - type: boolean + - type: object + - type: array + items: + type: string + Result: + type: object + properties: + event: + $ref: '#/components/schemas/models.Event' + foo.bar.Thing: + properties: + attributes: + type: object + foo.bar.Thang: + properties: + attributes: + type: array + items: + type: object + foo.bar.Clone: + allOf: + - $ref: '#/components/schemas/foo.bar.Thing' + - type: object + properties: + others: + type: object + properties: + name: + type: string + + foo.Tea: + properties: + flavour: + type: string + id: + $ref: '#/components/schemas/Id' + Source: + properties: + country: + type: string + foo.Cocoa: + properties: + quality: + type: integer + bar.Field: + type: string + example: green + woo.boo.Chocolate: + properties: + flavour: + type: string + source: + $ref: '#/components/schemas/Source' + cocoa: + $ref: '#/components/schemas/foo.Cocoa' + field: + $ref: '#/components/schemas/bar.Field' + differentTea: + type: object + properties: + foo: + $ref: '#/components/schemas/foo.Tea' + nested: + $ref: '#/components/schemas/nested.foo.Tea' + nested.foo.Tea: + properties: + flavour: + type: string + id: + $ref: '#/components/schemas/Id' + self: + $ref: '#/components/schemas/nested.foo.Tea' + optional: + type: array + items: + $ref: '#/components/schemas/optional' + nested.foo.TeaClone: + properties: + flavour: + type: string + id: + $ref: '#/components/schemas/Id' + self: + $ref: '#/components/schemas/nested.foo.Tea' + optional: + type: array + items: + $ref: '#/components/schemas/optional' + nested.foo.List: + type: array + items: + $ref: '#/components/schemas/nested.foo.Tea' + ``` + + **Output:** + + ```python + # generated by datamodel-codegen: + # filename: _internal + + from __future__ import annotations + + from typing import TYPE_CHECKING + + from pydantic import BaseModel, RootModel + + if TYPE_CHECKING: + from . import models + + + class Optional(RootModel[str]): + root: str + + + class Id(RootModel[str]): + root: str + + + class Error(BaseModel): + code: int + message: str + + + class Result(BaseModel): + event: models.Event | None = None + + + class Source(BaseModel): + country: str | None = None + + + class DifferentTea(BaseModel): + foo: Tea | None = None + nested: Tea_1 | None = None + + + class Tea(BaseModel): + flavour: str | None = None + id: Id | None = None + + + class Cocoa(BaseModel): + quality: int | None = None + + + class Tea_1(BaseModel): + flavour: str | None = None + id: Id | None = None + self: Tea_1 | None = None + optional: list[Optional] | None = None + + + class TeaClone(BaseModel): + flavour: str | None = None + id: Id | None = None + self: Tea_1 | None = None + optional: list[Optional] | None = None + + + class List(RootModel[list[Tea_1]]): + root: list[Tea_1] + + + Tea_1.model_rebuild() + ``` + +--- + ## `--validators` {#validators} Add custom field validators to generated Pydantic v2 models. @@ -23582,6 +23971,7 @@ datamodel-codegen [OPTIONS] | [`--treat-dot-as-module`](template-customization.md#treat-dot-as-module) | Treat dots in schema names as module separators. | | [`--use-double-quotes`](template-customization.md#use-double-quotes) | Use double quotes for string literals in generated code. | | [`--use-exact-imports`](template-customization.md#use-exact-imports) | Import exact types instead of modules. | +| [`--use-type-checking-imports`](template-customization.md#use-type-checking-imports) | Allow Ruff to move typing-only imports into TYPE_CHECKING blocks. | | [`--validators`](template-customization.md#validators) | Add custom field validators to generated Pydantic v2 models. | | [`--wrap-string-literal`](template-customization.md#wrap-string-literal) | Wrap long string literals across multiple lines. | @@ -23783,6 +24173,7 @@ All options sorted alphabetically: - [`--use-title-as-name`](field-customization.md#use-title-as-name) - Use schema title as the generated class name. - [`--use-tuple-for-fixed-items`](typing-customization.md#use-tuple-for-fixed-items) - Generate tuple types for arrays with items array syntax. - [`--use-type-alias`](typing-customization.md#use-type-alias) - Use TypeAlias instead of root models for type definitions (e... +- [`--use-type-checking-imports`](template-customization.md#use-type-checking-imports) - Allow Ruff to move typing-only imports into TYPE_CHECKING bl... - [`--use-union-operator`](typing-customization.md#use-union-operator) - Use | operator for Union types (PEP 604). - [`--use-unique-items-as-set`](typing-customization.md#use-unique-items-as-set) - Generate set types for arrays with uniqueItems constraint. - [`--validation`](openapi-only-options.md#validation) - Enable validation constraints (deprecated, use --field-const... From c5f46c1582784c31b7e128739b2d883c99e1195a Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 10 Mar 2026 08:43:09 +0000 Subject: [PATCH 08/13] Clarify Ruff TYPE_CHECKING defaults --- docs/cli-reference/template-customization.md | 5 +++-- src/datamodel_code_generator/format.py | 4 ++-- tests/main/test_main_general.py | 10 ++++++---- tests/test_format.py | 6 ++++++ 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/cli-reference/template-customization.md b/docs/cli-reference/template-customization.md index b3924c0c7..1b0a41e77 100644 --- a/docs/cli-reference/template-customization.md +++ b/docs/cli-reference/template-customization.md @@ -2348,8 +2348,9 @@ Keep generated model imports available at runtime when using Ruff fixes. The `--no-use-type-checking-imports` flag prevents Ruff from moving generated model imports into `TYPE_CHECKING` blocks. This is useful for modular Pydantic output where referenced models need to be importable at runtime without calling `model_rebuild()` manually. -By default, TYPE_CHECKING imports stay enabled; `--use-type-checking-imports` explicitly -re-enables that default behavior when runtime imports were otherwise preserved. +In the multi-module Pydantic + `ruff-check` case, runtime imports are preserved by default. +`--use-type-checking-imports` opts back into the old TYPE_CHECKING-only behavior, which can +require manual `model_rebuild()` calls for cross-module runtime references. **Related:** [`--formatters`](template-customization.md#formatters), [`--use-exact-imports`](template-customization.md#use-exact-imports) diff --git a/src/datamodel_code_generator/format.py b/src/datamodel_code_generator/format.py index 9b6a7dc99..ec3600e3f 100644 --- a/src/datamodel_code_generator/format.py +++ b/src/datamodel_code_generator/format.py @@ -214,8 +214,8 @@ def resolve_use_type_checking_imports( if use_type_checking_imports is not None: return use_type_checking_imports - has_ruff = bool(formatters) and (Formatter.RUFF_CHECK in formatters or Formatter.RUFF_FORMAT in formatters) - return not (is_multi_module_output and has_ruff and preserve_runtime_imports_for_multi_module_ruff) + has_ruff_check = bool(formatters) and Formatter.RUFF_CHECK in formatters + return not (is_multi_module_output and has_ruff_check and preserve_runtime_imports_for_multi_module_ruff) class CodeFormatter: diff --git a/tests/main/test_main_general.py b/tests/main/test_main_general.py index f61defe94..e852e6fe3 100644 --- a/tests/main/test_main_general.py +++ b/tests/main/test_main_general.py @@ -1730,8 +1730,9 @@ def test_type_checking_imports_default_to_runtime_imports_for_modular_pydantic_r The `--no-use-type-checking-imports` flag prevents Ruff from moving generated model imports into `TYPE_CHECKING` blocks. This is useful for modular Pydantic output where referenced models need to be importable at runtime without calling `model_rebuild()` manually. -By default, TYPE_CHECKING imports stay enabled; `--use-type-checking-imports` explicitly -re-enables that default behavior when runtime imports were otherwise preserved.""", +In the multi-module Pydantic + `ruff-check` case, runtime imports are preserved by default. +`--use-type-checking-imports` opts back into the old TYPE_CHECKING-only behavior, which can +require manual `model_rebuild()` calls for cross-module runtime references.""", input_schema="openapi/modular.yaml", cli_args=[ "--output-model-type", @@ -1751,8 +1752,9 @@ def test_no_use_type_checking_imports(output_dir: Path) -> None: The `--no-use-type-checking-imports` flag prevents Ruff from moving generated model imports into `TYPE_CHECKING` blocks. This is useful for modular Pydantic output where referenced models need to be importable at runtime without calling `model_rebuild()` manually. - By default, TYPE_CHECKING imports stay enabled; `--use-type-checking-imports` explicitly - re-enables that default behavior when runtime imports were otherwise preserved. + In the multi-module Pydantic + `ruff-check` case, runtime imports are preserved by default. + `--use-type-checking-imports` opts back into the old TYPE_CHECKING-only behavior, which can + require manual `model_rebuild()` calls for cross-module runtime references. """ import importlib import sys diff --git a/tests/test_format.py b/tests/test_format.py index 35e65f9bf..95c8b6962 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -276,6 +276,12 @@ def test_resolve_use_type_checking_imports_keeps_existing_default_outside_deferr formatters=[Formatter.RUFF_CHECK], preserve_runtime_imports_for_multi_module_ruff=False, ) + assert resolve_use_type_checking_imports( + None, + is_multi_module_output=True, + formatters=[Formatter.RUFF_FORMAT], + preserve_runtime_imports_for_multi_module_ruff=True, + ) def test_format_code_ruff_check_and_format_uses_resolved_ruff_path( From 6623adc10cc509dd0aa84e698d09cfa25200c716 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Mar 2026 08:43:36 +0000 Subject: [PATCH 09/13] docs: update llms.txt files Generated by GitHub Actions --- docs/llms-full.txt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 8afb8d2f7..8647b9d7c 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -18538,8 +18538,9 @@ Keep generated model imports available at runtime when using Ruff fixes. The `--no-use-type-checking-imports` flag prevents Ruff from moving generated model imports into `TYPE_CHECKING` blocks. This is useful for modular Pydantic output where referenced models need to be importable at runtime without calling `model_rebuild()` manually. -By default, TYPE_CHECKING imports stay enabled; `--use-type-checking-imports` explicitly -re-enables that default behavior when runtime imports were otherwise preserved. +In the multi-module Pydantic + `ruff-check` case, runtime imports are preserved by default. +`--use-type-checking-imports` opts back into the old TYPE_CHECKING-only behavior, which can +require manual `model_rebuild()` calls for cross-module runtime references. **Related:** [`--formatters`](template-customization.md#formatters), [`--use-exact-imports`](template-customization.md#use-exact-imports) From a27d38e329231c1b82b135ec4d0abc6bc4cad000 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 10 Mar 2026 08:55:40 +0000 Subject: [PATCH 10/13] Preserve parser Ruff import compatibility --- src/datamodel_code_generator/__init__.py | 4 +- src/datamodel_code_generator/format.py | 4 +- src/datamodel_code_generator/model/base.py | 2 +- .../model/pydantic_base.py | 2 +- .../model/pydantic_v2/dataclass.py | 2 +- src/datamodel_code_generator/parser/base.py | 4 +- tests/parser/test_openapi.py | 50 ++++++++++++++++++- tests/test_format.py | 10 ++-- 8 files changed, 61 insertions(+), 17 deletions(-) diff --git a/src/datamodel_code_generator/__init__.py b/src/datamodel_code_generator/__init__.py index b49b9693a..8333be020 100644 --- a/src/datamodel_code_generator/__init__.py +++ b/src/datamodel_code_generator/__init__.py @@ -909,8 +909,8 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]: config.use_type_checking_imports, is_multi_module_output=True, formatters=config.formatters, - preserve_runtime_imports_for_multi_module_ruff=( - data_model_types.data_model.PRESERVE_RUNTIME_IMPORTS_FOR_MULTI_MODULE_RUFF + requires_runtime_imports_with_ruff_check=( + data_model_types.data_model.REQUIRES_RUNTIME_IMPORTS_WITH_RUFF_CHECK ), ) code_formatter = CodeFormatter( diff --git a/src/datamodel_code_generator/format.py b/src/datamodel_code_generator/format.py index ec3600e3f..20a14aad7 100644 --- a/src/datamodel_code_generator/format.py +++ b/src/datamodel_code_generator/format.py @@ -208,14 +208,14 @@ def resolve_use_type_checking_imports( *, is_multi_module_output: bool, formatters: list[Formatter] | None, - preserve_runtime_imports_for_multi_module_ruff: bool, + requires_runtime_imports_with_ruff_check: bool, ) -> bool: """Resolve the effective TYPE_CHECKING import behavior.""" if use_type_checking_imports is not None: return use_type_checking_imports has_ruff_check = bool(formatters) and Formatter.RUFF_CHECK in formatters - return not (is_multi_module_output and has_ruff_check and preserve_runtime_imports_for_multi_module_ruff) + return not (is_multi_module_output and has_ruff_check and requires_runtime_imports_with_ruff_check) class CodeFormatter: diff --git a/src/datamodel_code_generator/model/base.py b/src/datamodel_code_generator/model/base.py index fd8329a6d..9f1a5abaa 100644 --- a/src/datamodel_code_generator/model/base.py +++ b/src/datamodel_code_generator/model/base.py @@ -600,7 +600,7 @@ class DataModel(TemplateBase, Nullable, ABC): # noqa: PLR0904 SUPPORTS_FIELD_RENAMING: ClassVar[bool] = False SUPPORTS_WRAPPED_DEFAULT: ClassVar[bool] = False SUPPORTS_KW_ONLY: ClassVar[bool] = False - PRESERVE_RUNTIME_IMPORTS_FOR_MULTI_MODULE_RUFF: ClassVar[bool] = False + REQUIRES_RUNTIME_IMPORTS_WITH_RUFF_CHECK: ClassVar[bool] = False has_forward_reference: bool = False def __init__( # noqa: PLR0913 diff --git a/src/datamodel_code_generator/model/pydantic_base.py b/src/datamodel_code_generator/model/pydantic_base.py index 04f8a70ac..5355616a3 100644 --- a/src/datamodel_code_generator/model/pydantic_base.py +++ b/src/datamodel_code_generator/model/pydantic_base.py @@ -304,7 +304,7 @@ def imports(self) -> tuple[Import, ...]: class BaseModelBase(DataModel, ABC): """Abstract base class for Pydantic BaseModel implementations.""" - PRESERVE_RUNTIME_IMPORTS_FOR_MULTI_MODULE_RUFF: ClassVar[bool] = True + REQUIRES_RUNTIME_IMPORTS_WITH_RUFF_CHECK: ClassVar[bool] = True def __init__( # noqa: PLR0913 self, diff --git a/src/datamodel_code_generator/model/pydantic_v2/dataclass.py b/src/datamodel_code_generator/model/pydantic_v2/dataclass.py index 3d8544c84..dee7249cb 100644 --- a/src/datamodel_code_generator/model/pydantic_v2/dataclass.py +++ b/src/datamodel_code_generator/model/pydantic_v2/dataclass.py @@ -33,7 +33,7 @@ class DataClass(DataModel): TEMPLATE_FILE_PATH: ClassVar[str] = "pydantic_v2/dataclass.jinja2" DEFAULT_IMPORTS: ClassVar[tuple[Import, ...]] = (IMPORT_PYDANTIC_DATACLASS,) - PRESERVE_RUNTIME_IMPORTS_FOR_MULTI_MODULE_RUFF: ClassVar[bool] = True + REQUIRES_RUNTIME_IMPORTS_WITH_RUFF_CHECK: ClassVar[bool] = True SUPPORTS_DISCRIMINATOR: ClassVar[bool] = True SUPPORTS_KW_ONLY: ClassVar[bool] = True # frozen/allow_mutation are handled as dataclass decorator arguments, not ConfigDict diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index 96b64b497..c5d282434 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -3054,9 +3054,7 @@ def _build_code_formatter( self.use_type_checking_imports, is_multi_module_output=is_multi_module_output, formatters=self.formatters, - preserve_runtime_imports_for_multi_module_ruff=( - self.data_model_type.PRESERVE_RUNTIME_IMPORTS_FOR_MULTI_MODULE_RUFF - ), + requires_runtime_imports_with_ruff_check=self.data_model_type.REQUIRES_RUNTIME_IMPORTS_WITH_RUFF_CHECK, ) return CodeFormatter( self.target_python_version, diff --git a/tests/parser/test_openapi.py b/tests/parser/test_openapi.py index e1db5f541..32841c0ec 100644 --- a/tests/parser/test_openapi.py +++ b/tests/parser/test_openapi.py @@ -2,8 +2,10 @@ from __future__ import annotations +import importlib import os import platform +import sys from pathlib import Path from typing import Any @@ -12,8 +14,9 @@ import pytest from packaging import version -from datamodel_code_generator import OpenAPIScope, PythonVersionMin -from datamodel_code_generator.model import DataModelFieldBase +from datamodel_code_generator import DataModelType, OpenAPIScope, PythonVersionMin +from datamodel_code_generator.format import Formatter +from datamodel_code_generator.model import DataModelFieldBase, get_data_model_types from datamodel_code_generator.model.pydantic_v2 import DataModelField from datamodel_code_generator.parser.base import dump_templates from datamodel_code_generator.parser.jsonschema import JsonSchemaObject @@ -472,6 +475,49 @@ def test_openapi_parser_parse_modular(tmp_path: Path, monkeypatch: pytest.Monkey assert_parser_modules(modules, EXPECTED_OPEN_API_PATH / "openapi_parser_parse_modular") +def test_openapi_parser_parse_modular_pydantic_v2_ruff_keeps_runtime_imports( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test OpenAPIParser.parse() keeps runtime imports for modular Pydantic v2 Ruff output.""" + monkeypatch.chdir(tmp_path) + data_model_types = get_data_model_types(DataModelType.PydanticV2BaseModel, target_python_version=PythonVersionMin) + parser = OpenAPIParser( + Path(DATA_PATH / "modular.yaml"), + data_model_type=data_model_types.data_model, + data_model_root_type=data_model_types.root_model, + data_model_field_type=data_model_types.field_model, + data_type_manager_type=data_model_types.data_type_manager, + dump_resolve_reference_action=data_model_types.dump_resolve_reference_action, + formatters=[Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT], + ) + + modules = parser.parse(settings_path=DATA_PATH.parent) + assert isinstance(modules, dict) + + internal = modules["_internal.py",].body + assert "TYPE_CHECKING" not in internal + assert "from . import models" in internal + + package_dir = tmp_path / "model" + for module_path, result in modules.items(): + file_path = package_dir.joinpath(*module_path) + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(result.body, encoding="utf-8") + + sys.path.insert(0, str(tmp_path)) + importlib.invalidate_caches() + try: + from model._internal import Result + + result = Result.model_validate({"event": {"id": "abc"}}) + assert result.event is not None + assert result.event.__class__.__name__ == "Event" + finally: + sys.path.pop(0) + for name in [module for module in sys.modules if module == "model" or module.startswith("model.")]: + del sys.modules[name] + + @pytest.mark.parametrize( ("with_import", "format_", "base_class"), [ diff --git a/tests/test_format.py b/tests/test_format.py index 95c8b6962..7f64550f8 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -246,7 +246,7 @@ def test_resolve_use_type_checking_imports_respects_explicit_value(explicit_valu explicit_value, is_multi_module_output=True, formatters=[Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT], - preserve_runtime_imports_for_multi_module_ruff=True, + requires_runtime_imports_with_ruff_check=True, ) is explicit_value ) @@ -258,7 +258,7 @@ def test_resolve_use_type_checking_imports_defaults_to_runtime_imports_for_defer None, is_multi_module_output=True, formatters=[Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT], - preserve_runtime_imports_for_multi_module_ruff=True, + requires_runtime_imports_with_ruff_check=True, ) @@ -268,19 +268,19 @@ def test_resolve_use_type_checking_imports_keeps_existing_default_outside_deferr None, is_multi_module_output=False, formatters=[Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT], - preserve_runtime_imports_for_multi_module_ruff=True, + requires_runtime_imports_with_ruff_check=True, ) assert resolve_use_type_checking_imports( None, is_multi_module_output=True, formatters=[Formatter.RUFF_CHECK], - preserve_runtime_imports_for_multi_module_ruff=False, + requires_runtime_imports_with_ruff_check=False, ) assert resolve_use_type_checking_imports( None, is_multi_module_output=True, formatters=[Formatter.RUFF_FORMAT], - preserve_runtime_imports_for_multi_module_ruff=True, + requires_runtime_imports_with_ruff_check=True, ) From 1ffed9b8335cad04a4858a2baeea29cb4ad35fcb Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 10 Mar 2026 08:57:13 +0000 Subject: [PATCH 11/13] Extract runtime import test helpers --- tests/conftest.py | 26 ++++++++++++++++++++++++ tests/main/test_main_general.py | 36 +++------------------------------ tests/parser/test_openapi.py | 29 +++++++++----------------- 3 files changed, 38 insertions(+), 53 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3c591496d..7a15c1cf0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,9 +3,11 @@ from __future__ import annotations import difflib +import importlib import inspect import json import re +import sys import time from datetime import datetime, timezone from pathlib import Path @@ -663,6 +665,30 @@ def assert_parser_modules( _assert_with_external_file(_get_full_body(result), expected_path) +def write_generated_modules(output_dir: Path, modules: dict[tuple[str, ...], Any]) -> None: + """Write parser-generated module output to a package directory.""" + for module_path, result in modules.items(): + file_path = output_dir.joinpath(*module_path) + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(result.body, encoding="utf-8") + + +def assert_runtime_result_model(output_dir: Path) -> None: + """Assert generated modular output resolves cross-module Pydantic references at runtime.""" + sys.path.insert(0, str(output_dir.parent)) + importlib.invalidate_caches() + try: + from model._internal import Result + + result = Result.model_validate({"event": {"id": "abc"}}) + assert result.event is not None + assert result.event.__class__.__name__ == "Event" + finally: + sys.path.pop(0) + for name in [module for module in sys.modules if module == "model" or module.startswith("model.")]: + del sys.modules[name] + + def assert_error_message( capsys: pytest.CaptureFixture[str], expected: str, diff --git a/tests/main/test_main_general.py b/tests/main/test_main_general.py index e852e6fe3..d08e40e85 100644 --- a/tests/main/test_main_general.py +++ b/tests/main/test_main_general.py @@ -28,7 +28,7 @@ from datamodel_code_generator.format import CodeFormatter, Formatter, PythonVersion from datamodel_code_generator.model.pydantic_v2 import UnionMode from datamodel_code_generator.parser.openapi import OpenAPIParser -from tests.conftest import assert_output, create_assert_file_content, freeze_time +from tests.conftest import assert_output, assert_runtime_result_model, create_assert_file_content, freeze_time from tests.main.conftest import ( DATA_PATH, DEFAULT_VALUES_DATA_PATH, @@ -1687,9 +1687,6 @@ def test_ruff_batch_formatting_directory(output_dir: Path) -> None: def test_type_checking_imports_default_to_runtime_imports_for_modular_pydantic_ruff(output_dir: Path) -> None: """Test modular Pydantic output keeps runtime imports by default when Ruff formats a directory.""" - import importlib - import sys - run_main_and_assert( input_path=OPEN_API_DATA_PATH / "modular.yaml", output_path=output_dir, @@ -1708,19 +1705,7 @@ def test_type_checking_imports_default_to_runtime_imports_for_modular_pydantic_r content = internal_path.read_text() assert "TYPE_CHECKING" not in content assert content == (EXPECTED_MAIN_PATH / "openapi" / "no_use_type_checking_imports_internal.py").read_text() - - sys.path.insert(0, str(output_dir.parent)) - importlib.invalidate_caches() - try: - from model._internal import Result - - result = Result.model_validate({"event": {"id": "abc"}}) - assert result.event is not None - assert result.event.__class__.__name__ == "Event" - finally: - sys.path.pop(0) - for name in [module for module in sys.modules if module == "model" or module.startswith("model.")]: - del sys.modules[name] + assert_runtime_result_model(output_dir) @pytest.mark.cli_doc( @@ -1756,9 +1741,6 @@ def test_no_use_type_checking_imports(output_dir: Path) -> None: `--use-type-checking-imports` opts back into the old TYPE_CHECKING-only behavior, which can require manual `model_rebuild()` calls for cross-module runtime references. """ - import importlib - import sys - run_main_and_assert( input_path=OPEN_API_DATA_PATH / "modular.yaml", output_path=output_dir, @@ -1778,19 +1760,7 @@ def test_no_use_type_checking_imports(output_dir: Path) -> None: content = internal_path.read_text() assert "TYPE_CHECKING" not in content assert content == (EXPECTED_MAIN_PATH / "openapi" / "no_use_type_checking_imports_internal.py").read_text() - - sys.path.insert(0, str(output_dir.parent)) - importlib.invalidate_caches() - try: - from model._internal import Result - - result = Result.model_validate({"event": {"id": "abc"}}) - assert result.event is not None - assert result.event.__class__.__name__ == "Event" - finally: - sys.path.pop(0) - for name in [module for module in sys.modules if module == "model" or module.startswith("model.")]: - del sys.modules[name] + assert_runtime_result_model(output_dir) def test_generate_multi_module_pydantic_ruff_defaults_to_runtime_imports() -> None: diff --git a/tests/parser/test_openapi.py b/tests/parser/test_openapi.py index 32841c0ec..b1b6004f3 100644 --- a/tests/parser/test_openapi.py +++ b/tests/parser/test_openapi.py @@ -2,10 +2,8 @@ from __future__ import annotations -import importlib import os import platform -import sys from pathlib import Path from typing import Any @@ -27,7 +25,13 @@ RequestBodyObject, ResponseObject, ) -from tests.conftest import assert_output, assert_parser_modules, assert_parser_results +from tests.conftest import ( + assert_output, + assert_parser_modules, + assert_parser_results, + assert_runtime_result_model, + write_generated_modules, +) DATA_PATH: Path = Path(__file__).parents[1] / "data" / "openapi" @@ -499,23 +503,8 @@ def test_openapi_parser_parse_modular_pydantic_v2_ruff_keeps_runtime_imports( assert "from . import models" in internal package_dir = tmp_path / "model" - for module_path, result in modules.items(): - file_path = package_dir.joinpath(*module_path) - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.write_text(result.body, encoding="utf-8") - - sys.path.insert(0, str(tmp_path)) - importlib.invalidate_caches() - try: - from model._internal import Result - - result = Result.model_validate({"event": {"id": "abc"}}) - assert result.event is not None - assert result.event.__class__.__name__ == "Event" - finally: - sys.path.pop(0) - for name in [module for module in sys.modules if module == "model" or module.startswith("model.")]: - del sys.modules[name] + write_generated_modules(package_dir, modules) + assert_runtime_result_model(package_dir) @pytest.mark.parametrize( From b259e4a22f1d128ff9e01327368e7cd2eeabefcd Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 10 Mar 2026 09:06:50 +0000 Subject: [PATCH 12/13] Compare modular runtime output as files --- tests/conftest.py | 44 +++++++++++++ .../no_use_type_checking_imports/__init__.py | 6 ++ .../no_use_type_checking_imports/_internal.py | 62 +++++++++++++++++++ .../no_use_type_checking_imports/bar.py | 9 +++ .../collections.py | 46 ++++++++++++++ .../foo/__init__.py | 6 ++ .../no_use_type_checking_imports/foo/bar.py | 22 +++++++ .../no_use_type_checking_imports/models.py | 30 +++++++++ .../nested/__init__.py | 2 + .../nested/foo.py | 6 ++ .../woo/__init__.py | 2 + .../no_use_type_checking_imports/woo/boo.py | 14 +++++ .../__init__.py | 5 ++ .../_internal.py | 61 ++++++++++++++++++ .../bar.py | 7 +++ .../collections.py | 38 ++++++++++++ .../foo/__init__.py | 5 ++ .../foo/bar.py | 21 +++++++ .../models.py | 29 +++++++++ .../nested/__init__.py | 0 .../nested/foo.py | 6 ++ .../woo/__init__.py | 0 .../woo/boo.py | 13 ++++ tests/main/test_main_general.py | 16 +---- tests/parser/test_openapi.py | 17 ++--- 25 files changed, 443 insertions(+), 24 deletions(-) create mode 100644 tests/data/expected/main/openapi/no_use_type_checking_imports/__init__.py create mode 100644 tests/data/expected/main/openapi/no_use_type_checking_imports/_internal.py create mode 100644 tests/data/expected/main/openapi/no_use_type_checking_imports/bar.py create mode 100644 tests/data/expected/main/openapi/no_use_type_checking_imports/collections.py create mode 100644 tests/data/expected/main/openapi/no_use_type_checking_imports/foo/__init__.py create mode 100644 tests/data/expected/main/openapi/no_use_type_checking_imports/foo/bar.py create mode 100644 tests/data/expected/main/openapi/no_use_type_checking_imports/models.py create mode 100644 tests/data/expected/main/openapi/no_use_type_checking_imports/nested/__init__.py create mode 100644 tests/data/expected/main/openapi/no_use_type_checking_imports/nested/foo.py create mode 100644 tests/data/expected/main/openapi/no_use_type_checking_imports/woo/__init__.py create mode 100644 tests/data/expected/main/openapi/no_use_type_checking_imports/woo/boo.py create mode 100644 tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/__init__.py create mode 100644 tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/_internal.py create mode 100644 tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/bar.py create mode 100644 tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/collections.py create mode 100644 tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/foo/__init__.py create mode 100644 tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/foo/bar.py create mode 100644 tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/models.py create mode 100644 tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/nested/__init__.py create mode 100644 tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/nested/foo.py create mode 100644 tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/woo/__init__.py create mode 100644 tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/woo/boo.py diff --git a/tests/conftest.py b/tests/conftest.py index 7a15c1cf0..bf17c8cc8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -617,6 +617,34 @@ def assert_directory_content( _assert_with_external_file(result, expected_path) +def assert_exact_directory_content( + output_dir: Path, + expected_dir: Path, + pattern: str = "*.py", + encoding: str = "utf-8", +) -> None: + """Assert all files in output_dir match expected_dir exactly without snapshot indirection.""" + __tracebackhide__ = True + output_files = {p.relative_to(output_dir) for p in output_dir.rglob(pattern)} + expected_files = {p.relative_to(expected_dir) for p in expected_dir.rglob(pattern)} + + extra = expected_files - output_files + assert not extra, f"Expected files not in output: {extra}" + + missing = output_files - expected_files + assert not missing, f"Output has files not in expected: {missing}" + + for output_path in output_dir.rglob(pattern): + relative_path = output_path.relative_to(output_dir) + expected_path = expected_dir / relative_path + output = _normalize_line_endings(output_path.read_text(encoding=encoding)) + expected = _normalize_line_endings(expected_path.read_text(encoding=encoding)) + if output != expected: + diff = _format_diff(expected, output, expected_path) + msg = f"Content mismatch for {expected_path}\n{diff}" + raise AssertionError(msg) + + def _get_full_body(result: object) -> str: """Get full body from Result.""" return getattr(result, "body", "") @@ -689,6 +717,22 @@ def assert_runtime_result_model(output_dir: Path) -> None: del sys.modules[name] +def assert_runtime_import_package(output_dir: Path, expected_dir: Path) -> None: + """Assert generated modular package matches expected files and imports correctly at runtime.""" + assert_exact_directory_content(output_dir, expected_dir) + assert_runtime_result_model(output_dir) + + +def assert_generated_runtime_package( + output_dir: Path, + modules: dict[tuple[str, ...], Any], + expected_dir: Path, +) -> None: + """Write parser-generated modules, compare the package tree, and verify runtime imports.""" + write_generated_modules(output_dir, modules) + assert_runtime_import_package(output_dir, expected_dir) + + def assert_error_message( capsys: pytest.CaptureFixture[str], expected: str, diff --git a/tests/data/expected/main/openapi/no_use_type_checking_imports/__init__.py b/tests/data/expected/main/openapi/no_use_type_checking_imports/__init__.py new file mode 100644 index 000000000..6e3120581 --- /dev/null +++ b/tests/data/expected/main/openapi/no_use_type_checking_imports/__init__.py @@ -0,0 +1,6 @@ +# generated by datamodel-codegen: +# filename: modular.yaml + +from ._internal import DifferentTea, Error, Id, Optional, Result, Source + +__all__ = ["DifferentTea", "Error", "Id", "Optional", "Result", "Source"] diff --git a/tests/data/expected/main/openapi/no_use_type_checking_imports/_internal.py b/tests/data/expected/main/openapi/no_use_type_checking_imports/_internal.py new file mode 100644 index 000000000..0e26ffa34 --- /dev/null +++ b/tests/data/expected/main/openapi/no_use_type_checking_imports/_internal.py @@ -0,0 +1,62 @@ +# generated by datamodel-codegen: +# filename: _internal + +from __future__ import annotations +from pydantic import BaseModel, RootModel +from . import models + + +class Optional(RootModel[str]): + root: str + + +class Id(RootModel[str]): + root: str + + +class Error(BaseModel): + code: int + message: str + + +class Result(BaseModel): + event: models.Event | None = None + + +class Source(BaseModel): + country: str | None = None + + +class DifferentTea(BaseModel): + foo: Tea | None = None + nested: Tea_1 | None = None + + +class Tea(BaseModel): + flavour: str | None = None + id: Id | None = None + + +class Cocoa(BaseModel): + quality: int | None = None + + +class Tea_1(BaseModel): + flavour: str | None = None + id: Id | None = None + self: Tea_1 | None = None + optional: list[Optional] | None = None + + +class TeaClone(BaseModel): + flavour: str | None = None + id: Id | None = None + self: Tea_1 | None = None + optional: list[Optional] | None = None + + +class List(RootModel[list[Tea_1]]): + root: list[Tea_1] + + +Tea_1.model_rebuild() diff --git a/tests/data/expected/main/openapi/no_use_type_checking_imports/bar.py b/tests/data/expected/main/openapi/no_use_type_checking_imports/bar.py new file mode 100644 index 000000000..e7de55136 --- /dev/null +++ b/tests/data/expected/main/openapi/no_use_type_checking_imports/bar.py @@ -0,0 +1,9 @@ +# generated by datamodel-codegen: +# filename: modular.yaml + +from __future__ import annotations +from pydantic import Field, RootModel + + +class FieldModel(RootModel[str]): + root: str = Field(..., examples=["green"]) diff --git a/tests/data/expected/main/openapi/no_use_type_checking_imports/collections.py b/tests/data/expected/main/openapi/no_use_type_checking_imports/collections.py new file mode 100644 index 000000000..2d8362031 --- /dev/null +++ b/tests/data/expected/main/openapi/no_use_type_checking_imports/collections.py @@ -0,0 +1,46 @@ +# generated by datamodel-codegen: +# filename: modular.yaml + +from __future__ import annotations +from pydantic import AnyUrl, BaseModel, Field, RootModel +from . import models +from enum import Enum + + +class Pets(RootModel[list[models.Pet]]): + root: list[models.Pet] + + +class Users(RootModel[list[models.User]]): + root: list[models.User] + + +class Rules(RootModel[list[str]]): + root: list[str] + + +class Stage(Enum): + test = "test" + dev = "dev" + stg = "stg" + prod = "prod" + + +class Api(BaseModel): + apiKey: str | None = Field( + None, description="To be used as a dataset parameter value" + ) + apiVersionNumber: str | None = Field( + None, description="To be used as a version parameter value" + ) + apiUrl: AnyUrl | None = Field( + None, description="The URL describing the dataset's fields" + ) + apiDocumentationUrl: AnyUrl | None = Field( + None, description="A URL to the API console for each API" + ) + stage: Stage | None = None + + +class Apis(RootModel[list[Api]]): + root: list[Api] diff --git a/tests/data/expected/main/openapi/no_use_type_checking_imports/foo/__init__.py b/tests/data/expected/main/openapi/no_use_type_checking_imports/foo/__init__.py new file mode 100644 index 000000000..b2a56ddfc --- /dev/null +++ b/tests/data/expected/main/openapi/no_use_type_checking_imports/foo/__init__.py @@ -0,0 +1,6 @@ +# generated by datamodel-codegen: +# filename: modular.yaml + +from .._internal import Cocoa, Tea + +__all__ = ["Cocoa", "Tea"] diff --git a/tests/data/expected/main/openapi/no_use_type_checking_imports/foo/bar.py b/tests/data/expected/main/openapi/no_use_type_checking_imports/foo/bar.py new file mode 100644 index 000000000..49a9713f3 --- /dev/null +++ b/tests/data/expected/main/openapi/no_use_type_checking_imports/foo/bar.py @@ -0,0 +1,22 @@ +# generated by datamodel-codegen: +# filename: modular.yaml + +from __future__ import annotations +from typing import Any +from pydantic import BaseModel + + +class Thing(BaseModel): + attributes: dict[str, Any] | None = None + + +class Thang(BaseModel): + attributes: list[dict[str, Any]] | None = None + + +class Others(BaseModel): + name: str | None = None + + +class Clone(Thing): + others: Others | None = None diff --git a/tests/data/expected/main/openapi/no_use_type_checking_imports/models.py b/tests/data/expected/main/openapi/no_use_type_checking_imports/models.py new file mode 100644 index 000000000..758f76e88 --- /dev/null +++ b/tests/data/expected/main/openapi/no_use_type_checking_imports/models.py @@ -0,0 +1,30 @@ +# generated by datamodel-codegen: +# filename: modular.yaml + +from __future__ import annotations +from enum import Enum +from pydantic import BaseModel +from typing import Any + + +class Species(Enum): + dog = "dog" + cat = "cat" + snake = "snake" + + +class Pet(BaseModel): + id: int + name: str + tag: str | None = None + species: Species | None = None + + +class User(BaseModel): + id: int + name: str + tag: str | None = None + + +class Event(BaseModel): + name: str | float | int | bool | dict[str, Any] | list[str] | None = None diff --git a/tests/data/expected/main/openapi/no_use_type_checking_imports/nested/__init__.py b/tests/data/expected/main/openapi/no_use_type_checking_imports/nested/__init__.py new file mode 100644 index 000000000..80d26ec5a --- /dev/null +++ b/tests/data/expected/main/openapi/no_use_type_checking_imports/nested/__init__.py @@ -0,0 +1,2 @@ +# generated by datamodel-codegen: +# filename: modular.yaml diff --git a/tests/data/expected/main/openapi/no_use_type_checking_imports/nested/foo.py b/tests/data/expected/main/openapi/no_use_type_checking_imports/nested/foo.py new file mode 100644 index 000000000..a7caf2254 --- /dev/null +++ b/tests/data/expected/main/openapi/no_use_type_checking_imports/nested/foo.py @@ -0,0 +1,6 @@ +# generated by datamodel-codegen: +# filename: modular.yaml + +from .._internal import List, TeaClone, Tea_1 as Tea + +__all__ = ["List", "Tea", "TeaClone"] diff --git a/tests/data/expected/main/openapi/no_use_type_checking_imports/woo/__init__.py b/tests/data/expected/main/openapi/no_use_type_checking_imports/woo/__init__.py new file mode 100644 index 000000000..80d26ec5a --- /dev/null +++ b/tests/data/expected/main/openapi/no_use_type_checking_imports/woo/__init__.py @@ -0,0 +1,2 @@ +# generated by datamodel-codegen: +# filename: modular.yaml diff --git a/tests/data/expected/main/openapi/no_use_type_checking_imports/woo/boo.py b/tests/data/expected/main/openapi/no_use_type_checking_imports/woo/boo.py new file mode 100644 index 000000000..a847a4663 --- /dev/null +++ b/tests/data/expected/main/openapi/no_use_type_checking_imports/woo/boo.py @@ -0,0 +1,14 @@ +# generated by datamodel-codegen: +# filename: modular.yaml + +from __future__ import annotations +from pydantic import BaseModel +from .._internal import Cocoa, Source +from .. import bar + + +class Chocolate(BaseModel): + flavour: str | None = None + source: Source | None = None + cocoa: Cocoa | None = None + field: bar.FieldModel | None = None diff --git a/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/__init__.py b/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/__init__.py new file mode 100644 index 000000000..bd660c478 --- /dev/null +++ b/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from ._internal import DifferentTea, Error, Id, OptionalModel, Result, Source + +__all__ = ["DifferentTea", "Error", "Id", "OptionalModel", "Result", "Source"] diff --git a/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/_internal.py b/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/_internal.py new file mode 100644 index 000000000..4d9f09ad2 --- /dev/null +++ b/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/_internal.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from pydantic import BaseModel, RootModel + +from . import models + + +class OptionalModel(RootModel[str]): + root: str + + +class Id(RootModel[str]): + root: str + + +class Error(BaseModel): + code: int + message: str + + +class Result(BaseModel): + event: models.Event | None = None + + +class Source(BaseModel): + country: str | None = None + + +class DifferentTea(BaseModel): + foo: Tea | None = None + nested: Tea_1 | None = None + + +class Tea(BaseModel): + flavour: str | None = None + id: Id | None = None + + +class Cocoa(BaseModel): + quality: int | None = None + + +class Tea_1(BaseModel): + flavour: str | None = None + id: Id | None = None + self: Tea_1 | None = None + optional: list[OptionalModel] | None = None + + +class TeaClone(BaseModel): + flavour: str | None = None + id: Id | None = None + self: Tea_1 | None = None + optional: list[OptionalModel] | None = None + + +class ListModel(RootModel[list[Tea_1]]): + root: list[Tea_1] + + +Tea_1.model_rebuild() diff --git a/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/bar.py b/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/bar.py new file mode 100644 index 000000000..0e80aa04d --- /dev/null +++ b/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/bar.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from pydantic import Field, RootModel + + +class FieldModel(RootModel[str]): + root: str = Field(..., examples=["green"]) diff --git a/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/collections.py b/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/collections.py new file mode 100644 index 000000000..21649bcd6 --- /dev/null +++ b/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/collections.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from enum import Enum + +from pydantic import AnyUrl, BaseModel, Field, RootModel + +from . import models + + +class Pets(RootModel[list[models.Pet]]): + root: list[models.Pet] + + +class Users(RootModel[list[models.User]]): + root: list[models.User] + + +class Rules(RootModel[list[str]]): + root: list[str] + + +class Stage(Enum): + test = "test" + dev = "dev" + stg = "stg" + prod = "prod" + + +class Api(BaseModel): + apiKey: str | None = Field(None, description="To be used as a dataset parameter value") + apiVersionNumber: str | None = Field(None, description="To be used as a version parameter value") + apiUrl: AnyUrl | None = Field(None, description="The URL describing the dataset's fields") + apiDocumentationUrl: AnyUrl | None = Field(None, description="A URL to the API console for each API") + stage: Stage | None = None + + +class Apis(RootModel[list[Api]]): + root: list[Api] diff --git a/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/foo/__init__.py b/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/foo/__init__.py new file mode 100644 index 000000000..19d26e0c4 --- /dev/null +++ b/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/foo/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .._internal import Cocoa, Tea + +__all__ = ["Cocoa", "Tea"] diff --git a/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/foo/bar.py b/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/foo/bar.py new file mode 100644 index 000000000..04bcc0e06 --- /dev/null +++ b/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/foo/bar.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel + + +class Thing(BaseModel): + attributes: dict[str, Any] | None = None + + +class Thang(BaseModel): + attributes: list[dict[str, Any]] | None = None + + +class Others(BaseModel): + name: str | None = None + + +class Clone(Thing): + others: Others | None = None diff --git a/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/models.py b/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/models.py new file mode 100644 index 000000000..ebd344dd6 --- /dev/null +++ b/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/models.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any + +from pydantic import BaseModel + + +class Species(Enum): + dog = "dog" + cat = "cat" + snake = "snake" + + +class Pet(BaseModel): + id: int + name: str + tag: str | None = None + species: Species | None = None + + +class User(BaseModel): + id: int + name: str + tag: str | None = None + + +class Event(BaseModel): + name: str | float | int | bool | dict[str, Any] | list[str] | None = None diff --git a/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/nested/__init__.py b/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/nested/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/nested/foo.py b/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/nested/foo.py new file mode 100644 index 000000000..a75b6095c --- /dev/null +++ b/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/nested/foo.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from .._internal import ListModel, TeaClone +from .._internal import Tea_1 as Tea + +__all__ = ["ListModel", "Tea", "TeaClone"] diff --git a/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/woo/__init__.py b/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/woo/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/woo/boo.py b/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/woo/boo.py new file mode 100644 index 000000000..96e373edd --- /dev/null +++ b/tests/data/expected/parser/openapi/openapi_parser_parse_modular_pydantic_v2_ruff/woo/boo.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from pydantic import BaseModel + +from .. import bar +from .._internal import Cocoa, Source + + +class Chocolate(BaseModel): + flavour: str | None = None + source: Source | None = None + cocoa: Cocoa | None = None + field: bar.FieldModel | None = None diff --git a/tests/main/test_main_general.py b/tests/main/test_main_general.py index d08e40e85..5a69dc8ef 100644 --- a/tests/main/test_main_general.py +++ b/tests/main/test_main_general.py @@ -28,7 +28,7 @@ from datamodel_code_generator.format import CodeFormatter, Formatter, PythonVersion from datamodel_code_generator.model.pydantic_v2 import UnionMode from datamodel_code_generator.parser.openapi import OpenAPIParser -from tests.conftest import assert_output, assert_runtime_result_model, create_assert_file_content, freeze_time +from tests.conftest import assert_output, assert_runtime_import_package, create_assert_file_content, freeze_time from tests.main.conftest import ( DATA_PATH, DEFAULT_VALUES_DATA_PATH, @@ -1700,12 +1700,7 @@ def test_type_checking_imports_default_to_runtime_imports_for_modular_pydantic_r "--disable-timestamp", ], ) - - internal_path = output_dir / "_internal.py" - content = internal_path.read_text() - assert "TYPE_CHECKING" not in content - assert content == (EXPECTED_MAIN_PATH / "openapi" / "no_use_type_checking_imports_internal.py").read_text() - assert_runtime_result_model(output_dir) + assert_runtime_import_package(output_dir, EXPECTED_MAIN_PATH / "openapi" / "no_use_type_checking_imports") @pytest.mark.cli_doc( @@ -1755,12 +1750,7 @@ def test_no_use_type_checking_imports(output_dir: Path) -> None: "--disable-timestamp", ], ) - - internal_path = output_dir / "_internal.py" - content = internal_path.read_text() - assert "TYPE_CHECKING" not in content - assert content == (EXPECTED_MAIN_PATH / "openapi" / "no_use_type_checking_imports_internal.py").read_text() - assert_runtime_result_model(output_dir) + assert_runtime_import_package(output_dir, EXPECTED_MAIN_PATH / "openapi" / "no_use_type_checking_imports") def test_generate_multi_module_pydantic_ruff_defaults_to_runtime_imports() -> None: diff --git a/tests/parser/test_openapi.py b/tests/parser/test_openapi.py index b1b6004f3..168e9dae1 100644 --- a/tests/parser/test_openapi.py +++ b/tests/parser/test_openapi.py @@ -26,11 +26,10 @@ ResponseObject, ) from tests.conftest import ( + assert_generated_runtime_package, assert_output, assert_parser_modules, assert_parser_results, - assert_runtime_result_model, - write_generated_modules, ) DATA_PATH: Path = Path(__file__).parents[1] / "data" / "openapi" @@ -496,15 +495,11 @@ def test_openapi_parser_parse_modular_pydantic_v2_ruff_keeps_runtime_imports( ) modules = parser.parse(settings_path=DATA_PATH.parent) - assert isinstance(modules, dict) - - internal = modules["_internal.py",].body - assert "TYPE_CHECKING" not in internal - assert "from . import models" in internal - - package_dir = tmp_path / "model" - write_generated_modules(package_dir, modules) - assert_runtime_result_model(package_dir) + assert_generated_runtime_package( + tmp_path / "model", + modules, + EXPECTED_OPEN_API_PATH / "openapi_parser_parse_modular_pydantic_v2_ruff", + ) @pytest.mark.parametrize( From b04e14e444528c04872a545ce8c5a20f98c14c7a Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 10 Mar 2026 18:20:14 +0000 Subject: [PATCH 13/13] Cover exact directory diff assertions --- tests/test_conftest_helpers.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/test_conftest_helpers.py diff --git a/tests/test_conftest_helpers.py b/tests/test_conftest_helpers.py new file mode 100644 index 000000000..7e29d7e1b --- /dev/null +++ b/tests/test_conftest_helpers.py @@ -0,0 +1,28 @@ +"""Tests for shared assertion helpers in tests.conftest.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from tests.conftest import assert_exact_directory_content + +if TYPE_CHECKING: + from pathlib import Path + + +def test_assert_exact_directory_content_reports_diff(tmp_path: Path) -> None: + """Test exact directory comparison reports the mismatched file path.""" + output_dir = tmp_path / "output" + expected_dir = tmp_path / "expected" + output_dir.mkdir() + expected_dir.mkdir() + + (output_dir / "sample.py").write_text("value = 1\n", encoding="utf-8") + (expected_dir / "sample.py").write_text("value = 2\n", encoding="utf-8") + + with pytest.raises(AssertionError, match="Content mismatch") as exc_info: + assert_exact_directory_content(output_dir, expected_dir) + + assert "sample.py" in str(exc_info.value)