Skip to content

Commit a6ea2a4

Browse files
Wrap RootModel default values with type constructors (#2615)
* Wrap RootModel default values with their type constructors * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add default values for RootModel fields with type annotations * Add docstring to __repr__ method for type constructor representation * Refactor test docstrings for RootModel default value tests --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 2659b2a commit a6ea2a4

10 files changed

Lines changed: 309 additions & 0 deletions

src/datamodel_code_generator/model/base.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from abc import ABC, abstractmethod
1111
from collections import defaultdict
1212
from copy import deepcopy
13+
from dataclasses import dataclass
1314
from functools import cached_property, lru_cache
1415
from pathlib import Path
1516
from typing import TYPE_CHECKING, Any, ClassVar, Optional, TypeVar, Union
@@ -98,6 +99,18 @@ def merge_constraints(a: ConstraintsBaseT | None, b: ConstraintsBaseT | None) ->
9899
})
99100

100101

102+
@dataclass(repr=False)
103+
class WrappedDefault:
104+
"""Represents a default value wrapped with its type constructor."""
105+
106+
value: Any
107+
type_name: str
108+
109+
def __repr__(self) -> str:
110+
"""Return type constructor representation, e.g., 'CountType(10)'."""
111+
return f"{self.type_name}({self.value!r})"
112+
113+
101114
class DataModelFieldBase(_BaseModel):
102115
"""Base class for model field representation and rendering."""
103116

src/datamodel_code_generator/parser/base.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
ConstraintsBase,
5757
DataModel,
5858
DataModelFieldBase,
59+
WrappedDefault,
5960
)
6061
from datamodel_code_generator.model.enum import Enum, Member
6162
from datamodel_code_generator.model.type_alias import TypeAliasBase, TypeStatement
@@ -1498,6 +1499,33 @@ def __set_default_enum_member( # noqa: PLR0912
14981499
else:
14991500
enum_member.alias = data_type.alias
15001501

1502+
def __wrap_root_model_default_values(
1503+
self,
1504+
models: list[DataModel],
1505+
) -> None:
1506+
"""Wrap RootModel reference default values with their type constructors."""
1507+
if not self.use_annotated:
1508+
return
1509+
for model in models:
1510+
if isinstance(model, (Enum, self.data_model_root_type)):
1511+
continue
1512+
for model_field in model.fields:
1513+
if model_field.default is None:
1514+
continue
1515+
if isinstance(model_field.default, (WrappedDefault, Member)):
1516+
continue
1517+
if isinstance(model_field.default, list):
1518+
continue
1519+
for data_type in model_field.data_type.all_data_types:
1520+
if data_type.reference and isinstance(data_type.reference.source, pydantic_model_v2.RootModel):
1521+
# Use alias if available (handles import collisions)
1522+
type_name = data_type.alias or data_type.reference.short_name
1523+
model_field.default = WrappedDefault(
1524+
value=model_field.default,
1525+
type_name=type_name,
1526+
)
1527+
break
1528+
15011529
def __override_required_field(
15021530
self,
15031531
models: list[DataModel],
@@ -2188,6 +2216,7 @@ class Processed(NamedTuple):
21882216
self.__reuse_model(models, require_update_action_models)
21892217
self.__collapse_root_models(models, unused_models, imports, scoped_model_resolver)
21902218
self.__set_default_enum_member(models)
2219+
self.__wrap_root_model_default_values(models)
21912220
self.__sort_models(models, imports)
21922221
self.__change_field_name(models)
21932222
self.__apply_discriminator_type(models, imports)
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: root_model_default_value.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from enum import Enum
8+
from typing import Annotated, Optional
9+
10+
from pydantic import BaseModel, Field, RootModel
11+
12+
13+
class AdminStateLeaf(Enum):
14+
enable = 'enable'
15+
disable = 'disable'
16+
17+
18+
class CountType(RootModel[int]):
19+
root: Annotated[int, Field(ge=0, le=100)]
20+
21+
22+
class NameType(RootModel[str]):
23+
root: Annotated[str, Field(max_length=50, min_length=1)]
24+
25+
26+
class Model(BaseModel):
27+
admin_state: Optional[AdminStateLeaf] = AdminStateLeaf.enable
28+
count: Annotated[Optional[CountType], Field()] = CountType(10)
29+
name: Annotated[Optional[NameType], Field()] = NameType('default_name')
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: root_model_default_value_branches.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Annotated, List, Optional
8+
9+
from pydantic import BaseModel, Field, RootModel
10+
11+
12+
class CountType(RootModel[int]):
13+
root: Annotated[int, Field(ge=0, le=100)]
14+
15+
16+
class Model(BaseModel):
17+
count_with_default: Annotated[Optional[CountType], Field()] = CountType(10)
18+
count_no_default: Optional[CountType] = None
19+
count_list_default: Annotated[Optional[List[CountType]], Field()] = [1, 2, 3]
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: root_model_default_value.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from enum import Enum
8+
from typing import Optional
9+
10+
from pydantic import BaseModel, Field, RootModel, conint, constr
11+
12+
13+
class AdminStateLeaf(Enum):
14+
enable = 'enable'
15+
disable = 'disable'
16+
17+
18+
class CountType(RootModel[conint(ge=0, le=100)]):
19+
root: conint(ge=0, le=100)
20+
21+
22+
class NameType(RootModel[constr(min_length=1, max_length=50)]):
23+
root: constr(min_length=1, max_length=50)
24+
25+
26+
class Model(BaseModel):
27+
admin_state: Optional[AdminStateLeaf] = AdminStateLeaf.enable
28+
count: Optional[CountType] = Field(
29+
default_factory=lambda: CountType.model_validate(10)
30+
)
31+
name: Optional[NameType] = Field(
32+
default_factory=lambda: NameType.model_validate('default_name')
33+
)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# generated by datamodel-codegen:
2+
# filename: root_model_default_value_non_root.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Annotated, Optional
8+
9+
from pydantic import BaseModel, Field, RootModel
10+
11+
12+
class CountType(RootModel[int]):
13+
root: Annotated[int, Field(ge=0, le=100)]
14+
15+
16+
class PersonType(BaseModel):
17+
name: Optional[str] = None
18+
19+
20+
class Model(BaseModel):
21+
root_model_field: Annotated[Optional[CountType], Field()] = CountType(10)
22+
non_root_model_field: Annotated[Optional[PersonType], Field()] = {'name': 'John'}
23+
primitive_field: Optional[str] = 'hello'
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"type": "object",
4+
"properties": {
5+
"admin_state": {
6+
"$ref": "#/$defs/AdminStateLeaf",
7+
"default": "enable"
8+
},
9+
"count": {
10+
"$ref": "#/$defs/CountType",
11+
"default": 10
12+
},
13+
"name": {
14+
"$ref": "#/$defs/NameType",
15+
"default": "default_name"
16+
}
17+
},
18+
"$defs": {
19+
"AdminStateLeaf": {
20+
"type": "string",
21+
"enum": ["enable", "disable"]
22+
},
23+
"CountType": {
24+
"type": "integer",
25+
"minimum": 0,
26+
"maximum": 100
27+
},
28+
"NameType": {
29+
"type": "string",
30+
"minLength": 1,
31+
"maxLength": 50
32+
}
33+
}
34+
}
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+
"type": "object",
4+
"properties": {
5+
"count_with_default": {
6+
"$ref": "#/$defs/CountType",
7+
"default": 10
8+
},
9+
"count_no_default": {
10+
"$ref": "#/$defs/CountType"
11+
},
12+
"count_list_default": {
13+
"type": "array",
14+
"items": {
15+
"$ref": "#/$defs/CountType"
16+
},
17+
"default": [1, 2, 3]
18+
}
19+
},
20+
"$defs": {
21+
"CountType": {
22+
"type": "integer",
23+
"minimum": 0,
24+
"maximum": 100
25+
}
26+
}
27+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"type": "object",
4+
"properties": {
5+
"root_model_field": {
6+
"$ref": "#/$defs/CountType",
7+
"default": 10
8+
},
9+
"non_root_model_field": {
10+
"$ref": "#/$defs/PersonType",
11+
"default": {"name": "John"}
12+
},
13+
"primitive_field": {
14+
"type": "string",
15+
"default": "hello"
16+
}
17+
},
18+
"$defs": {
19+
"CountType": {
20+
"type": "integer",
21+
"minimum": 0,
22+
"maximum": 100
23+
},
24+
"PersonType": {
25+
"type": "object",
26+
"properties": {
27+
"name": {
28+
"type": "string"
29+
}
30+
}
31+
}
32+
}
33+
}

tests/main/jsonschema/test_main_jsonschema.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3189,3 +3189,72 @@ def test_main_jsonschema_file_url_ref_percent_encoded(tmp_path: Path) -> None:
31893189
ignore_whitespace=True,
31903190
extra_args=["--disable-timestamp"],
31913191
)
3192+
3193+
3194+
@pytest.mark.benchmark
3195+
def test_main_jsonschema_root_model_default_value(output_file: Path) -> None:
3196+
"""Test RootModel default values are wrapped with type constructors."""
3197+
run_main_and_assert(
3198+
input_path=JSON_SCHEMA_DATA_PATH / "root_model_default_value.json",
3199+
output_path=output_file,
3200+
input_file_type="jsonschema",
3201+
assert_func=assert_file_content,
3202+
expected_file="root_model_default_value.py",
3203+
extra_args=[
3204+
"--output-model-type",
3205+
"pydantic_v2.BaseModel",
3206+
"--use-annotated",
3207+
"--set-default-enum-member",
3208+
],
3209+
)
3210+
3211+
3212+
@pytest.mark.benchmark
3213+
def test_main_jsonschema_root_model_default_value_no_annotated(output_file: Path) -> None:
3214+
"""Test RootModel default values without --use-annotated flag."""
3215+
run_main_and_assert(
3216+
input_path=JSON_SCHEMA_DATA_PATH / "root_model_default_value.json",
3217+
output_path=output_file,
3218+
input_file_type="jsonschema",
3219+
assert_func=assert_file_content,
3220+
expected_file="root_model_default_value_no_annotated.py",
3221+
extra_args=[
3222+
"--output-model-type",
3223+
"pydantic_v2.BaseModel",
3224+
"--set-default-enum-member",
3225+
],
3226+
)
3227+
3228+
3229+
@pytest.mark.benchmark
3230+
def test_main_jsonschema_root_model_default_value_branches(output_file: Path) -> None:
3231+
"""Test RootModel default value branches."""
3232+
run_main_and_assert(
3233+
input_path=JSON_SCHEMA_DATA_PATH / "root_model_default_value_branches.json",
3234+
output_path=output_file,
3235+
input_file_type="jsonschema",
3236+
assert_func=assert_file_content,
3237+
expected_file="root_model_default_value_branches.py",
3238+
extra_args=[
3239+
"--output-model-type",
3240+
"pydantic_v2.BaseModel",
3241+
"--use-annotated",
3242+
],
3243+
)
3244+
3245+
3246+
@pytest.mark.benchmark
3247+
def test_main_jsonschema_root_model_default_value_non_root(output_file: Path) -> None:
3248+
"""Test that non-RootModel references are not wrapped."""
3249+
run_main_and_assert(
3250+
input_path=JSON_SCHEMA_DATA_PATH / "root_model_default_value_non_root.json",
3251+
output_path=output_file,
3252+
input_file_type="jsonschema",
3253+
assert_func=assert_file_content,
3254+
expected_file="root_model_default_value_non_root.py",
3255+
extra_args=[
3256+
"--output-model-type",
3257+
"pydantic_v2.BaseModel",
3258+
"--use-annotated",
3259+
],
3260+
)

0 commit comments

Comments
 (0)