Skip to content

Commit 838b2a0

Browse files
Fix array RootModel default value handling in parser (#2963)
* Fix array RootModel default value handling in parser * docs: update llms.txt files Generated by GitHub Actions * Show uncovered lines when coverage check fails * Fix coverage report path in CI workflow * Remove redundant comments and use direct attribute access * Remove unnecessary comment * Inline _should_field_be_required to improve branch coverage --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent e717208 commit 838b2a0

File tree

8 files changed

+54
-42
lines changed

8 files changed

+54
-42
lines changed

.github/workflows/test.yaml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,23 +120,34 @@ jobs:
120120
pattern: .coverage.*
121121
merge-multiple: true
122122
- name: Combine and report coverage
123+
id: coverage
123124
run: tox run -e coverage --skip-uv-sync --skip-pkg-install
125+
continue-on-error: true
124126
env:
125127
UV_PYTHON_PREFERENCE: only-managed
128+
- name: Show uncovered lines on failure
129+
if: steps.coverage.outcome == 'failure'
130+
run: |
131+
echo "::error::Coverage check failed. Lines not covered:"
132+
COVERAGE_FILE=.tox/.coverage .tox/coverage/bin/coverage report --show-missing --fail-under=0 | grep -v "100%"
126133
- name: Upload HTML report
134+
if: always()
127135
uses: actions/upload-artifact@v4
128136
with:
129137
name: html-report
130138
path: .tox/htmlcov
131139
- name: Upload coverage to Codecov
132-
if: success() || failure()
140+
if: always()
133141
uses: codecov/codecov-action@v5
134142
with:
135143
flags: unittests
136144
files: .tox/coverage.xml
137145
fail_ci_if_error: true
138146
env:
139147
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
148+
- name: Fail if coverage check failed
149+
if: steps.coverage.outcome == 'failure'
150+
run: exit 1
140151

141152
check:
142153
name: ${{ matrix.tox_env }}

docs/cli-reference/field-customization.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3912,7 +3912,7 @@ This is useful when schemas have descriptive titles that should be preserved.
39123912
class ProcessingStatusUnionTitle(BaseModel):
39133913
__root__: (
39143914
ProcessingStatusDetail | ExtendedProcessingTask | ProcessingStatusTitle
3915-
) = Field(..., title='Processing Status Union Title')
3915+
) = Field('COMPLETED', title='Processing Status Union Title')
39163916

39173917

39183918
class ProcessingTaskTitle(BaseModel):

docs/llms-full.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11680,7 +11680,7 @@ This is useful when schemas have descriptive titles that should be preserved.
1168011680
class ProcessingStatusUnionTitle(BaseModel):
1168111681
__root__: (
1168211682
ProcessingStatusDetail | ExtendedProcessingTask | ProcessingStatusTitle
11683-
) = Field(..., title='Processing Status Union Title')
11683+
) = Field('COMPLETED', title='Processing Status Union Title')
1168411684

1168511685

1168611686
class ProcessingTaskTitle(BaseModel):

src/datamodel_code_generator/model/pydantic/base_model.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ def __str__(self) -> str: # noqa: PLR0912
220220
elif isinstance(discriminator, dict): # pragma: no cover
221221
data["discriminator"] = discriminator["propertyName"]
222222

223-
if self.required and not self.has_default:
223+
if self.required:
224224
default_factory = None
225225
elif self.default is not UNDEFINED and self.default is not None and "default_factory" not in data:
226226
default_factory = self._get_default_as_pydantic_model()
@@ -249,7 +249,7 @@ def __str__(self) -> str: # noqa: PLR0912
249249

250250
if self.use_annotated:
251251
field_arguments = self._process_annotated_field_arguments(field_arguments)
252-
elif self.required and not default_factory:
252+
elif self.required:
253253
field_arguments = ["...", *field_arguments]
254254
elif not default_factory:
255255
default_repr = repr_set_sorted(self.default) if isinstance(self.default, set) else repr(self.default)

src/datamodel_code_generator/model/template/pydantic/BaseModel_root.jinja2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class {{ class_name }}({{ base_class }}):{% if comment is defined %} # {{ comme
2424
{%- else %}
2525
__root__: {{ field.type_hint }}
2626
{%- endif %}
27-
{%- 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))
27+
{%- if not field.has_default_factory_in_field and not (field.required or (field.represented_default == 'None' and field.strip_default_none))
2828
%} = {{ field.represented_default }}
2929
{%- endif -%}
3030
{%- endif %}

src/datamodel_code_generator/model/template/pydantic_v2/RootModel.jinja2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ class {{ class_name }}({{ base_class }}{%- if fields -%}[{{get_type_hint(fields,
4242
{%- else %}
4343
root: {{ field.type_hint }}
4444
{%- endif %}
45-
{%- 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))
45+
{%- if not field.has_default_factory_in_field and not (field.required or (field.represented_default == 'None' and field.strip_default_none))
4646
%} = {{ field.represented_default }}
4747
{%- endif -%}
4848
{%- endif %}

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 35 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -903,7 +903,7 @@ def _create_synthetic_enum_obj(
903903
title=original.title,
904904
description=original.description,
905905
x_enum_varnames=final_varnames,
906-
default=original.default if original.has_default else None,
906+
**({"default": original.default} if original.has_default else {}),
907907
)
908908

909909
def is_constraints_field(self, obj: JsonSchemaObject) -> bool:
@@ -1528,22 +1528,6 @@ def _apply_title_as_name(self, name: str, obj: JsonSchemaObject) -> str:
15281528
return sanitize_module_name(obj.title, treat_dot_as_module=self.treat_dot_as_module)
15291529
return name
15301530

1531-
def _should_field_be_required(
1532-
self,
1533-
*,
1534-
in_required_list: bool = True,
1535-
has_default: bool = False,
1536-
is_nullable: bool = False,
1537-
) -> bool:
1538-
"""Determine if a field should be marked as required."""
1539-
if self.force_optional_for_required_fields:
1540-
return False
1541-
if self.apply_default_values_for_required_fields and has_default: # pragma: no cover
1542-
return False
1543-
if is_nullable:
1544-
return False
1545-
return in_required_list
1546-
15471531
def _deep_merge(self, dict1: dict[Any, Any], dict2: dict[Any, Any]) -> dict[Any, Any]:
15481532
"""Deep merge two dictionaries, combining nested dicts and lists."""
15491533
result = dict1.copy()
@@ -3054,7 +3038,7 @@ def parse_list_item(
30543038
for index, item in enumerate(target_items)
30553039
]
30563040

3057-
def parse_array_fields( # noqa: PLR0912
3041+
def parse_array_fields( # noqa: PLR0912, PLR0915
30583042
self,
30593043
name: str,
30603044
obj: JsonSchemaObject,
@@ -3069,12 +3053,17 @@ def parse_array_fields( # noqa: PLR0912
30693053
required: bool = False
30703054
nullable: Optional[bool] = None # noqa: UP045
30713055
else:
3072-
required = not (obj.has_default and self.apply_default_values_for_required_fields)
3056+
required = not obj.has_default
30733057
if self.strict_nullable:
30743058
nullable = obj.nullable if obj.has_default or required else True
30753059
else:
30763060
required = not obj.nullable and required
3077-
nullable = None
3061+
if obj.nullable:
3062+
nullable = True
3063+
elif obj.has_default:
3064+
nullable = False
3065+
else:
3066+
nullable = None
30783067
is_tuple = False
30793068
suppress_item_constraints = False
30803069
if isinstance(obj.items, JsonSchemaObject):
@@ -3259,10 +3248,27 @@ def parse_root_type( # noqa: PLR0912, PLR0914, PLR0915
32593248
data_type = self.data_type_manager.get_data_type(
32603249
Types.any,
32613250
)
3262-
required = self._should_field_be_required(
3263-
has_default=obj.has_default,
3264-
is_nullable=bool(obj.nullable),
3265-
)
3251+
is_type_alias = self.data_model_root_type.IS_ALIAS
3252+
if self.force_optional_for_required_fields:
3253+
required = False
3254+
nullable = None
3255+
has_default_override = True
3256+
default_value = obj.default if obj.has_default else None
3257+
elif obj.nullable:
3258+
required = False
3259+
nullable = True
3260+
has_default_override = True
3261+
default_value = obj.default if obj.has_default else None
3262+
elif obj.has_default and not is_type_alias:
3263+
required = False
3264+
nullable = False
3265+
has_default_override = True
3266+
default_value = obj.default
3267+
else:
3268+
required = True
3269+
nullable = None
3270+
has_default_override = obj.has_default
3271+
default_value = obj.default if obj.has_default else UNDEFINED
32663272
name = self._apply_title_as_name(name, obj)
32673273
if not reference:
32683274
reference = self.model_resolver.add(path, name, loaded=True, class_name=True)
@@ -3276,20 +3282,18 @@ def parse_root_type( # noqa: PLR0912, PLR0914, PLR0915
32763282
fields=[
32773283
self.data_model_field_type(
32783284
data_type=data_type,
3279-
default=obj.default,
3285+
default=default_value,
32803286
required=required,
32813287
constraints=constraints,
3282-
nullable=obj.nullable
3283-
if self.strict_nullable and obj.nullable is not None
3284-
else (False if self.strict_nullable and obj.has_default else None),
3288+
nullable=nullable,
32853289
strip_default_none=self.strip_default_none,
32863290
extras=self.get_field_extras(obj),
32873291
use_annotated=self.use_annotated,
32883292
use_field_description=self.use_field_description,
32893293
use_field_description_example=self.use_field_description_example,
32903294
use_inline_field_description=self.use_inline_field_description,
32913295
original_name=None,
3292-
has_default=obj.has_default,
3296+
has_default=has_default_override,
32933297
)
32943298
],
32953299
custom_base_class=self._resolve_base_class(name, obj.custom_base_path),
@@ -3298,7 +3302,7 @@ def parse_root_type( # noqa: PLR0912, PLR0914, PLR0915
32983302
path=self.current_source_path,
32993303
nullable=obj.type_has_null,
33003304
treat_dot_as_module=self.treat_dot_as_module,
3301-
default=obj.default if obj.has_default else UNDEFINED,
3305+
default=default_value if has_default_override else UNDEFINED,
33023306
)
33033307
self.results.append(data_model_root_type)
33043308
return self.data_type(reference=reference)
@@ -3326,10 +3330,7 @@ def _parse_multiple_types_with_properties(
33263330
)
33273331

33283332
is_nullable = obj.nullable or obj.type_has_null
3329-
required = self._should_field_be_required(
3330-
has_default=obj.has_default,
3331-
is_nullable=bool(is_nullable),
3332-
)
3333+
required = not (self.force_optional_for_required_fields or is_nullable)
33333334

33343335
reference = self.model_resolver.add(path, name, loaded=True, class_name=True)
33353336
self._set_schema_metadata(reference.path, obj)

tests/data/expected/main/jsonschema/titles_use_title_as_name.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ class ExtendedProcessingTasksTitle(BaseModel):
4747
class ProcessingStatusUnionTitle(BaseModel):
4848
__root__: (
4949
ProcessingStatusDetail | ExtendedProcessingTask | ProcessingStatusTitle
50-
) = Field(..., title='Processing Status Union Title')
50+
) = Field('COMPLETED', title='Processing Status Union Title')
5151

5252

5353
class ProcessingTaskTitle(BaseModel):

0 commit comments

Comments
 (0)