diff --git a/docs/cli-reference/field-customization.md b/docs/cli-reference/field-customization.md index 8156d06c3..67336098a 100644 --- a/docs/cli-reference/field-customization.md +++ b/docs/cli-reference/field-customization.md @@ -12,6 +12,7 @@ | [`--field-extra-keys`](#field-extra-keys) | Include specific extra keys in Field() definitions. | | [`--field-extra-keys-without-x-prefix`](#field-extra-keys-without-x-prefix) | Include specified schema extension keys in Field() without r... | | [`--field-include-all-keys`](#field-include-all-keys) | Include all schema keys in Field() json_schema_extra. | +| [`--field-type-collision-strategy`](#field-type-collision-strategy) | Configure how to resolve naming conflicts between field name... | | [`--no-alias`](#no-alias) | Disable Field alias generation for non-Python-safe property ... | | [`--original-field-name-delimiter`](#original-field-name-delimiter) | Specify delimiter for original field names when using snake-... | | [`--remove-special-field-name-prefix`](#remove-special-field-name-prefix) | Remove the special prefix from field names. | @@ -1813,6 +1814,71 @@ The `--field-include-all-keys` flag configures the code generation behavior. --- +## `--field-type-collision-strategy` {#field-type-collision-strategy} + +Configure how to resolve naming conflicts between field names and generated type names. + +When a schema property name matches a generated type name (e.g., a property "Fruit" with +an enum type that would also be named "Fruit"), a collision occurs. This option controls +the resolution strategy: + +- `rename-field` (default): Rename the field with a suffix (e.g., `Fruit_1`) and preserve + the original name via `Field(alias='Fruit')` +- `rename-type`: Rename the type class with a suffix (e.g., `Fruit_`) and keep the original + field name + +!!! tip "Usage" + + ```bash + datamodel-codegen --input schema.json --output-model-type pydantic_v2.BaseModel --field-type-collision-strategy rename-type # (1)! + ``` + + 1. :material-arrow-left: `--field-type-collision-strategy` - the option documented here + +??? example "Examples" + + **Input Schema:** + + ```json + { + "title": "Test", + "type": "object", + "properties": { + "Fruit": { + "enum": [ + "apple", + "banana" + ] + } + } + } + ``` + + **Output:** + + ```python + # generated by datamodel-codegen: + # filename: field_type_collision_rename_type.json + # timestamp: 2019-07-26T00:00:00+00:00 + + from __future__ import annotations + + from enum import Enum + + from pydantic import BaseModel + + + class Fruit_1(Enum): + apple = 'apple' + banana = 'banana' + + + class Test(BaseModel): + Fruit: Fruit_1 | None = None + ``` + +--- + ## `--no-alias` {#no-alias} Disable Field alias generation for non-Python-safe property names. diff --git a/docs/cli-reference/index.md b/docs/cli-reference/index.md index f7a3d7886..e509e3855 100644 --- a/docs/cli-reference/index.md +++ b/docs/cli-reference/index.md @@ -10,7 +10,7 @@ This documentation is auto-generated from test cases. |----------|---------|-------------| | 📁 [Base Options](base-options.md) | 5 | Input/output configuration | | 🔧 [Typing Customization](typing-customization.md) | 17 | Type annotation and import behavior | -| 🏷️ [Field Customization](field-customization.md) | 20 | Field naming and docstring behavior | +| 🏷️ [Field Customization](field-customization.md) | 21 | Field naming and docstring behavior | | 🏗️ [Model Customization](model-customization.md) | 29 | Model generation behavior | | 🎨 [Template Customization](template-customization.md) | 16 | Output formatting and custom rendering | | 📘 [OpenAPI-only Options](openapi-only-options.md) | 6 | OpenAPI-specific features | @@ -75,6 +75,7 @@ This documentation is auto-generated from test cases. - [`--field-extra-keys`](field-customization.md#field-extra-keys) - [`--field-extra-keys-without-x-prefix`](field-customization.md#field-extra-keys-without-x-prefix) - [`--field-include-all-keys`](field-customization.md#field-include-all-keys) +- [`--field-type-collision-strategy`](field-customization.md#field-type-collision-strategy) - [`--force-optional`](model-customization.md#force-optional) - [`--formatters`](template-customization.md#formatters) - [`--frozen-dataclasses`](model-customization.md#frozen-dataclasses) diff --git a/docs/cli-reference/quick-reference.md b/docs/cli-reference/quick-reference.md index c8b2b343c..4e735d431 100644 --- a/docs/cli-reference/quick-reference.md +++ b/docs/cli-reference/quick-reference.md @@ -56,6 +56,7 @@ datamodel-codegen [OPTIONS] | [`--field-extra-keys`](field-customization.md#field-extra-keys) | Include specific extra keys in Field() definitions. | | [`--field-extra-keys-without-x-prefix`](field-customization.md#field-extra-keys-without-x-prefix) | Include specified schema extension keys in Field() without requiring 'x-' prefix... | | [`--field-include-all-keys`](field-customization.md#field-include-all-keys) | Include all schema keys in Field() json_schema_extra. | +| [`--field-type-collision-strategy`](field-customization.md#field-type-collision-strategy) | Configure how to resolve naming conflicts between field names and generated type... | | [`--no-alias`](field-customization.md#no-alias) | Disable Field alias generation for non-Python-safe property names. | | [`--original-field-name-delimiter`](field-customization.md#original-field-name-delimiter) | Specify delimiter for original field names when using snake-case conversion. | | [`--remove-special-field-name-prefix`](field-customization.md#remove-special-field-name-prefix) | Remove the special prefix from field names. | @@ -206,6 +207,7 @@ All options sorted alphabetically: - [`--field-extra-keys`](field-customization.md#field-extra-keys) - Include specific extra keys in Field() definitions. - [`--field-extra-keys-without-x-prefix`](field-customization.md#field-extra-keys-without-x-prefix) - Include specified schema extension keys in Field() without r... - [`--field-include-all-keys`](field-customization.md#field-include-all-keys) - Include all schema keys in Field() json_schema_extra. +- [`--field-type-collision-strategy`](field-customization.md#field-type-collision-strategy) - Configure how to resolve naming conflicts between field name... - [`--force-optional`](model-customization.md#force-optional) - Force all fields to be Optional regardless of required statu... - [`--formatters`](template-customization.md#formatters) - Specify code formatters to apply to generated output. - [`--frozen-dataclasses`](model-customization.md#frozen-dataclasses) - Generate frozen dataclasses with optional keyword-only field... diff --git a/src/datamodel_code_generator/__init__.py b/src/datamodel_code_generator/__init__.py index 3008dfb09..42b4dac40 100644 --- a/src/datamodel_code_generator/__init__.py +++ b/src/datamodel_code_generator/__init__.py @@ -309,6 +309,17 @@ class ModuleSplitMode(Enum): Single = "single" +class FieldTypeCollisionStrategy(Enum): + """Strategy for handling field name and type name collisions in Pydantic v2. + + RenameField: Rename the field with a suffix (e.g., Fruit_1) and add alias (default). + RenameType: Rename the type class with a suffix (e.g., Fruit_) to preserve field name. + """ + + RenameField = "rename-field" + RenameType = "rename-type" + + class Error(Exception): """Base exception for datamodel-code-generator errors.""" @@ -485,6 +496,7 @@ def generate( # noqa: PLR0912, PLR0913, PLR0914, PLR0915 all_exports_scope: AllExportsScope | None = None, all_exports_collision_strategy: AllExportsCollisionStrategy | None = None, module_split_mode: ModuleSplitMode | None = None, + field_type_collision_strategy: FieldTypeCollisionStrategy | None = None, ) -> None: """Generate Python data models from schema definitions or structured data. @@ -730,6 +742,7 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]: dataclass_arguments=dataclass_arguments, type_mappings=type_mappings, read_only_write_only_model_type=read_only_write_only_model_type, + field_type_collision_strategy=field_type_collision_strategy, **kwargs, ) @@ -876,6 +889,7 @@ def infer_input_type(text: str) -> InputFileType: "DatetimeClassType", "DefaultPutDict", "Error", + "FieldTypeCollisionStrategy", "InputFileType", "InvalidClassNameError", "LiteralType", diff --git a/src/datamodel_code_generator/__main__.py b/src/datamodel_code_generator/__main__.py index fb2791d2e..c31783e2d 100644 --- a/src/datamodel_code_generator/__main__.py +++ b/src/datamodel_code_generator/__main__.py @@ -28,6 +28,7 @@ DataclassArguments, DataModelType, Error, + FieldTypeCollisionStrategy, InputFileType, InvalidClassNameError, ModuleSplitMode, @@ -471,6 +472,7 @@ def validate_all_exports_collision_strategy(cls, values: dict[str, Any]) -> dict all_exports_scope: Optional[AllExportsScope] = None # noqa: UP045 all_exports_collision_strategy: Optional[AllExportsCollisionStrategy] = None # noqa: UP045 module_split_mode: Optional[ModuleSplitMode] = None # noqa: UP045 + field_type_collision_strategy: Optional[FieldTypeCollisionStrategy] = None # noqa: UP045 watch: bool = False watch_delay: float = 0.5 @@ -778,6 +780,7 @@ def run_generate_from_config( # noqa: PLR0913, PLR0917 all_exports_scope=config.all_exports_scope, all_exports_collision_strategy=config.all_exports_collision_strategy, module_split_mode=config.module_split_mode, + field_type_collision_strategy=config.field_type_collision_strategy, ) diff --git a/src/datamodel_code_generator/arguments.py b/src/datamodel_code_generator/arguments.py index 9cedb2ef6..bf4396553 100644 --- a/src/datamodel_code_generator/arguments.py +++ b/src/datamodel_code_generator/arguments.py @@ -21,6 +21,7 @@ AllOfMergeMode, DataclassArguments, DataModelType, + FieldTypeCollisionStrategy, InputFileType, ModuleSplitMode, OpenAPIScope, @@ -601,6 +602,14 @@ def start_section(self, heading: str | None) -> None: choices=[u.value for u in UnionMode], default=None, ) +field_options.add_argument( + "--field-type-collision-strategy", + help="Strategy for handling field name and type name collisions (Pydantic v2 only). " + "'rename-field': rename field with suffix and add alias (default). " + "'rename-type': rename type class with suffix to preserve field name.", + choices=[s.value for s in FieldTypeCollisionStrategy], + default=None, +) field_options.add_argument( "--no-alias", help="""Do not add a field alias. E.g., if --snake-case-field is used along with a base class, which has an diff --git a/src/datamodel_code_generator/cli_options.py b/src/datamodel_code_generator/cli_options.py index fc86244e8..7586635c0 100644 --- a/src/datamodel_code_generator/cli_options.py +++ b/src/datamodel_code_generator/cli_options.py @@ -135,6 +135,9 @@ class CLIOptionMeta: "--use-enum-values-in-discriminator": CLIOptionMeta( name="--use-enum-values-in-discriminator", category=OptionCategory.FIELD ), + "--field-type-collision-strategy": CLIOptionMeta( + name="--field-type-collision-strategy", category=OptionCategory.FIELD + ), # ========================================================================== # Typing Customization # ========================================================================== diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index 253a2ea51..52dc0d22c 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -28,6 +28,7 @@ AllExportsScope, AllOfMergeMode, Error, + FieldTypeCollisionStrategy, ModuleSplitMode, ReadOnlyWriteOnlyModelType, ReuseScope, @@ -770,6 +771,7 @@ def __init__( # noqa: PLR0912, PLR0913, PLR0915 dataclass_arguments: DataclassArguments | None = None, type_mappings: list[str] | None = None, read_only_write_only_model_type: ReadOnlyWriteOnlyModelType | None = None, + field_type_collision_strategy: FieldTypeCollisionStrategy | None = None, ) -> None: """Initialize the Parser with configuration options.""" self.keyword_only = keyword_only @@ -926,6 +928,7 @@ def __init__( # noqa: PLR0912, PLR0913, PLR0915 self.read_only_write_only_model_type: ReadOnlyWriteOnlyModelType | None = read_only_write_only_model_type self.use_frozen_field: bool = use_frozen_field self.use_default_factory_for_optional_nested_models: bool = use_default_factory_for_optional_nested_models + self.field_type_collision_strategy: FieldTypeCollisionStrategy | None = field_type_collision_strategy @property def field_name_model_type(self) -> ModelType: @@ -1844,22 +1847,34 @@ def __change_field_name( ) -> None: if not self.data_model_type.SUPPORTS_FIELD_RENAMING: return + + rename_type = self.field_type_collision_strategy == FieldTypeCollisionStrategy.RenameType + for model in models: - if "Enum" in model.base_class: - continue - if not model.BASE_CLASS: + if "Enum" in model.base_class or not model.BASE_CLASS: continue for field in model.fields: filed_name = field.name - filed_name_resolver = ModelResolver(snake_case_field=self.snake_case_field, remove_suffix_number=True) + resolver = ModelResolver(snake_case_field=self.snake_case_field, remove_suffix_number=True) + colliding_reference: Reference | None = None for data_type in field.data_type.all_data_types: - if data_type.reference: - filed_name_resolver.exclude_names.add(data_type.reference.short_name) - new_filed_name = filed_name_resolver.add(["field"], cast("str", filed_name)).name - if filed_name != new_filed_name: - field.alias = filed_name - field.name = new_filed_name + if not data_type.reference: + continue + resolver.exclude_names.add(data_type.reference.short_name) + if rename_type and colliding_reference is None and data_type.reference.short_name == filed_name: + colliding_reference = data_type.reference + + if colliding_reference is not None: + source = cast("DataModel", colliding_reference.source) + resolver.exclude_names.add(cast("str", filed_name)) + new_class_name = resolver.add(["type"], cast("str", source.class_name)).name + source.class_name = new_class_name + else: + new_filed_name = resolver.add(["field"], cast("str", filed_name)).name + if filed_name != new_filed_name: + field.alias = filed_name + field.name = new_filed_name def __set_one_literal_on_default(self, models: list[DataModel]) -> None: if not self.use_one_literal_as_default: diff --git a/src/datamodel_code_generator/parser/graphql.py b/src/datamodel_code_generator/parser/graphql.py index 3ee1974d7..1ae0200ec 100644 --- a/src/datamodel_code_generator/parser/graphql.py +++ b/src/datamodel_code_generator/parser/graphql.py @@ -18,6 +18,7 @@ AllOfMergeMode, DataclassArguments, DefaultPutDict, + FieldTypeCollisionStrategy, LiteralType, PythonVersion, PythonVersionMin, @@ -193,6 +194,7 @@ def __init__( # noqa: PLR0913 use_serialize_as_any: bool = False, use_frozen_field: bool = False, use_default_factory_for_optional_nested_models: bool = False, + field_type_collision_strategy: FieldTypeCollisionStrategy | None = None, ) -> None: """Initialize the GraphQL parser with configuration options.""" super().__init__( @@ -290,6 +292,7 @@ def __init__( # noqa: PLR0913 use_serialize_as_any=use_serialize_as_any, use_frozen_field=use_frozen_field, use_default_factory_for_optional_nested_models=use_default_factory_for_optional_nested_models, + field_type_collision_strategy=field_type_collision_strategy, ) self.data_model_scalar_type = data_model_scalar_type diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index 8e69d3a50..8290e7c42 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -25,6 +25,7 @@ DEFAULT_SHARED_MODULE_NAME, AllOfMergeMode, DataclassArguments, + FieldTypeCollisionStrategy, InvalidClassNameError, ReadOnlyWriteOnlyModelType, ReuseScope, @@ -605,6 +606,7 @@ def __init__( # noqa: PLR0913 dataclass_arguments: DataclassArguments | None = None, type_mappings: list[str] | None = None, read_only_write_only_model_type: ReadOnlyWriteOnlyModelType | None = None, + field_type_collision_strategy: FieldTypeCollisionStrategy | None = None, ) -> None: """Initialize the JSON Schema parser with configuration options.""" target_datetime_class = target_datetime_class or DatetimeClassType.Awaredatetime @@ -703,6 +705,7 @@ def __init__( # noqa: PLR0913 dataclass_arguments=dataclass_arguments, type_mappings=type_mappings, read_only_write_only_model_type=read_only_write_only_model_type, + field_type_collision_strategy=field_type_collision_strategy, ) self.remote_object_cache: DefaultPutDict[str, dict[str, YamlValue]] = DefaultPutDict() diff --git a/src/datamodel_code_generator/parser/openapi.py b/src/datamodel_code_generator/parser/openapi.py index 9d9511421..a36b6ad4c 100644 --- a/src/datamodel_code_generator/parser/openapi.py +++ b/src/datamodel_code_generator/parser/openapi.py @@ -22,6 +22,7 @@ AllOfMergeMode, DataclassArguments, Error, + FieldTypeCollisionStrategy, LiteralType, OpenAPIScope, PythonVersion, @@ -277,6 +278,7 @@ def __init__( # noqa: PLR0913 use_frozen_field: bool = False, use_default_factory_for_optional_nested_models: bool = False, use_status_code_in_response_name: bool = False, + field_type_collision_strategy: FieldTypeCollisionStrategy | None = None, ) -> None: """Initialize the OpenAPI parser with extensive configuration options.""" target_datetime_class = target_datetime_class or DatetimeClassType.Awaredatetime @@ -375,6 +377,7 @@ def __init__( # noqa: PLR0913 read_only_write_only_model_type=read_only_write_only_model_type, use_frozen_field=use_frozen_field, use_default_factory_for_optional_nested_models=use_default_factory_for_optional_nested_models, + field_type_collision_strategy=field_type_collision_strategy, ) self.open_api_scopes: list[OpenAPIScope] = openapi_scopes or [OpenAPIScope.Schemas] self.include_path_parameters: bool = include_path_parameters diff --git a/tests/data/expected/main/jsonschema/field_type_collision_rename_type.py b/tests/data/expected/main/jsonschema/field_type_collision_rename_type.py new file mode 100644 index 000000000..ebc256bbf --- /dev/null +++ b/tests/data/expected/main/jsonschema/field_type_collision_rename_type.py @@ -0,0 +1,18 @@ +# generated by datamodel-codegen: +# filename: field_type_collision_rename_type.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel + + +class Fruit_1(Enum): + apple = 'apple' + banana = 'banana' + + +class Test(BaseModel): + Fruit: Fruit_1 | None = None diff --git a/tests/data/expected/main/jsonschema/field_type_collision_rename_type_multi_model.py b/tests/data/expected/main/jsonschema/field_type_collision_rename_type_multi_model.py new file mode 100644 index 000000000..56c7f83e3 --- /dev/null +++ b/tests/data/expected/main/jsonschema/field_type_collision_rename_type_multi_model.py @@ -0,0 +1,23 @@ +# generated by datamodel-codegen: +# filename: field_type_collision_rename_type_multi_model.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel + + +class Fruit_1(Enum): + apple = 'apple' + banana = 'banana' + + +class Child(BaseModel): + Fruit: Fruit_1 | None = None + + +class Parent(BaseModel): + Fruit: Fruit_1 | None = None + child: Child | None = None diff --git a/tests/data/jsonschema/field_type_collision_rename_type.json b/tests/data/jsonschema/field_type_collision_rename_type.json new file mode 100644 index 000000000..547d2166e --- /dev/null +++ b/tests/data/jsonschema/field_type_collision_rename_type.json @@ -0,0 +1,12 @@ +{ + "title": "Test", + "type": "object", + "properties": { + "Fruit": { + "enum": [ + "apple", + "banana" + ] + } + } +} diff --git a/tests/data/jsonschema/field_type_collision_rename_type_multi_model.json b/tests/data/jsonschema/field_type_collision_rename_type_multi_model.json new file mode 100644 index 000000000..8fdfdc83c --- /dev/null +++ b/tests/data/jsonschema/field_type_collision_rename_type_multi_model.json @@ -0,0 +1,27 @@ +{ + "title": "Parent", + "type": "object", + "properties": { + "Fruit": { + "$ref": "#/definitions/Fruit" + }, + "child": { + "$ref": "#/definitions/Child" + } + }, + "definitions": { + "Fruit": { + "title": "Fruit", + "enum": ["apple", "banana"] + }, + "Child": { + "title": "Child", + "type": "object", + "properties": { + "Fruit": { + "$ref": "#/definitions/Fruit" + } + } + } + } +} diff --git a/tests/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index 1475e83a3..4a60a1477 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -3572,6 +3572,56 @@ def test_main_jsonschema_field_has_same_name(output_model: str, expected_output: ) +@pytest.mark.cli_doc( + options=["--field-type-collision-strategy"], + input_schema="jsonschema/field_type_collision_rename_type.json", + cli_args=["--output-model-type", "pydantic_v2.BaseModel", "--field-type-collision-strategy", "rename-type"], + golden_output="main/jsonschema/field_type_collision_rename_type.py", +) +def test_main_jsonschema_field_type_collision_rename_type(output_file: Path) -> None: + """Configure how to resolve naming conflicts between field names and generated type names. + + When a schema property name matches a generated type name (e.g., a property "Fruit" with + an enum type that would also be named "Fruit"), a collision occurs. This option controls + the resolution strategy: + + - `rename-field` (default): Rename the field with a suffix (e.g., `Fruit_1`) and preserve + the original name via `Field(alias='Fruit')` + - `rename-type`: Rename the type class with a suffix (e.g., `Fruit_`) and keep the original + field name + """ + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "field_type_collision_rename_type.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="field_type_collision_rename_type.py", + extra_args=[ + "--output-model-type", + "pydantic_v2.BaseModel", + "--field-type-collision-strategy", + "rename-type", + ], + ) + + +def test_main_jsonschema_field_type_collision_rename_type_multi_model(output_file: Path) -> None: + """Test field-type collision with rename-type strategy across multiple models.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "field_type_collision_rename_type_multi_model.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="field_type_collision_rename_type_multi_model.py", + extra_args=[ + "--output-model-type", + "pydantic_v2.BaseModel", + "--field-type-collision-strategy", + "rename-type", + ], + ) + + @pytest.mark.benchmark def test_main_jsonschema_required_and_any_of_required(output_file: Path) -> None: """Test required field with anyOf required."""