Skip to content

Commit 025e33e

Browse files
authored
Fix type overrides for base refs (#3589)
1 parent be80b48 commit 025e33e

8 files changed

Lines changed: 257 additions & 17 deletions

File tree

docs/cli-reference/typing-customization.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2813,7 +2813,7 @@ instead of generating them.
28132813

28142814
| Format | Description |
28152815
|--------|-------------|
2816-
| `{"ModelName": "package.Type"}` | Model-level: Skip generating `ModelName` and import from `package` |
2816+
| `{"ModelName": "package.Type"}` | Model-level: Skip generation; replace field and inheritance refs |
28172817
| `{"Model.field": "package.Type"}` | Scoped: Override only specific field in specific model |
28182818

28192819
**Common Use Cases:**
@@ -2836,6 +2836,7 @@ instead of generating them.
28362836
!!! note "Model-level overrides skip generation"
28372837
When you specify a model-level override (without a dot in the key), the generator will
28382838
**skip generating that model entirely** and import it from the specified package instead.
2839+
References to that model are replaced in field annotations and `allOf` inheritance base classes.
28392840

28402841

28412842
??? example "Examples"

docs/llms-full.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24606,7 +24606,7 @@ instead of generating them.
2460624606

2460724607
| Format | Description |
2460824608
|--------|-------------|
24609-
| `{"ModelName": "package.Type"}` | Model-level: Skip generating `ModelName` and import from `package` |
24609+
| `{"ModelName": "package.Type"}` | Model-level: Skip generation; replace field and inheritance refs |
2461024610
| `{"Model.field": "package.Type"}` | Scoped: Override only specific field in specific model |
2461124611

2461224612
**Common Use Cases:**
@@ -24629,6 +24629,7 @@ instead of generating them.
2462924629
!!! note "Model-level overrides skip generation"
2463024630
When you specify a model-level override (without a dot in the key), the generator will
2463124631
**skip generating that model entirely** and import it from the specified package instead.
24632+
References to that model are replaced in field annotations and `allOf` inheritance base classes.
2463224633

2463324634

2463424635
??? example "Examples"

src/datamodel_code_generator/parser/base.py

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1522,6 +1522,9 @@ def __init__( # noqa: PLR0912, PLR0915
15221522
self._type_override_imports: dict[str, Import] = {
15231523
key: Import.from_full_path(value) for key, value in self.type_overrides.items()
15241524
}
1525+
self._model_type_override_imports: dict[str, Import] = {
1526+
key: import_ for key, import_ in self._type_override_imports.items() if "." not in key
1527+
}
15251528
self.read_only_write_only_model_type: ReadOnlyWriteOnlyModelType | None = config.read_only_write_only_model_type
15261529
self.use_frozen_field: bool = config.use_frozen_field
15271530
self.use_serialization_alias: bool = config.use_serialization_alias
@@ -2805,14 +2808,12 @@ def __remove_overridden_models(self, models: list[DataModel]) -> list[DataModel]
28052808
Only model-level overrides (keys without dots) cause model removal.
28062809
Scoped overrides (ClassName.field) only affect specific fields.
28072810
"""
2808-
if not self.type_overrides:
2811+
if not self._model_type_override_imports:
28092812
return models
2810-
# Only model-level overrides (no dot) cause model removal
2811-
model_level_overrides = {k for k in self.type_overrides if "." not in k}
2812-
return [m for m in models if m.class_name not in model_level_overrides]
2813+
return [m for m in models if m.class_name not in self._model_type_override_imports]
28132814

28142815
def __apply_type_overrides(self, models: list[DataModel]) -> None:
2815-
"""Replace field type references with custom import types.
2816+
"""Replace type references with custom import types.
28162817
28172818
Supports two key formats:
28182819
- Model-level: {"CustomType": "my_app.Type"} - applies to all references
@@ -2823,14 +2824,31 @@ def __apply_type_overrides(self, models: list[DataModel]) -> None:
28232824
if not self._type_override_imports:
28242825
return
28252826
for model in models:
2827+
fields_overridden = False
28262828
for field in model.fields:
28272829
# Check scoped override first: "ClassName.field_name"
28282830
scoped_key = f"{model.class_name}.{field.name}"
2829-
if scoped_key in self._type_override_imports:
2830-
self._apply_override_to_field(field, self._type_override_imports[scoped_key])
2831-
else:
2832-
# Apply model-level overrides to nested types
2833-
self._apply_override_to_data_type(field.data_type)
2831+
match self._type_override_imports.get(scoped_key):
2832+
case Import() as override_import:
2833+
self._apply_override_to_field(field, override_import)
2834+
fields_overridden = True
2835+
case None if self._model_type_override_imports:
2836+
# Apply model-level overrides to nested types
2837+
fields_overridden = self._apply_override_to_data_type(field.data_type) or fields_overridden
2838+
case None:
2839+
pass
2840+
if fields_overridden:
2841+
model.clear_imports_cache()
2842+
if not self._model_type_override_imports:
2843+
continue
2844+
base_classes_overridden = False
2845+
for base_class in model.base_classes:
2846+
if self._apply_override_to_data_type(base_class):
2847+
base_classes_overridden = True
2848+
for import_ in base_class.all_imports:
2849+
Parser._append_model_import(model, import_)
2850+
if base_classes_overridden:
2851+
model.clear_imports_cache()
28342852

28352853
def _apply_override_to_field(self, field: DataModelFieldBase, override_import: Import) -> None:
28362854
"""Apply override to entire field's data_type."""
@@ -2841,16 +2859,27 @@ def _apply_override_to_field(self, field: DataModelFieldBase, override_import: I
28412859
self.generation_store.detach_data_type_ref(field.data_type)
28422860
self.generation_store.set_nested_data_types(field.data_type, [])
28432861

2844-
def _apply_override_to_data_type(self, data_type: DataType) -> None:
2862+
@staticmethod
2863+
def _append_model_import(model: DataModel, import_: Import) -> None:
2864+
"""Append an import to a model once."""
2865+
if import_ in model.imports:
2866+
return
2867+
model._additional_imports.append(import_) # noqa: SLF001
2868+
2869+
def _apply_override_to_data_type(self, data_type: DataType) -> bool:
28452870
"""Recursively apply model-level overrides to a DataType."""
2846-
if data_type.reference and data_type.reference.name in self._type_override_imports:
2847-
override_import = self._type_override_imports[data_type.reference.name]
2871+
overridden = False
2872+
if data_type.reference and (override_import := self._model_type_override_imports.get(data_type.reference.name)):
28482873
data_type.import_ = override_import
28492874
data_type.alias = override_import.import_
28502875
self.generation_store.detach_data_type_ref(data_type)
2876+
overridden = True
28512877
# Handle nested types (List[CustomType], Optional[CustomType], etc.)
28522878
for nested in data_type.data_types:
2853-
self._apply_override_to_data_type(nested)
2879+
overridden = self._apply_override_to_data_type(nested) or overridden
2880+
if data_type.dict_key:
2881+
overridden = self._apply_override_to_data_type(data_type.dict_key) or overridden
2882+
return overridden
28542883

28552884
@staticmethod
28562885
def __disable_union_operator_for_forward_ref_parents(data_type: DataType) -> None:
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# generated by datamodel-codegen:
2+
# filename: type_overrides_base_class.json
3+
# timestamp: 1985-10-26T08:21:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from my_app.models import Base
8+
from pydantic import BaseModel
9+
10+
11+
class Other(BaseModel):
12+
code: str | None = None
13+
14+
15+
class Child(Base):
16+
parent: Base | None = None
17+
name: str | None = None
18+
19+
20+
class BareChild(Base):
21+
title: str | None = None
22+
23+
24+
class Holder(BaseModel):
25+
base: Base | None = None
26+
child: Child | None = None
27+
bare_child: BareChild | None = None
28+
other: Other | None = None
29+
30+
31+
class Model(BaseModel):
32+
holder: Holder | None = None
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# generated by datamodel-codegen:
2+
# filename: type_overrides_dict_key.json
3+
# timestamp: 1985-10-26T08:21:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from my_app.keys import Key
8+
from pydantic import BaseModel, RootModel
9+
10+
11+
class ValueMap(RootModel[dict[Key, int]]):
12+
root: dict[Key, int]
13+
14+
15+
class Model(BaseModel):
16+
values: ValueMap | None = None
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"$defs": {
4+
"Base": {
5+
"type": "object",
6+
"properties": {
7+
"id": {
8+
"type": "integer"
9+
}
10+
}
11+
},
12+
"Other": {
13+
"type": "object",
14+
"properties": {
15+
"code": {
16+
"type": "string"
17+
}
18+
}
19+
},
20+
"Child": {
21+
"allOf": [
22+
{
23+
"$ref": "#/$defs/Base"
24+
}
25+
],
26+
"properties": {
27+
"parent": {
28+
"$ref": "#/$defs/Base"
29+
},
30+
"name": {
31+
"type": "string"
32+
}
33+
}
34+
},
35+
"BareChild": {
36+
"allOf": [
37+
{
38+
"$ref": "#/$defs/Base"
39+
}
40+
],
41+
"properties": {
42+
"title": {
43+
"type": "string"
44+
}
45+
}
46+
},
47+
"Holder": {
48+
"type": "object",
49+
"properties": {
50+
"base": {
51+
"$ref": "#/$defs/Base"
52+
},
53+
"child": {
54+
"$ref": "#/$defs/Child"
55+
},
56+
"bare_child": {
57+
"$ref": "#/$defs/BareChild"
58+
},
59+
"other": {
60+
"$ref": "#/$defs/Other"
61+
}
62+
}
63+
}
64+
},
65+
"type": "object",
66+
"properties": {
67+
"holder": {
68+
"$ref": "#/$defs/Holder"
69+
}
70+
}
71+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"definitions": {
4+
"Key": {
5+
"type": "string",
6+
"enum": [
7+
"primary",
8+
"secondary"
9+
]
10+
},
11+
"ValueMap": {
12+
"type": "object",
13+
"propertyNames": {
14+
"$ref": "#/definitions/Key"
15+
},
16+
"additionalProperties": {
17+
"type": "integer"
18+
}
19+
}
20+
},
21+
"type": "object",
22+
"properties": {
23+
"values": {
24+
"$ref": "#/definitions/ValueMap"
25+
}
26+
}
27+
}

tests/main/test_main_general.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1501,12 +1501,13 @@ def test_dataclass_arguments_invalid(json_str: str, match: str) -> None:
15011501
15021502
| Format | Description |
15031503
|--------|-------------|
1504-
| `{"ModelName": "package.Type"}` | Model-level: Skip generating `ModelName` and import from `package` |
1504+
| `{"ModelName": "package.Type"}` | Model-level: Skip generation; replace field and inheritance refs |
15051505
| `{"Model.field": "package.Type"}` | Scoped: Override only specific field in specific model |
15061506
15071507
!!! note "Model-level overrides skip generation"
15081508
When you specify a model-level override (without a dot in the key), the generator will
15091509
**skip generating that model entirely** and import it from the specified package instead.
1510+
References to that model are replaced in field annotations and `allOf` inheritance base classes.
15101511
15111512
**Common Use Cases:**
15121513
@@ -1610,6 +1611,68 @@ def test_type_overrides_nested_types(output_file: Path) -> None:
16101611
)
16111612

16121613

1614+
@freeze_time(TIMESTAMP)
1615+
def test_type_overrides_model_level_base_class(
1616+
output_file: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1617+
) -> None:
1618+
"""Test model-level --type-overrides replaces base class references."""
1619+
package_dir = tmp_path / "my_app"
1620+
package_dir.mkdir()
1621+
(package_dir / "__init__.py").write_text("", encoding="utf-8")
1622+
(package_dir / "models.py").write_text(
1623+
"from __future__ import annotations\n\n"
1624+
"from pydantic import BaseModel\n\n\n"
1625+
"class Base(BaseModel):\n"
1626+
" id: int | None = None\n",
1627+
encoding="utf-8",
1628+
)
1629+
monkeypatch.syspath_prepend(str(tmp_path))
1630+
1631+
module_names = ("my_app", "my_app.models")
1632+
for module_name in module_names:
1633+
monkeypatch.delitem(sys.modules, module_name, raising=False)
1634+
1635+
try:
1636+
run_main_and_assert(
1637+
input_path=JSON_SCHEMA_DATA_PATH / "type_overrides_base_class.json",
1638+
output_path=output_file,
1639+
input_file_type="jsonschema",
1640+
assert_func=assert_file_content,
1641+
expected_file="type_overrides_base_class.py",
1642+
extra_args=[
1643+
"--output-model-type",
1644+
"pydantic_v2.BaseModel",
1645+
"--formatters",
1646+
"builtin",
1647+
"--type-overrides",
1648+
'{"Base": "my_app.models.Base"}',
1649+
],
1650+
importable_module_name="generated_type_overrides_base_class",
1651+
importable_module_attribute="Holder",
1652+
)
1653+
finally:
1654+
for module_name in module_names:
1655+
sys.modules.pop(module_name, None)
1656+
1657+
1658+
@freeze_time(TIMESTAMP)
1659+
def test_type_overrides_model_level_dict_key(output_file: Path) -> None:
1660+
"""Test model-level --type-overrides replaces dict key references."""
1661+
run_main_and_assert(
1662+
input_path=JSON_SCHEMA_DATA_PATH / "type_overrides_dict_key.json",
1663+
output_path=output_file,
1664+
input_file_type="jsonschema",
1665+
assert_func=assert_file_content,
1666+
expected_file="type_overrides_dict_key.py",
1667+
extra_args=[
1668+
"--output-model-type",
1669+
"pydantic_v2.BaseModel",
1670+
"--type-overrides",
1671+
'{"Key": "my_app.keys.Key"}',
1672+
],
1673+
)
1674+
1675+
16131676
def test_skip_root_model(tmp_path: Path) -> None:
16141677
"""Test --skip-root-model flag functionality using generate()."""
16151678
output_file = tmp_path / "output.py"

0 commit comments

Comments
 (0)