Skip to content

Commit 77af774

Browse files
keyzkoxudaxi
andauthored
fix #3034 and #3035 by introducing ValidatedDefault (#3040)
* new fixtures * one more fixture * fix * fix: use resolved.data_types for union branch filtering in _needs_validated_default * more fixtures * another fixture * use model-level capability, and defer set * fix overrides * another fix * one more * test: stub my_app for py312 exec validation * test: cover validated default parser helpers * test: cover remaining validated default branches --------- Co-authored-by: Koudai Aono <koxudaxi@gmail.com>
1 parent 95daa4e commit 77af774

49 files changed

Lines changed: 2092 additions & 8 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/datamodel_code_generator/model/_types.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,15 @@ class WrappedDefault:
1616
def __repr__(self) -> str:
1717
"""Return type constructor representation, e.g., 'CountType(10)'."""
1818
return f"{self.type_name}({self.value!r})"
19+
20+
21+
@dataclass(repr=False)
22+
class ValidatedDefault:
23+
"""Represents a structured default validated through the field type."""
24+
25+
value: Any
26+
type_name: str
27+
28+
def __repr__(self) -> str:
29+
"""Return TypeAdapter validation representation for structured defaults."""
30+
return f"TypeAdapter({self.type_name}).validate_python({self.value!r})"

src/datamodel_code_generator/model/base.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
IMPORT_UNION,
2626
Import,
2727
)
28-
from datamodel_code_generator.model._types import WrappedDefault
28+
from datamodel_code_generator.model._types import ValidatedDefault, WrappedDefault
2929
from datamodel_code_generator.reference import Reference, _BaseModel
3030
from datamodel_code_generator.types import (
3131
ANY,
@@ -38,7 +38,7 @@
3838
get_optional_type,
3939
)
4040

41-
__all__ = ["WrappedDefault"]
41+
__all__ = ["ValidatedDefault", "WrappedDefault"]
4242

4343
if TYPE_CHECKING:
4444
from collections.abc import Iterator
@@ -599,6 +599,7 @@ class DataModel(TemplateBase, Nullable, ABC): # noqa: PLR0904
599599
SUPPORTS_DISCRIMINATOR: ClassVar[bool] = False
600600
SUPPORTS_FIELD_RENAMING: ClassVar[bool] = False
601601
SUPPORTS_WRAPPED_DEFAULT: ClassVar[bool] = False
602+
SUPPORTS_VALIDATED_DEFAULT: ClassVar[bool] = False
602603
SUPPORTS_KW_ONLY: ClassVar[bool] = False
603604
has_forward_reference: bool = False
604605

src/datamodel_code_generator/model/pydantic_base.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,15 @@
1919
DataModel,
2020
DataModelFieldBase,
2121
)
22-
from datamodel_code_generator.model._types import WrappedDefault
22+
from datamodel_code_generator.model._types import ValidatedDefault, WrappedDefault
2323
from datamodel_code_generator.model.base import UNDEFINED, repr_set_sorted
2424
from datamodel_code_generator.types import STANDARD_LIST, UnionIntFloat, chain_as_tuple
2525

2626
# Defined here instead of importing from pydantic_v2.imports to avoid circular import
2727
# (pydantic_base -> pydantic_v2.imports -> pydantic_v2/__init__ -> pydantic_v2.base_model -> pydantic_base)
2828
IMPORT_ANYURL = Import.from_full_path("pydantic.AnyUrl")
2929
IMPORT_FIELD = Import.from_full_path("pydantic.Field")
30+
IMPORT_TYPE_ADAPTER = Import.from_full_path("pydantic.TypeAdapter")
3031

3132
if TYPE_CHECKING:
3233
from collections import defaultdict
@@ -126,7 +127,7 @@ def _get_strict_field_constraint_value(self, constraint: str, value: Any) -> Any
126127
return int(value)
127128

128129
def _get_default_as_pydantic_model(self) -> str | None: # noqa: PLR0911, PLR0912
129-
if isinstance(self.default, WrappedDefault):
130+
if isinstance(self.default, (ValidatedDefault, WrappedDefault)):
130131
return f"lambda :{self.default!r}"
131132
if self.data_type.is_list and len(self.data_type.data_types) == 1:
132133
data_type_child = self.data_type.data_types[0]
@@ -296,8 +297,12 @@ def annotated(self) -> str | None:
296297
@property
297298
def imports(self) -> tuple[Import, ...]:
298299
"""Get all required imports including Field if needed."""
299-
if self.field:
300-
return chain_as_tuple(super().imports, (IMPORT_FIELD,))
300+
field = self.field
301+
if field:
302+
extra_imports = [IMPORT_FIELD]
303+
if isinstance(self.default, ValidatedDefault):
304+
extra_imports.append(IMPORT_TYPE_ADAPTER)
305+
return chain_as_tuple(super().imports, extra_imports)
301306
return super().imports
302307

303308

src/datamodel_code_generator/model/pydantic_v2/base_model.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ class BaseModel(BaseModelBase):
223223
SUPPORTS_DISCRIMINATOR: ClassVar[bool] = True
224224
SUPPORTS_FIELD_RENAMING: ClassVar[bool] = True
225225
SUPPORTS_WRAPPED_DEFAULT: ClassVar[bool] = True
226+
SUPPORTS_VALIDATED_DEFAULT: ClassVar[bool] = True
226227
# In Pydantic 2.11+, populate_by_name is deprecated in favor of validate_by_name + validate_by_alias
227228
# Default to V2 compatible (populate_by_name) unless target_pydantic_version is specified
228229
_CONFIG_ATTRIBUTES_V2: ClassVar[list[ConfigAttribute]] = [

src/datamodel_code_generator/parser/base.py

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
ConstraintsBase,
7575
DataModel,
7676
DataModelFieldBase,
77+
ValidatedDefault,
7778
WrappedDefault,
7879
)
7980
from datamodel_code_generator.model.enum import Enum, Member
@@ -82,7 +83,7 @@
8283
from datamodel_code_generator.parser._graph import stable_toposort
8384
from datamodel_code_generator.parser._scc import find_circular_sccs, strongly_connected_components
8485
from datamodel_code_generator.reference import ModelResolver, ModelType, Reference
85-
from datamodel_code_generator.types import ANY, DataType, DataTypeManager
86+
from datamodel_code_generator.types import ANY, NONE, DataType, DataTypeManager, _remove_none_from_union
8687
from datamodel_code_generator.util import camel_to_snake
8788

8889
if TYPE_CHECKING:
@@ -399,6 +400,90 @@ def iter_models_field_data_types(
399400
yield model, field, data_type
400401

401402

403+
def _unwrap_type_alias(data_type: DataType) -> DataType:
404+
current = data_type
405+
seen: set[int] = set()
406+
while current.reference and isinstance(current.reference.source, TypeAliasBase):
407+
source = current.reference.source
408+
if id(source) in seen or not source.fields:
409+
break
410+
seen.add(id(source))
411+
current = source.fields[0].data_type
412+
return current
413+
414+
415+
def _supports_validated_default_model(source: object) -> bool:
416+
return isinstance(source, DataModel) and source.SUPPORTS_VALIDATED_DEFAULT and not source.is_alias
417+
418+
419+
def _contains_model_reference(data_type: DataType) -> bool:
420+
stack = [data_type]
421+
seen: set[int] = set()
422+
423+
while stack:
424+
resolved = _unwrap_type_alias(stack.pop())
425+
resolved_id = id(resolved)
426+
if resolved_id in seen:
427+
continue
428+
seen.add(resolved_id)
429+
430+
if resolved.reference and _supports_validated_default_model(resolved.reference.source):
431+
return True
432+
433+
if resolved.dict_key:
434+
stack.append(resolved.dict_key)
435+
stack.extend(resolved.data_types)
436+
437+
return False
438+
439+
440+
def _uses_existing_model_factory_path(data_type: DataType) -> bool:
441+
for candidate in data_type.data_types or (data_type,):
442+
if candidate.reference and _supports_validated_default_model(candidate.reference.source):
443+
return True
444+
if (
445+
candidate.is_list
446+
and len(candidate.data_types) == 1
447+
and candidate.data_types[0].reference
448+
and _supports_validated_default_model(candidate.data_types[0].reference.source)
449+
):
450+
return True
451+
return False
452+
453+
454+
def _default_matches_plain_container_branch(default: Any, data_type: DataType) -> bool:
455+
resolved = _unwrap_type_alias(data_type)
456+
return (isinstance(default, dict) and resolved.is_dict and not _contains_model_reference(resolved)) or (
457+
isinstance(default, list) and resolved.is_list and not _contains_model_reference(resolved)
458+
)
459+
460+
461+
def _get_validated_default_type_name(data_type: DataType) -> str:
462+
type_name = data_type.type_hint
463+
if data_type.is_optional:
464+
return _remove_none_from_union(type_name, use_union_operator=data_type.use_union_operator)
465+
return type_name
466+
467+
468+
def _needs_validated_default(default: Any, data_type: DataType) -> bool:
469+
if not isinstance(default, (dict, list)):
470+
return False
471+
472+
resolved = _unwrap_type_alias(data_type)
473+
if not _contains_model_reference(resolved):
474+
return False
475+
476+
if resolved.is_union:
477+
non_none_branches = tuple(branch for branch in resolved.data_types if branch.type_hint != NONE)
478+
# This must use the declared field type, not the unwrapped branch: alias-backed
479+
# unions like `type Alias = A | None` only get the correct factory via `Alias`.
480+
if len(non_none_branches) == 1 and _uses_existing_model_factory_path(data_type):
481+
return False
482+
return not any(_default_matches_plain_container_branch(default, branch) for branch in resolved.data_types)
483+
484+
return not _uses_existing_model_factory_path(data_type)
485+
486+
402487
def _alias_base_class_imports(
403488
model: DataModel,
404489
aliased_imports: dict[tuple[str | None, str], Import],
@@ -2152,6 +2237,31 @@ def __wrap_root_model_default_values(
21522237
type_name=type_name,
21532238
)
21542239

2240+
def __wrap_validated_default_values(
2241+
self,
2242+
models: list[DataModel],
2243+
) -> None:
2244+
"""Wrap structured defaults that must be validated through the declared field type."""
2245+
if not self.data_model_type.SUPPORTS_VALIDATED_DEFAULT:
2246+
return
2247+
for model in models:
2248+
if isinstance(model, (Enum, self.data_model_root_type)):
2249+
continue
2250+
for model_field in model.fields:
2251+
if model_field.default is None:
2252+
continue
2253+
if isinstance(model_field.default, (Member, WrappedDefault)):
2254+
continue
2255+
if isinstance(model_field.default, ValidatedDefault):
2256+
model_field.default.type_name = _get_validated_default_type_name(model_field.data_type)
2257+
continue
2258+
if not _needs_validated_default(model_field.default, model_field.data_type):
2259+
continue
2260+
model_field.default = ValidatedDefault(
2261+
value=model_field.default,
2262+
type_name=_get_validated_default_type_name(model_field.data_type),
2263+
)
2264+
21552265
def __override_required_field(
21562266
self,
21572267
models: list[DataModel],
@@ -3187,6 +3297,7 @@ def _process_single_module( # noqa: PLR0913, PLR0917
31873297
models = self.__remove_overridden_models(models)
31883298
self.__apply_type_overrides(models)
31893299
self.__update_type_aliases(models)
3300+
self.__wrap_validated_default_values(models)
31903301

31913302
return ModuleContext(module, module_, models, is_init, imports, scoped_model_resolver)
31923303

@@ -3219,6 +3330,7 @@ def _finalize_modules(
32193330

32203331
for ctx in contexts:
32213332
self.__change_imported_model_name(ctx.models, ctx.imports, ctx.scoped_model_resolver)
3333+
self.__wrap_validated_default_values(ctx.models)
32223334

32233335
def _generate_module_output( # noqa: PLR0913, PLR0917
32243336
self,
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# generated by datamodel-codegen:
2+
# filename: type_alias_chain_model_default_object_ref.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Literal
8+
9+
from pydantic import BaseModel, Field, TypeAdapter
10+
11+
12+
class A(BaseModel):
13+
type: Literal['a'] = 'a'
14+
15+
16+
type Alias1 = A
17+
18+
19+
type Alias2 = Alias1
20+
21+
22+
class Model(BaseModel):
23+
x: Alias2 | None = Field(
24+
default_factory=lambda: TypeAdapter(Alias2).validate_python({'type': 'a'})
25+
)
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_alias_chain_union_default_object_ref.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'] = 'a'
14+
15+
16+
class B(BaseModel):
17+
type: Literal['b'] = 'b'
18+
19+
20+
type Alias1 = A | B
21+
22+
23+
type Alias2 = Alias1
24+
25+
26+
class Model(BaseModel):
27+
x_value: Annotated[
28+
Alias2 | None,
29+
Field(
30+
default_factory=lambda: TypeAdapter(Alias2).validate_python({'type': 'b'})
31+
),
32+
]
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_alias_dict_union_default_object_ref.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'] = 'a'
14+
15+
16+
class B(BaseModel):
17+
type: Literal['b']
18+
value: int | None = None
19+
20+
21+
type Alias = dict[str, A | B]
22+
23+
24+
class Model(BaseModel):
25+
x_value: Annotated[
26+
Alias | None,
27+
Field(
28+
default_factory=lambda: TypeAdapter(Alias).validate_python(
29+
{'k': {'type': 'b', 'value': 1}}
30+
)
31+
),
32+
]
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# generated by datamodel-codegen:
2+
# filename: type_alias_inline_dict_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'] = 'a'
14+
15+
16+
class B(BaseModel):
17+
type: Literal['b']
18+
value: int | None = None
19+
20+
21+
class Model(BaseModel):
22+
x_value: Annotated[
23+
dict[str, A | B] | None,
24+
Field(
25+
default_factory=lambda: TypeAdapter(dict[str, A | B]).validate_python(
26+
{'k': {'type': 'b', 'value': 1}}
27+
)
28+
),
29+
]
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# generated by datamodel-codegen:
2+
# filename: type_alias_inline_list_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'] = 'a'
14+
15+
16+
class B(BaseModel):
17+
type: Literal['b']
18+
value: int | None = None
19+
20+
21+
class Model(BaseModel):
22+
x_value: Annotated[
23+
list[A | B] | None,
24+
Field(
25+
default_factory=lambda: TypeAdapter(list[A | B]).validate_python(
26+
[{'type': 'b', 'value': 1}]
27+
)
28+
),
29+
]

0 commit comments

Comments
 (0)