Skip to content

Commit 738f3e0

Browse files
committed
fix: use TypeAdapter for union dict defaults in type aliases and inline unions
1 parent 3c244da commit 738f3e0

9 files changed

Lines changed: 273 additions & 1 deletion

File tree

src/datamodel_code_generator/model/pydantic_base.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,47 @@ def _get_default_as_pydantic_model(self) -> str | None: # noqa: PLR0911, PLR091
141141
f"lambda :[{data_type_child.alias or data_type_child.reference.source.class_name}."
142142
f"{self._PARSE_METHOD}(v) for v in {self.default!r}]"
143143
)
144+
# If the field references a type alias whose underlying type is a union of BaseModels,
145+
# use TypeAdapter to validate the default value against the full union.
146+
if (
147+
not self.data_type.data_types
148+
and self.data_type.reference
149+
and isinstance(self.data_type.reference.source, DataModel)
150+
and getattr(self.data_type.reference.source, "IS_ALIAS", False)
151+
and isinstance(self.default, (dict, list))
152+
):
153+
alias_source = self.data_type.reference.source
154+
if alias_source.fields and alias_source.fields[0].data_type.data_types:
155+
has_base_model = any(
156+
dt.reference and isinstance(dt.reference.source, BaseModelBase)
157+
for dt in alias_source.fields[0].data_type.data_types
158+
)
159+
if has_base_model:
160+
alias_name = self.data_type.alias or alias_source.class_name
161+
self.__dict__["_type_adapter_import_needed"] = True
162+
return f"lambda :TypeAdapter({alias_name}).validate_python({self.default!r})"
163+
164+
# Inline union with multiple BaseModel candidates: validate against the full union via TypeAdapter
165+
if (
166+
self.data_type.is_union
167+
and isinstance(self.default, dict)
168+
and not any(dt.is_dict for dt in self.data_type.data_types)
169+
):
170+
base_model_count = sum(
171+
1 for dt in self.data_type.data_types if dt.reference and isinstance(dt.reference.source, BaseModelBase)
172+
)
173+
if base_model_count > 1:
174+
type_parts = []
175+
for dt in self.data_type.data_types:
176+
hint = dt.type_hint
177+
if hint and hint != "None":
178+
type_parts.append(hint)
179+
if type_parts:
180+
union_expr = ", ".join(type_parts)
181+
self.__dict__["_type_adapter_import_needed"] = True
182+
self.__dict__["_union_import_for_type_adapter"] = True
183+
return f"lambda :TypeAdapter(Union[{union_expr}]).validate_python({self.default!r})"
184+
144185
for data_type in self.data_type.data_types or (self.data_type,):
145186
# TODO: Check nested data_types
146187
if data_type.is_dict:

src/datamodel_code_generator/model/pydantic_v2/base_model.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from pydantic import Field, field_validator, model_validator
1414

15-
from datamodel_code_generator.imports import IMPORT_ANY, Import
15+
from datamodel_code_generator.imports import IMPORT_ANY, IMPORT_UNION, Import
1616
from datamodel_code_generator.model.base import ALL_MODEL, UNDEFINED, BaseClassDataType, DataModelFieldBase
1717
from datamodel_code_generator.model.imports import IMPORT_CLASSVAR
1818
from datamodel_code_generator.model.pydantic_base import (
@@ -200,6 +200,12 @@ def imports(self) -> tuple[Import, ...]:
200200
extra_imports.append(IMPORT_ALIAS_CHOICES)
201201
if self._has_discriminator_in_data_type():
202202
extra_imports.append(IMPORT_FIELD)
203+
if getattr(self, "_type_adapter_import_needed", False):
204+
from datamodel_code_generator.model.pydantic_v2.imports import IMPORT_TYPE_ADAPTER # noqa: PLC0415
205+
206+
extra_imports.append(IMPORT_TYPE_ADAPTER)
207+
if getattr(self, "_union_import_for_type_adapter", False):
208+
extra_imports.append(IMPORT_UNION)
203209
if extra_imports:
204210
return chain_as_tuple(base_imports, tuple(extra_imports))
205211
return base_imports

src/datamodel_code_generator/model/pydantic_v2/imports.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
IMPORT_SERIALIZE_AS_ANY = Import.from_full_path("pydantic.SerializeAsAny")
2121
IMPORT_PYDANTIC_DATACLASS = Import.from_full_path("pydantic.dataclasses.dataclass")
2222
IMPORT_ROOT_MODEL = Import.from_full_path("pydantic.RootModel")
23+
IMPORT_TYPE_ADAPTER = Import.from_full_path("pydantic.TypeAdapter")
2324
IMPORT_FIELD_VALIDATOR = Import.from_full_path("pydantic.field_validator")
2425
IMPORT_VALIDATION_INFO = Import.from_full_path("pydantic.ValidationInfo")
2526
IMPORT_VALIDATOR_FUNCTION_WRAP_HANDLER = Import.from_full_path("pydantic.ValidatorFunctionWrapHandler")
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# generated by datamodel-codegen:
2+
# filename: multi_model_union_default_object.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Annotated, Literal, Union
8+
9+
from pydantic import BaseModel, Field, TypeAdapter
10+
11+
12+
class A(BaseModel):
13+
type: Literal['a']
14+
15+
16+
class B(BaseModel):
17+
type: Literal['b']
18+
19+
20+
class Model(BaseModel):
21+
x: Annotated[
22+
A | B | None,
23+
Field(
24+
default_factory=lambda: TypeAdapter(Union[A, B]).validate_python(
25+
{'type': 'b'}
26+
)
27+
),
28+
]
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# generated by datamodel-codegen:
2+
# filename: multi_model_union_default_object.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Annotated, Literal, Union
8+
9+
from pydantic import BaseModel, Field, TypeAdapter
10+
11+
12+
class A(BaseModel):
13+
type: Literal['a']
14+
15+
16+
class B(BaseModel):
17+
type: Literal['b']
18+
19+
20+
class Model(BaseModel):
21+
x: Annotated[
22+
A | B | None,
23+
Field(
24+
default_factory=lambda: TypeAdapter(Union[A, B]).validate_python(
25+
{'type': 'b'}
26+
)
27+
),
28+
]
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# generated by datamodel-codegen:
2+
# filename: ref_union_default_object.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Annotated, Literal
8+
9+
from pydantic import BaseModel, Field, TypeAdapter
10+
11+
12+
class A(BaseModel):
13+
type: Literal['a']
14+
15+
16+
class B(BaseModel):
17+
type: Literal['b']
18+
19+
20+
type U = A | B
21+
22+
23+
class Model(BaseModel):
24+
x: Annotated[
25+
U | None,
26+
Field(default_factory=lambda: TypeAdapter(U).validate_python({'type': 'b'})),
27+
]
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"type": "object",
4+
"$defs": {
5+
"A": {
6+
"type": "object",
7+
"properties": {
8+
"type": {
9+
"const": "a",
10+
"type": "string"
11+
}
12+
},
13+
"required": ["type"]
14+
},
15+
"B": {
16+
"type": "object",
17+
"properties": {
18+
"type": {
19+
"const": "b",
20+
"type": "string"
21+
}
22+
},
23+
"required": ["type"]
24+
}
25+
},
26+
"properties": {
27+
"x": {
28+
"anyOf": [
29+
{ "$ref": "#/$defs/A" },
30+
{ "$ref": "#/$defs/B" }
31+
],
32+
"default": { "type": "b" }
33+
}
34+
}
35+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"type": "object",
4+
"$defs": {
5+
"A": {
6+
"type": "object",
7+
"properties": {
8+
"type": {
9+
"const": "a",
10+
"type": "string"
11+
}
12+
},
13+
"required": ["type"]
14+
},
15+
"B": {
16+
"type": "object",
17+
"properties": {
18+
"type": {
19+
"const": "b",
20+
"type": "string"
21+
}
22+
},
23+
"required": ["type"]
24+
},
25+
"U": {
26+
"anyOf": [
27+
{ "$ref": "#/$defs/A" },
28+
{ "$ref": "#/$defs/B" }
29+
]
30+
}
31+
},
32+
"properties": {
33+
"x": {
34+
"$ref": "#/$defs/U",
35+
"default": { "type": "b" }
36+
}
37+
}
38+
}

tests/main/jsonschema/test_main_jsonschema.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5591,6 +5591,74 @@ def test_main_jsonschema_enum_literal_type_alias_default(output_file: Path) -> N
55915591
)
55925592

55935593

5594+
@pytest.mark.skipif(
5595+
int(black.__version__.split(".")[0]) < 23,
5596+
reason="Installed black doesn't support the new 'type' statement",
5597+
)
5598+
def test_main_jsonschema_ref_union_default_object(output_file: Path) -> None:
5599+
"""TypeAlias ref to union of BaseModels uses TypeAdapter for dict default (#3034)."""
5600+
run_main_and_assert(
5601+
input_path=JSON_SCHEMA_DATA_PATH / "ref_union_default_object.json",
5602+
output_path=output_file,
5603+
input_file_type=None,
5604+
assert_func=assert_file_content,
5605+
expected_file="ref_union_default_object.py",
5606+
extra_args=[
5607+
"--use-type-alias",
5608+
"--use-annotated",
5609+
"--target-python-version",
5610+
"3.12",
5611+
"--output-model-type",
5612+
"pydantic_v2.BaseModel",
5613+
],
5614+
)
5615+
5616+
5617+
@pytest.mark.skipif(
5618+
int(black.__version__.split(".")[0]) < 23,
5619+
reason="Installed black doesn't support the new 'type' statement",
5620+
)
5621+
def test_main_jsonschema_pydantic_v2_multi_model_union_default_object_type_alias(output_file: Path) -> None:
5622+
"""Inline union of multiple BaseModels uses TypeAdapter with --use-type-alias (#3035)."""
5623+
run_main_and_assert(
5624+
input_path=JSON_SCHEMA_DATA_PATH / "multi_model_union_default_object.json",
5625+
output_path=output_file,
5626+
input_file_type=None,
5627+
assert_func=assert_file_content,
5628+
expected_file="pydantic_v2_multi_model_union_default_object_type_alias.py",
5629+
extra_args=[
5630+
"--use-type-alias",
5631+
"--use-annotated",
5632+
"--target-python-version",
5633+
"3.12",
5634+
"--output-model-type",
5635+
"pydantic_v2.BaseModel",
5636+
],
5637+
)
5638+
5639+
5640+
@pytest.mark.skipif(
5641+
int(black.__version__.split(".")[0]) < 23,
5642+
reason="Installed black doesn't support the new 'type' statement",
5643+
)
5644+
def test_main_jsonschema_pydantic_v2_multi_model_union_default_object(output_file: Path) -> None:
5645+
"""Inline union of multiple BaseModels uses TypeAdapter without --use-type-alias (#3035)."""
5646+
run_main_and_assert(
5647+
input_path=JSON_SCHEMA_DATA_PATH / "multi_model_union_default_object.json",
5648+
output_path=output_file,
5649+
input_file_type=None,
5650+
assert_func=assert_file_content,
5651+
expected_file="pydantic_v2_multi_model_union_default_object.py",
5652+
extra_args=[
5653+
"--use-annotated",
5654+
"--target-python-version",
5655+
"3.12",
5656+
"--output-model-type",
5657+
"pydantic_v2.BaseModel",
5658+
],
5659+
)
5660+
5661+
55945662
@pytest.mark.cli_doc(
55955663
options=["--type-mappings"],
55965664
option_description="""Override default type mappings for schema formats.

0 commit comments

Comments
 (0)