From e9cd2438e1226061b03dad783fd0823e3271a908 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Mon, 12 Jan 2026 16:07:00 +0000 Subject: [PATCH 1/7] Fix array RootModel default value handling in parser --- docs/cli-reference/field-customization.md | 2 +- .../model/pydantic/base_model.py | 4 +- .../template/pydantic/BaseModel_root.jinja2 | 2 +- .../template/pydantic_v2/RootModel.jinja2 | 2 +- .../parser/jsonschema.py | 60 ++++++++++++++----- .../jsonschema/titles_use_title_as_name.py | 2 +- 6 files changed, 52 insertions(+), 20 deletions(-) diff --git a/docs/cli-reference/field-customization.md b/docs/cli-reference/field-customization.md index f56474b89..7ac791034 100644 --- a/docs/cli-reference/field-customization.md +++ b/docs/cli-reference/field-customization.md @@ -3912,7 +3912,7 @@ This is useful when schemas have descriptive titles that should be preserved. class ProcessingStatusUnionTitle(BaseModel): __root__: ( ProcessingStatusDetail | ExtendedProcessingTask | ProcessingStatusTitle - ) = Field(..., title='Processing Status Union Title') + ) = Field('COMPLETED', title='Processing Status Union Title') class ProcessingTaskTitle(BaseModel): diff --git a/src/datamodel_code_generator/model/pydantic/base_model.py b/src/datamodel_code_generator/model/pydantic/base_model.py index 1007eba33..6368a9379 100644 --- a/src/datamodel_code_generator/model/pydantic/base_model.py +++ b/src/datamodel_code_generator/model/pydantic/base_model.py @@ -220,7 +220,7 @@ def __str__(self) -> str: # noqa: PLR0912 elif isinstance(discriminator, dict): # pragma: no cover data["discriminator"] = discriminator["propertyName"] - if self.required and not self.has_default: + if self.required: default_factory = None elif self.default is not UNDEFINED and self.default is not None and "default_factory" not in data: default_factory = self._get_default_as_pydantic_model() @@ -249,7 +249,7 @@ 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: + elif self.required: 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/pydantic/BaseModel_root.jinja2 b/src/datamodel_code_generator/model/template/pydantic/BaseModel_root.jinja2 index 47cc4a9a2..4a6d4ad7d 100644 --- a/src/datamodel_code_generator/model/template/pydantic/BaseModel_root.jinja2 +++ b/src/datamodel_code_generator/model/template/pydantic/BaseModel_root.jinja2 @@ -24,7 +24,7 @@ class {{ class_name }}({{ base_class }}):{% if comment is defined %} # {{ comme {%- else %} __root__: {{ field.type_hint }} {%- endif %} - {%- if not field.has_default_factory_in_field and not ((field.required and not field.has_default) or (field.represented_default == 'None' and field.strip_default_none)) + {%- if not field.has_default_factory_in_field and not (field.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/pydantic_v2/RootModel.jinja2 b/src/datamodel_code_generator/model/template/pydantic_v2/RootModel.jinja2 index 0d108a4a0..af5de33b7 100644 --- a/src/datamodel_code_generator/model/template/pydantic_v2/RootModel.jinja2 +++ b/src/datamodel_code_generator/model/template/pydantic_v2/RootModel.jinja2 @@ -42,7 +42,7 @@ class {{ class_name }}({{ base_class }}{%- if fields -%}[{{get_type_hint(fields, {%- else %} root: {{ field.type_hint }} {%- endif %} - {%- if not field.has_default_factory_in_field and not ((field.required and not field.has_default) or (field.represented_default == 'None' and field.strip_default_none)) + {%- if not field.has_default_factory_in_field and not (field.required or (field.represented_default == 'None' and field.strip_default_none)) %} = {{ field.represented_default }} {%- endif -%} {%- endif %} diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index 79c0e6e28..0ed82dcf6 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -897,13 +897,15 @@ def _create_synthetic_enum_obj( final_enum = [*enum_values, None] if nullable else enum_values final_varnames = varnames if len(varnames) == len(enum_values) else [] + # Only include default if original has one; otherwise don't set it at all + # to avoid incorrectly marking the synthetic object as having a default return self.SCHEMA_OBJECT_TYPE( type=enum_type, enum=final_enum, title=original.title, description=original.description, x_enum_varnames=final_varnames, - default=original.default if original.has_default else None, + **({"default": original.default} if original.has_default else {}), ) def is_constraints_field(self, obj: JsonSchemaObject) -> bool: @@ -3054,7 +3056,7 @@ def parse_list_item( for index, item in enumerate(target_items) ] - def parse_array_fields( # noqa: PLR0912 + def parse_array_fields( # noqa: PLR0912, PLR0915 self, name: str, obj: JsonSchemaObject, @@ -3069,12 +3071,19 @@ def parse_array_fields( # noqa: PLR0912 required: bool = False nullable: Optional[bool] = None # noqa: UP045 else: - required = not (obj.has_default and self.apply_default_values_for_required_fields) + required = not obj.has_default if self.strict_nullable: nullable = obj.nullable if obj.has_default or required else True else: required = not obj.nullable and required - nullable = None + # Set nullable=False when has_default and schema doesn't explicitly allow null + # This prevents fall_back_to_nullable from adding | None + if obj.nullable: + nullable = True + elif obj.has_default: + nullable = False + else: + nullable = None is_tuple = False suppress_item_constraints = False if isinstance(obj.items, JsonSchemaObject): @@ -3259,10 +3268,35 @@ def parse_root_type( # noqa: PLR0912, PLR0914, PLR0915 data_type = self.data_type_manager.get_data_type( Types.any, ) - required = self._should_field_be_required( - has_default=obj.has_default, - is_nullable=bool(obj.nullable), - ) + # For RootModels (not TypeAlias): + # - required=False when nullable or has_default (preserves = None for nullable types) + # - nullable=False when has_default and not explicitly nullable (prevents fall_back_to_nullable) + # For TypeAlias (IS_ALIAS=True): + # - Defaults are meaningless for type aliases, so don't process has_default + is_type_alias = getattr(self.data_model_root_type, "IS_ALIAS", False) + if self.force_optional_for_required_fields: + required = False + nullable = None # Will be handled by fall_back_to_nullable + # Force optional makes all fields optional, so use None as default + has_default_override = True + default_value = obj.default if obj.has_default else None + elif obj.nullable: + required = False + nullable = True + # Nullable fields should have None as default when no explicit default is set + has_default_override = True + default_value = obj.default if obj.has_default else None + elif obj.has_default and not is_type_alias: + required = False + # Set nullable=False to prevent fall_back_to_nullable from adding | None + nullable = False + has_default_override = True + default_value = obj.default + else: + required = True + nullable = None + has_default_override = obj.has_default + default_value = obj.default if obj.has_default else UNDEFINED name = self._apply_title_as_name(name, obj) if not reference: reference = self.model_resolver.add(path, name, loaded=True, class_name=True) @@ -3276,12 +3310,10 @@ def parse_root_type( # noqa: PLR0912, PLR0914, PLR0915 fields=[ self.data_model_field_type( data_type=data_type, - default=obj.default, + default=default_value, required=required, constraints=constraints, - nullable=obj.nullable - if self.strict_nullable and obj.nullable is not None - else (False if self.strict_nullable and obj.has_default else None), + nullable=nullable, strip_default_none=self.strip_default_none, extras=self.get_field_extras(obj), use_annotated=self.use_annotated, @@ -3289,7 +3321,7 @@ def parse_root_type( # noqa: PLR0912, PLR0914, PLR0915 use_field_description_example=self.use_field_description_example, use_inline_field_description=self.use_inline_field_description, original_name=None, - has_default=obj.has_default, + has_default=has_default_override, ) ], custom_base_class=self._resolve_base_class(name, obj.custom_base_path), @@ -3298,7 +3330,7 @@ def parse_root_type( # noqa: PLR0912, PLR0914, PLR0915 path=self.current_source_path, nullable=obj.type_has_null, treat_dot_as_module=self.treat_dot_as_module, - default=obj.default if obj.has_default else UNDEFINED, + default=default_value if has_default_override else UNDEFINED, ) self.results.append(data_model_root_type) return self.data_type(reference=reference) diff --git a/tests/data/expected/main/jsonschema/titles_use_title_as_name.py b/tests/data/expected/main/jsonschema/titles_use_title_as_name.py index 6124c3eea..151175019 100644 --- a/tests/data/expected/main/jsonschema/titles_use_title_as_name.py +++ b/tests/data/expected/main/jsonschema/titles_use_title_as_name.py @@ -47,7 +47,7 @@ class ExtendedProcessingTasksTitle(BaseModel): class ProcessingStatusUnionTitle(BaseModel): __root__: ( ProcessingStatusDetail | ExtendedProcessingTask | ProcessingStatusTitle - ) = Field(..., title='Processing Status Union Title') + ) = Field('COMPLETED', title='Processing Status Union Title') class ProcessingTaskTitle(BaseModel): From 2d19a0a18120b9df30e584ef36b7c35f7297aa2c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 12 Jan 2026 16:25:15 +0000 Subject: [PATCH 2/7] docs: update llms.txt files Generated by GitHub Actions --- docs/llms-full.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 91cfa8eae..ce47d8cfb 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -11680,7 +11680,7 @@ This is useful when schemas have descriptive titles that should be preserved. class ProcessingStatusUnionTitle(BaseModel): __root__: ( ProcessingStatusDetail | ExtendedProcessingTask | ProcessingStatusTitle - ) = Field(..., title='Processing Status Union Title') + ) = Field('COMPLETED', title='Processing Status Union Title') class ProcessingTaskTitle(BaseModel): From 57d1b8f1d0c6e29afe2662e1ca2cd6f6af4a3ec8 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Mon, 12 Jan 2026 16:44:30 +0000 Subject: [PATCH 3/7] Show uncovered lines when coverage check fails --- .github/workflows/test.yaml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f706762b3..882cd8514 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -120,16 +120,24 @@ jobs: pattern: .coverage.* merge-multiple: true - name: Combine and report coverage + id: coverage run: tox run -e coverage --skip-uv-sync --skip-pkg-install + continue-on-error: true env: UV_PYTHON_PREFERENCE: only-managed + - name: Show uncovered lines on failure + if: steps.coverage.outcome == 'failure' + run: | + echo "::error::Coverage check failed. Lines not covered:" + .tox/coverage/bin/coverage report --show-missing --fail-under=0 | grep -v "100%" - name: Upload HTML report + if: always() uses: actions/upload-artifact@v4 with: name: html-report path: .tox/htmlcov - name: Upload coverage to Codecov - if: success() || failure() + if: always() uses: codecov/codecov-action@v5 with: flags: unittests @@ -137,6 +145,9 @@ jobs: fail_ci_if_error: true env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + - name: Fail if coverage check failed + if: steps.coverage.outcome == 'failure' + run: exit 1 check: name: ${{ matrix.tox_env }} From 42d78694a6c5e432cbc548f3a3d2c507edfe9f39 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Mon, 12 Jan 2026 16:52:46 +0000 Subject: [PATCH 4/7] Fix coverage report path in CI workflow --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 882cd8514..56436e9e0 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -129,7 +129,7 @@ jobs: if: steps.coverage.outcome == 'failure' run: | echo "::error::Coverage check failed. Lines not covered:" - .tox/coverage/bin/coverage report --show-missing --fail-under=0 | grep -v "100%" + COVERAGE_FILE=.tox/.coverage .tox/coverage/bin/coverage report --show-missing --fail-under=0 | grep -v "100%" - name: Upload HTML report if: always() uses: actions/upload-artifact@v4 From b5b6cab639faab284444aee5705be6805827e7c3 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Mon, 12 Jan 2026 17:00:47 +0000 Subject: [PATCH 5/7] Remove redundant comments and use direct attribute access --- src/datamodel_code_generator/parser/jsonschema.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index 0ed82dcf6..1a40f3e8f 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -3076,8 +3076,6 @@ def parse_array_fields( # noqa: PLR0912, PLR0915 nullable = obj.nullable if obj.has_default or required else True else: required = not obj.nullable and required - # Set nullable=False when has_default and schema doesn't explicitly allow null - # This prevents fall_back_to_nullable from adding | None if obj.nullable: nullable = True elif obj.has_default: @@ -3268,27 +3266,19 @@ def parse_root_type( # noqa: PLR0912, PLR0914, PLR0915 data_type = self.data_type_manager.get_data_type( Types.any, ) - # For RootModels (not TypeAlias): - # - required=False when nullable or has_default (preserves = None for nullable types) - # - nullable=False when has_default and not explicitly nullable (prevents fall_back_to_nullable) - # For TypeAlias (IS_ALIAS=True): - # - Defaults are meaningless for type aliases, so don't process has_default - is_type_alias = getattr(self.data_model_root_type, "IS_ALIAS", False) + is_type_alias = self.data_model_root_type.IS_ALIAS if self.force_optional_for_required_fields: required = False - nullable = None # Will be handled by fall_back_to_nullable - # Force optional makes all fields optional, so use None as default + nullable = None has_default_override = True default_value = obj.default if obj.has_default else None elif obj.nullable: required = False nullable = True - # Nullable fields should have None as default when no explicit default is set has_default_override = True default_value = obj.default if obj.has_default else None elif obj.has_default and not is_type_alias: required = False - # Set nullable=False to prevent fall_back_to_nullable from adding | None nullable = False has_default_override = True default_value = obj.default From 1c19fe7fbdf95f8efcd44f64783d16c52d3b402a Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Mon, 12 Jan 2026 17:12:42 +0000 Subject: [PATCH 6/7] Remove unnecessary comment --- src/datamodel_code_generator/parser/jsonschema.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index 1a40f3e8f..0f1f585a7 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -897,8 +897,6 @@ def _create_synthetic_enum_obj( final_enum = [*enum_values, None] if nullable else enum_values final_varnames = varnames if len(varnames) == len(enum_values) else [] - # Only include default if original has one; otherwise don't set it at all - # to avoid incorrectly marking the synthetic object as having a default return self.SCHEMA_OBJECT_TYPE( type=enum_type, enum=final_enum, From e47e3936dc11300f9877a25845fcf9ee2d31f997 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Mon, 12 Jan 2026 17:19:17 +0000 Subject: [PATCH 7/7] Inline _should_field_be_required to improve branch coverage --- .../parser/jsonschema.py | 21 +------------------ 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index 0f1f585a7..1f8c4982e 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -1528,22 +1528,6 @@ def _apply_title_as_name(self, name: str, obj: JsonSchemaObject) -> str: return sanitize_module_name(obj.title, treat_dot_as_module=self.treat_dot_as_module) return name - def _should_field_be_required( - self, - *, - in_required_list: bool = True, - has_default: bool = False, - is_nullable: bool = False, - ) -> bool: - """Determine if a field should be marked as required.""" - if self.force_optional_for_required_fields: - return False - if self.apply_default_values_for_required_fields and has_default: # pragma: no cover - return False - if is_nullable: - return False - return in_required_list - def _deep_merge(self, dict1: dict[Any, Any], dict2: dict[Any, Any]) -> dict[Any, Any]: """Deep merge two dictionaries, combining nested dicts and lists.""" result = dict1.copy() @@ -3346,10 +3330,7 @@ def _parse_multiple_types_with_properties( ) is_nullable = obj.nullable or obj.type_has_null - required = self._should_field_be_required( - has_default=obj.has_default, - is_nullable=bool(is_nullable), - ) + required = not (self.force_optional_for_required_fields or is_nullable) reference = self.model_resolver.add(path, name, loaded=True, class_name=True) self._set_schema_metadata(reference.path, obj)