Use validate_default=True for structured union defaults#3050
Use validate_default=True for structured union defaults#3050koxudaxi merged 6 commits intokoxudaxi:mainfrom
Conversation
…tructured defaults Replace the ValidatedDefault/TypeAdapter mechanism from koxudaxi#3040 and the original model_validate() codegen with Pydantic's native validate_default=True. Instead of generating complex default_factory lambdas like: default_factory=lambda: A.model_validate({'type': 'b'}) default_factory=lambda: TypeAdapter(A | B).validate_python({'type': 'b'}) Now generates: Field({'type': 'b'}, validate_default=True) This lets Pydantic handle union branch selection, dict-to-model coercion, and RootModel wrapping at runtime. Fixes koxudaxi#3034, fixes koxudaxi#3035. Removed: - ValidatedDefault class and SUPPORTS_VALIDATED_DEFAULT flag - TypeAdapter import in generated code - 70-line branch-matching loop in _get_default_as_pydantic_model - Helper functions: _supports_validated_default_model, _uses_existing_model_factory_path, _default_matches_plain_container_branch, _get_validated_default_type_name Added: - _needs_validate_default: simple check (is default dict/list/scalar and does the type contain a model reference?) - __set_validate_default_on_fields: sets extras["validate_default"] = True Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughRemoved custom WrappedDefault/ValidatedDefault wrappers and their flags; default handling now marks fields with Pydantic's Changes
Sequence Diagram(s)sequenceDiagram
participant Parser as Parser
participant Generator as CodeGenerator
participant Pydantic as PydanticModels
Parser->>Generator: analyze schema, detect defaults
alt default needs validation
Parser->>Parser: _needs_validate_default(data_type) -> true
Parser->>Generator: mark field extras["validate_default"]=True
end
Generator->>Pydantic: emit Field(default=literal, validate_default=True)
Pydantic->>Runtime: validate default at model initialization
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Merging this PR will not alter performance
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3050 +/- ##
==========================================
Coverage 100.00% 100.00%
==========================================
Files 86 85 -1
Lines 18031 17911 -120
Branches 2112 2074 -38
==========================================
- Hits 18031 17911 -120
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
The caller already filters None/UNDEFINED defaults before calling this function, so the None check was unreachable. Removed the unused `default` parameter entirely. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… defaults WrappedDefault wrapped RootModel scalar defaults (e.g., CountType(10)) in default_factory lambdas. Since validate_default=True handles this natively, the entire mechanism is redundant. Removed: WrappedDefault class, _types.py module, SUPPORTS_WRAPPED_DEFAULT flag, __wrap_root_model_default_values method, _get_default_as_pydantic_model method. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
_DataTypeScalarBase generated the same type alias syntax as TypeAliasBase but didn't inherit from it, causing _unwrap_type_alias to not see through scalar references. This led to unnecessary validate_default=True on fields like `String | None = 'default_user'`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a required field had validate_default=True set (e.g., empty list default on list[Container]), the rendering logic prepended ... instead of the actual default value. Now skips the ... override when validate_default is set, since the default is needed for Pydantic to validate at runtime. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/datamodel_code_generator/model/pydantic_base.py (1)
212-218:⚠️ Potential issue | 🟠 Major
use_annotatedbranch emits type-incompatible validated defaults that static type checkers won't understand.When rendering with
--use-annotated, the code generates patterns likeAnnotated[ModelSettingB | None, Field(validate_default=True)] = 5, placing the raw default on the right-hand side while the type annotation remainsAnnotated[Type | None, ...]. Pydantic v2 documentation explicitly warns that static type checkers do not understand defaults embedded inAnnotated[..., Field(...)]metadata—they only recognize defaults from class-body assignments. However, when the raw default value type (e.g.,5) doesn't match the annotated type (e.g.,ModelSettingB | None), type checkers will flag these assignments as incompatible.To fix this, either render
Field(default=..., validate_default=True)as the RHS assignment value (letting Pydantic handle the type), or fall back to the non-annotated form for fields withvalidate_default=True.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/datamodel_code_generator/model/pydantic_base.py` around lines 212 - 218, The current use_annotated branch emits raw defaults that can conflict with the Annotated type and static checkers when validate_default=True; change the logic in the use_annotated branch (the code path guarded by self.use_annotated and the helper _process_annotated_field_arguments) so that if self.extras.get("validate_default") is truthy you output a RHS of Field(default=<default_repr>, validate_default=True, ...) instead of the raw default literal, or alternatively fall back to the non-annotated branch for that case; update construction of field_arguments and the call site that consumes field_arguments so the default is represented via Field(default=...) when validate_default=True to keep the Annotated type and the assignment compatible with type checkers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@tests/parser/test_base.py`:
- Line 710: The docstring for the test currently says "Test
_needs_validate_default returns True for A | None with dict default." but the
test does not set a default; update the docstring to accurately describe the
assertion (e.g., "Test _needs_validate_default returns True for A | None.") so
it reflects that the test checks union composition involving None for the
_needs_validate_default behavior.
---
Outside diff comments:
In `@src/datamodel_code_generator/model/pydantic_base.py`:
- Around line 212-218: The current use_annotated branch emits raw defaults that
can conflict with the Annotated type and static checkers when
validate_default=True; change the logic in the use_annotated branch (the code
path guarded by self.use_annotated and the helper
_process_annotated_field_arguments) so that if
self.extras.get("validate_default") is truthy you output a RHS of
Field(default=<default_repr>, validate_default=True, ...) instead of the raw
default literal, or alternatively fall back to the non-annotated branch for that
case; update construction of field_arguments and the call site that consumes
field_arguments so the default is represented via Field(default=...) when
validate_default=True to keep the Annotated type and the assignment compatible
with type checkers.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: c3fa025d-169c-488b-8401-a832126aeda9
📒 Files selected for processing (54)
src/datamodel_code_generator/model/_types.pysrc/datamodel_code_generator/model/base.pysrc/datamodel_code_generator/model/pydantic_base.pysrc/datamodel_code_generator/model/pydantic_v2/base_model.pysrc/datamodel_code_generator/model/scalar.pysrc/datamodel_code_generator/parser/base.pytests/data/expected/main/graphql/pydantic_v2_empty_list_default.pytests/data/expected/main/jsonschema/all_of_any_of_base_class_ref.pytests/data/expected/main/jsonschema/has_default_value_pydantic_v2.pytests/data/expected/main/jsonschema/jsonschema_root_model_ordering.pytests/data/expected/main/jsonschema/jsonschema_root_model_ordering_keep_model_order.pytests/data/expected/main/jsonschema/pydantic_v2_model_default_dict_empty.pytests/data/expected/main/jsonschema/pydantic_v2_model_default_dict_non_empty.pytests/data/expected/main/jsonschema/pydantic_v2_model_default_nullable_dict_empty.pytests/data/expected/main/jsonschema/pydantic_v2_model_default_nullable_dict_non_empty.pytests/data/expected/main/jsonschema/root_model_default_value.pytests/data/expected/main/jsonschema/root_model_default_value_branches.pytests/data/expected/main/jsonschema/root_model_default_value_no_annotated.pytests/data/expected/main/jsonschema/root_model_default_value_non_root.pytests/data/expected/main/jsonschema/titles.pytests/data/expected/main/jsonschema/titles_use_title_as_name.pytests/data/expected/main/jsonschema/type_alias_chain_model_default_object_ref.pytests/data/expected/main/jsonschema/type_alias_chain_union_default_object_ref.pytests/data/expected/main/jsonschema/type_alias_dict_union_default_object_ref.pytests/data/expected/main/jsonschema/type_alias_inline_dict_union_default_object.pytests/data/expected/main/jsonschema/type_alias_inline_list_union_default_object.pytests/data/expected/main/jsonschema/type_alias_inline_union_default_object.pytests/data/expected/main/jsonschema/type_alias_inline_union_default_object_import_collision.pytests/data/expected/main/jsonschema/type_alias_inline_union_default_object_one_of.pytests/data/expected/main/jsonschema/type_alias_inline_union_default_object_silent_wrong_branch.pytests/data/expected/main/jsonschema/type_alias_inline_union_default_object_type_override.pytests/data/expected/main/jsonschema/type_alias_list_model_default_object_ref.pytests/data/expected/main/jsonschema/type_alias_list_union_default_object_ref.pytests/data/expected/main/jsonschema/type_alias_model_default_object_ref.pytests/data/expected/main/jsonschema/type_alias_nullable_list_model_default_object_ref.pytests/data/expected/main/jsonschema/type_alias_nullable_model_default_object_ref.pytests/data/expected/main/jsonschema/type_alias_union_default_object_ref.pytests/data/expected/main/jsonschema/type_alias_union_default_object_ref_any_of.pytests/data/expected/main/jsonschema/type_alias_union_default_object_ref_dict_alias_branch.pytests/data/expected/main/jsonschema/type_alias_union_default_object_ref_mixed_scalar.pytests/data/expected/main/jsonschema/type_alias_union_default_object_ref_one_of.pytests/data/expected/main/jsonschema/without_titles_use_title_as_name.pytests/data/expected/main/openapi/default_values_parameters_use_default.pytests/data/expected/main/openapi/enum_models/one.pytests/data/expected/main/openapi/enum_models/one_literal_as_default.pytests/data/expected/main/openapi/pydantic_v2_default_object/Another.pytests/data/expected/main/openapi/pydantic_v2_default_object/Nested.pytests/data/expected/main/openapi/pydantic_v2_default_object/__init__.pytests/data/expected/main/openapi/pydantic_v2_empty_list_default.pytests/data/expected/main/openapi/referenced_default.pytests/data/expected/main/openapi/referenced_default_use_annotated.pytests/data/expected/main/openapi/root_model_default_primitive.pytests/data/expected/parser/openapi/openapi_parser_parse_enum_models/output.pytests/parser/test_base.py
💤 Files with no reviewable changes (3)
- src/datamodel_code_generator/model/pydantic_v2/base_model.py
- src/datamodel_code_generator/model/base.py
- src/datamodel_code_generator/model/_types.py
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Breaking Change AnalysisResult: Breaking changes detected Reasoning: This PR changes how default values are generated for fields containing model references. The generated code format changes from complex default_factory lambdas to simpler Field() calls with validate_default=True. Additionally, internal types (ValidatedDefault, WrappedDefault) that were exported in all and class variables (SUPPORTS_WRAPPED_DEFAULT, SUPPORTS_VALIDATED_DEFAULT) on DataModel base class were removed. While these were implementation details, they were technically public API that could be imported or overridden by advanced users. Content for Release NotesCode Generation Changes
API/CLI Changes
This analysis was performed by Claude Code Action |
|
🎉 Released in 0.56.0 This PR is now available in the latest release. See the release notes for details. |
Alternative to #3040. Fixes #3034, fixes #3035
Instead of wrapping defaults in
TypeAdapter().validate_python(),model_validate(), ordefault_factory=lambda: RootModel(value), use Pydantic's nativevalidate_default=TrueField option. This lets Pydantic handle union branch selection, dict-to-model coercion, and RootModel wrapping at runtime, producing simpler generated code with no extra imports.Example
Given this schema (covers all affected cases):
{ "$defs": { "A": { "properties": { "type": { "const": "a" } } }, "B": { "properties": { "type": { "const": "b" } } }, "U": { "anyOf": [{ "$ref": "#/$defs/A" }, { "$ref": "#/$defs/B" }] }, "Pet": { "properties": { "name": { "type": "string" } } }, "CountType": { "type": "integer", "minimum": 0, "maximum": 100 } }, "properties": { "union_default": { "default": { "type": "b" }, "anyOf": [{ "$ref": "#/$defs/A" }, { "$ref": "#/$defs/B" }, { "type": "null" }] }, "alias_union_default": { "default": { "type": "b" }, "anyOf": [{ "$ref": "#/$defs/U" }, { "type": "null" }] }, "single_model_default": { "default": { "type": "a" }, "anyOf": [{ "$ref": "#/$defs/A" }, { "type": "null" }] }, "list_of_models": { "default": [{ "name": "taro" }], "type": "array", "items": { "$ref": "#/$defs/Pet" } }, "dict_of_models": { "default": { "k": { "name": "taro" } }, "type": "object", "additionalProperties": { "$ref": "#/$defs/Pet" } }, "empty_list": { "default": [], "type": "array", "items": { "$ref": "#/$defs/Pet" } }, "count": { "default": 10, "anyOf": [{ "$ref": "#/$defs/CountType" }, { "type": "null" }] } } }Before (main with #3040)
After (this PR)
What changed in source
Removed:
WrappedDefaultclass,ValidatedDefaultclass, and_types.pymoduleSUPPORTS_VALIDATED_DEFAULTandSUPPORTS_WRAPPED_DEFAULTcapability flagsIMPORT_TYPE_ADAPTER— noTypeAdapterin generated code_get_default_as_pydantic_model()method (entire branch-matching loop)__wrap_root_model_default_values()method_supports_validated_default_model,_uses_existing_model_factory_path,_default_matches_plain_container_branch,_get_validated_default_type_nameAdded:
_needs_validate_default()— checks if the field type contains a model reference__set_validate_default_on_fields()— setsextras["validate_default"] = Trueon fields needing validationTest plan
tox run -e fix)Model().xproducesB(type='b')for both --use-type-alias emits raw dict defaults for $ref object unions #3034 and --use-type-alias: inline object unions validate defaults through the first branch only #3035 schemas🤖 Generated with Claude Code
Summary by CodeRabbit
Improvements
Tests