diff --git a/docs/cli-reference/index.md b/docs/cli-reference/index.md index 20baaf6d4..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) | 19 | 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 | @@ -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} @@ -218,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 ef8892f83..3d78642ed 100644 --- a/docs/cli-reference/quick-reference.md +++ b/docs/cli-reference/quick-reference.md @@ -152,9 +152,11 @@ 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. | +| [`--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. | @@ -292,6 +294,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... @@ -355,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 05fa4d41e..1b0a41e77 100644 --- a/docs/cli-reference/template-customization.md +++ b/docs/cli-reference/template-customization.md @@ -18,9 +18,11 @@ | [`--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. | +| [`--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. | @@ -2339,6 +2341,390 @@ 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. +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) + +!!! tip "Usage" + + ```bash + 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 + +??? 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. @@ -2627,6 +3013,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/docs/llms-full.txt b/docs/llms-full.txt index 88eee0e97..8647b9d7c 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) | 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 | @@ -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} @@ -450,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) @@ -16206,9 +16208,11 @@ 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. | +| [`--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. | @@ -18527,6 +18531,390 @@ 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. +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) + +!!! tip "Usage" + + ```bash + 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 + +??? 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. @@ -18578,7 +18966,228 @@ The `--treat-dot-as-module` flag configures the code generation behavior. # model/schema.py # generated by datamodel-codegen: - # filename: model.schema.json + # filename: model.schema.json + # timestamp: 2019-07-26T00:00:00+00:00 + + from __future__ import annotations + + from pydantic import BaseModel + + + class User(BaseModel): + name: str + age: int | None = None + ``` + +--- + +## `--use-double-quotes` {#use-double-quotes} + +Use double quotes for string literals in generated code. + +The --use-double-quotes option formats all string literals in the generated +Python code with double quotes instead of the default single quotes. This +helps maintain consistency with codebases that prefer double-quote formatting. + +!!! tip "Usage" + + ```bash + datamodel-codegen --input schema.json --use-double-quotes # (1)! + ``` + + 1. :material-arrow-left: `--use-double-quotes` - the option documented here + +??? example "Examples" + + **Input Schema:** + + ```json + { + "$id": "https://example.com/schemas/MapState.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MapState", + "allOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "latitude": {"type": "number", "minimum": -90, "maximum": 90}, + "longitude": {"type": "number", "minimum": -180, "maximum": 180}, + "zoom": {"type": "number", "minimum": 0, "maximum": 25, "default": 0}, + "bearing": {"type": "number"}, + "pitch": {"type": "number", "minimum": 0, "exclusiveMaximum": 90}, + "dragRotate": {"type": "boolean"}, + "mapSplitMode": {"type": "string", "const": "SINGLE_MAP"}, + "isSplit": {"type": "boolean", "const": false, "default": false} + }, + "required": ["latitude", "longitude", "pitch", "mapSplitMode"] + }, + { + "type": "object", + "properties": { + "latitude": {"$ref": "#/allOf/0/anyOf/0/properties/latitude"}, + "longitude": {"$ref": "#/allOf/0/anyOf/0/properties/longitude"}, + "zoom": {"$ref": "#/allOf/0/anyOf/0/properties/zoom"}, + "bearing": {"$ref": "#/allOf/0/anyOf/0/properties/bearing"}, + "pitch": {"$ref": "#/allOf/0/anyOf/0/properties/pitch"}, + "dragRotate": {"$ref": "#/allOf/0/anyOf/0/properties/dragRotate"}, + "mapSplitMode": {"type": "string", "const": "SWIPE_COMPARE"}, + "isSplit": {"type": "boolean", "const": true, "default": true} + }, + "required": ["latitude", "longitude", "pitch", "mapSplitMode"] + } + ] + }, + { + "anyOf": [ + { + "type": "object", + "properties": { + "mapViewMode": {"type": "string", "const": "MODE_2D"} + }, + "required": ["mapViewMode"] + }, + { + "type": "object", + "properties": { + "mapViewMode": {"type": "string", "const": "MODE_3D"} + }, + "required": ["mapViewMode"] + } + ] + } + ] + } + ``` + + **Output:** + + ```python + # generated by datamodel-codegen: + # filename: all_of_any_of_base_class_ref.json + # timestamp: 2019-07-26T00:00:00+00:00 + + from __future__ import annotations + + from typing import Literal + + from pydantic import BaseModel, Field, RootModel, confloat + + + class MapState1(BaseModel): + map_view_mode: Literal["MODE_2D"] = Field(..., alias="mapViewMode") + + + class MapState2(BaseModel): + latitude: Latitude + longitude: Longitude + zoom: Zoom | None = Field(default_factory=lambda: Zoom(0)) + bearing: Bearing | None = None + pitch: Pitch + drag_rotate: DragRotate | None = Field(None, alias="dragRotate") + map_split_mode: Literal["SWIPE_COMPARE"] = Field(..., alias="mapSplitMode") + is_split: Literal[True] = Field(True, alias="isSplit") + + + class MapState3(BaseModel): + pass + + + class MapState4(MapState1, MapState3): + pass + + + class MapState5(MapState2, MapState3): + pass + + + class MapState6(MapState4): + pass + + + class MapState7(MapState5): + pass + + + class MapState(RootModel[MapState4 | MapState5 | MapState6 | MapState7]): + root: MapState4 | MapState5 | MapState6 | MapState7 = Field(..., title="MapState") + + + class Bearing(RootModel[float]): + root: float + + + class DragRotate(RootModel[bool]): + root: bool + + + class Latitude(RootModel[confloat(ge=-90.0, le=90.0)]): + root: confloat(ge=-90.0, le=90.0) + + + class Longitude(RootModel[confloat(ge=-180.0, le=180.0)]): + root: confloat(ge=-180.0, le=180.0) + + + class Pitch(RootModel[confloat(ge=0.0, lt=90.0)]): + root: confloat(ge=0.0, lt=90.0) + + + class Zoom(RootModel[confloat(ge=0.0, le=25.0)]): + root: confloat(ge=0.0, le=25.0) = 0 + ``` + +--- + +## `--use-exact-imports` {#use-exact-imports} + +Import exact types instead of modules. + +The `--use-exact-imports` flag changes import style from module imports +to exact type imports. For example, instead of `from . import foo` then +`foo.Bar`, it generates `from .foo import Bar`. This can make the generated +code more explicit and easier to read. + +Note: This option primarily affects modular output where imports between +modules are generated. For single-file output, the difference is minimal. + +!!! tip "Usage" + + ```bash + datamodel-codegen --input schema.json --use-exact-imports # (1)! + ``` + + 1. :material-arrow-left: `--use-exact-imports` - the option documented here + +??? example "Examples" + + **Input Schema:** + + ```json + { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Pet", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + } + ``` + + **Output:** + + ```python + # generated by datamodel-codegen: + # filename: pet_simple.json # timestamp: 2019-07-26T00:00:00+00:00 from __future__ import annotations @@ -18586,231 +19195,395 @@ The `--treat-dot-as-module` flag configures the code generation behavior. from pydantic import BaseModel - class User(BaseModel): - name: str - age: int | None = None + class Pet(BaseModel): + id: int | None = None + name: str | None = None + tag: str | None = None ``` --- -## `--use-double-quotes` {#use-double-quotes} +## `--use-type-checking-imports` {#use-type-checking-imports} -Use double quotes for string literals in generated code. +Allow Ruff to move typing-only imports into TYPE_CHECKING blocks. -The --use-double-quotes option formats all string literals in the generated -Python code with double quotes instead of the default single quotes. This -helps maintain consistency with codebases that prefer double-quote formatting. +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 --use-double-quotes # (1)! + 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-double-quotes` - the option documented here + 1. :material-arrow-left: `--use-type-checking-imports` - the option documented here ??? example "Examples" **Input Schema:** - ```json - { - "$id": "https://example.com/schemas/MapState.json", - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "MapState", - "allOf": [ - { - "anyOf": [ - { - "type": "object", - "properties": { - "latitude": {"type": "number", "minimum": -90, "maximum": 90}, - "longitude": {"type": "number", "minimum": -180, "maximum": 180}, - "zoom": {"type": "number", "minimum": 0, "maximum": 25, "default": 0}, - "bearing": {"type": "number"}, - "pitch": {"type": "number", "minimum": 0, "exclusiveMaximum": 90}, - "dragRotate": {"type": "boolean"}, - "mapSplitMode": {"type": "string", "const": "SINGLE_MAP"}, - "isSplit": {"type": "boolean", "const": false, "default": false} - }, - "required": ["latitude", "longitude", "pitch", "mapSplitMode"] - }, - { - "type": "object", - "properties": { - "latitude": {"$ref": "#/allOf/0/anyOf/0/properties/latitude"}, - "longitude": {"$ref": "#/allOf/0/anyOf/0/properties/longitude"}, - "zoom": {"$ref": "#/allOf/0/anyOf/0/properties/zoom"}, - "bearing": {"$ref": "#/allOf/0/anyOf/0/properties/bearing"}, - "pitch": {"$ref": "#/allOf/0/anyOf/0/properties/pitch"}, - "dragRotate": {"$ref": "#/allOf/0/anyOf/0/properties/dragRotate"}, - "mapSplitMode": {"type": "string", "const": "SWIPE_COMPARE"}, - "isSplit": {"type": "boolean", "const": true, "default": true} - }, - "required": ["latitude", "longitude", "pitch", "mapSplitMode"] - } - ] - }, - { - "anyOf": [ - { - "type": "object", - "properties": { - "mapViewMode": {"type": "string", "const": "MODE_2D"} - }, - "required": ["mapViewMode"] - }, - { - "type": "object", - "properties": { - "mapViewMode": {"type": "string", "const": "MODE_3D"} - }, - "required": ["mapViewMode"] - } - ] - } - ] - } + ```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: all_of_any_of_base_class_ref.json - # timestamp: 2019-07-26T00:00:00+00:00 + # filename: _internal from __future__ import annotations - from typing import Literal - - from pydantic import BaseModel, Field, RootModel, confloat - - - class MapState1(BaseModel): - map_view_mode: Literal["MODE_2D"] = Field(..., alias="mapViewMode") - - - class MapState2(BaseModel): - latitude: Latitude - longitude: Longitude - zoom: Zoom | None = Field(default_factory=lambda: Zoom(0)) - bearing: Bearing | None = None - pitch: Pitch - drag_rotate: DragRotate | None = Field(None, alias="dragRotate") - map_split_mode: Literal["SWIPE_COMPARE"] = Field(..., alias="mapSplitMode") - is_split: Literal[True] = Field(True, alias="isSplit") - - - class MapState3(BaseModel): - pass + from typing import TYPE_CHECKING + from pydantic import BaseModel, RootModel - class MapState4(MapState1, MapState3): - pass + if TYPE_CHECKING: + from . import models - class MapState5(MapState2, MapState3): - pass + class Optional(RootModel[str]): + root: str - class MapState6(MapState4): - pass + class Id(RootModel[str]): + root: str - class MapState7(MapState5): - pass + class Error(BaseModel): + code: int + message: str - class MapState(RootModel[MapState4 | MapState5 | MapState6 | MapState7]): - root: MapState4 | MapState5 | MapState6 | MapState7 = Field(..., title="MapState") + class Result(BaseModel): + event: models.Event | None = None - class Bearing(RootModel[float]): - root: float + class Source(BaseModel): + country: str | None = None - class DragRotate(RootModel[bool]): - root: bool + class DifferentTea(BaseModel): + foo: Tea | None = None + nested: Tea_1 | None = None - class Latitude(RootModel[confloat(ge=-90.0, le=90.0)]): - root: confloat(ge=-90.0, le=90.0) + class Tea(BaseModel): + flavour: str | None = None + id: Id | None = None - class Longitude(RootModel[confloat(ge=-180.0, le=180.0)]): - root: confloat(ge=-180.0, le=180.0) + class Cocoa(BaseModel): + quality: int | None = None - class Pitch(RootModel[confloat(ge=0.0, lt=90.0)]): - root: confloat(ge=0.0, lt=90.0) + class Tea_1(BaseModel): + flavour: str | None = None + id: Id | None = None + self: Tea_1 | None = None + optional: list[Optional] | None = None - class Zoom(RootModel[confloat(ge=0.0, le=25.0)]): - root: confloat(ge=0.0, le=25.0) = 0 - ``` - ---- - -## `--use-exact-imports` {#use-exact-imports} - -Import exact types instead of modules. - -The `--use-exact-imports` flag changes import style from module imports -to exact type imports. For example, instead of `from . import foo` then -`foo.Bar`, it generates `from .foo import Bar`. This can make the generated -code more explicit and easier to read. - -Note: This option primarily affects modular output where imports between -modules are generated. For single-file output, the difference is minimal. - -!!! tip "Usage" - - ```bash - datamodel-codegen --input schema.json --use-exact-imports # (1)! - ``` - - 1. :material-arrow-left: `--use-exact-imports` - the option documented here - -??? example "Examples" - - **Input Schema:** - - ```json - { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Pet", - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "tag": { - "type": "string" - } - } - } - ``` - - **Output:** - - ```python - # generated by datamodel-codegen: - # filename: pet_simple.json - # timestamp: 2019-07-26T00:00:00+00:00 + class TeaClone(BaseModel): + flavour: str | None = None + id: Id | None = None + self: Tea_1 | None = None + optional: list[Optional] | None = None - from __future__ import annotations - from pydantic import BaseModel + class List(RootModel[list[Tea_1]]): + root: list[Tea_1] - class Pet(BaseModel): - id: int | None = None - name: str | None = None - tag: str | None = None + Tea_1.model_rebuild() ``` --- @@ -23195,9 +23968,11 @@ 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. | +| [`--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. | @@ -23335,6 +24110,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... @@ -23398,6 +24174,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/src/datamodel_code_generator/__init__.py b/src/datamodel_code_generator/__init__.py index d7138dda6..8333be020 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 @@ -713,6 +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": config.use_type_checking_imports, "enum_field_as_literal": ( config.enum_field_as_literal if config.enum_field_as_literal is not None @@ -903,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, + requires_runtime_imports_with_ruff_check=( + data_model_types.data_model.REQUIRES_RUNTIME_IMPORTS_WITH_RUFF_CHECK + ), + ) code_formatter = CodeFormatter( config.target_python_version, config.settings_path, @@ -913,6 +923,8 @@ 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=effective_use_type_checking_imports, + defer_formatting=True, ) code_formatter.format_directory(output) diff --git a/src/datamodel_code_generator/__main__.py b/src/datamodel_code_generator/__main__.py index b23746119..feab8e070 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: 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 @@ -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..17aa9bd0c 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 | 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 d274d0460..9402aefc5 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 | 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 ec30584e0..92c654a7c 100644 --- a/src/datamodel_code_generator/arguments.py +++ b/src/datamodel_code_generator/arguments.py @@ -915,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/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..e6354409c 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 | None = None 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 | 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 4338af841..20a14aad7 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 + *, + is_multi_module_output: bool, + formatters: list[Formatter] | None, + 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 requires_runtime_imports_with_ruff_check) + + class CodeFormatter: """Formats generated code using black, isort, ruff, and custom formatters.""" @@ -217,6 +232,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 +263,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 +388,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, @@ -382,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, @@ -395,7 +413,7 @@ 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("-", ruff_path=ruff_path), input=code.encode(self.encoding), capture_output=True, check=False, @@ -410,6 +428,15 @@ 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 | 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") + return (*command, *paths) + @staticmethod def _find_ruff_path() -> str: """Find ruff executable path, checking virtual environment first.""" @@ -433,16 +460,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 - ("ruff", "check", "--fix", "--unsafe-fixes", 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/model/base.py b/src/datamodel_code_generator/model/base.py index 5c5a90e21..a475681b5 100644 --- a/src/datamodel_code_generator/model/base.py +++ b/src/datamodel_code_generator/model/base.py @@ -601,6 +601,7 @@ class DataModel(TemplateBase, Nullable, ABC): # noqa: PLR0904 SUPPORTS_WRAPPED_DEFAULT: ClassVar[bool] = False SUPPORTS_VALIDATED_DEFAULT: ClassVar[bool] = False SUPPORTS_KW_ONLY: 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 fd7e855ca..a95373bf9 100644 --- a/src/datamodel_code_generator/model/pydantic_base.py +++ b/src/datamodel_code_generator/model/pydantic_base.py @@ -309,6 +309,8 @@ def imports(self) -> tuple[Import, ...]: class BaseModelBase(DataModel, ABC): """Abstract base class for Pydantic BaseModel implementations.""" + 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 ad5151ead..dee7249cb 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,) + 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 5e7cb4fc0..b55a0fe90 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, @@ -1007,6 +1008,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 | None = config.use_type_checking_imports self._append_additional_imports(additional_imports=config.additional_imports) self.class_decorators: list[str] = config.class_decorators or [] @@ -3123,11 +3125,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, @@ -3145,30 +3145,41 @@ def _prepare_parse_config( # noqa: PLR0913, PLR0917 ): self.imports.append(IMPORT_ANNOTATIONS) - code_formatter: CodeFormatter | None = None - if format_: - 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, - 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, + requires_runtime_imports_with_ruff_check=self.data_model_type.REQUIRES_RUNTIME_IMPORTS_WITH_RUFF_CHECK, + ) + 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, @@ -3455,8 +3466,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, @@ -3475,6 +3484,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 298c47211..6247e1d81 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).", @@ -144,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/conftest.py b/tests/conftest.py index 3c591496d..bf17c8cc8 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 @@ -615,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", "") @@ -663,6 +693,46 @@ 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_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/input_model/config_class.py b/tests/data/expected/main/input_model/config_class.py index d32d2756d..913e47756 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 | None] 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/__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/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/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/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 a6bfafebc..5a69dc8ef 100644 --- a/tests/main/test_main_general.py +++ b/tests/main/test_main_general.py @@ -25,10 +25,10 @@ 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 +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, @@ -1685,6 +1685,133 @@ 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.""" + run_main_and_assert( + input_path=OPEN_API_DATA_PATH / "modular.yaml", + output_path=output_dir, + input_file_type="openapi", + extra_args=[ + "--output-model-type", + "pydantic_v2.BaseModel", + "--formatters", + "ruff-check", + "ruff-format", + "--disable-timestamp", + ], + ) + assert_runtime_import_package(output_dir, EXPECTED_MAIN_PATH / "openapi" / "no_use_type_checking_imports") + + +@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. +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", + "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"], +) +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. + 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. + """ + run_main_and_assert( + input_path=OPEN_API_DATA_PATH / "modular.yaml", + output_path=output_dir, + input_file_type="openapi", + extra_args=[ + "--output-model-type", + "pydantic_v2.BaseModel", + "--formatters", + "ruff-check", + "ruff-format", + "--no-use-type-checking-imports", + "--disable-timestamp", + ], + ) + 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: + """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/main/test_public_api_signature_baseline.py b/tests/main/test_public_api_signature_baseline.py index c5d303bf2..fa447c084 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 | None = None, 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 | 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/parser/test_openapi.py b/tests/parser/test_openapi.py index e1db5f541..168e9dae1 100644 --- a/tests/parser/test_openapi.py +++ b/tests/parser/test_openapi.py @@ -12,8 +12,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 @@ -24,7 +25,12 @@ RequestBodyObject, ResponseObject, ) -from tests.conftest import assert_output, assert_parser_modules, assert_parser_results +from tests.conftest import ( + assert_generated_runtime_package, + assert_output, + assert_parser_modules, + assert_parser_results, +) DATA_PATH: Path = Path(__file__).parents[1] / "data" / "openapi" @@ -472,6 +478,30 @@ 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_generated_runtime_package( + tmp_path / "model", + modules, + EXPECTED_OPEN_API_PATH / "openapi_parser_parse_modular_pydantic_v2_ruff", + ) + + @pytest.mark.parametrize( ("with_import", "format_", "base_class"), [ 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) diff --git a/tests/test_format.py b/tests/test_format.py index c790b0190..7f64550f8 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") @@ -18,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: @@ -163,13 +170,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=FAKE_RUFF_PATH), + 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) + (FAKE_RUFF_PATH, "format", "-"), + input=b"input", + capture_output=True, + check=False, + cwd=str(tmp_path), ) @@ -180,13 +194,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=FAKE_RUFF_PATH), + 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", "-"), + (FAKE_RUFF_PATH, "check", "--fix", "--unsafe-fixes", "-"), input=b"input", capture_output=True, check=False, @@ -194,6 +211,118 @@ 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.object(formatter, "_find_ruff_path", return_value=FAKE_RUFF_PATH), + 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( + (FAKE_RUFF_PATH, "check", "--fix", "--unsafe-fixes", "--unfixable", "TC001,TC002,TC003", "-"), + input=b"input", + capture_output=True, + check=False, + cwd=str(tmp_path), + ) + + +@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, + is_multi_module_output=True, + formatters=[Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT], + requires_runtime_imports_with_ruff_check=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, + is_multi_module_output=True, + formatters=[Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT], + requires_runtime_imports_with_ruff_check=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, + is_multi_module_output=False, + formatters=[Formatter.RUFF_CHECK, Formatter.RUFF_FORMAT], + requires_runtime_imports_with_ruff_check=True, + ) + assert resolve_use_type_checking_imports( + None, + is_multi_module_output=True, + formatters=[Formatter.RUFF_CHECK], + requires_runtime_imports_with_ruff_check=False, + ) + assert resolve_use_type_checking_imports( + None, + is_multi_module_output=True, + formatters=[Formatter.RUFF_FORMAT], + requires_runtime_imports_with_ruff_check=True, + ) + + +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=FAKE_RUFF_PATH) 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( + (FAKE_RUFF_PATH, "check", "--fix", "--unsafe-fixes", "-"), + input=b"input", + capture_output=True, + check=False, + cwd=str(tmp_path), + ), + mock.call( + (FAKE_RUFF_PATH, "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" @@ -244,11 +373,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=FAKE_RUFF_PATH), + 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)), + (FAKE_RUFF_PATH, "check", "--fix", "--unsafe-fixes", str(output_dir)), capture_output=True, check=False, cwd=str(tmp_path), @@ -265,11 +397,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=FAKE_RUFF_PATH), + mock.patch("subprocess.run") as mock_run, + ): formatter.format_directory(output_dir) mock_run.assert_called_once_with( - ("ruff", "format", str(output_dir)), + (FAKE_RUFF_PATH, "format", str(output_dir)), capture_output=True, check=False, cwd=str(tmp_path), @@ -286,18 +421,82 @@ 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=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( - ("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( - ("ruff", "format", str(output_dir)), + (FAKE_RUFF_PATH, "format", str(output_dir)), capture_output=True, check=False, cwd=str(tmp_path), @@ -334,23 +533,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=FAKE_RUFF_PATH), + 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( + ( + FAKE_RUFF_PATH, + "check", + "--fix", + "--unsafe-fixes", + "--unfixable", + "TC001,TC002,TC003", + str(output_dir), + ), + capture_output=True, + check=False, + cwd=mock.ANY, + ) + mock_run.assert_any_call( + (FAKE_RUFF_PATH, "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=FAKE_RUFF_PATH), + 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)), + (FAKE_RUFF_PATH, "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)), + (FAKE_RUFF_PATH, "format", str(output_dir)), capture_output=True, check=False, cwd=mock.ANY,