diff --git a/docs/cli-reference/index.md b/docs/cli-reference/index.md index d7c5becfb..a84e03f1f 100644 --- a/docs/cli-reference/index.md +++ b/docs/cli-reference/index.md @@ -11,7 +11,7 @@ This documentation is auto-generated from test cases. | 📁 [Base Options](base-options.md) | 5 | Input/output configuration | | 🔧 [Typing Customization](typing-customization.md) | 17 | Type annotation and import behavior | | 🏷️ [Field Customization](field-customization.md) | 20 | Field naming and docstring behavior | -| 🏗️ [Model Customization](model-customization.md) | 26 | Model generation behavior | +| 🏗️ [Model Customization](model-customization.md) | 27 | Model generation behavior | | 🎨 [Template Customization](template-customization.md) | 16 | Output formatting and custom rendering | | 📘 [OpenAPI-only Options](openapi-only-options.md) | 6 | OpenAPI-specific features | | ⚙️ [General Options](general-options.md) | 14 | Utilities and meta options | @@ -160,6 +160,7 @@ This documentation is auto-generated from test cases. - [`--use-attribute-docstrings`](field-customization.md#use-attribute-docstrings) - [`--use-decimal-for-multiple-of`](typing-customization.md#use-decimal-for-multiple-of) - [`--use-default`](model-customization.md#use-default) +- [`--use-default-factory-for-optional-nested-models`](model-customization.md#use-default-factory-for-optional-nested-models) - [`--use-default-kwarg`](model-customization.md#use-default-kwarg) - [`--use-double-quotes`](template-customization.md#use-double-quotes) - [`--use-enum-values-in-discriminator`](field-customization.md#use-enum-values-in-discriminator) diff --git a/docs/cli-reference/model-customization.md b/docs/cli-reference/model-customization.md index dbfcb39c0..846a238d8 100644 --- a/docs/cli-reference/model-customization.md +++ b/docs/cli-reference/model-customization.md @@ -25,6 +25,7 @@ | [`--target-python-version`](#target-python-version) | Target Python version for generated code syntax and imports.... | | [`--union-mode`](#union-mode) | Union mode for combining anyOf/oneOf schemas (smart or left_... | | [`--use-default`](#use-default) | Use default values from schema in generated models. | +| [`--use-default-factory-for-optional-nested-models`](#use-default-factory-for-optional-nested-models) | Generate default_factory for optional nested model fields. | | [`--use-default-kwarg`](#use-default-kwarg) | Use default= keyword argument instead of positional argument... | | [`--use-frozen-field`](#use-frozen-field) | Generate frozen (immutable) field definitions for readOnly p... | | [`--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. --- +## `--use-default-factory-for-optional-nested-models` {#use-default-factory-for-optional-nested-models} + +Generate default_factory for optional nested model fields. + +The `--use-default-factory-for-optional-nested-models` flag generates default_factory +for optional nested model fields instead of None default: +- Dataclasses: `field: Model | None = field(default_factory=Model)` +- Pydantic: `field: Model | None = Field(default_factory=Model)` +- msgspec: `field: Model | UnsetType = field(default_factory=Model)` + +!!! tip "Usage" + + ```bash + datamodel-codegen --input schema.json --use-default-factory-for-optional-nested-models # (1)! + ``` + + 1. :material-arrow-left: `--use-default-factory-for-optional-nested-models` - the option documented here + +??? example "Examples" + + **Input Schema:** + + ```json + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "name": {"type": "string"}, + "address": {"$ref": "#/$defs/Address"}, + "contact": {"$ref": "#/$defs/Contact"} + }, + "required": ["name"], + "$defs": { + "Address": { + "type": "object", + "properties": { + "street": {"type": "string"}, + "city": {"type": "string"} + } + }, + "Contact": { + "type": "object", + "properties": { + "email": {"type": "string"}, + "phone": {"type": "string"} + } + } + } + } + ``` + + **Output:** + + === "Pydantic v2" + + ```python + # generated by datamodel-codegen: + # filename: default_factory_nested_model.json + # timestamp: 2019-07-26T00:00:00+00:00 + + from __future__ import annotations + + from pydantic import BaseModel, Field + + + class Address(BaseModel): + street: str | None = None + city: str | None = None + + + class Contact(BaseModel): + email: str | None = None + phone: str | None = None + + + class Model(BaseModel): + name: str + address: Address | None = Field(default_factory=Address) + contact: Contact | None = Field(default_factory=Contact) + ``` + + === "dataclass" + + ```python + # generated by datamodel-codegen: + # filename: default_factory_nested_model.json + # timestamp: 2019-07-26T00:00:00+00:00 + + from __future__ import annotations + + from dataclasses import dataclass, field + + + @dataclass + class Address: + street: str | None = None + city: str | None = None + + + @dataclass + class Contact: + email: str | None = None + phone: str | None = None + + + @dataclass + class Model: + name: str + address: Address | None = field(default_factory=Address) + contact: Contact | None = field(default_factory=Contact) + ``` + + === "msgspec" + + ```python + # generated by datamodel-codegen: + # filename: default_factory_nested_model.json + # timestamp: 2019-07-26T00:00:00+00:00 + + from __future__ import annotations + + from msgspec import UNSET, Struct, UnsetType, field + + + class Address(Struct): + street: str | UnsetType = UNSET + city: str | UnsetType = UNSET + + + class Contact(Struct): + email: str | UnsetType = UNSET + phone: str | UnsetType = UNSET + + + class Model(Struct): + name: str + address: Address | UnsetType = field(default_factory=Address) + contact: Contact | UnsetType = field(default_factory=Contact) + ``` + +--- + ## `--use-default-kwarg` {#use-default-kwarg} Use default= keyword argument instead of positional argument for fields with defaults. diff --git a/docs/cli-reference/quick-reference.md b/docs/cli-reference/quick-reference.md index 93a77063e..df007fce8 100644 --- a/docs/cli-reference/quick-reference.md +++ b/docs/cli-reference/quick-reference.md @@ -94,6 +94,7 @@ datamodel-codegen [OPTIONS] | [`--target-python-version`](model-customization.md#target-python-version) | Target Python version for generated code syntax and imports. | | [`--union-mode`](model-customization.md#union-mode) | Union mode for combining anyOf/oneOf schemas (smart or left_to_right). | | [`--use-default`](model-customization.md#use-default) | Use default values from schema in generated models. | +| [`--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. | | [`--use-default-kwarg`](model-customization.md#use-default-kwarg) | Use default= keyword argument instead of positional argument for fields with def... | | [`--use-frozen-field`](model-customization.md#use-frozen-field) | Generate frozen (immutable) field definitions for readOnly properties. | | [`--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: - [`--use-attribute-docstrings`](field-customization.md#use-attribute-docstrings) - Generate field descriptions as attribute docstrings instead ... - [`--use-decimal-for-multiple-of`](typing-customization.md#use-decimal-for-multiple-of) - Generate Decimal types for fields with multipleOf constraint... - [`--use-default`](model-customization.md#use-default) - Use default values from schema in generated models. +- [`--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. - [`--use-default-kwarg`](model-customization.md#use-default-kwarg) - Use default= keyword argument instead of positional argument... - [`--use-double-quotes`](template-customization.md#use-double-quotes) - Use double quotes for string literals in generated code. - [`--use-enum-values-in-discriminator`](field-customization.md#use-enum-values-in-discriminator) - Use enum values in discriminator mappings for union types. diff --git a/src/datamodel_code_generator/__init__.py b/src/datamodel_code_generator/__init__.py index 6b61d92c1..d55976345 100644 --- a/src/datamodel_code_generator/__init__.py +++ b/src/datamodel_code_generator/__init__.py @@ -470,6 +470,7 @@ def generate( # noqa: PLR0912, PLR0913, PLR0914, PLR0915 frozen_dataclasses: bool = False, no_alias: bool = False, use_frozen_field: bool = False, + use_default_factory_for_optional_nested_models: bool = False, formatters: list[Formatter] = DEFAULT_FORMATTERS, settings_path: Path | None = None, parent_scoped_naming: bool = False, @@ -717,6 +718,7 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]: frozen_dataclasses=frozen_dataclasses, no_alias=no_alias, use_frozen_field=use_frozen_field, + use_default_factory_for_optional_nested_models=use_default_factory_for_optional_nested_models, formatters=formatters, encoding=encoding, parent_scoped_naming=parent_scoped_naming, diff --git a/src/datamodel_code_generator/__main__.py b/src/datamodel_code_generator/__main__.py index 02d6fc74d..7f52d6864 100644 --- a/src/datamodel_code_generator/__main__.py +++ b/src/datamodel_code_generator/__main__.py @@ -459,6 +459,7 @@ def validate_all_exports_collision_strategy(cls, values: dict[str, Any]) -> dict dataclass_arguments: Optional[DataclassArguments] = None # noqa: UP045 no_alias: bool = False use_frozen_field: bool = False + use_default_factory_for_optional_nested_models: bool = False formatters: list[Formatter] = DEFAULT_FORMATTERS parent_scoped_naming: bool = False disable_future_imports: bool = False @@ -761,6 +762,7 @@ def run_generate_from_config( # noqa: PLR0913, PLR0917 frozen_dataclasses=config.frozen_dataclasses, no_alias=config.no_alias, use_frozen_field=config.use_frozen_field, + use_default_factory_for_optional_nested_models=config.use_default_factory_for_optional_nested_models, formatters=config.formatters, settings_path=settings_path, parent_scoped_naming=config.parent_scoped_naming, diff --git a/src/datamodel_code_generator/arguments.py b/src/datamodel_code_generator/arguments.py index d6ad800f5..7d3be08da 100644 --- a/src/datamodel_code_generator/arguments.py +++ b/src/datamodel_code_generator/arguments.py @@ -594,6 +594,13 @@ def start_section(self, heading: str | None) -> None: action="store_true", default=None, ) +field_options.add_argument( + "--use-default-factory-for-optional-nested-models", + help="Use default_factory for optional nested model fields instead of None default. " + "E.g., `field: Model | None = Field(default_factory=Model)` instead of `field: Model | None = None`", + action="store_true", + default=None, +) # ====================================================================================== # Options for templating output diff --git a/src/datamodel_code_generator/cli_options.py b/src/datamodel_code_generator/cli_options.py index 22be083a7..efcae4910 100644 --- a/src/datamodel_code_generator/cli_options.py +++ b/src/datamodel_code_generator/cli_options.py @@ -92,6 +92,9 @@ class CLIOptionMeta: "--strip-default-none": CLIOptionMeta(name="--strip-default-none", category=OptionCategory.MODEL), "--dataclass-arguments": CLIOptionMeta(name="--dataclass-arguments", category=OptionCategory.MODEL), "--use-frozen-field": CLIOptionMeta(name="--use-frozen-field", category=OptionCategory.MODEL), + "--use-default-factory-for-optional-nested-models": CLIOptionMeta( + name="--use-default-factory-for-optional-nested-models", category=OptionCategory.MODEL + ), "--union-mode": CLIOptionMeta(name="--union-mode", category=OptionCategory.MODEL), "--parent-scoped-naming": CLIOptionMeta(name="--parent-scoped-naming", category=OptionCategory.MODEL), "--use-one-literal-as-default": CLIOptionMeta(name="--use-one-literal-as-default", category=OptionCategory.MODEL), diff --git a/src/datamodel_code_generator/model/base.py b/src/datamodel_code_generator/model/base.py index 1935cfc40..0a8892cc6 100644 --- a/src/datamodel_code_generator/model/base.py +++ b/src/datamodel_code_generator/model/base.py @@ -166,6 +166,7 @@ class Config: read_only: bool = False write_only: bool = False use_frozen_field: bool = False + use_default_factory_for_optional_nested_models: bool = False if not TYPE_CHECKING: if not PYDANTIC_V2: diff --git a/src/datamodel_code_generator/model/dataclass.py b/src/datamodel_code_generator/model/dataclass.py index fa9a3853f..c3ef3b0c6 100644 --- a/src/datamodel_code_generator/model/dataclass.py +++ b/src/datamodel_code_generator/model/dataclass.py @@ -143,6 +143,19 @@ def field(self) -> str | None: return None return result + def _get_default_factory_for_nested_model(self) -> str | None: + """Get default_factory for nested dataclass model fields. + + Returns the class name if the field type references a DataClass, + otherwise returns None. + """ + for data_type in self.data_type.data_types or (self.data_type,): + if data_type.is_dict: + continue + if data_type.reference and isinstance(data_type.reference.source, DataClass): + return data_type.alias or data_type.reference.source.class_name + return None + def __str__(self) -> str: """Generate field() call or default value representation.""" 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: } } + if ( + self.use_default_factory_for_optional_nested_models + and not self.required + and (self.default is None or self.default is UNDEFINED) + and "default_factory" not in data + ): + nested_model_name = self._get_default_factory_for_nested_model() + if nested_model_name: + data["default_factory"] = nested_model_name + if not data: return "" diff --git a/src/datamodel_code_generator/model/msgspec.py b/src/datamodel_code_generator/model/msgspec.py index fa03ceb46..a53bcdbaa 100644 --- a/src/datamodel_code_generator/model/msgspec.py +++ b/src/datamodel_code_generator/model/msgspec.py @@ -247,7 +247,7 @@ def field(self) -> str | None: return None return result - def __str__(self) -> str: + def __str__(self) -> str: # noqa: PLR0912 """Generate field() call or default value representation.""" data: dict[str, Any] = {k: v for k, v in self.extras.items() if k in self._FIELD_KEYS} if self.alias: @@ -284,6 +284,17 @@ def __str__(self) -> str: else: data["default_factory"] = type(default_value).__name__ + if ( + self.use_default_factory_for_optional_nested_models + and not self.required + and (self.default is None or self.default is UNDEFINED) + and "default_factory" not in data + ): + nested_model_name = self._get_default_factory_for_optional_nested_model() + if nested_model_name: + data["default_factory"] = nested_model_name + data.pop("default", None) + if not data: return "" @@ -412,6 +423,19 @@ def _get_default_as_struct_model(self) -> str | None: ) return None + def _get_default_factory_for_optional_nested_model(self) -> str | None: + """Get default_factory for optional nested Struct model fields. + + Returns the class name if the field type references a Struct, + otherwise returns None. + """ + for data_type in self.data_type.data_types or (self.data_type,): + if data_type.is_dict: + continue + if data_type.reference and isinstance(data_type.reference.source, Struct): + return data_type.alias or data_type.reference.source.class_name + return None + class DataTypeManager(_DataTypeManager): """Type manager for msgspec Struct models.""" diff --git a/src/datamodel_code_generator/model/pydantic/base_model.py b/src/datamodel_code_generator/model/pydantic/base_model.py index 7cc864ba6..b16c2c62d 100644 --- a/src/datamodel_code_generator/model/pydantic/base_model.py +++ b/src/datamodel_code_generator/model/pydantic/base_model.py @@ -152,6 +152,19 @@ def _get_default_as_pydantic_model(self) -> str | None: ) return None + def _get_default_factory_for_optional_nested_model(self) -> str | None: + """Get default_factory for optional nested Pydantic model fields. + + Returns the class name if the field type references a BaseModel, + otherwise returns None. + """ + for data_type in self.data_type.data_types or (self.data_type,): + if data_type.is_dict: + continue + if data_type.reference and isinstance(data_type.reference.source, BaseModelBase): + return data_type.alias or data_type.reference.source.class_name + return None + def _process_data_in_str(self, data: dict[str, Any]) -> None: if self.const: data["const"] = True @@ -204,6 +217,14 @@ def __str__(self) -> str: # noqa: PLR0912 else: default_factory = data.pop("default_factory", None) + if ( + default_factory is None + and self.use_default_factory_for_optional_nested_models + and not self.required + and (self.default is None or self.default is UNDEFINED) + ): + default_factory = self._get_default_factory_for_optional_nested_model() + self.__dict__["_computed_default_factory"] = default_factory field_arguments = sorted(f"{k}={v!r}" for k, v in data.items() if v is not None) diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index 48d4f2a6f..747929fa0 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -734,6 +734,7 @@ def __init__( # noqa: PLR0913, PLR0915 frozen_dataclasses: bool = False, no_alias: bool = False, use_frozen_field: bool = False, + use_default_factory_for_optional_nested_models: bool = False, formatters: list[Formatter] = DEFAULT_FORMATTERS, parent_scoped_naming: bool = False, dataclass_arguments: DataclassArguments | None = None, @@ -875,6 +876,7 @@ def __init__( # noqa: PLR0913, PLR0915 self.type_mappings: dict[tuple[str, str], str] = Parser._parse_type_mappings(type_mappings) self.read_only_write_only_model_type: ReadOnlyWriteOnlyModelType | None = read_only_write_only_model_type self.use_frozen_field: bool = use_frozen_field + self.use_default_factory_for_optional_nested_models: bool = use_default_factory_for_optional_nested_models @property def field_name_model_type(self) -> ModelType: diff --git a/src/datamodel_code_generator/parser/graphql.py b/src/datamodel_code_generator/parser/graphql.py index 2bf9f725c..4d133a70e 100644 --- a/src/datamodel_code_generator/parser/graphql.py +++ b/src/datamodel_code_generator/parser/graphql.py @@ -190,6 +190,7 @@ def __init__( # noqa: PLR0913 read_only_write_only_model_type: ReadOnlyWriteOnlyModelType | None = None, use_serialize_as_any: bool = False, use_frozen_field: bool = False, + use_default_factory_for_optional_nested_models: bool = False, ) -> None: """Initialize the GraphQL parser with configuration options.""" super().__init__( @@ -284,6 +285,7 @@ def __init__( # noqa: PLR0913 read_only_write_only_model_type=read_only_write_only_model_type, use_serialize_as_any=use_serialize_as_any, use_frozen_field=use_frozen_field, + use_default_factory_for_optional_nested_models=use_default_factory_for_optional_nested_models, ) self.data_model_scalar_type = data_model_scalar_type diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index 7e0e096c6..d7b65da90 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -596,6 +596,7 @@ def __init__( # noqa: PLR0913 frozen_dataclasses: bool = False, no_alias: bool = False, use_frozen_field: bool = False, + use_default_factory_for_optional_nested_models: bool = False, formatters: list[Formatter] = DEFAULT_FORMATTERS, parent_scoped_naming: bool = False, dataclass_arguments: DataclassArguments | None = None, @@ -691,6 +692,7 @@ def __init__( # noqa: PLR0913 frozen_dataclasses=frozen_dataclasses, no_alias=no_alias, use_frozen_field=use_frozen_field, + use_default_factory_for_optional_nested_models=use_default_factory_for_optional_nested_models, formatters=formatters, parent_scoped_naming=parent_scoped_naming, dataclass_arguments=dataclass_arguments, @@ -1035,6 +1037,7 @@ def get_object_field( # noqa: PLR0913 read_only=self._resolve_field_flag(field, "readOnly"), write_only=self._resolve_field_flag(field, "writeOnly"), use_frozen_field=self.use_frozen_field, + use_default_factory_for_optional_nested_models=self.use_default_factory_for_optional_nested_models, ) def get_data_type(self, obj: JsonSchemaObject) -> DataType: diff --git a/src/datamodel_code_generator/parser/openapi.py b/src/datamodel_code_generator/parser/openapi.py index cef0fbddb..37e17ee19 100644 --- a/src/datamodel_code_generator/parser/openapi.py +++ b/src/datamodel_code_generator/parser/openapi.py @@ -273,6 +273,7 @@ def __init__( # noqa: PLR0913 type_mappings: list[str] | None = None, read_only_write_only_model_type: ReadOnlyWriteOnlyModelType | None = None, use_frozen_field: bool = False, + use_default_factory_for_optional_nested_models: bool = False, use_status_code_in_response_name: bool = False, ) -> None: """Initialize the OpenAPI parser with extensive configuration options.""" @@ -369,6 +370,7 @@ def __init__( # noqa: PLR0913 type_mappings=type_mappings, read_only_write_only_model_type=read_only_write_only_model_type, use_frozen_field=use_frozen_field, + use_default_factory_for_optional_nested_models=use_default_factory_for_optional_nested_models, ) self.open_api_scopes: list[OpenAPIScope] = openapi_scopes or [OpenAPIScope.Schemas] self.include_path_parameters: bool = include_path_parameters diff --git a/tests/data/expected/main/jsonschema/default_factory_nested_model_dataclass.py b/tests/data/expected/main/jsonschema/default_factory_nested_model_dataclass.py new file mode 100644 index 000000000..9e3030e4c --- /dev/null +++ b/tests/data/expected/main/jsonschema/default_factory_nested_model_dataclass.py @@ -0,0 +1,26 @@ +# generated by datamodel-codegen: +# filename: default_factory_nested_model.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class Address: + street: str | None = None + city: str | None = None + + +@dataclass +class Contact: + email: str | None = None + phone: str | None = None + + +@dataclass +class Model: + name: str + address: Address | None = field(default_factory=Address) + contact: Contact | None = field(default_factory=Contact) diff --git a/tests/data/expected/main/jsonschema/default_factory_nested_model_msgspec.py b/tests/data/expected/main/jsonschema/default_factory_nested_model_msgspec.py new file mode 100644 index 000000000..021d310d7 --- /dev/null +++ b/tests/data/expected/main/jsonschema/default_factory_nested_model_msgspec.py @@ -0,0 +1,23 @@ +# generated by datamodel-codegen: +# filename: default_factory_nested_model.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from msgspec import UNSET, Struct, UnsetType, field + + +class Address(Struct): + street: str | UnsetType = UNSET + city: str | UnsetType = UNSET + + +class Contact(Struct): + email: str | UnsetType = UNSET + phone: str | UnsetType = UNSET + + +class Model(Struct): + name: str + address: Address | UnsetType = field(default_factory=Address) + contact: Contact | UnsetType = field(default_factory=Contact) diff --git a/tests/data/expected/main/jsonschema/default_factory_nested_model_pydantic_v2.py b/tests/data/expected/main/jsonschema/default_factory_nested_model_pydantic_v2.py new file mode 100644 index 000000000..4fef821ca --- /dev/null +++ b/tests/data/expected/main/jsonschema/default_factory_nested_model_pydantic_v2.py @@ -0,0 +1,23 @@ +# generated by datamodel-codegen: +# filename: default_factory_nested_model.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class Address(BaseModel): + street: str | None = None + city: str | None = None + + +class Contact(BaseModel): + email: str | None = None + phone: str | None = None + + +class Model(BaseModel): + name: str + address: Address | None = Field(default_factory=Address) + contact: Contact | None = Field(default_factory=Contact) diff --git a/tests/data/expected/main/jsonschema/default_factory_nested_model_with_dict_dataclass.py b/tests/data/expected/main/jsonschema/default_factory_nested_model_with_dict_dataclass.py new file mode 100644 index 000000000..7987d996f --- /dev/null +++ b/tests/data/expected/main/jsonschema/default_factory_nested_model_with_dict_dataclass.py @@ -0,0 +1,26 @@ +# generated by datamodel-codegen: +# filename: default_factory_nested_model_with_dict.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class Address: + street: str | None = None + city: str | None = None + + +@dataclass +class Contact: + email: str | None = None + phone: str | None = None + + +@dataclass +class Model: + name: str + address: Address | None = field(default_factory=Address) + metadata: dict[str, str] | Contact | None = field(default_factory=Contact) diff --git a/tests/data/expected/main/jsonschema/default_factory_nested_model_with_dict_msgspec.py b/tests/data/expected/main/jsonschema/default_factory_nested_model_with_dict_msgspec.py new file mode 100644 index 000000000..c3857a94c --- /dev/null +++ b/tests/data/expected/main/jsonschema/default_factory_nested_model_with_dict_msgspec.py @@ -0,0 +1,23 @@ +# generated by datamodel-codegen: +# filename: default_factory_nested_model_with_dict.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from msgspec import UNSET, Struct, UnsetType, field + + +class Address(Struct): + street: str | UnsetType = UNSET + city: str | UnsetType = UNSET + + +class Contact(Struct): + email: str | UnsetType = UNSET + phone: str | UnsetType = UNSET + + +class Model(Struct): + name: str + address: Address | UnsetType = field(default_factory=Address) + metadata: dict[str, str] | Contact | UnsetType = field(default_factory=Contact) diff --git a/tests/data/expected/main/jsonschema/default_factory_nested_model_with_dict_pydantic_v2.py b/tests/data/expected/main/jsonschema/default_factory_nested_model_with_dict_pydantic_v2.py new file mode 100644 index 000000000..971860a83 --- /dev/null +++ b/tests/data/expected/main/jsonschema/default_factory_nested_model_with_dict_pydantic_v2.py @@ -0,0 +1,23 @@ +# generated by datamodel-codegen: +# filename: default_factory_nested_model_with_dict.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class Address(BaseModel): + street: str | None = None + city: str | None = None + + +class Contact(BaseModel): + email: str | None = None + phone: str | None = None + + +class Model(BaseModel): + name: str + address: Address | None = Field(default_factory=Address) + metadata: dict[str, str] | Contact | None = Field(default_factory=Contact) diff --git a/tests/data/jsonschema/default_factory_nested_model.json b/tests/data/jsonschema/default_factory_nested_model.json new file mode 100644 index 000000000..7ab0d714b --- /dev/null +++ b/tests/data/jsonschema/default_factory_nested_model.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "name": {"type": "string"}, + "address": {"$ref": "#/$defs/Address"}, + "contact": {"$ref": "#/$defs/Contact"} + }, + "required": ["name"], + "$defs": { + "Address": { + "type": "object", + "properties": { + "street": {"type": "string"}, + "city": {"type": "string"} + } + }, + "Contact": { + "type": "object", + "properties": { + "email": {"type": "string"}, + "phone": {"type": "string"} + } + } + } +} diff --git a/tests/data/jsonschema/default_factory_nested_model_with_dict.json b/tests/data/jsonschema/default_factory_nested_model_with_dict.json new file mode 100644 index 000000000..ad9113c94 --- /dev/null +++ b/tests/data/jsonschema/default_factory_nested_model_with_dict.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "name": {"type": "string"}, + "address": {"$ref": "#/$defs/Address"}, + "metadata": { + "oneOf": [ + {"type": "object", "additionalProperties": {"type": "string"}}, + {"$ref": "#/$defs/Contact"} + ] + } + }, + "required": ["name"], + "$defs": { + "Address": { + "type": "object", + "properties": { + "street": {"type": "string"}, + "city": {"type": "string"} + } + }, + "Contact": { + "type": "object", + "properties": { + "email": {"type": "string"}, + "phone": {"type": "string"} + } + } + } +} diff --git a/tests/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index 8902d4418..72f0f9305 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -4417,6 +4417,79 @@ def test_main_use_frozen_field_no_readonly(output_file: Path) -> None: ) +@pytest.mark.parametrize( + ("output_model", "expected_file"), + [ + ("dataclasses.dataclass", "default_factory_nested_model_dataclass.py"), + ("pydantic_v2.BaseModel", "default_factory_nested_model_pydantic_v2.py"), + ("msgspec.Struct", "default_factory_nested_model_msgspec.py"), + ], +) +@pytest.mark.cli_doc( + options=["--use-default-factory-for-optional-nested-models"], + input_schema="jsonschema/default_factory_nested_model.json", + cli_args=["--use-default-factory-for-optional-nested-models"], + model_outputs={ + "dataclass": "main/jsonschema/default_factory_nested_model_dataclass.py", + "pydantic_v2": "main/jsonschema/default_factory_nested_model_pydantic_v2.py", + "msgspec": "main/jsonschema/default_factory_nested_model_msgspec.py", + }, +) +@pytest.mark.benchmark +@LEGACY_BLACK_SKIP +def test_main_use_default_factory_for_optional_nested_models( + output_model: str, expected_file: str, output_file: Path +) -> None: + """Generate default_factory for optional nested model fields. + + The `--use-default-factory-for-optional-nested-models` flag generates default_factory + for optional nested model fields instead of None default: + - Dataclasses: `field: Model | None = field(default_factory=Model)` + - Pydantic: `field: Model | None = Field(default_factory=Model)` + - msgspec: `field: Model | UnsetType = field(default_factory=Model)` + """ + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "default_factory_nested_model.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file=expected_file, + extra_args=[ + "--output-model-type", + output_model, + "--use-default-factory-for-optional-nested-models", + ], + ) + + +@pytest.mark.parametrize( + ("output_model", "expected_file"), + [ + ("dataclasses.dataclass", "default_factory_nested_model_with_dict_dataclass.py"), + ("pydantic_v2.BaseModel", "default_factory_nested_model_with_dict_pydantic_v2.py"), + ("msgspec.Struct", "default_factory_nested_model_with_dict_msgspec.py"), + ], +) +@pytest.mark.benchmark +@LEGACY_BLACK_SKIP +def test_main_use_default_factory_for_optional_nested_models_with_dict( + output_model: str, expected_file: str, output_file: Path +) -> None: + """Test --use-default-factory-for-optional-nested-models with dict union skips dict types.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "default_factory_nested_model_with_dict.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file=expected_file, + extra_args=[ + "--output-model-type", + output_model, + "--use-default-factory-for-optional-nested-models", + ], + ) + + @pytest.mark.benchmark def test_main_field_name_shadows_class_name(output_file: Path) -> None: """Test field name shadowing class name is renamed with alias for Pydantic v2."""