Skip to content

Commit 502f80a

Browse files
authored
Fix dataclass field ordering conflict when inheriting from parent with default fields (#2664)
* Fix dataclass field ordering for inheritance and add warning for conflicts * Fix dataclass inheritance field ordering and add tests for conflicts * Fix dataclass inherited info method to be a class method
1 parent f7ffbd0 commit 502f80a

7 files changed

Lines changed: 208 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ filterwarnings = [
209209
"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..*",
210210
"ignore:^.*`experimental string processing` has been included in `preview` and deprecated. Use `preview` instead..*",
211211
"ignore:^.*No schemas found in components/schemas.*",
212+
"ignore:^.*Dataclass .* has a field ordering conflict due to inheritance.*:UserWarning",
212213
]
213214
norecursedirs = "tests/data/*"
214215
verbosity_assertions = 2

src/datamodel_code_generator/model/dataclass.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
from datamodel_code_generator.reference import Reference
3232

3333

34-
def _has_field_assignment(field: DataModelFieldBase) -> bool:
34+
def has_field_assignment(field: DataModelFieldBase) -> bool:
35+
"""Check if a dataclass field has a default value or field() assignment."""
3536
return bool(field.field) or not (
3637
field.required or (field.represented_default == "None" and field.strip_default_none)
3738
)
@@ -66,7 +67,7 @@ def __init__( # noqa: PLR0913
6667
"""Initialize dataclass with fields sorted by field assignment requirement."""
6768
super().__init__(
6869
reference=reference,
69-
fields=sorted(fields, key=_has_field_assignment),
70+
fields=sorted(fields, key=has_field_assignment),
7071
decorators=decorators,
7172
base_classes=base_classes,
7273
custom_base_class=custom_base_class,

src/datamodel_code_generator/parser/base.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from pathlib import Path
1919
from typing import TYPE_CHECKING, Any, Callable, NamedTuple, Optional, Protocol, TypeVar, cast, runtime_checkable
2020
from urllib.parse import ParseResult
21+
from warnings import warn
2122

2223
from pydantic import BaseModel
2324
from typing_extensions import TypeAlias
@@ -1798,6 +1799,65 @@ def __set_one_literal_on_default(self, models: list[DataModel]) -> None:
17981799
if model_field.nullable is not True: # pragma: no cover
17991800
model_field.nullable = False
18001801

1802+
def __fix_dataclass_field_ordering(self, models: list[DataModel]) -> None:
1803+
"""Fix field ordering for dataclasses with inheritance after defaults are set."""
1804+
for model in models:
1805+
if (inherited := self.__get_dataclass_inherited_info(model)) is None:
1806+
continue
1807+
inherited_names, has_default = inherited
1808+
if not has_default or not any(self.__is_new_required_field(f, inherited_names) for f in model.fields):
1809+
continue
1810+
1811+
if self.target_python_version.has_kw_only_dataclass:
1812+
for field in model.fields:
1813+
if self.__is_new_required_field(field, inherited_names):
1814+
field.extras["kw_only"] = True
1815+
else:
1816+
warn(
1817+
f"Dataclass '{model.class_name}' has a field ordering conflict due to inheritance. "
1818+
f"An inherited field has a default value, but new required fields are added. "
1819+
f"This will cause a TypeError at runtime. Consider using --target-python-version 3.10 "
1820+
f"or higher to enable automatic field(kw_only=True) fix.",
1821+
category=UserWarning,
1822+
stacklevel=2,
1823+
)
1824+
model.fields = sorted(model.fields, key=dataclass_model.has_field_assignment)
1825+
1826+
@classmethod
1827+
def __get_dataclass_inherited_info(cls, model: DataModel) -> tuple[set[str], bool] | None:
1828+
"""Get inherited field names and whether any has default. Returns None if not applicable."""
1829+
if not isinstance(model, dataclass_model.DataClass):
1830+
return None
1831+
if not model.base_classes or model.dataclass_arguments.get("kw_only"):
1832+
return None
1833+
1834+
inherited_names: set[str] = set()
1835+
has_default = False
1836+
for base in model.base_classes:
1837+
if not base.reference or not isinstance(base.reference.source, DataModel):
1838+
continue # pragma: no cover
1839+
for f in base.reference.source.iter_all_fields():
1840+
if not f.name or f.extras.get("init") is False:
1841+
continue # pragma: no cover
1842+
inherited_names.add(f.name)
1843+
if dataclass_model.has_field_assignment(f):
1844+
has_default = True
1845+
1846+
for f in model.fields:
1847+
if f.name not in inherited_names or f.extras.get("init") is False:
1848+
continue
1849+
if dataclass_model.has_field_assignment(f): # pragma: no branch
1850+
has_default = True
1851+
return (inherited_names, has_default) if inherited_names else None
1852+
1853+
def __is_new_required_field(self, field: DataModelFieldBase, inherited: set[str]) -> bool: # noqa: PLR6301
1854+
"""Check if field is a new required init field."""
1855+
return (
1856+
field.name not in inherited
1857+
and field.extras.get("init") is not False
1858+
and not dataclass_model.has_field_assignment(field)
1859+
)
1860+
18011861
@classmethod
18021862
def __update_type_aliases(cls, models: list[DataModel]) -> None:
18031863
"""Update type aliases to properly handle forward references per PEP 484."""
@@ -2471,6 +2531,7 @@ class Processed(NamedTuple):
24712531
self.__change_field_name(models)
24722532
self.__apply_discriminator_type(models, imports)
24732533
self.__set_one_literal_on_default(models)
2534+
self.__fix_dataclass_field_ordering(models)
24742535

24752536
processed_models.append(Processed(module, module_, models, init, imports, scoped_model_resolver))
24762537

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# generated by datamodel-codegen:
2+
# filename: dataclass_inheritance_field_ordering.yaml
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from dataclasses import dataclass, field
8+
from typing import Optional
9+
10+
11+
@dataclass
12+
class ParentWithDefault:
13+
name: Optional[str] = 'default_name'
14+
read_only_field: Optional[str] = None
15+
16+
17+
@dataclass
18+
class ChildWithRequired(ParentWithDefault):
19+
child_id: str = field(kw_only=True)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# generated by datamodel-codegen:
2+
# filename: discriminator_enum.yaml
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from dataclasses import dataclass, field
8+
from enum import Enum
9+
from typing import Literal, TypeAlias, Union
10+
11+
12+
class RequestVersionEnum(Enum):
13+
v1 = 'v1'
14+
v2 = 'v2'
15+
16+
17+
@dataclass
18+
class RequestBase:
19+
version: RequestVersionEnum
20+
21+
22+
@dataclass
23+
class RequestV1(RequestBase):
24+
request_id: str = field(kw_only=True)
25+
version: Literal['v1'] = 'v1'
26+
27+
28+
@dataclass
29+
class RequestV2(RequestBase):
30+
version: Literal['v2'] = 'v2'
31+
32+
33+
Request: TypeAlias = Union[RequestV1, RequestV2]
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
openapi: "3.0.0"
2+
info:
3+
title: Dataclass Inheritance Field Ordering Test
4+
version: "1.0"
5+
components:
6+
schemas:
7+
ParentWithDefault:
8+
type: object
9+
properties:
10+
name:
11+
type: string
12+
default: "default_name"
13+
read_only_field:
14+
type: string
15+
readOnly: true
16+
17+
ChildWithRequired:
18+
allOf:
19+
- $ref: '#/components/schemas/ParentWithDefault'
20+
- type: object
21+
properties:
22+
child_id:
23+
type: string
24+
required:
25+
- child_id

tests/main/openapi/test_main_openapi.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2413,6 +2413,72 @@ def test_main_openapi_discriminator_one_literal_as_default_dataclass(output_file
24132413
)
24142414

24152415

2416+
@pytest.mark.skipif(
2417+
black.__version__.split(".")[0] == "19",
2418+
reason="Installed black doesn't support the old style",
2419+
)
2420+
def test_main_openapi_discriminator_one_literal_as_default_dataclass_py310(output_file: Path) -> None:
2421+
"""Test OpenAPI generation with discriminator one literal as default for dataclass with Python 3.10+."""
2422+
run_main_and_assert(
2423+
input_path=OPEN_API_DATA_PATH / "discriminator_enum.yaml",
2424+
output_path=output_file,
2425+
input_file_type="openapi",
2426+
assert_func=assert_file_content,
2427+
expected_file=EXPECTED_OPENAPI_PATH / "discriminator" / "dataclass_enum_one_literal_as_default_py310.py",
2428+
extra_args=[
2429+
"--output-model-type",
2430+
"dataclasses.dataclass",
2431+
"--use-one-literal-as-default",
2432+
"--target-python-version",
2433+
"3.10",
2434+
],
2435+
)
2436+
2437+
2438+
@pytest.mark.skipif(
2439+
black.__version__.split(".")[0] == "19",
2440+
reason="Installed black doesn't support the old style",
2441+
)
2442+
def test_main_openapi_discriminator_one_literal_as_default_dataclass_py39_warning(output_file: Path) -> None:
2443+
"""Test that Python 3.9 emits warning for dataclass field ordering conflict."""
2444+
with pytest.warns(UserWarning, match=r"Dataclass .* has a field ordering conflict due to inheritance"):
2445+
run_main_and_assert(
2446+
input_path=OPEN_API_DATA_PATH / "discriminator_enum.yaml",
2447+
output_path=output_file,
2448+
input_file_type="openapi",
2449+
assert_func=assert_file_content,
2450+
expected_file=EXPECTED_OPENAPI_PATH / "discriminator" / "dataclass_enum_one_literal_as_default.py",
2451+
extra_args=[
2452+
"--output-model-type",
2453+
"dataclasses.dataclass",
2454+
"--use-one-literal-as-default",
2455+
"--target-python-version",
2456+
"3.9",
2457+
],
2458+
)
2459+
2460+
2461+
@pytest.mark.skipif(
2462+
black.__version__.split(".")[0] == "19",
2463+
reason="Installed black doesn't support the old style",
2464+
)
2465+
def test_main_openapi_dataclass_inheritance_parent_default(output_file: Path) -> None:
2466+
"""Test dataclass field ordering fix when parent has default field."""
2467+
run_main_and_assert(
2468+
input_path=OPEN_API_DATA_PATH / "dataclass_inheritance_field_ordering.yaml",
2469+
output_path=output_file,
2470+
input_file_type="openapi",
2471+
assert_func=assert_file_content,
2472+
expected_file=EXPECTED_OPENAPI_PATH / "dataclass_inheritance_field_ordering_py310.py",
2473+
extra_args=[
2474+
"--output-model-type",
2475+
"dataclasses.dataclass",
2476+
"--target-python-version",
2477+
"3.10",
2478+
],
2479+
)
2480+
2481+
24162482
@pytest.mark.skipif(
24172483
black.__version__.split(".")[0] == "19",
24182484
reason="Installed black doesn't support the old style",

0 commit comments

Comments
 (0)