Skip to content

Commit 90659d9

Browse files
Add --use-default-factory-for-optional-nested-models option (#2711)
* Add --use-default-factory-for-optional-nested-models option * docs: update CLI reference documentation 🤖 Generated by GitHub Actions --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent d41a19b commit 90659d9

24 files changed

Lines changed: 514 additions & 2 deletions

docs/cli-reference/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ This documentation is auto-generated from test cases.
1111
| 📁 [Base Options](base-options.md) | 5 | Input/output configuration |
1212
| 🔧 [Typing Customization](typing-customization.md) | 17 | Type annotation and import behavior |
1313
| 🏷️ [Field Customization](field-customization.md) | 20 | Field naming and docstring behavior |
14-
| 🏗️ [Model Customization](model-customization.md) | 26 | Model generation behavior |
14+
| 🏗️ [Model Customization](model-customization.md) | 27 | Model generation behavior |
1515
| 🎨 [Template Customization](template-customization.md) | 16 | Output formatting and custom rendering |
1616
| 📘 [OpenAPI-only Options](openapi-only-options.md) | 6 | OpenAPI-specific features |
1717
| ⚙️ [General Options](general-options.md) | 14 | Utilities and meta options |
@@ -160,6 +160,7 @@ This documentation is auto-generated from test cases.
160160
- [`--use-attribute-docstrings`](field-customization.md#use-attribute-docstrings)
161161
- [`--use-decimal-for-multiple-of`](typing-customization.md#use-decimal-for-multiple-of)
162162
- [`--use-default`](model-customization.md#use-default)
163+
- [`--use-default-factory-for-optional-nested-models`](model-customization.md#use-default-factory-for-optional-nested-models)
163164
- [`--use-default-kwarg`](model-customization.md#use-default-kwarg)
164165
- [`--use-double-quotes`](template-customization.md#use-double-quotes)
165166
- [`--use-enum-values-in-discriminator`](field-customization.md#use-enum-values-in-discriminator)

docs/cli-reference/model-customization.md

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
| [`--target-python-version`](#target-python-version) | Target Python version for generated code syntax and imports.... |
2626
| [`--union-mode`](#union-mode) | Union mode for combining anyOf/oneOf schemas (smart or left_... |
2727
| [`--use-default`](#use-default) | Use default values from schema in generated models. |
28+
| [`--use-default-factory-for-optional-nested-models`](#use-default-factory-for-optional-nested-models) | Generate default_factory for optional nested model fields. |
2829
| [`--use-default-kwarg`](#use-default-kwarg) | Use default= keyword argument instead of positional argument... |
2930
| [`--use-frozen-field`](#use-frozen-field) | Generate frozen (immutable) field definitions for readOnly p... |
3031
| [`--use-one-literal-as-default`](#use-one-literal-as-default) | Use single literal value as default when enum has only one o... |
@@ -4834,6 +4835,148 @@ The `--use-default` flag configures the code generation behavior.
48344835

48354836
---
48364837

4838+
## `--use-default-factory-for-optional-nested-models` {#use-default-factory-for-optional-nested-models}
4839+
4840+
Generate default_factory for optional nested model fields.
4841+
4842+
The `--use-default-factory-for-optional-nested-models` flag generates default_factory
4843+
for optional nested model fields instead of None default:
4844+
- Dataclasses: `field: Model | None = field(default_factory=Model)`
4845+
- Pydantic: `field: Model | None = Field(default_factory=Model)`
4846+
- msgspec: `field: Model | UnsetType = field(default_factory=Model)`
4847+
4848+
!!! tip "Usage"
4849+
4850+
```bash
4851+
datamodel-codegen --input schema.json --use-default-factory-for-optional-nested-models # (1)!
4852+
```
4853+
4854+
1. :material-arrow-left: `--use-default-factory-for-optional-nested-models` - the option documented here
4855+
4856+
??? example "Examples"
4857+
4858+
**Input Schema:**
4859+
4860+
```json
4861+
{
4862+
"$schema": "http://json-schema.org/draft-07/schema#",
4863+
"type": "object",
4864+
"properties": {
4865+
"name": {"type": "string"},
4866+
"address": {"$ref": "#/$defs/Address"},
4867+
"contact": {"$ref": "#/$defs/Contact"}
4868+
},
4869+
"required": ["name"],
4870+
"$defs": {
4871+
"Address": {
4872+
"type": "object",
4873+
"properties": {
4874+
"street": {"type": "string"},
4875+
"city": {"type": "string"}
4876+
}
4877+
},
4878+
"Contact": {
4879+
"type": "object",
4880+
"properties": {
4881+
"email": {"type": "string"},
4882+
"phone": {"type": "string"}
4883+
}
4884+
}
4885+
}
4886+
}
4887+
```
4888+
4889+
**Output:**
4890+
4891+
=== "Pydantic v2"
4892+
4893+
```python
4894+
# generated by datamodel-codegen:
4895+
# filename: default_factory_nested_model.json
4896+
# timestamp: 2019-07-26T00:00:00+00:00
4897+
4898+
from __future__ import annotations
4899+
4900+
from pydantic import BaseModel, Field
4901+
4902+
4903+
class Address(BaseModel):
4904+
street: str | None = None
4905+
city: str | None = None
4906+
4907+
4908+
class Contact(BaseModel):
4909+
email: str | None = None
4910+
phone: str | None = None
4911+
4912+
4913+
class Model(BaseModel):
4914+
name: str
4915+
address: Address | None = Field(default_factory=Address)
4916+
contact: Contact | None = Field(default_factory=Contact)
4917+
```
4918+
4919+
=== "dataclass"
4920+
4921+
```python
4922+
# generated by datamodel-codegen:
4923+
# filename: default_factory_nested_model.json
4924+
# timestamp: 2019-07-26T00:00:00+00:00
4925+
4926+
from __future__ import annotations
4927+
4928+
from dataclasses import dataclass, field
4929+
4930+
4931+
@dataclass
4932+
class Address:
4933+
street: str | None = None
4934+
city: str | None = None
4935+
4936+
4937+
@dataclass
4938+
class Contact:
4939+
email: str | None = None
4940+
phone: str | None = None
4941+
4942+
4943+
@dataclass
4944+
class Model:
4945+
name: str
4946+
address: Address | None = field(default_factory=Address)
4947+
contact: Contact | None = field(default_factory=Contact)
4948+
```
4949+
4950+
=== "msgspec"
4951+
4952+
```python
4953+
# generated by datamodel-codegen:
4954+
# filename: default_factory_nested_model.json
4955+
# timestamp: 2019-07-26T00:00:00+00:00
4956+
4957+
from __future__ import annotations
4958+
4959+
from msgspec import UNSET, Struct, UnsetType, field
4960+
4961+
4962+
class Address(Struct):
4963+
street: str | UnsetType = UNSET
4964+
city: str | UnsetType = UNSET
4965+
4966+
4967+
class Contact(Struct):
4968+
email: str | UnsetType = UNSET
4969+
phone: str | UnsetType = UNSET
4970+
4971+
4972+
class Model(Struct):
4973+
name: str
4974+
address: Address | UnsetType = field(default_factory=Address)
4975+
contact: Contact | UnsetType = field(default_factory=Contact)
4976+
```
4977+
4978+
---
4979+
48374980
## `--use-default-kwarg` {#use-default-kwarg}
48384981

48394982
Use default= keyword argument instead of positional argument for fields with defaults.

docs/cli-reference/quick-reference.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ datamodel-codegen [OPTIONS]
9494
| [`--target-python-version`](model-customization.md#target-python-version) | Target Python version for generated code syntax and imports. |
9595
| [`--union-mode`](model-customization.md#union-mode) | Union mode for combining anyOf/oneOf schemas (smart or left_to_right). |
9696
| [`--use-default`](model-customization.md#use-default) | Use default values from schema in generated models. |
97+
| [`--use-default-factory-for-optional-nested-models`](model-customization.md#use-default-factory-for-optional-nested-models) | Generate default_factory for optional nested model fields. |
9798
| [`--use-default-kwarg`](model-customization.md#use-default-kwarg) | Use default= keyword argument instead of positional argument for fields with def... |
9899
| [`--use-frozen-field`](model-customization.md#use-frozen-field) | Generate frozen (immutable) field definitions for readOnly properties. |
99100
| [`--use-one-literal-as-default`](model-customization.md#use-one-literal-as-default) | Use single literal value as default when enum has only one option. |
@@ -252,6 +253,7 @@ All options sorted alphabetically:
252253
- [`--use-attribute-docstrings`](field-customization.md#use-attribute-docstrings) - Generate field descriptions as attribute docstrings instead ...
253254
- [`--use-decimal-for-multiple-of`](typing-customization.md#use-decimal-for-multiple-of) - Generate Decimal types for fields with multipleOf constraint...
254255
- [`--use-default`](model-customization.md#use-default) - Use default values from schema in generated models.
256+
- [`--use-default-factory-for-optional-nested-models`](model-customization.md#use-default-factory-for-optional-nested-models) - Generate default_factory for optional nested model fields.
255257
- [`--use-default-kwarg`](model-customization.md#use-default-kwarg) - Use default= keyword argument instead of positional argument...
256258
- [`--use-double-quotes`](template-customization.md#use-double-quotes) - Use double quotes for string literals in generated code.
257259
- [`--use-enum-values-in-discriminator`](field-customization.md#use-enum-values-in-discriminator) - Use enum values in discriminator mappings for union types.

src/datamodel_code_generator/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,7 @@ def generate( # noqa: PLR0912, PLR0913, PLR0914, PLR0915
470470
frozen_dataclasses: bool = False,
471471
no_alias: bool = False,
472472
use_frozen_field: bool = False,
473+
use_default_factory_for_optional_nested_models: bool = False,
473474
formatters: list[Formatter] = DEFAULT_FORMATTERS,
474475
settings_path: Path | None = None,
475476
parent_scoped_naming: bool = False,
@@ -717,6 +718,7 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]:
717718
frozen_dataclasses=frozen_dataclasses,
718719
no_alias=no_alias,
719720
use_frozen_field=use_frozen_field,
721+
use_default_factory_for_optional_nested_models=use_default_factory_for_optional_nested_models,
720722
formatters=formatters,
721723
encoding=encoding,
722724
parent_scoped_naming=parent_scoped_naming,

src/datamodel_code_generator/__main__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,7 @@ def validate_all_exports_collision_strategy(cls, values: dict[str, Any]) -> dict
459459
dataclass_arguments: Optional[DataclassArguments] = None # noqa: UP045
460460
no_alias: bool = False
461461
use_frozen_field: bool = False
462+
use_default_factory_for_optional_nested_models: bool = False
462463
formatters: list[Formatter] = DEFAULT_FORMATTERS
463464
parent_scoped_naming: bool = False
464465
disable_future_imports: bool = False
@@ -761,6 +762,7 @@ def run_generate_from_config( # noqa: PLR0913, PLR0917
761762
frozen_dataclasses=config.frozen_dataclasses,
762763
no_alias=config.no_alias,
763764
use_frozen_field=config.use_frozen_field,
765+
use_default_factory_for_optional_nested_models=config.use_default_factory_for_optional_nested_models,
764766
formatters=config.formatters,
765767
settings_path=settings_path,
766768
parent_scoped_naming=config.parent_scoped_naming,

src/datamodel_code_generator/arguments.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,13 @@ def start_section(self, heading: str | None) -> None:
594594
action="store_true",
595595
default=None,
596596
)
597+
field_options.add_argument(
598+
"--use-default-factory-for-optional-nested-models",
599+
help="Use default_factory for optional nested model fields instead of None default. "
600+
"E.g., `field: Model | None = Field(default_factory=Model)` instead of `field: Model | None = None`",
601+
action="store_true",
602+
default=None,
603+
)
597604

598605
# ======================================================================================
599606
# Options for templating output

src/datamodel_code_generator/cli_options.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ class CLIOptionMeta:
9292
"--strip-default-none": CLIOptionMeta(name="--strip-default-none", category=OptionCategory.MODEL),
9393
"--dataclass-arguments": CLIOptionMeta(name="--dataclass-arguments", category=OptionCategory.MODEL),
9494
"--use-frozen-field": CLIOptionMeta(name="--use-frozen-field", category=OptionCategory.MODEL),
95+
"--use-default-factory-for-optional-nested-models": CLIOptionMeta(
96+
name="--use-default-factory-for-optional-nested-models", category=OptionCategory.MODEL
97+
),
9598
"--union-mode": CLIOptionMeta(name="--union-mode", category=OptionCategory.MODEL),
9699
"--parent-scoped-naming": CLIOptionMeta(name="--parent-scoped-naming", category=OptionCategory.MODEL),
97100
"--use-one-literal-as-default": CLIOptionMeta(name="--use-one-literal-as-default", category=OptionCategory.MODEL),

src/datamodel_code_generator/model/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ class Config:
156156
read_only: bool = False
157157
write_only: bool = False
158158
use_frozen_field: bool = False
159+
use_default_factory_for_optional_nested_models: bool = False
159160

160161
if not TYPE_CHECKING:
161162
if not PYDANTIC_V2:

src/datamodel_code_generator/model/dataclass.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,19 @@ def field(self) -> str | None:
143143
return None
144144
return result
145145

146+
def _get_default_factory_for_nested_model(self) -> str | None:
147+
"""Get default_factory for nested dataclass model fields.
148+
149+
Returns the class name if the field type references a DataClass,
150+
otherwise returns None.
151+
"""
152+
for data_type in self.data_type.data_types or (self.data_type,):
153+
if data_type.is_dict:
154+
continue
155+
if data_type.reference and isinstance(data_type.reference.source, DataClass):
156+
return data_type.alias or data_type.reference.source.class_name
157+
return None
158+
146159
def __str__(self) -> str:
147160
"""Generate field() call or default value representation."""
148161
data: dict[str, Any] = {k: v for k, v in self.extras.items() if k in self._FIELD_KEYS}
@@ -161,6 +174,16 @@ def __str__(self) -> str:
161174
}
162175
}
163176

177+
if (
178+
self.use_default_factory_for_optional_nested_models
179+
and not self.required
180+
and (self.default is None or self.default is UNDEFINED)
181+
and "default_factory" not in data
182+
):
183+
nested_model_name = self._get_default_factory_for_nested_model()
184+
if nested_model_name:
185+
data["default_factory"] = nested_model_name
186+
164187
if not data:
165188
return ""
166189

src/datamodel_code_generator/model/msgspec.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ def field(self) -> str | None:
247247
return None
248248
return result
249249

250-
def __str__(self) -> str:
250+
def __str__(self) -> str: # noqa: PLR0912
251251
"""Generate field() call or default value representation."""
252252
data: dict[str, Any] = {k: v for k, v in self.extras.items() if k in self._FIELD_KEYS}
253253
if self.alias:
@@ -284,6 +284,17 @@ def __str__(self) -> str:
284284
else:
285285
data["default_factory"] = type(default_value).__name__
286286

287+
if (
288+
self.use_default_factory_for_optional_nested_models
289+
and not self.required
290+
and (self.default is None or self.default is UNDEFINED)
291+
and "default_factory" not in data
292+
):
293+
nested_model_name = self._get_default_factory_for_optional_nested_model()
294+
if nested_model_name:
295+
data["default_factory"] = nested_model_name
296+
data.pop("default", None)
297+
287298
if not data:
288299
return ""
289300

@@ -412,6 +423,19 @@ def _get_default_as_struct_model(self) -> str | None:
412423
)
413424
return None
414425

426+
def _get_default_factory_for_optional_nested_model(self) -> str | None:
427+
"""Get default_factory for optional nested Struct model fields.
428+
429+
Returns the class name if the field type references a Struct,
430+
otherwise returns None.
431+
"""
432+
for data_type in self.data_type.data_types or (self.data_type,):
433+
if data_type.is_dict:
434+
continue
435+
if data_type.reference and isinstance(data_type.reference.source, Struct):
436+
return data_type.alias or data_type.reference.source.class_name
437+
return None
438+
415439

416440
class DataTypeManager(_DataTypeManager):
417441
"""Type manager for msgspec Struct models."""

0 commit comments

Comments
 (0)