From 84ca7a43b96da9f606cee9e189e11cb48fe6fdb9 Mon Sep 17 00:00:00 2001 From: Mikhail Butvin Date: Mon, 16 Mar 2026 05:49:13 +0300 Subject: [PATCH 1/5] Fix required fields inconsistently rendering defaults (#3048) __set_validate_default_on_fields set validate_default=True on fields with model references without checking field.required, causing required model-ref fields to render defaults while required scalar fields didn't. Skip required fields (unless use_default_with_required) so all required fields consistently have no defaults rendered. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/datamodel_code_generator/parser/base.py | 2 ++ .../expected/main/graphql/pydantic_v2_empty_list_default.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index a89a62a92..81aee4263 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -2177,6 +2177,8 @@ def __set_validate_default_on_fields( # noqa: PLR6301 if isinstance(model, Enum): continue for model_field in model.fields: + if model_field.required and not model_field.use_default_with_required: + continue if model_field.default is None or model_field.default is UNDEFINED: continue if isinstance(model_field.default, Member): diff --git a/tests/data/expected/main/graphql/pydantic_v2_empty_list_default.py b/tests/data/expected/main/graphql/pydantic_v2_empty_list_default.py index 052c98f4d..1f90a7edd 100644 --- a/tests/data/expected/main/graphql/pydantic_v2_empty_list_default.py +++ b/tests/data/expected/main/graphql/pydantic_v2_empty_list_default.py @@ -26,8 +26,8 @@ class Container(BaseModel): class PodSpec(BaseModel): - container_list: list[Container] = Field([], validate_default=True) - container_list_or_none: list[Container | None] = Field([], validate_default=True) + container_list: list[Container] + container_list_or_none: list[Container | None] container_or_none_list_or_none: list[Container | None] | None = Field( [], validate_default=True ) From dc47a971105d3123657197fcdd2420d27e0ebaa9 Mon Sep 17 00:00:00 2001 From: Mikhail Butvin Date: Mon, 16 Mar 2026 05:49:30 +0300 Subject: [PATCH 2/5] Fix --use-default making required fields nullable --use-default previously skipped setting field.required=True, which made fields non-required and therefore nullable (e.g. str | None = 'foo'). Now fields stay required=True with a new use_default_with_required flag that allows defaults to render without changing the type. Produces `status: str = 'foo'` instead of `status: str | None = 'foo'`. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/datamodel_code_generator/model/base.py | 1 + .../model/dataclass.py | 2 +- src/datamodel_code_generator/model/msgspec.py | 2 +- .../model/pydantic_base.py | 7 +- .../model/template/dataclass.jinja2 | 2 +- .../model/template/msgspec.jinja2 | 2 +- .../template/pydantic_v2/BaseModel.jinja2 | 2 +- .../template/pydantic_v2/dataclass.jinja2 | 2 +- .../parser/graphql.py | 7 +- .../parser/jsonschema.py | 39 ++++---- .../parser/openapi.py | 94 ++++++++++--------- .../default_values_required_use_default.py | 4 +- .../main/jsonschema/all_of_use_default.py | 4 +- .../default_values_parameters_use_default.py | 4 +- .../data/expected/main/openapi/use_default.py | 2 +- 15 files changed, 91 insertions(+), 83 deletions(-) diff --git a/src/datamodel_code_generator/model/base.py b/src/datamodel_code_generator/model/base.py index 7110f58bc..02eadbd61 100644 --- a/src/datamodel_code_generator/model/base.py +++ b/src/datamodel_code_generator/model/base.py @@ -173,6 +173,7 @@ class DataModelFieldBase(_BaseModel): use_frozen_field: bool = False use_serialization_alias: bool = False use_default_factory_for_optional_nested_models: bool = False + use_default_with_required: bool = False if not TYPE_CHECKING: # pragma: no branch diff --git a/src/datamodel_code_generator/model/dataclass.py b/src/datamodel_code_generator/model/dataclass.py index 00040d6d8..5399a9b30 100644 --- a/src/datamodel_code_generator/model/dataclass.py +++ b/src/datamodel_code_generator/model/dataclass.py @@ -172,7 +172,7 @@ def __str__(self) -> str: if self.default != UNDEFINED and self.default is not None: data["default"] = self.default - if self.required: + if self.required and not self.use_default_with_required: data = { k: v for k, v in data.items() diff --git a/src/datamodel_code_generator/model/msgspec.py b/src/datamodel_code_generator/model/msgspec.py index 2119f41d9..c929de75e 100644 --- a/src/datamodel_code_generator/model/msgspec.py +++ b/src/datamodel_code_generator/model/msgspec.py @@ -311,7 +311,7 @@ def __str__(self) -> str: # noqa: PLR0912 elif self._not_required and "default_factory" not in data: data["default"] = None if self.nullable else UNSET - if self.required: + if self.required and not self.use_default_with_required: data = { k: v for k, v in data.items() diff --git a/src/datamodel_code_generator/model/pydantic_base.py b/src/datamodel_code_generator/model/pydantic_base.py index 9c359afb7..5b60048fa 100644 --- a/src/datamodel_code_generator/model/pydantic_base.py +++ b/src/datamodel_code_generator/model/pydantic_base.py @@ -211,7 +211,12 @@ def __str__(self) -> str: # noqa: PLR0912 if self.use_annotated: field_arguments = self._process_annotated_field_arguments(field_arguments) - elif self.required and not default_factory and not self.extras.get("validate_default"): + elif ( + self.required + and not self.use_default_with_required + and not default_factory + and not self.extras.get("validate_default") + ): field_arguments = ["...", *field_arguments] elif not default_factory: default_repr = repr_set_sorted(self.default) if isinstance(self.default, set) else repr(self.default) diff --git a/src/datamodel_code_generator/model/template/dataclass.jinja2 b/src/datamodel_code_generator/model/template/dataclass.jinja2 index ecd31bffa..46b9ddab6 100644 --- a/src/datamodel_code_generator/model/template/dataclass.jinja2 +++ b/src/datamodel_code_generator/model/template/dataclass.jinja2 @@ -30,7 +30,7 @@ class {{ class_name }}: {{ field.name }}: {{ field.type_hint }} = {{ field.field }} {%- else %} {{ field.name }}: {{ field.type_hint }} - {%- if not (field.required or (field.represented_default == 'None' and field.strip_default_none)) + {%- if not ((field.required and not field.use_default_with_required) or (field.represented_default == 'None' and field.strip_default_none)) %} = {{ field.represented_default }} {%- endif -%} {%- endif %} diff --git a/src/datamodel_code_generator/model/template/msgspec.jinja2 b/src/datamodel_code_generator/model/template/msgspec.jinja2 index 6ab0540d2..0b712b297 100644 --- a/src/datamodel_code_generator/model/template/msgspec.jinja2 +++ b/src/datamodel_code_generator/model/template/msgspec.jinja2 @@ -29,7 +29,7 @@ class {{ class_name }}: {%- else %} {{ field.name }}: {{ field.type_hint }} {%- endif %} - {%- if not field.field and (not field.required or field.data_type.is_optional or field.nullable) + {%- if not field.field and (not field.required or field.use_default_with_required or field.data_type.is_optional or field.nullable) %} = {{ field.represented_default }} {%- endif -%} {%- endif %} diff --git a/src/datamodel_code_generator/model/template/pydantic_v2/BaseModel.jinja2 b/src/datamodel_code_generator/model/template/pydantic_v2/BaseModel.jinja2 index 828c4b416..0d812ff60 100644 --- a/src/datamodel_code_generator/model/template/pydantic_v2/BaseModel.jinja2 +++ b/src/datamodel_code_generator/model/template/pydantic_v2/BaseModel.jinja2 @@ -27,7 +27,7 @@ class {{ class_name }}({{ base_class }}):{% if comment is defined %} # {{ comme {%- else %} {{ field.name }}: {{ field.type_hint }} {%- endif %} - {%- if not field.has_default_factory_in_field and not field.required and (field.represented_default != 'None' or not field.strip_default_none or field.data_type.is_optional) + {%- if not field.has_default_factory_in_field and (not field.required or field.use_default_with_required) and (field.represented_default != 'None' or not field.strip_default_none or field.data_type.is_optional) %} = {{ field.represented_default }} {%- endif -%} {%- endif %} diff --git a/src/datamodel_code_generator/model/template/pydantic_v2/dataclass.jinja2 b/src/datamodel_code_generator/model/template/pydantic_v2/dataclass.jinja2 index 3fcc5ad83..00b12e628 100644 --- a/src/datamodel_code_generator/model/template/pydantic_v2/dataclass.jinja2 +++ b/src/datamodel_code_generator/model/template/pydantic_v2/dataclass.jinja2 @@ -41,7 +41,7 @@ class {{ class_name }}: {%- else %} {{ field.name }}: {{ field.type_hint }} {%- endif %} - {%- if not field.has_default_factory_in_field and not field.required and (field.represented_default != 'None' or not field.strip_default_none or field.data_type.is_optional) + {%- if not field.has_default_factory_in_field and (not field.required or field.use_default_with_required) and (field.represented_default != 'None' or not field.strip_default_none or field.data_type.is_optional) %} = {{ field.represented_default }} {%- endif -%} {%- endif %} diff --git a/src/datamodel_code_generator/parser/graphql.py b/src/datamodel_code_generator/parser/graphql.py index 82f7e7051..c8c7d5d4d 100644 --- a/src/datamodel_code_generator/parser/graphql.py +++ b/src/datamodel_code_generator/parser/graphql.py @@ -372,8 +372,7 @@ def parse_field( class_name=class_name, ) - if self.apply_default_values_for_required_fields and effective_has_default: - required = False + use_default_with_required = required and self.apply_default_values_for_required_fields and effective_has_default extras = {} if self.default_field_extras is None else self.default_field_extras.copy() @@ -387,7 +386,7 @@ def parse_field( validation_aliases = alias else: single_alias = alias - return self.data_model_field_type( + new_field = self.data_model_field_type( name=field_name, default=effective_default, data_type=final_data_type, @@ -406,6 +405,8 @@ def parse_field( has_default=effective_has_default, use_serialization_alias=self.use_serialization_alias, ) + new_field.use_default_with_required = use_default_with_required + return new_field def parse_object_like( self, diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index 3851be697..9dfb88815 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -2477,22 +2477,22 @@ def _parse_object_common_part( # noqa: PLR0912, PLR0913, PLR0915 return self.data_type(reference=base_classes[0]) if required: for field in fields: - if self.force_optional_for_required_fields or ( # pragma: no cover - self.apply_default_values_for_required_fields and field.has_default - ): + if self.force_optional_for_required_fields: # pragma: no cover continue # pragma: no cover if (field.original_name or field.name) in required: field.required = True + if self.apply_default_values_for_required_fields and field.has_default: + field.use_default_with_required = True if obj.required: field_name_to_field = {f.original_name or f.name: f for f in fields} for required_ in obj.required: if required_ in field_name_to_field: field = field_name_to_field[required_] - if self.force_optional_for_required_fields or ( - self.apply_default_values_for_required_fields and field.has_default - ): + if self.force_optional_for_required_fields: continue field.required = True + if self.apply_default_values_for_required_fields and field.has_default: + field.use_default_with_required = True else: fields.append( self.data_model_field_type(required=True, original_name=required_, data_type=DataType()) @@ -2828,24 +2828,23 @@ def parse_object_fields( class_name=class_name, ) - if self.force_optional_for_required_fields or ( - self.apply_default_values_for_required_fields and effective_has_default - ): + if self.force_optional_for_required_fields: required: bool = False else: required = original_field_name in requires - fields.append( - self.get_object_field( - field_name=field_name, - field=field, - required=required, - field_type=field_type, - alias=alias, - original_field_name=original_field_name, - effective_default=effective_default, - effective_has_default=effective_has_default, - ) + new_field = self.get_object_field( + field_name=field_name, + field=field, + required=required, + field_type=field_type, + alias=alias, + original_field_name=original_field_name, + effective_default=effective_default, + effective_has_default=effective_has_default, ) + if required and self.apply_default_values_for_required_fields and effective_has_default: + new_field.use_default_with_required = True + fields.append(new_field) return fields def parse_object( diff --git a/src/datamodel_code_generator/parser/openapi.py b/src/datamodel_code_generator/parser/openapi.py index 2e30a46b1..11d162d1d 100644 --- a/src/datamodel_code_generator/parser/openapi.py +++ b/src/datamodel_code_generator/parser/openapi.py @@ -551,20 +551,21 @@ def parse_all_parameters( # noqa: PLR0912, PLR0914, PLR0915 class_name=reference.name, ) effective_required = parameter.required - if self.apply_default_values_for_required_fields and effective_has_default: - effective_required = False - fields.append( - self.get_object_field( - field_name=field_name, - field=param_schema, - field_type=self.parse_item(field_name, param_schema, [*path, name, parameter_name]), - original_field_name=parameter_name, - required=effective_required, - alias=alias, - effective_default=effective_default, - effective_has_default=effective_has_default, - ) + use_default_with_required = ( + effective_required and self.apply_default_values_for_required_fields and effective_has_default + ) + new_field = self.get_object_field( + field_name=field_name, + field=param_schema, + field_type=self.parse_item(field_name, param_schema, [*path, name, parameter_name]), + original_field_name=parameter_name, + required=effective_required, + alias=alias, + effective_default=effective_default, + effective_has_default=effective_has_default, ) + new_field.use_default_with_required = use_default_with_required + fields.append(new_field) else: data_types: list[DataType] = [] object_schema: JsonSchemaObject | None = None @@ -600,8 +601,9 @@ def parse_all_parameters( # noqa: PLR0912, PLR0914, PLR0915 class_name=reference.name, ) effective_required = parameter.required - if self.apply_default_values_for_required_fields and effective_has_default: - effective_required = False + use_default_with_required = ( + effective_required and self.apply_default_values_for_required_fields and effective_has_default + ) # Handle multiple aliases (Pydantic v2 AliasChoices) single_alias: str | None = None validation_aliases: list[str] | None = None @@ -609,38 +611,38 @@ def parse_all_parameters( # noqa: PLR0912, PLR0914, PLR0915 validation_aliases = alias else: single_alias = alias - fields.append( - self.data_model_field_type( - name=field_name, - default=effective_default, - data_type=data_type, - required=effective_required, - alias=single_alias, - validation_aliases=validation_aliases, - constraints=object_schema.model_dump(exclude_none=True) - if object_schema and self.is_constraints_field(object_schema) - else None, - nullable=object_schema.nullable - if object_schema and self.strict_nullable and object_schema.nullable is not None - else ( - False - if object_schema and self.strict_nullable and (effective_has_default or effective_required) - else None - ), - strip_default_none=self.strip_default_none, - extras=self.get_field_extras(object_schema) if object_schema else {}, - use_annotated=self.use_annotated, - use_serialize_as_any=self.use_serialize_as_any, - use_field_description=self.use_field_description, - use_field_description_example=self.use_field_description_example, - use_inline_field_description=self.use_inline_field_description, - use_default_kwarg=self.use_default_kwarg, - original_name=parameter_name, - has_default=effective_has_default, - type_has_null=object_schema.type_has_null if object_schema else None, - use_serialization_alias=self.use_serialization_alias, - ) + new_field = self.data_model_field_type( + name=field_name, + default=effective_default, + data_type=data_type, + required=effective_required, + alias=single_alias, + validation_aliases=validation_aliases, + constraints=object_schema.model_dump(exclude_none=True) + if object_schema and self.is_constraints_field(object_schema) + else None, + nullable=object_schema.nullable + if object_schema and self.strict_nullable and object_schema.nullable is not None + else ( + False + if object_schema and self.strict_nullable and (effective_has_default or effective_required) + else None + ), + strip_default_none=self.strip_default_none, + extras=self.get_field_extras(object_schema) if object_schema else {}, + use_annotated=self.use_annotated, + use_serialize_as_any=self.use_serialize_as_any, + use_field_description=self.use_field_description, + use_field_description_example=self.use_field_description_example, + use_inline_field_description=self.use_inline_field_description, + use_default_kwarg=self.use_default_kwarg, + original_name=parameter_name, + has_default=effective_has_default, + type_has_null=object_schema.type_has_null if object_schema else None, + use_serialization_alias=self.use_serialization_alias, ) + new_field.use_default_with_required = use_default_with_required + fields.append(new_field) if OpenAPIScope.Parameters in self.open_api_scopes and fields: # Using _create_data_model from parent class JsonSchemaParser diff --git a/tests/data/expected/main/graphql/default_values_required_use_default.py b/tests/data/expected/main/graphql/default_values_required_use_default.py index e9fed1bf3..6dc4c1b3b 100644 --- a/tests/data/expected/main/graphql/default_values_required_use_default.py +++ b/tests/data/expected/main/graphql/default_values_required_use_default.py @@ -29,6 +29,6 @@ class User(BaseModel): id: ID - name: String | None = 'default_user' - status: String | None = 'active' + name: String = 'default_user' + status: String = 'active' typename__: Literal['User'] | None = Field('User', alias='__typename') diff --git a/tests/data/expected/main/jsonschema/all_of_use_default.py b/tests/data/expected/main/jsonschema/all_of_use_default.py index e4f6603e6..3ba8692e4 100644 --- a/tests/data/expected/main/jsonschema/all_of_use_default.py +++ b/tests/data/expected/main/jsonschema/all_of_use_default.py @@ -8,5 +8,5 @@ class Item(BaseModel): - test: str | None = 'test123' - testarray: list[str] | None = Field(['test123'], min_length=1, title='test array') + test: str = 'test123' + testarray: list[str] = Field(['test123'], min_length=1, title='test array') diff --git a/tests/data/expected/main/openapi/default_values_parameters_use_default.py b/tests/data/expected/main/openapi/default_values_parameters_use_default.py index 04a43ac6e..3b25fe17f 100644 --- a/tests/data/expected/main/openapi/default_values_parameters_use_default.py +++ b/tests/data/expected/main/openapi/default_values_parameters_use_default.py @@ -12,8 +12,8 @@ class Filter(BaseModel): class UsersGetParametersQuery(BaseModel): - status: str | None = 'active' - filter: Filter | None = Field({}, validate_default=True) + status: str = 'active' + filter: Filter = Field({}, validate_default=True) class User(BaseModel): diff --git a/tests/data/expected/main/openapi/use_default.py b/tests/data/expected/main/openapi/use_default.py index a1a76458e..d8f4c79aa 100644 --- a/tests/data/expected/main/openapi/use_default.py +++ b/tests/data/expected/main/openapi/use_default.py @@ -8,7 +8,7 @@ class Pet(BaseModel): - id: int | None = 1 + id: int = 1 name: str tag: str | None = None From 9a963ab06c62bc4b548dcd6cc22d34068d9cc2b7 Mon Sep 17 00:00:00 2001 From: Mikhail Butvin Date: Mon, 16 Mar 2026 09:29:25 +0300 Subject: [PATCH 3/5] Add tests for allOf --use-default and --force-optional coverage Cover the previously uncovered code paths: - allOf with $ref + sub-schema required fields + --use-default - allOf with outer required fields + --force-optional Co-Authored-By: Claude Opus 4.6 (1M context) --- .../jsonschema/allof_required_use_default.py | 16 ++++++++++ .../jsonschema/force_optional_required.py | 16 ++++++++++ .../allof_required_use_default.json | 31 +++++++++++++++++++ .../jsonschema/force_optional_required.json | 29 +++++++++++++++++ tests/main/jsonschema/test_main_jsonschema.py | 22 +++++++++++++ 5 files changed, 114 insertions(+) create mode 100644 tests/data/expected/main/jsonschema/allof_required_use_default.py create mode 100644 tests/data/expected/main/jsonschema/force_optional_required.py create mode 100644 tests/data/jsonschema/allof_required_use_default.json create mode 100644 tests/data/jsonschema/force_optional_required.json diff --git a/tests/data/expected/main/jsonschema/allof_required_use_default.py b/tests/data/expected/main/jsonschema/allof_required_use_default.py new file mode 100644 index 000000000..5202311f7 --- /dev/null +++ b/tests/data/expected/main/jsonschema/allof_required_use_default.py @@ -0,0 +1,16 @@ +# generated by datamodel-codegen: +# filename: allof_required_use_default.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel + + +class Base(BaseModel): + id: int + + +class Container(Base): + name: str = 'unnamed' + tag: str diff --git a/tests/data/expected/main/jsonschema/force_optional_required.py b/tests/data/expected/main/jsonschema/force_optional_required.py new file mode 100644 index 000000000..5cbb7b306 --- /dev/null +++ b/tests/data/expected/main/jsonschema/force_optional_required.py @@ -0,0 +1,16 @@ +# generated by datamodel-codegen: +# filename: force_optional_required.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel + + +class Base(BaseModel): + id: int | None = None + + +class Container(Base): + name: str | None = None + value: int | None = None diff --git a/tests/data/jsonschema/allof_required_use_default.json b/tests/data/jsonschema/allof_required_use_default.json new file mode 100644 index 000000000..632a02b56 --- /dev/null +++ b/tests/data/jsonschema/allof_required_use_default.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Container", + "definitions": { + "Base": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + }, + "required": ["id"] + } + }, + "allOf": [ + {"$ref": "#/definitions/Base"}, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "default": "unnamed" + }, + "tag": { + "type": "string" + } + }, + "required": ["name", "tag"] + } + ] +} diff --git a/tests/data/jsonschema/force_optional_required.json b/tests/data/jsonschema/force_optional_required.json new file mode 100644 index 000000000..9e2838dc8 --- /dev/null +++ b/tests/data/jsonschema/force_optional_required.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Container", + "definitions": { + "Base": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + } + }, + "allOf": [ + {"$ref": "#/definitions/Base"}, + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "integer" + } + } + } + ], + "required": ["name", "value"] +} diff --git a/tests/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index 40a397eb2..fd5b0e207 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -4753,6 +4753,28 @@ def test_all_of_use_default(output_file: Path) -> None: ) +def test_allof_required_use_default(output_file: Path) -> None: + """Test allOf with required fields and --use-default renders defaults without nullable types.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "allof_required_use_default.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + extra_args=["--use-default"], + ) + + +def test_force_optional_required(output_file: Path) -> None: + """Test --force-optional makes required fields optional.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "force_optional_required.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + extra_args=["--force-optional"], + ) + + def test_main_root_one_of(output_dir: Path) -> None: """Test root-level oneOf schemas.""" run_main_and_assert( From bcef9e0817776019bea7112093ed36876f833d44 Mon Sep 17 00:00:00 2001 From: Mikhail Butvin Date: Mon, 16 Mar 2026 11:48:50 +0300 Subject: [PATCH 4/5] Pass use_default_with_required via constructor instead of patching Co-Authored-By: Claude Opus 4.6 (1M context) --- .../parser/graphql.py | 5 +- .../parser/jsonschema.py | 29 +++--- .../parser/openapi.py | 88 ++++++++++--------- 3 files changed, 64 insertions(+), 58 deletions(-) diff --git a/src/datamodel_code_generator/parser/graphql.py b/src/datamodel_code_generator/parser/graphql.py index c8c7d5d4d..be6184d1f 100644 --- a/src/datamodel_code_generator/parser/graphql.py +++ b/src/datamodel_code_generator/parser/graphql.py @@ -386,7 +386,7 @@ def parse_field( validation_aliases = alias else: single_alias = alias - new_field = self.data_model_field_type( + return self.data_model_field_type( name=field_name, default=effective_default, data_type=final_data_type, @@ -404,9 +404,8 @@ def parse_field( original_name=field_name, has_default=effective_has_default, use_serialization_alias=self.use_serialization_alias, + use_default_with_required=use_default_with_required, ) - new_field.use_default_with_required = use_default_with_required - return new_field def parse_object_like( self, diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index 9dfb88815..fc2a3d169 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -1201,6 +1201,7 @@ def get_object_field( # noqa: PLR0913 original_field_name: str | None, effective_default: Any = None, effective_has_default: bool | None = None, + use_default_with_required: bool = False, ) -> DataModelFieldBase: """Create a data model field from a JSON Schema object field.""" default_value = effective_default if effective_has_default is not None else field.default @@ -1251,6 +1252,7 @@ def get_object_field( # noqa: PLR0913 use_frozen_field=self.use_frozen_field, use_serialization_alias=self.use_serialization_alias, use_default_factory_for_optional_nested_models=self.use_default_factory_for_optional_nested_models, + use_default_with_required=use_default_with_required, ) def get_data_type(self, obj: JsonSchemaObject) -> DataType: @@ -2832,19 +2834,22 @@ def parse_object_fields( required: bool = False else: required = original_field_name in requires - new_field = self.get_object_field( - field_name=field_name, - field=field, - required=required, - field_type=field_type, - alias=alias, - original_field_name=original_field_name, - effective_default=effective_default, - effective_has_default=effective_has_default, + use_default_with_required = ( + required and self.apply_default_values_for_required_fields and effective_has_default + ) + fields.append( + self.get_object_field( + field_name=field_name, + field=field, + required=required, + field_type=field_type, + alias=alias, + original_field_name=original_field_name, + effective_default=effective_default, + effective_has_default=effective_has_default, + use_default_with_required=use_default_with_required, + ) ) - if required and self.apply_default_values_for_required_fields and effective_has_default: - new_field.use_default_with_required = True - fields.append(new_field) return fields def parse_object( diff --git a/src/datamodel_code_generator/parser/openapi.py b/src/datamodel_code_generator/parser/openapi.py index 11d162d1d..5f327d218 100644 --- a/src/datamodel_code_generator/parser/openapi.py +++ b/src/datamodel_code_generator/parser/openapi.py @@ -508,7 +508,7 @@ def _get_model_name(cls, path_name: str, method: str, suffix: str) -> str: camel_path_name = snake_to_upper_camel(normalized) return f"{camel_path_name}{method.capitalize()}{suffix}" - def parse_all_parameters( # noqa: PLR0912, PLR0914, PLR0915 + def parse_all_parameters( # noqa: PLR0912, PLR0914 self, name: str, parameters: list[ReferenceObject | ParameterObject], @@ -554,18 +554,19 @@ def parse_all_parameters( # noqa: PLR0912, PLR0914, PLR0915 use_default_with_required = ( effective_required and self.apply_default_values_for_required_fields and effective_has_default ) - new_field = self.get_object_field( - field_name=field_name, - field=param_schema, - field_type=self.parse_item(field_name, param_schema, [*path, name, parameter_name]), - original_field_name=parameter_name, - required=effective_required, - alias=alias, - effective_default=effective_default, - effective_has_default=effective_has_default, + fields.append( + self.get_object_field( + field_name=field_name, + field=param_schema, + field_type=self.parse_item(field_name, param_schema, [*path, name, parameter_name]), + original_field_name=parameter_name, + required=effective_required, + alias=alias, + effective_default=effective_default, + effective_has_default=effective_has_default, + use_default_with_required=use_default_with_required, + ) ) - new_field.use_default_with_required = use_default_with_required - fields.append(new_field) else: data_types: list[DataType] = [] object_schema: JsonSchemaObject | None = None @@ -611,38 +612,39 @@ def parse_all_parameters( # noqa: PLR0912, PLR0914, PLR0915 validation_aliases = alias else: single_alias = alias - new_field = self.data_model_field_type( - name=field_name, - default=effective_default, - data_type=data_type, - required=effective_required, - alias=single_alias, - validation_aliases=validation_aliases, - constraints=object_schema.model_dump(exclude_none=True) - if object_schema and self.is_constraints_field(object_schema) - else None, - nullable=object_schema.nullable - if object_schema and self.strict_nullable and object_schema.nullable is not None - else ( - False - if object_schema and self.strict_nullable and (effective_has_default or effective_required) - else None - ), - strip_default_none=self.strip_default_none, - extras=self.get_field_extras(object_schema) if object_schema else {}, - use_annotated=self.use_annotated, - use_serialize_as_any=self.use_serialize_as_any, - use_field_description=self.use_field_description, - use_field_description_example=self.use_field_description_example, - use_inline_field_description=self.use_inline_field_description, - use_default_kwarg=self.use_default_kwarg, - original_name=parameter_name, - has_default=effective_has_default, - type_has_null=object_schema.type_has_null if object_schema else None, - use_serialization_alias=self.use_serialization_alias, + fields.append( + self.data_model_field_type( + name=field_name, + default=effective_default, + data_type=data_type, + required=effective_required, + alias=single_alias, + validation_aliases=validation_aliases, + constraints=object_schema.model_dump(exclude_none=True) + if object_schema and self.is_constraints_field(object_schema) + else None, + nullable=object_schema.nullable + if object_schema and self.strict_nullable and object_schema.nullable is not None + else ( + False + if object_schema and self.strict_nullable and (effective_has_default or effective_required) + else None + ), + strip_default_none=self.strip_default_none, + extras=self.get_field_extras(object_schema) if object_schema else {}, + use_annotated=self.use_annotated, + use_serialize_as_any=self.use_serialize_as_any, + use_field_description=self.use_field_description, + use_field_description_example=self.use_field_description_example, + use_inline_field_description=self.use_inline_field_description, + use_default_kwarg=self.use_default_kwarg, + original_name=parameter_name, + has_default=effective_has_default, + type_has_null=object_schema.type_has_null if object_schema else None, + use_serialization_alias=self.use_serialization_alias, + use_default_with_required=use_default_with_required, + ) ) - new_field.use_default_with_required = use_default_with_required - fields.append(new_field) if OpenAPIScope.Parameters in self.open_api_scopes and fields: # Using _create_data_model from parent class JsonSchemaParser From 4297c8a8a5e21d0336755039dde8c585bc0b61e0 Mon Sep 17 00:00:00 2001 From: Mikhail Butvin Date: Sun, 26 Apr 2026 01:53:50 +0300 Subject: [PATCH 5/5] Propagate use_default_with_required to missed field-assignment sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three regressions where the new use_default_with_required flag wasn't propagated to all the rendering sites that decide whether a field has an assignment. Each produces broken output on this branch but the correct output on baseline (92bdc276). 1. pydantic_base.py:205 — early-return for required+nullable fields with no other Field args returned Field(...) without checking use_default_with_required, dropping the user's default. Repro: --use-default --strict-nullable, required nullable str with default. Got: x: str | None = Field(...). Want: x: str | None = 'hello'. 2. dataclass.py:has_field_assignment — sort-key helper didn't account for use_default_with_required, so a required field with default null (use_default_with_required=True, field.field empty) was sorted as no-assignment alongside truly required fields. Schema order was then preserved, placing the defaulted field before the non-defaulted one. Repro: --use-default --output-model-type dataclasses.dataclass, required nullable a (default null) before required b. Got: a before b (TypeError on import). Want: b before a. Same fix covers pydantic_v2.dataclass since it shares the helper. 3. msgspec.py:_has_field_assignment — same class of bug. Helper checked neither bool(field.field) nor use_default_with_required, breaking ordering for both string-defaulted and null-defaulted required fields. Repro: --use-default --output-model-type msgspec.Struct on the existing allof_required_use_default.json fixture. Got: name='unnamed' before tag (TypeError on import). Want: tag before name='unnamed'. Adds three regression tests in tests/main/jsonschema/test_main_jsonschema.py covering the three repros above. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../model/dataclass.py | 3 +- src/datamodel_code_generator/model/msgspec.py | 6 +++- .../model/pydantic_base.py | 2 +- .../use_default_msgspec_field_ordering.py | 16 +++++++++ .../use_default_required_null_default.py | 13 ++++++++ .../use_default_strict_nullable_required.py | 11 +++++++ .../use_default_required_null_default.json | 10 ++++++ .../use_default_strict_nullable_required.json | 9 +++++ tests/main/jsonschema/test_main_jsonschema.py | 33 +++++++++++++++++++ 9 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 tests/data/expected/main/jsonschema/use_default_msgspec_field_ordering.py create mode 100644 tests/data/expected/main/jsonschema/use_default_required_null_default.py create mode 100644 tests/data/expected/main/jsonschema/use_default_strict_nullable_required.py create mode 100644 tests/data/jsonschema/use_default_required_null_default.json create mode 100644 tests/data/jsonschema/use_default_strict_nullable_required.json diff --git a/src/datamodel_code_generator/model/dataclass.py b/src/datamodel_code_generator/model/dataclass.py index 5399a9b30..5fff58dbb 100644 --- a/src/datamodel_code_generator/model/dataclass.py +++ b/src/datamodel_code_generator/model/dataclass.py @@ -39,7 +39,8 @@ def has_field_assignment(field: DataModelFieldBase) -> bool: """Check if a dataclass field has a default value or field() assignment.""" return bool(field.field) or not ( - field.required or (field.represented_default == "None" and field.strip_default_none) + (field.required and not field.use_default_with_required) + or (field.represented_default == "None" and field.strip_default_none) ) diff --git a/src/datamodel_code_generator/model/msgspec.py b/src/datamodel_code_generator/model/msgspec.py index c929de75e..ba2cb78f7 100644 --- a/src/datamodel_code_generator/model/msgspec.py +++ b/src/datamodel_code_generator/model/msgspec.py @@ -70,7 +70,11 @@ def __str__(self) -> str: def _has_field_assignment(field: DataModelFieldBase) -> bool: - return not (field.required or (field.represented_default == "None" and field.strip_default_none)) + return ( + bool(field.field) + or field.use_default_with_required + or not (field.required or (field.represented_default == "None" and field.strip_default_none)) + ) DataModelFieldBaseT = TypeVar("DataModelFieldBaseT", bound=DataModelFieldBase) diff --git a/src/datamodel_code_generator/model/pydantic_base.py b/src/datamodel_code_generator/model/pydantic_base.py index 5b60048fa..998547d69 100644 --- a/src/datamodel_code_generator/model/pydantic_base.py +++ b/src/datamodel_code_generator/model/pydantic_base.py @@ -202,7 +202,7 @@ def __str__(self) -> str: # noqa: PLR0912 field_arguments = sorted(f"{k}={v!r}" for k, v in data.items() if v is not None) if not field_arguments and not default_factory: - if self.nullable and self.required: + if self.nullable and self.required and not self.use_default_with_required: return "Field(...)" # Field() is for mypy return "" diff --git a/tests/data/expected/main/jsonschema/use_default_msgspec_field_ordering.py b/tests/data/expected/main/jsonschema/use_default_msgspec_field_ordering.py new file mode 100644 index 000000000..39a44d1fc --- /dev/null +++ b/tests/data/expected/main/jsonschema/use_default_msgspec_field_ordering.py @@ -0,0 +1,16 @@ +# generated by datamodel-codegen: +# filename: allof_required_use_default.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from msgspec import Struct + + +class Base(Struct): + id: int + + +class Container(Base): + tag: str + name: str = 'unnamed' diff --git a/tests/data/expected/main/jsonschema/use_default_required_null_default.py b/tests/data/expected/main/jsonschema/use_default_required_null_default.py new file mode 100644 index 000000000..b0c6cbb74 --- /dev/null +++ b/tests/data/expected/main/jsonschema/use_default_required_null_default.py @@ -0,0 +1,13 @@ +# generated by datamodel-codegen: +# filename: use_default_required_null_default.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class Container: + b: int + a: str | None = None diff --git a/tests/data/expected/main/jsonschema/use_default_strict_nullable_required.py b/tests/data/expected/main/jsonschema/use_default_strict_nullable_required.py new file mode 100644 index 000000000..e99c216cc --- /dev/null +++ b/tests/data/expected/main/jsonschema/use_default_strict_nullable_required.py @@ -0,0 +1,11 @@ +# generated by datamodel-codegen: +# filename: use_default_strict_nullable_required.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel + + +class Container(BaseModel): + x: str | None = 'hello' diff --git a/tests/data/jsonschema/use_default_required_null_default.json b/tests/data/jsonschema/use_default_required_null_default.json new file mode 100644 index 000000000..5eef1a8c0 --- /dev/null +++ b/tests/data/jsonschema/use_default_required_null_default.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Container", + "type": "object", + "properties": { + "a": {"type": ["string", "null"], "default": null}, + "b": {"type": "integer"} + }, + "required": ["a", "b"] +} diff --git a/tests/data/jsonschema/use_default_strict_nullable_required.json b/tests/data/jsonschema/use_default_strict_nullable_required.json new file mode 100644 index 000000000..53db65065 --- /dev/null +++ b/tests/data/jsonschema/use_default_strict_nullable_required.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Container", + "type": "object", + "properties": { + "x": {"type": "string", "nullable": true, "default": "hello"} + }, + "required": ["x"] +} diff --git a/tests/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index bc26e2b3c..1fc0ca989 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -5200,6 +5200,39 @@ def test_force_optional_required(output_file: Path) -> None: ) +def test_use_default_msgspec_field_ordering(output_file: Path) -> None: + """Required fields with defaults must sort after required fields without for msgspec.Struct.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "allof_required_use_default.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + extra_args=["--use-default", "--output-model-type", "msgspec.Struct"], + ) + + +def test_use_default_required_null_default(output_file: Path) -> None: + """Required nullable field with default null must not break dataclass field ordering.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "use_default_required_null_default.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + extra_args=["--use-default", "--output-model-type", "dataclasses.dataclass"], + ) + + +def test_use_default_strict_nullable_required(output_file: Path) -> None: + """Required nullable field with default must render the default, not Field(...), under --strict-nullable.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "use_default_strict_nullable_required.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + extra_args=["--use-default", "--strict-nullable", "--output-model-type", "pydantic_v2.BaseModel"], + ) + + def test_main_root_one_of(output_dir: Path) -> None: """Test root-level oneOf schemas.""" run_main_and_assert(