From 4691e1c5729cedfe82f76a2adf188109266d48aa Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Sun, 14 Dec 2025 18:12:53 +0000 Subject: [PATCH 1/3] Fix dataclass field ordering for inheritance and add warning for conflicts --- pyproject.toml | 1 + .../model/dataclass.py | 5 +- src/datamodel_code_generator/parser/base.py | 60 +++++++++++++++++++ ...class_enum_one_literal_as_default_py310.py | 33 ++++++++++ tests/main/openapi/test_main_openapi.py | 22 +++++++ 5 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 tests/data/expected/main/openapi/discriminator/dataclass_enum_one_literal_as_default_py310.py diff --git a/pyproject.toml b/pyproject.toml index ed59a7ef0..0f418f92f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -209,6 +209,7 @@ filterwarnings = [ "ignore:^.*jsonschema.exceptions.RefResolutionError is deprecated as of version 4.18.0. If you wish to catch potential reference resolution errors, directly catch referencing.exceptions.Unresolvable..*", "ignore:^.*`experimental string processing` has been included in `preview` and deprecated. Use `preview` instead..*", "ignore:^.*No schemas found in components/schemas.*", + "ignore:^.*Dataclass .* has a field ordering conflict due to inheritance.*:UserWarning", ] norecursedirs = "tests/data/*" verbosity_assertions = 2 diff --git a/src/datamodel_code_generator/model/dataclass.py b/src/datamodel_code_generator/model/dataclass.py index a9632a6c9..02b8a970d 100644 --- a/src/datamodel_code_generator/model/dataclass.py +++ b/src/datamodel_code_generator/model/dataclass.py @@ -31,7 +31,8 @@ from datamodel_code_generator.reference import Reference -def _has_field_assignment(field: DataModelFieldBase) -> bool: +def has_field_assignment(field: DataModelFieldBase) -> bool: + """Check if a dataclass field has a default value or field() assignment.""" return bool(field.field) or not ( field.required or (field.represented_default == "None" and field.strip_default_none) ) @@ -66,7 +67,7 @@ def __init__( # noqa: PLR0913 """Initialize dataclass with fields sorted by field assignment requirement.""" super().__init__( reference=reference, - fields=sorted(fields, key=_has_field_assignment), + fields=sorted(fields, key=has_field_assignment), decorators=decorators, base_classes=base_classes, custom_base_class=custom_base_class, diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index 45ef24fd9..f387d4f57 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -18,6 +18,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, NamedTuple, Optional, Protocol, TypeVar, cast, runtime_checkable from urllib.parse import ParseResult +from warnings import warn from pydantic import BaseModel from typing_extensions import TypeAlias @@ -1798,6 +1799,64 @@ def __set_one_literal_on_default(self, models: list[DataModel]) -> None: if model_field.nullable is not True: # pragma: no cover model_field.nullable = False + def __fix_dataclass_field_ordering(self, models: list[DataModel]) -> None: + """Fix field ordering for dataclasses with inheritance after defaults are set.""" + for model in models: + if (inherited := self.__get_dataclass_inherited_info(model)) is None: + continue + inherited_names, has_default = inherited + if not has_default or not any(self.__is_new_required_field(f, inherited_names) for f in model.fields): + continue + + if self.target_python_version.has_kw_only_dataclass: + for field in model.fields: + if self.__is_new_required_field(field, inherited_names): + field.extras["kw_only"] = True + else: + warn( + f"Dataclass '{model.class_name}' has a field ordering conflict due to inheritance. " + f"An inherited field has a default value, but new required fields are added. " + f"This will cause a TypeError at runtime. Consider using --target-python-version 3.10 " + f"or higher to enable automatic field(kw_only=True) fix.", + category=UserWarning, + stacklevel=2, + ) + model.fields = sorted(model.fields, key=dataclass_model.has_field_assignment) + + def __get_dataclass_inherited_info(self, model: DataModel) -> tuple[set[str], bool] | None: + """Get inherited field names and whether any has default. Returns None if not applicable.""" + if not isinstance(model, dataclass_model.DataClass): + return None + if not model.base_classes or model.dataclass_arguments.get("kw_only"): + return None + + inherited_names: set[str] = set() + has_default = False + for base in model.base_classes: + if not base.reference or not isinstance(base.reference.source, DataModel): + continue + for f in base.reference.source.iter_all_fields(): + if not f.name or f.extras.get("init") is False: + continue + inherited_names.add(f.name) + if dataclass_model.has_field_assignment(f): + has_default = True + + for f in model.fields: + if f.name not in inherited_names or f.extras.get("init") is False: + continue + if dataclass_model.has_field_assignment(f): + has_default = True + return (inherited_names, has_default) if inherited_names else None + + def __is_new_required_field(self, field: DataModelFieldBase, inherited: set[str]) -> bool: # noqa: PLR6301 + """Check if field is a new required init field.""" + return ( + field.name not in inherited + and field.extras.get("init") is not False + and not dataclass_model.has_field_assignment(field) + ) + @classmethod def __update_type_aliases(cls, models: list[DataModel]) -> None: """Update type aliases to properly handle forward references per PEP 484.""" @@ -2463,6 +2522,7 @@ class Processed(NamedTuple): self.__change_field_name(models) self.__apply_discriminator_type(models, imports) self.__set_one_literal_on_default(models) + self.__fix_dataclass_field_ordering(models) processed_models.append(Processed(module, module_, models, init, imports, scoped_model_resolver)) diff --git a/tests/data/expected/main/openapi/discriminator/dataclass_enum_one_literal_as_default_py310.py b/tests/data/expected/main/openapi/discriminator/dataclass_enum_one_literal_as_default_py310.py new file mode 100644 index 000000000..3f14bff2d --- /dev/null +++ b/tests/data/expected/main/openapi/discriminator/dataclass_enum_one_literal_as_default_py310.py @@ -0,0 +1,33 @@ +# generated by datamodel-codegen: +# filename: discriminator_enum.yaml +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Literal, TypeAlias, Union + + +class RequestVersionEnum(Enum): + v1 = 'v1' + v2 = 'v2' + + +@dataclass +class RequestBase: + version: RequestVersionEnum + + +@dataclass +class RequestV1(RequestBase): + request_id: str = field(kw_only=True) + version: Literal['v1'] = 'v1' + + +@dataclass +class RequestV2(RequestBase): + version: Literal['v2'] = 'v2' + + +Request: TypeAlias = Union[RequestV1, RequestV2] diff --git a/tests/main/openapi/test_main_openapi.py b/tests/main/openapi/test_main_openapi.py index 156f24e27..1b44773d8 100644 --- a/tests/main/openapi/test_main_openapi.py +++ b/tests/main/openapi/test_main_openapi.py @@ -2413,6 +2413,28 @@ def test_main_openapi_discriminator_one_literal_as_default_dataclass(output_file ) +@pytest.mark.skipif( + black.__version__.split(".")[0] == "19", + reason="Installed black doesn't support the old style", +) +def test_main_openapi_discriminator_one_literal_as_default_dataclass_py310(output_file: Path) -> None: + """Test OpenAPI generation with discriminator one literal as default for dataclass with Python 3.10+.""" + run_main_and_assert( + input_path=OPEN_API_DATA_PATH / "discriminator_enum.yaml", + output_path=output_file, + input_file_type="openapi", + assert_func=assert_file_content, + expected_file=EXPECTED_OPENAPI_PATH / "discriminator" / "dataclass_enum_one_literal_as_default_py310.py", + extra_args=[ + "--output-model-type", + "dataclasses.dataclass", + "--use-one-literal-as-default", + "--target-python-version", + "3.10", + ], + ) + + @pytest.mark.skipif( black.__version__.split(".")[0] == "19", reason="Installed black doesn't support the old style", From b66f7f57078288ea790cb8d81d85499ebdf76655 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Sun, 14 Dec 2025 18:23:02 +0000 Subject: [PATCH 2/3] Fix dataclass inheritance field ordering and add tests for conflicts --- src/datamodel_code_generator/parser/base.py | 6 +-- ...aclass_inheritance_field_ordering_py310.py | 19 ++++++++ .../dataclass_inheritance_field_ordering.yaml | 25 +++++++++++ tests/main/openapi/test_main_openapi.py | 44 +++++++++++++++++++ 4 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 tests/data/expected/main/openapi/dataclass_inheritance_field_ordering_py310.py create mode 100644 tests/data/openapi/dataclass_inheritance_field_ordering.yaml diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index f387d4f57..2dcaec5c4 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -1834,10 +1834,10 @@ def __get_dataclass_inherited_info(self, model: DataModel) -> tuple[set[str], bo has_default = False for base in model.base_classes: if not base.reference or not isinstance(base.reference.source, DataModel): - continue + continue # pragma: no cover for f in base.reference.source.iter_all_fields(): if not f.name or f.extras.get("init") is False: - continue + continue # pragma: no cover inherited_names.add(f.name) if dataclass_model.has_field_assignment(f): has_default = True @@ -1845,7 +1845,7 @@ def __get_dataclass_inherited_info(self, model: DataModel) -> tuple[set[str], bo for f in model.fields: if f.name not in inherited_names or f.extras.get("init") is False: continue - if dataclass_model.has_field_assignment(f): + if dataclass_model.has_field_assignment(f): # pragma: no branch has_default = True return (inherited_names, has_default) if inherited_names else None diff --git a/tests/data/expected/main/openapi/dataclass_inheritance_field_ordering_py310.py b/tests/data/expected/main/openapi/dataclass_inheritance_field_ordering_py310.py new file mode 100644 index 000000000..e43ca83e6 --- /dev/null +++ b/tests/data/expected/main/openapi/dataclass_inheritance_field_ordering_py310.py @@ -0,0 +1,19 @@ +# generated by datamodel-codegen: +# filename: dataclass_inheritance_field_ordering.yaml +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class ParentWithDefault: + name: Optional[str] = 'default_name' + read_only_field: Optional[str] = None + + +@dataclass +class ChildWithRequired(ParentWithDefault): + child_id: str = field(kw_only=True) diff --git a/tests/data/openapi/dataclass_inheritance_field_ordering.yaml b/tests/data/openapi/dataclass_inheritance_field_ordering.yaml new file mode 100644 index 000000000..1689e188a --- /dev/null +++ b/tests/data/openapi/dataclass_inheritance_field_ordering.yaml @@ -0,0 +1,25 @@ +openapi: "3.0.0" +info: + title: Dataclass Inheritance Field Ordering Test + version: "1.0" +components: + schemas: + ParentWithDefault: + type: object + properties: + name: + type: string + default: "default_name" + read_only_field: + type: string + readOnly: true + + ChildWithRequired: + allOf: + - $ref: '#/components/schemas/ParentWithDefault' + - type: object + properties: + child_id: + type: string + required: + - child_id diff --git a/tests/main/openapi/test_main_openapi.py b/tests/main/openapi/test_main_openapi.py index 1b44773d8..02c9783bc 100644 --- a/tests/main/openapi/test_main_openapi.py +++ b/tests/main/openapi/test_main_openapi.py @@ -2435,6 +2435,50 @@ def test_main_openapi_discriminator_one_literal_as_default_dataclass_py310(outpu ) +@pytest.mark.skipif( + black.__version__.split(".")[0] == "19", + reason="Installed black doesn't support the old style", +) +def test_main_openapi_discriminator_one_literal_as_default_dataclass_py39_warning(output_file: Path) -> None: + """Test that Python 3.9 emits warning for dataclass field ordering conflict.""" + with pytest.warns(UserWarning, match=r"Dataclass .* has a field ordering conflict due to inheritance"): + run_main_and_assert( + input_path=OPEN_API_DATA_PATH / "discriminator_enum.yaml", + output_path=output_file, + input_file_type="openapi", + assert_func=assert_file_content, + expected_file=EXPECTED_OPENAPI_PATH / "discriminator" / "dataclass_enum_one_literal_as_default.py", + extra_args=[ + "--output-model-type", + "dataclasses.dataclass", + "--use-one-literal-as-default", + "--target-python-version", + "3.9", + ], + ) + + +@pytest.mark.skipif( + black.__version__.split(".")[0] == "19", + reason="Installed black doesn't support the old style", +) +def test_main_openapi_dataclass_inheritance_parent_default(output_file: Path) -> None: + """Test dataclass field ordering fix when parent has default field.""" + run_main_and_assert( + input_path=OPEN_API_DATA_PATH / "dataclass_inheritance_field_ordering.yaml", + output_path=output_file, + input_file_type="openapi", + assert_func=assert_file_content, + expected_file=EXPECTED_OPENAPI_PATH / "dataclass_inheritance_field_ordering_py310.py", + extra_args=[ + "--output-model-type", + "dataclasses.dataclass", + "--target-python-version", + "3.10", + ], + ) + + @pytest.mark.skipif( black.__version__.split(".")[0] == "19", reason="Installed black doesn't support the old style", From 35df2988eeb74ab591a04209f745d0856b0d2c3b Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Sun, 14 Dec 2025 18:23:58 +0000 Subject: [PATCH 3/3] Fix dataclass inherited info method to be a class method --- src/datamodel_code_generator/parser/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index 2dcaec5c4..9f29de22f 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -1823,7 +1823,8 @@ def __fix_dataclass_field_ordering(self, models: list[DataModel]) -> None: ) model.fields = sorted(model.fields, key=dataclass_model.has_field_assignment) - def __get_dataclass_inherited_info(self, model: DataModel) -> tuple[set[str], bool] | None: + @classmethod + def __get_dataclass_inherited_info(cls, model: DataModel) -> tuple[set[str], bool] | None: """Get inherited field names and whether any has default. Returns None if not applicable.""" if not isinstance(model, dataclass_model.DataClass): return None