From c514f7f193ce92282214e521c45182ad2b715312 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 10:51:27 +0000 Subject: [PATCH 01/10] Add --default-values CLI option for overriding field defaults --- docs/cli-reference/field-customization.md | 69 +++++++++++++++++++ docs/cli-reference/index.md | 3 +- docs/cli-reference/quick-reference.md | 2 + src/datamodel_code_generator/__main__.py | 27 +++++++- .../_types/generate_config_dict.py | 1 + .../_types/parser_config_dicts.py | 1 + src/datamodel_code_generator/arguments.py | 12 ++++ src/datamodel_code_generator/cli_options.py | 1 + src/datamodel_code_generator/config.py | 2 + src/datamodel_code_generator/parser/base.py | 1 + .../parser/graphql.py | 28 ++++++-- .../parser/jsonschema.py | 24 +++++-- .../parser/openapi.py | 40 ++++++++--- src/datamodel_code_generator/prompt_data.py | 1 + src/datamodel_code_generator/reference.py | 45 ++++++++++++ src/datamodel_code_generator/watch.py | 2 + .../default_values/graphql_user_defaults.json | 3 + tests/data/default_values/invalid_json.json | 1 + tests/data/default_values/non_dict.json | 1 + .../openapi_params_defaults.json | 4 ++ .../data/default_values/scoped_defaults.json | 4 ++ .../default_values_required_use_default.py | 33 +++++++++ .../expected/main/input_model/config_class.py | 1 + .../jsonschema_default_values_override.py | 19 +++++ .../default_values_parameters_use_default.py | 25 +++++++ .../graphql/default_values_required.graphql | 5 ++ .../jsonschema/default_values_override.json | 20 ++++++ .../openapi/default_values_parameters.yaml | 44 ++++++++++++ tests/main/conftest.py | 1 + tests/main/graphql/test_main_graphql.py | 18 ++++- tests/main/jsonschema/test_main_jsonschema.py | 26 +++++++ tests/main/openapi/test_main_openapi.py | 20 ++++++ tests/main/test_main_general.py | 27 ++++++++ .../test_public_api_signature_baseline.py | 2 + 34 files changed, 492 insertions(+), 21 deletions(-) create mode 100644 tests/data/default_values/graphql_user_defaults.json create mode 100644 tests/data/default_values/invalid_json.json create mode 100644 tests/data/default_values/non_dict.json create mode 100644 tests/data/default_values/openapi_params_defaults.json create mode 100644 tests/data/default_values/scoped_defaults.json create mode 100644 tests/data/expected/main/graphql/default_values_required_use_default.py create mode 100644 tests/data/expected/main/jsonschema/jsonschema_default_values_override.py create mode 100644 tests/data/expected/main/openapi/default_values_parameters_use_default.py create mode 100644 tests/data/graphql/default_values_required.graphql create mode 100644 tests/data/jsonschema/default_values_override.json create mode 100644 tests/data/openapi/default_values_parameters.yaml diff --git a/docs/cli-reference/field-customization.md b/docs/cli-reference/field-customization.md index 76c14e9f8..f4a887798 100644 --- a/docs/cli-reference/field-customization.md +++ b/docs/cli-reference/field-customization.md @@ -6,6 +6,7 @@ |--------|-------------| | [`--aliases`](#aliases) | Apply custom field and class name aliases from JSON file. | | [`--capitalize-enum-members`](#capitalize-enum-members) | Capitalize enum member names to UPPER_CASE format. | +| [`--default-values`](#default-values) | Override field default values from external JSON file. | | [`--empty-enum-field-name`](#empty-enum-field-name) | Name for empty string enum field values. | | [`--extra-fields`](#extra-fields) | Configure how generated models handle extra fields not defin... | | [`--field-constraints`](#field-constraints) | Generate Field() with validation constraints from schema. | @@ -557,6 +558,74 @@ naming conventions for constants. --- +## `--default-values` {#default-values} + +Override field default values from external JSON file. + +The `--default-values` option allows specifying default values for fields via a JSON file. +Supports scoped format (ClassName.field) for hierarchical overrides. + +!!! tip "Usage" + + ```bash + datamodel-codegen --input schema.json --default-values default_values/scoped_defaults.json # (1)! + ``` + + 1. :material-arrow-left: `--default-values` - the option documented here + +??? example "Examples" + + **Input Schema:** + + ```json + { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "User": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "page": { + "type": "integer" + } + }, + "required": ["name"] + } + } + } + ``` + + **Output:** + + ```python + # generated by datamodel-codegen: + # filename: default_values_override.json + # timestamp: 2019-07-26T00:00:00+00:00 + + from __future__ import annotations + + from typing import Any + + from pydantic import BaseModel + + + class Model(BaseModel): + __root__: Any + + + class User(BaseModel): + name: str + status: str | None = 'active' + page: int | None = 1 + ``` + +--- + ## `--empty-enum-field-name` {#empty-enum-field-name} Name for empty string enum field values. diff --git a/docs/cli-reference/index.md b/docs/cli-reference/index.md index a7275f753..34b33d195 100644 --- a/docs/cli-reference/index.md +++ b/docs/cli-reference/index.md @@ -10,7 +10,7 @@ This documentation is auto-generated from test cases. |----------|---------|-------------| | 📁 [Base Options](base-options.md) | 7 | Input/output configuration | | 🔧 [Typing Customization](typing-customization.md) | 27 | Type annotation and import behavior | -| 🏷️ [Field Customization](field-customization.md) | 22 | Field naming and docstring behavior | +| 🏷️ [Field Customization](field-customization.md) | 23 | Field naming and docstring behavior | | 🏗️ [Model Customization](model-customization.md) | 39 | Model generation behavior | | 🎨 [Template Customization](template-customization.md) | 18 | Output formatting and custom rendering | | 📘 [OpenAPI-only Options](openapi-only-options.md) | 6 | OpenAPI-specific features | @@ -60,6 +60,7 @@ This documentation is auto-generated from test cases. - [`--dataclass-arguments`](model-customization.md#dataclass-arguments) - [`--debug`](utility-options.md#debug) +- [`--default-values`](field-customization.md#default-values) - [`--disable-appending-item-suffix`](template-customization.md#disable-appending-item-suffix) - [`--disable-future-imports`](typing-customization.md#disable-future-imports) - [`--disable-timestamp`](template-customization.md#disable-timestamp) diff --git a/docs/cli-reference/quick-reference.md b/docs/cli-reference/quick-reference.md index f63b388ac..f3a393e97 100644 --- a/docs/cli-reference/quick-reference.md +++ b/docs/cli-reference/quick-reference.md @@ -62,6 +62,7 @@ datamodel-codegen [OPTIONS] |--------|-------------| | [`--aliases`](field-customization.md#aliases) | Apply custom field and class name aliases from JSON file. | | [`--capitalize-enum-members`](field-customization.md#capitalize-enum-members) | Capitalize enum member names to UPPER_CASE format. | +| [`--default-values`](field-customization.md#default-values) | Override field default values from external JSON file. | | [`--empty-enum-field-name`](field-customization.md#empty-enum-field-name) | Name for empty string enum field values. | | [`--extra-fields`](field-customization.md#extra-fields) | Configure how generated models handle extra fields not defined in schema. | | [`--field-constraints`](field-customization.md#field-constraints) | Generate Field() with validation constraints from schema. | @@ -225,6 +226,7 @@ All options sorted alphabetically: - [`--custom-template-dir`](template-customization.md#custom-template-dir) - Use custom Jinja2 templates for model generation. - [`--dataclass-arguments`](model-customization.md#dataclass-arguments) - Customize dataclass decorator arguments via JSON dictionary. - [`--debug`](utility-options.md#debug) - Show debug messages during code generation +- [`--default-values`](field-customization.md#default-values) - Override field default values from external JSON file. - [`--disable-appending-item-suffix`](template-customization.md#disable-appending-item-suffix) - Disable appending 'Item' suffix to array item types. - [`--disable-future-imports`](typing-customization.md#disable-future-imports) - Prevent automatic addition of __future__ imports in generate... - [`--disable-timestamp`](template-customization.md#disable-timestamp) - Disable timestamp in generated file header for reproducible ... diff --git a/src/datamodel_code_generator/__main__.py b/src/datamodel_code_generator/__main__.py index 3c7025831..46243649b 100644 --- a/src/datamodel_code_generator/__main__.py +++ b/src/datamodel_code_generator/__main__.py @@ -177,7 +177,7 @@ def get_fields(cls) -> dict[str, Any]: """Get model fields.""" return cls.__fields__ - @field_validator("aliases", "extra_template_data", "custom_formatters_kwargs", mode="before") + @field_validator("aliases", "extra_template_data", "custom_formatters_kwargs", "default_values", mode="before") def validate_file(cls, value: Any) -> TextIOBase | None: # noqa: N805 """Validate and open file path.""" if value is None: # pragma: no cover @@ -504,6 +504,7 @@ def validate_class_name_affix_scope(cls, v: str | ClassNameAffixScope | None) -> snake_case_field: bool = False strip_default_none: bool = False aliases: Optional[TextIOBase] = None # noqa: UP045 + default_values: Optional[TextIOBase] = None # noqa: UP045 disable_timestamp: bool = False enable_version_header: bool = False enable_command_header: bool = False @@ -862,6 +863,7 @@ def run_generate_from_config( # noqa: PLR0913, PLR0917 command_line: str | None, custom_formatters_kwargs: dict[str, str] | None, settings_path: Path | None = None, + default_value_overrides: dict[str, Any] | None = None, ) -> None: """Run code generation with the given config and parameters.""" result = generate( @@ -987,6 +989,7 @@ def run_generate_from_config( # noqa: PLR0913, PLR0917 all_exports_collision_strategy=config.all_exports_collision_strategy, field_type_collision_strategy=config.field_type_collision_strategy, module_split_mode=config.module_split_mode, + default_value_overrides=default_value_overrides, ) if output is None and result is not None: # pragma: no cover @@ -1189,6 +1192,23 @@ def main(args: Sequence[str] | None = None) -> Exit: # noqa: PLR0911, PLR0912, ) return Exit.ERROR + default_value_overrides: dict[str, Any] | None + if config.default_values is None: + default_value_overrides = None + else: + with config.default_values as data: + try: + default_value_overrides = json.load(data) + except json.JSONDecodeError as e: + print(f"Unable to load default values mapping: {e}", file=sys.stderr) # noqa: T201 + return Exit.ERROR + if not isinstance(default_value_overrides, dict): + print("Unable to load default values mapping: must be a JSON object", file=sys.stderr) # noqa: T201 + return Exit.ERROR + if not all(isinstance(k, str) for k in default_value_overrides): # pragma: no cover + print("Unable to load default values mapping: all keys must be strings", file=sys.stderr) # noqa: T201 + return Exit.ERROR # pragma: no cover + if config.custom_formatters_kwargs is None: custom_formatters_kwargs = None else: @@ -1254,6 +1274,7 @@ def main(args: Sequence[str] | None = None) -> Exit: # noqa: PLR0911, PLR0912, command_line=shlex.join(["datamodel-codegen", *args]) if config.enable_command_header else None, custom_formatters_kwargs=custom_formatters_kwargs, settings_path=config.output, + default_value_overrides=default_value_overrides, ) except InvalidClassNameError as e: print(f"{e} You have to set `--class-name` option", file=sys.stderr) # noqa: T201 @@ -1302,7 +1323,9 @@ def main(args: Sequence[str] | None = None) -> Exit: # noqa: PLR0911, PLR0912, try: from datamodel_code_generator.watch import watch_and_regenerate # noqa: PLC0415 - return watch_and_regenerate(config, extra_template_data, aliases, custom_formatters_kwargs) + return watch_and_regenerate( + config, extra_template_data, aliases, custom_formatters_kwargs, default_value_overrides + ) except Exception as e: # noqa: BLE001 print(str(e), file=sys.stderr) # noqa: T201 return Exit.ERROR diff --git a/src/datamodel_code_generator/_types/generate_config_dict.py b/src/datamodel_code_generator/_types/generate_config_dict.py index 0c441c3fc..8e858fff8 100644 --- a/src/datamodel_code_generator/_types/generate_config_dict.py +++ b/src/datamodel_code_generator/_types/generate_config_dict.py @@ -162,3 +162,4 @@ class GenerateConfigDict(TypedDict): all_exports_collision_strategy: NotRequired[AllExportsCollisionStrategy | None] field_type_collision_strategy: NotRequired[FieldTypeCollisionStrategy | None] module_split_mode: NotRequired[ModuleSplitMode | None] + default_value_overrides: NotRequired[Mapping[str, Any] | None] diff --git a/src/datamodel_code_generator/_types/parser_config_dicts.py b/src/datamodel_code_generator/_types/parser_config_dicts.py index 7694b777e..59e707294 100644 --- a/src/datamodel_code_generator/_types/parser_config_dicts.py +++ b/src/datamodel_code_generator/_types/parser_config_dicts.py @@ -147,6 +147,7 @@ class ParserConfigDict(TypedDict): read_only_write_only_model_type: NotRequired[ReadOnlyWriteOnlyModelType | None] field_type_collision_strategy: NotRequired[FieldTypeCollisionStrategy | None] target_pydantic_version: NotRequired[TargetPydanticVersion | None] + default_value_overrides: NotRequired[Mapping[str, Any] | None] class GraphQLParserConfigDict(ParserConfigDict): diff --git a/src/datamodel_code_generator/arguments.py b/src/datamodel_code_generator/arguments.py index 97c111ed0..105e837f7 100644 --- a/src/datamodel_code_generator/arguments.py +++ b/src/datamodel_code_generator/arguments.py @@ -824,6 +824,18 @@ def start_section(self, heading: str | None) -> None: "Example: {'User.name': 'user_name', 'id': 'id_', 'field': ['my-field', 'my_field']}", type=Path, ) +template_options.add_argument( + "--default-values", + help="Default value overrides file (JSON). " + "Supports hierarchical formats: " + "Flat: {'field': value} applies to all occurrences. " + "Scoped: {'ClassName.field': value} applies to specific class. " + "Priority: scoped > flat. " + "Note: Scoped keys use the generated class name for JSON Schema/OpenAPI. " + "Required fields remain required unless --use-default is also specified. " + "Example: {'User.status': 'active', 'page': 1, 'limit': 10}", + type=Path, +) template_options.add_argument( "--custom-file-header", help="Custom file header", diff --git a/src/datamodel_code_generator/cli_options.py b/src/datamodel_code_generator/cli_options.py index 3929caf8e..7f8400306 100644 --- a/src/datamodel_code_generator/cli_options.py +++ b/src/datamodel_code_generator/cli_options.py @@ -139,6 +139,7 @@ class CLIOptionMeta: "--empty-enum-field-name": CLIOptionMeta(name="--empty-enum-field-name", category=OptionCategory.FIELD), "--set-default-enum-member": CLIOptionMeta(name="--set-default-enum-member", category=OptionCategory.FIELD), "--aliases": CLIOptionMeta(name="--aliases", category=OptionCategory.FIELD), + "--default-values": CLIOptionMeta(name="--default-values", category=OptionCategory.FIELD), "--no-alias": CLIOptionMeta(name="--no-alias", category=OptionCategory.FIELD), "--use-title-as-name": CLIOptionMeta(name="--use-title-as-name", category=OptionCategory.FIELD), "--use-schema-description": CLIOptionMeta(name="--use-schema-description", category=OptionCategory.FIELD), diff --git a/src/datamodel_code_generator/config.py b/src/datamodel_code_generator/config.py index 836fdcacc..5180a82e9 100644 --- a/src/datamodel_code_generator/config.py +++ b/src/datamodel_code_generator/config.py @@ -200,6 +200,7 @@ class Config: all_exports_collision_strategy: AllExportsCollisionStrategy | None = None field_type_collision_strategy: FieldTypeCollisionStrategy | None = None module_split_mode: ModuleSplitMode | None = None + default_value_overrides: Mapping[str, Any] | None = None class ParserConfig(BaseModel): @@ -329,6 +330,7 @@ class Config: read_only_write_only_model_type: ReadOnlyWriteOnlyModelType | None = None field_type_collision_strategy: FieldTypeCollisionStrategy | None = None target_pydantic_version: TargetPydanticVersion | None = None + default_value_overrides: Mapping[str, Any] | None = None class GraphQLParserConfig(ParserConfig): diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index bfcaaa93a..a9ba8331b 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -897,6 +897,7 @@ def __init__( # noqa: PLR0912, PLR0915 class_name_suffix=config.class_name_suffix, class_name_affix_scope=config.class_name_affix_scope, skip_affix_for_root=config.class_name is not None, + default_value_overrides=config.default_value_overrides, ) self.class_name: str | None = config.class_name self.wrap_string_literal: bool | None = config.wrap_string_literal diff --git a/src/datamodel_code_generator/parser/graphql.py b/src/datamodel_code_generator/parser/graphql.py index 06d974246..816294b4e 100644 --- a/src/datamodel_code_generator/parser/graphql.py +++ b/src/datamodel_code_generator/parser/graphql.py @@ -336,6 +336,8 @@ def parse_field( field_name: str, alias: str | list[str] | None, field: graphql.GraphQLField | graphql.GraphQLInputField, + original_field_name: str, + class_name: str | None = None, ) -> DataModelFieldBase: """Parse a GraphQL field and return a data model field.""" final_data_type = DataType( @@ -374,6 +376,20 @@ def parse_field( required = (not self.force_optional_for_required_fields) and (not final_data_type.is_optional) default = self._get_default(field, final_data_type, required=required) + has_default = default is not None + + # Resolve default value override (GraphQL uses original type name for scoping) + effective_default, effective_has_default = self.model_resolver.resolve_default_value( + original_field_name, + default, + has_default, + class_name=class_name, + ) + + # Recalculate required if default was added via override + if self.apply_default_values_for_required_fields and effective_has_default: + required = False + extras = {} if self.default_field_extras is None else self.default_field_extras.copy() if field.description is not None: # pragma: no cover @@ -388,7 +404,7 @@ def parse_field( single_alias = alias return self.data_model_field_type( name=field_name, - default=default, + default=effective_default, data_type=final_data_type, required=required, extras=extras, @@ -402,7 +418,7 @@ def parse_field( use_inline_field_description=self.use_inline_field_description, use_default_kwarg=self.use_default_kwarg, original_name=field_name, - has_default=default is not None, + has_default=effective_has_default, ) def parse_object_like( @@ -413,16 +429,18 @@ def parse_object_like( fields = [] exclude_field_names: set[str] = set() - for field_name, field in obj.fields.items(): + for original_field_name, field in obj.fields.items(): field_name_, alias = self.model_resolver.get_valid_field_name_and_alias( - field_name, + original_field_name, excludes=exclude_field_names, model_type=self.field_name_model_type, class_name=obj.name, ) exclude_field_names.add(field_name_) - data_model_field_type = self.parse_field(field_name_, alias, field) + data_model_field_type = self.parse_field( + field_name_, alias, field, original_field_name, class_name=obj.name + ) fields.append(data_model_field_type) fields.append(self._typename_field(obj.name)) diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index 00ab39444..80b473836 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -1016,8 +1016,14 @@ def get_object_field( # noqa: PLR0913 field_type: DataType, alias: str | list[str] | None, original_field_name: str | None, + effective_default: Any = None, + effective_has_default: bool | None = None, ) -> DataModelFieldBase: """Create a data model field from a JSON Schema object field.""" + # Use effective values if provided, otherwise fall back to field values + default_value = effective_default if effective_has_default is not None else field.default + has_default = effective_has_default if effective_has_default is not None else field.has_default + constraints = model_dump(field, exclude_none=True) if self.is_constraints_field(field) else None if constraints is not None and self.field_constraints and field.format == "hostname": constraints["pattern"] = self.data_type_manager.HOSTNAME_REGEX @@ -1034,7 +1040,7 @@ def get_object_field( # noqa: PLR0913 single_alias = alias return self.data_model_field_type( name=field_name, - default=field.default, + default=default_value, data_type=field_type, required=required, alias=single_alias, @@ -1042,7 +1048,7 @@ def get_object_field( # noqa: PLR0913 constraints=constraints, nullable=field.nullable if self.strict_nullable and field.nullable is not None - else (False if self.strict_nullable and (field.has_default or required) else None), + else (False if self.strict_nullable and (has_default or required) else None), strip_default_none=self.strip_default_none, extras=self.get_field_extras(field), use_annotated=self.use_annotated, @@ -1052,7 +1058,7 @@ def get_object_field( # noqa: PLR0913 use_inline_field_description=self.use_inline_field_description, use_default_kwarg=self.use_default_kwarg, original_name=original_field_name, - has_default=field.has_default, + has_default=has_default, type_has_null=field.type_has_null, read_only=self._resolve_field_flag(field, "readOnly"), write_only=self._resolve_field_flag(field, "writeOnly"), @@ -2391,8 +2397,16 @@ def parse_object_fields( field_type = self.parse_item(modular_name, field, [*path, field_name]) + # Resolve default value override + effective_default, effective_has_default = self.model_resolver.resolve_default_value( + original_field_name, + field.default, + field.has_default, + class_name=class_name, + ) + if self.force_optional_for_required_fields or ( - self.apply_default_values_for_required_fields and field.has_default + self.apply_default_values_for_required_fields and effective_has_default ): required: bool = False else: @@ -2405,6 +2419,8 @@ def parse_object_fields( field_type=field_type, alias=alias, original_field_name=original_field_name, + effective_default=effective_default, + effective_has_default=effective_has_default, ) ) return fields diff --git a/src/datamodel_code_generator/parser/openapi.py b/src/datamodel_code_generator/parser/openapi.py index 89310553c..436ae8fd9 100644 --- a/src/datamodel_code_generator/parser/openapi.py +++ b/src/datamodel_code_generator/parser/openapi.py @@ -473,7 +473,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 + def parse_all_parameters( # noqa: PLR0912, PLR0914 self, name: str, parameters: list[ReferenceObject | ParameterObject], @@ -506,14 +506,27 @@ def parse_all_parameters( # noqa: PLR0912 class_name=name, ) if parameter.schema_: + # Resolve default value override for parameter.schema_ branch + effective_default, effective_has_default = self.model_resolver.resolve_default_value( + parameter_name, + parameter.schema_.default, + parameter.schema_.has_default, + class_name=reference.name, + ) + # Recalculate required if default was added via override + 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=parameter.schema_, field_type=self.parse_item(field_name, parameter.schema_, [*path, name, parameter_name]), original_field_name=parameter_name, - required=parameter.required, + required=effective_required, alias=alias, + effective_default=effective_default, + effective_has_default=effective_has_default, ) ) else: @@ -542,6 +555,19 @@ def parse_all_parameters( # noqa: PLR0912 data_type = self.data_type(data_types=data_types) # multiple data_type parse as non-constraints field object_schema = None + # Resolve default value override for parameter.content branch + original_default = object_schema.default if object_schema else None + original_has_default = object_schema.has_default if object_schema else False + effective_default, effective_has_default = self.model_resolver.resolve_default_value( + parameter_name, + original_default, + original_has_default, + class_name=reference.name, + ) + # Recalculate required if default was added via override + effective_required = parameter.required + if self.apply_default_values_for_required_fields and effective_has_default: + effective_required = False # Handle multiple aliases (Pydantic v2 AliasChoices) single_alias: str | None = None validation_aliases: list[str] | None = None @@ -552,9 +578,9 @@ def parse_all_parameters( # noqa: PLR0912 fields.append( self.data_model_field_type( name=field_name, - default=object_schema.default if object_schema else None, + default=effective_default, data_type=data_type, - required=parameter.required, + required=effective_required, alias=single_alias, validation_aliases=validation_aliases, constraints=model_dump(object_schema, exclude_none=True) @@ -564,9 +590,7 @@ def parse_all_parameters( # noqa: PLR0912 if object_schema and self.strict_nullable and object_schema.nullable is not None else ( False - if object_schema - and self.strict_nullable - and (object_schema.has_default or parameter.required) + if object_schema and self.strict_nullable and (effective_has_default or effective_required) else None ), strip_default_none=self.strip_default_none, @@ -578,7 +602,7 @@ def parse_all_parameters( # noqa: PLR0912 use_inline_field_description=self.use_inline_field_description, use_default_kwarg=self.use_default_kwarg, original_name=parameter_name, - has_default=object_schema.has_default if object_schema else False, + has_default=effective_has_default, type_has_null=object_schema.type_has_null if object_schema else None, ) ) diff --git a/src/datamodel_code_generator/prompt_data.py b/src/datamodel_code_generator/prompt_data.py index 90343322c..293ffe181 100644 --- a/src/datamodel_code_generator/prompt_data.py +++ b/src/datamodel_code_generator/prompt_data.py @@ -9,6 +9,7 @@ OPTION_DESCRIPTIONS: dict[str, str] = { "--additional-imports": "Add custom imports to generated output files.", "--aliases": "Apply custom field and class name aliases from JSON file.", + "--default-values": "Override field default values from external JSON file.", "--all-exports-collision-strategy": "Handle name collisions when exporting recursive module hierarchies.", "--all-exports-scope": "Generate __all__ exports for child modules in __init__.py files.", "--allof-class-hierarchy": "Controls how allOf schemas are represented in the generated class hierarchy.", diff --git a/src/datamodel_code_generator/reference.py b/src/datamodel_code_generator/reference.py index 966875c24..88f5fbc8d 100644 --- a/src/datamodel_code_generator/reference.py +++ b/src/datamodel_code_generator/reference.py @@ -547,6 +547,7 @@ def __init__( # noqa: PLR0913, PLR0917 class_name_suffix: str | None = None, class_name_affix_scope: ClassNameAffixScope | None = None, skip_affix_for_root: bool = False, # noqa: FBT001, FBT002 + default_value_overrides: Mapping[str, Any] | None = None, ) -> None: """Initialize model resolver with naming and resolution options.""" self.references: dict[str, Reference] = {} @@ -607,6 +608,11 @@ def __init__( # noqa: PLR0913, PLR0917 # Incrementally maintained set of reference names for O(1) uniqueness checking self._reference_names_cache: set[str] = set() + # Default value overrides from external JSON file + self.default_value_overrides: Mapping[str, Any] = ( + {} if default_value_overrides is None else {**default_value_overrides} + ) + def _get_reference_names(self) -> set[str]: """Get the set of all reference names for uniqueness checking.""" return self._reference_names_cache @@ -1190,6 +1196,45 @@ def get_valid_field_name_and_alias( field_name, excludes, class_name=class_name ) + def resolve_default_value( + self, + field_name: str, + original_default: Any, + has_default: bool, # noqa: FBT001 + class_name: str | None = None, + ) -> tuple[Any, bool]: + """Resolve default value for a field, applying overrides if configured. + + Supports hierarchical override resolution with the following priority: + 1. Scoped overrides (ClassName.field_name) - class-level specificity + 2. Flat overrides (field_name) - applies to all occurrences + + Args: + field_name: The original field name from the schema. + original_default: The default value from the schema. + has_default: Whether the schema defined a default value. + class_name: Optional class name for scoped override resolution. + + Returns: + A tuple of (effective_default, effective_has_default) where: + - effective_default: The resolved default value to use. + - effective_has_default: Whether a default value is now present. + """ + if not self.default_value_overrides: + return original_default, has_default + + # Try scoped lookup first (ClassName.field_name) + if class_name: + scoped_key = f"{class_name}.{field_name}" + if scoped_key in self.default_value_overrides: + return self.default_value_overrides[scoped_key], True + + # Fall back to flat lookup (field_name) + if field_name in self.default_value_overrides: + return self.default_value_overrides[field_name], True + + return original_default, has_default + def _get_inflect_engine() -> inflect.engine: """Get or create the inflect engine lazily.""" diff --git a/src/datamodel_code_generator/watch.py b/src/datamodel_code_generator/watch.py index 91ed70b18..d1b8ffb47 100644 --- a/src/datamodel_code_generator/watch.py +++ b/src/datamodel_code_generator/watch.py @@ -25,6 +25,7 @@ def watch_and_regenerate( extra_template_data: dict[str, Any] | None, aliases: dict[str, str] | None, custom_formatters_kwargs: dict[str, str] | None, + default_value_overrides: dict[str, Any] | None = None, ) -> Exit: """Watch input files and regenerate on changes.""" from datamodel_code_generator.__main__ import Exit, run_generate_from_config # noqa: PLC0415 @@ -55,6 +56,7 @@ def watch_and_regenerate( aliases=aliases, command_line=None, custom_formatters_kwargs=custom_formatters_kwargs, + default_value_overrides=default_value_overrides, ) print("Done.") # noqa: T201 except Exception as e: # noqa: BLE001 diff --git a/tests/data/default_values/graphql_user_defaults.json b/tests/data/default_values/graphql_user_defaults.json new file mode 100644 index 000000000..2932bda59 --- /dev/null +++ b/tests/data/default_values/graphql_user_defaults.json @@ -0,0 +1,3 @@ +{ + "User.status": "active" +} diff --git a/tests/data/default_values/invalid_json.json b/tests/data/default_values/invalid_json.json new file mode 100644 index 000000000..34d218e07 --- /dev/null +++ b/tests/data/default_values/invalid_json.json @@ -0,0 +1 @@ +{not valid json diff --git a/tests/data/default_values/non_dict.json b/tests/data/default_values/non_dict.json new file mode 100644 index 000000000..8696b46c1 --- /dev/null +++ b/tests/data/default_values/non_dict.json @@ -0,0 +1 @@ +["not", "a", "dict"] diff --git a/tests/data/default_values/openapi_params_defaults.json b/tests/data/default_values/openapi_params_defaults.json new file mode 100644 index 000000000..8bc572fb8 --- /dev/null +++ b/tests/data/default_values/openapi_params_defaults.json @@ -0,0 +1,4 @@ +{ + "UsersGetParametersQuery.status": "active", + "UsersGetParametersQuery.filter": {} +} diff --git a/tests/data/default_values/scoped_defaults.json b/tests/data/default_values/scoped_defaults.json new file mode 100644 index 000000000..b7229c9e9 --- /dev/null +++ b/tests/data/default_values/scoped_defaults.json @@ -0,0 +1,4 @@ +{ + "User.status": "active", + "page": 1 +} 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 new file mode 100644 index 000000000..6d46087b2 --- /dev/null +++ b/tests/data/expected/main/graphql/default_values_required_use_default.py @@ -0,0 +1,33 @@ +# generated by datamodel-codegen: +# filename: default_values_required.graphql +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Literal, TypeAlias + +from pydantic import BaseModel, Field + +Boolean: TypeAlias = bool +""" +The `Boolean` scalar type represents `true` or `false`. +""" + + +ID: TypeAlias = str +""" +The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `"4"`) or integer (such as `4`) input value will be accepted as an ID. +""" + + +String: TypeAlias = str +""" +The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text. +""" + + +class User(BaseModel): + id: ID + name: String + status: String | None = 'active' + typename__: Literal['User'] | None = Field('User', alias='__typename') diff --git a/tests/data/expected/main/input_model/config_class.py b/tests/data/expected/main/input_model/config_class.py index 654c0865e..3514aa68f 100644 --- a/tests/data/expected/main/input_model/config_class.py +++ b/tests/data/expected/main/input_model/config_class.py @@ -237,3 +237,4 @@ class GenerateConfig(TypedDict): all_exports_collision_strategy: NotRequired[AllExportsCollisionStrategy | None] field_type_collision_strategy: NotRequired[FieldTypeCollisionStrategy | None] module_split_mode: NotRequired[ModuleSplitMode | None] + default_value_overrides: NotRequired[Mapping[str, Any] | None] diff --git a/tests/data/expected/main/jsonschema/jsonschema_default_values_override.py b/tests/data/expected/main/jsonschema/jsonschema_default_values_override.py new file mode 100644 index 000000000..92bea0f5b --- /dev/null +++ b/tests/data/expected/main/jsonschema/jsonschema_default_values_override.py @@ -0,0 +1,19 @@ +# generated by datamodel-codegen: +# filename: default_values_override.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel + + +class Model(BaseModel): + __root__: Any + + +class User(BaseModel): + name: str + status: str | None = 'active' + page: int | None = 1 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 new file mode 100644 index 000000000..719a56c60 --- /dev/null +++ b/tests/data/expected/main/openapi/default_values_parameters_use_default.py @@ -0,0 +1,25 @@ +# generated by datamodel-codegen: +# filename: default_values_parameters.yaml +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class Filter(BaseModel): + name: str | None = None + + +class UsersGetParametersQuery(BaseModel): + status: str | None = 'active' + filter: Filter | None = Field(default_factory=lambda: Filter.parse_obj({})) + + +class User(BaseModel): + id: int | None = None + name: str | None = None + + +class UserList(BaseModel): + __root__: list[User] diff --git a/tests/data/graphql/default_values_required.graphql b/tests/data/graphql/default_values_required.graphql new file mode 100644 index 000000000..92cba4564 --- /dev/null +++ b/tests/data/graphql/default_values_required.graphql @@ -0,0 +1,5 @@ +type User { + id: ID! + name: String! + status: String! +} diff --git a/tests/data/jsonschema/default_values_override.json b/tests/data/jsonschema/default_values_override.json new file mode 100644 index 000000000..c8b5f3d36 --- /dev/null +++ b/tests/data/jsonschema/default_values_override.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "User": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "page": { + "type": "integer" + } + }, + "required": ["name"] + } + } +} diff --git a/tests/data/openapi/default_values_parameters.yaml b/tests/data/openapi/default_values_parameters.yaml new file mode 100644 index 000000000..ca12a4468 --- /dev/null +++ b/tests/data/openapi/default_values_parameters.yaml @@ -0,0 +1,44 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: Test API +paths: + /users: + get: + operationId: listUsers + parameters: + - name: status + in: query + required: true + schema: + type: string + - name: filter + in: query + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/UserList' +components: + schemas: + UserList: + type: array + items: + $ref: '#/components/schemas/User' + User: + type: object + properties: + id: + type: integer + name: + type: string diff --git a/tests/main/conftest.py b/tests/main/conftest.py index a2f5a0e3e..6c8e8f3dd 100644 --- a/tests/main/conftest.py +++ b/tests/main/conftest.py @@ -68,6 +68,7 @@ CSV_DATA_PATH: Path = DATA_PATH / "csv" YAML_DATA_PATH: Path = DATA_PATH / "yaml" ALIASES_DATA_PATH: Path = DATA_PATH / "aliases" +DEFAULT_VALUES_DATA_PATH: Path = DATA_PATH / "default_values" EXPECTED_OPENAPI_PATH: Path = EXPECTED_MAIN_PATH / "openapi" EXPECTED_JSON_SCHEMA_PATH: Path = EXPECTED_MAIN_PATH / "jsonschema" diff --git a/tests/main/graphql/test_main_graphql.py b/tests/main/graphql/test_main_graphql.py index 253f4ceb7..d185090ab 100644 --- a/tests/main/graphql/test_main_graphql.py +++ b/tests/main/graphql/test_main_graphql.py @@ -7,7 +7,7 @@ import black import pytest -from tests.main.conftest import GRAPHQL_DATA_PATH, LEGACY_BLACK_SKIP, run_main_and_assert +from tests.main.conftest import DEFAULT_VALUES_DATA_PATH, GRAPHQL_DATA_PATH, LEGACY_BLACK_SKIP, run_main_and_assert from tests.main.graphql.conftest import assert_file_content if TYPE_CHECKING: @@ -783,3 +783,19 @@ def test_main_graphql_split_graphql_schemas(output_file: Path) -> None: assert_func=assert_file_content, expected_file="split_graphql_schemas.py", ) + + +def test_main_graphql_use_default_with_default_values(output_file: Path) -> None: + """Test --use-default combined with --default-values on required GraphQL fields.""" + run_main_and_assert( + input_path=GRAPHQL_DATA_PATH / "default_values_required.graphql", + output_path=output_file, + input_file_type="graphql", + assert_func=assert_file_content, + expected_file="default_values_required_use_default.py", + extra_args=[ + "--use-default", + "--default-values", + str(DEFAULT_VALUES_DATA_PATH / "graphql_user_defaults.json"), + ], + ) diff --git a/tests/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index 925f1cc80..69f6230b9 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -29,6 +29,7 @@ ALIASES_DATA_PATH, BLACK_PY313_SKIP, DATA_PATH, + DEFAULT_VALUES_DATA_PATH, EXPECTED_MAIN_PATH, JSON_SCHEMA_DATA_PATH, LEGACY_BLACK_SKIP, @@ -7405,3 +7406,28 @@ def test_x_python_type_union_anyof(output_file: Path) -> None: input_file_type=None, assert_func=assert_file_content, ) + + +@pytest.mark.cli_doc( + options=["--default-values"], + option_description="""Override field default values from external JSON file. + +The `--default-values` option allows specifying default values for fields via a JSON file. +Supports scoped format (ClassName.field) for hierarchical overrides.""", + input_schema="jsonschema/default_values_override.json", + cli_args=["--default-values", "default_values/scoped_defaults.json"], + golden_output="jsonschema/jsonschema_default_values_override.py", +) +def test_main_jsonschema_default_values_override(output_file: Path) -> None: + """Test default value overrides from external JSON file.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "default_values_override.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="jsonschema_default_values_override.py", + extra_args=[ + "--default-values", + str(DEFAULT_VALUES_DATA_PATH / "scoped_defaults.json"), + ], + ) diff --git a/tests/main/openapi/test_main_openapi.py b/tests/main/openapi/test_main_openapi.py index 5a273239f..12f79b352 100644 --- a/tests/main/openapi/test_main_openapi.py +++ b/tests/main/openapi/test_main_openapi.py @@ -35,6 +35,7 @@ BLACK_PY313_SKIP, BLACK_PY314_SKIP, DATA_PATH, + DEFAULT_VALUES_DATA_PATH, LEGACY_BLACK_SKIP, MSGSPEC_LEGACY_BLACK_SKIP, OPEN_API_DATA_PATH, @@ -4687,3 +4688,22 @@ def test_query_parameters_with_model_config(output_file: Path) -> None: "--allow-population-by-field-name", ], ) + + +def test_main_openapi_use_default_with_default_values_parameters(output_file: Path) -> None: + """Test --use-default combined with --default-values on required OpenAPI parameters.""" + run_main_and_assert( + input_path=OPEN_API_DATA_PATH / "default_values_parameters.yaml", + output_path=output_file, + input_file_type="openapi", + assert_func=assert_file_content, + expected_file="default_values_parameters_use_default.py", + extra_args=[ + "--use-default", + "--default-values", + str(DEFAULT_VALUES_DATA_PATH / "openapi_params_defaults.json"), + "--openapi-scopes", + "paths", + "parameters", + ], + ) diff --git a/tests/main/test_main_general.py b/tests/main/test_main_general.py index 086a48c2c..0560c81ba 100644 --- a/tests/main/test_main_general.py +++ b/tests/main/test_main_general.py @@ -29,6 +29,7 @@ from tests.conftest import assert_output, create_assert_file_content, freeze_time from tests.main.conftest import ( DATA_PATH, + DEFAULT_VALUES_DATA_PATH, EXPECTED_MAIN_PATH, JSON_SCHEMA_DATA_PATH, OPEN_API_DATA_PATH, @@ -2131,3 +2132,29 @@ def test_graphql_parser_with_config_object() -> None: config = GraphQLParserConfig(target_datetime_class=DatetimeClassType.Awaredatetime) parser = GraphQLParser(source="type Query { id: ID }", config=config) assert parser.data_type_manager.target_datetime_class == DatetimeClassType.Awaredatetime + + +def test_default_values_invalid_json(output_file: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Test --default-values with invalid JSON file returns error.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "person.json", + output_path=output_file, + input_file_type="jsonschema", + extra_args=["--default-values", str(DEFAULT_VALUES_DATA_PATH / "invalid_json.json")], + expected_exit=Exit.ERROR, + capsys=capsys, + expected_stderr_contains="Unable to load default values mapping", + ) + + +def test_default_values_non_dict(output_file: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Test --default-values with non-dict JSON file returns error.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "person.json", + output_path=output_file, + input_file_type="jsonschema", + extra_args=["--default-values", str(DEFAULT_VALUES_DATA_PATH / "non_dict.json")], + expected_exit=Exit.ERROR, + capsys=capsys, + expected_stderr_contains="Unable to load default values mapping: must be a JSON object", + ) diff --git a/tests/main/test_public_api_signature_baseline.py b/tests/main/test_public_api_signature_baseline.py index a6aa2c3d4..7931abc33 100644 --- a/tests/main/test_public_api_signature_baseline.py +++ b/tests/main/test_public_api_signature_baseline.py @@ -74,6 +74,7 @@ def _baseline_generate( snake_case_field: bool = False, strip_default_none: bool = False, aliases: Mapping[str, str | list[str]] | None = None, + default_value_overrides: Mapping[str, Any] | None = None, disable_timestamp: bool = False, enable_version_header: bool = False, enable_command_header: bool = False, @@ -207,6 +208,7 @@ def __init__( snake_case_field: bool = False, strip_default_none: bool = False, aliases: Mapping[str, str | list[str]] | None = None, + default_value_overrides: Mapping[str, Any] | None = None, allow_population_by_field_name: bool = False, apply_default_values_for_required_fields: bool = False, allow_extra_fields: bool = False, From 2793acb28a3bccc1e98d312844c81e0d784bcb95 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 2 Jan 2026 14:19:07 +0000 Subject: [PATCH 02/10] docs: update CLI reference documentation and prompt data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated by GitHub Actions --- src/datamodel_code_generator/prompt_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/datamodel_code_generator/prompt_data.py b/src/datamodel_code_generator/prompt_data.py index 5e389576f..9b02b5b30 100644 --- a/src/datamodel_code_generator/prompt_data.py +++ b/src/datamodel_code_generator/prompt_data.py @@ -9,7 +9,6 @@ OPTION_DESCRIPTIONS: dict[str, str] = { "--additional-imports": "Add custom imports to generated output files.", "--aliases": "Apply custom field and class name aliases from JSON file.", - "--default-values": "Override field default values from external JSON file.", "--all-exports-collision-strategy": "Handle name collisions when exporting recursive module hierarchies.", "--all-exports-scope": "Generate __all__ exports for child modules in __init__.py files.", "--allof-class-hierarchy": "Controls how allOf schemas are represented in the generated class hierarchy.", @@ -34,6 +33,7 @@ "--custom-formatters-kwargs": "Pass custom arguments to custom formatters via JSON file.", "--custom-template-dir": "Use custom Jinja2 templates for model generation.", "--dataclass-arguments": "Customize dataclass decorator arguments via JSON dictionary.", + "--default-values": "Override field default values from external JSON file.", "--disable-appending-item-suffix": "Disable appending 'Item' suffix to array item types.", "--disable-future-imports": "Prevent automatic addition of __future__ imports in generated code.", "--disable-timestamp": "Disable timestamp in generated file header for reproducible output.", From f59ca581eb9abc0dd38a52783708bb26b92c328d Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 14:34:06 +0000 Subject: [PATCH 03/10] Remove line comments and add flat key test coverage --- src/datamodel_code_generator/reference.py | 2 -- tests/data/default_values/graphql_user_defaults.json | 3 ++- .../main/graphql/default_values_required_use_default.py | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/datamodel_code_generator/reference.py b/src/datamodel_code_generator/reference.py index 88f5fbc8d..d6db8bfd0 100644 --- a/src/datamodel_code_generator/reference.py +++ b/src/datamodel_code_generator/reference.py @@ -1223,13 +1223,11 @@ def resolve_default_value( if not self.default_value_overrides: return original_default, has_default - # Try scoped lookup first (ClassName.field_name) if class_name: scoped_key = f"{class_name}.{field_name}" if scoped_key in self.default_value_overrides: return self.default_value_overrides[scoped_key], True - # Fall back to flat lookup (field_name) if field_name in self.default_value_overrides: return self.default_value_overrides[field_name], True diff --git a/tests/data/default_values/graphql_user_defaults.json b/tests/data/default_values/graphql_user_defaults.json index 2932bda59..585d4227e 100644 --- a/tests/data/default_values/graphql_user_defaults.json +++ b/tests/data/default_values/graphql_user_defaults.json @@ -1,3 +1,4 @@ { - "User.status": "active" + "User.status": "active", + "name": "default_user" } 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 6d46087b2..52b082996 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 @@ -28,6 +28,6 @@ class User(BaseModel): id: ID - name: String + name: String | None = 'default_user' status: String | None = 'active' typename__: Literal['User'] | None = Field('User', alias='__typename') From 88443d694c3f0df4e9019ec1b1a125a94a450987 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 14:39:49 +0000 Subject: [PATCH 04/10] Remove line comments from parser files --- src/datamodel_code_generator/parser/graphql.py | 2 -- src/datamodel_code_generator/parser/jsonschema.py | 2 -- src/datamodel_code_generator/parser/openapi.py | 4 ---- 3 files changed, 8 deletions(-) diff --git a/src/datamodel_code_generator/parser/graphql.py b/src/datamodel_code_generator/parser/graphql.py index 816294b4e..bb917c1b9 100644 --- a/src/datamodel_code_generator/parser/graphql.py +++ b/src/datamodel_code_generator/parser/graphql.py @@ -378,7 +378,6 @@ def parse_field( default = self._get_default(field, final_data_type, required=required) has_default = default is not None - # Resolve default value override (GraphQL uses original type name for scoping) effective_default, effective_has_default = self.model_resolver.resolve_default_value( original_field_name, default, @@ -386,7 +385,6 @@ def parse_field( class_name=class_name, ) - # Recalculate required if default was added via override if self.apply_default_values_for_required_fields and effective_has_default: required = False diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index 514595133..1e97f07ed 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -1041,7 +1041,6 @@ def get_object_field( # noqa: PLR0913 effective_has_default: bool | None = None, ) -> DataModelFieldBase: """Create a data model field from a JSON Schema object field.""" - # Use effective values if provided, otherwise fall back to field values default_value = effective_default if effective_has_default is not None else field.default has_default = effective_has_default if effective_has_default is not None else field.has_default @@ -2418,7 +2417,6 @@ def parse_object_fields( field_type = self.parse_item(modular_name, field, [*path, field_name]) - # Resolve default value override effective_default, effective_has_default = self.model_resolver.resolve_default_value( original_field_name, field.default, diff --git a/src/datamodel_code_generator/parser/openapi.py b/src/datamodel_code_generator/parser/openapi.py index 268ee4934..3a711183b 100644 --- a/src/datamodel_code_generator/parser/openapi.py +++ b/src/datamodel_code_generator/parser/openapi.py @@ -535,14 +535,12 @@ def parse_all_parameters( # noqa: PLR0912, PLR0914 class_name=name, ) if parameter.schema_: - # Resolve default value override for parameter.schema_ branch effective_default, effective_has_default = self.model_resolver.resolve_default_value( parameter_name, parameter.schema_.default, parameter.schema_.has_default, class_name=reference.name, ) - # Recalculate required if default was added via override effective_required = parameter.required if self.apply_default_values_for_required_fields and effective_has_default: effective_required = False @@ -584,7 +582,6 @@ def parse_all_parameters( # noqa: PLR0912, PLR0914 data_type = self.data_type(data_types=data_types) # multiple data_type parse as non-constraints field object_schema = None - # Resolve default value override for parameter.content branch original_default = object_schema.default if object_schema else None original_has_default = object_schema.has_default if object_schema else False effective_default, effective_has_default = self.model_resolver.resolve_default_value( @@ -593,7 +590,6 @@ def parse_all_parameters( # noqa: PLR0912, PLR0914 original_has_default, class_name=reference.name, ) - # Recalculate required if default was added via override effective_required = parameter.required if self.apply_default_values_for_required_fields and effective_has_default: effective_required = False From 1126a80182de0af601dea65b4ee34a1853c14f31 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 14:46:06 +0000 Subject: [PATCH 05/10] Simplify resolve_default_value by removing redundant class_name check --- src/datamodel_code_generator/reference.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/datamodel_code_generator/reference.py b/src/datamodel_code_generator/reference.py index d6db8bfd0..69533d592 100644 --- a/src/datamodel_code_generator/reference.py +++ b/src/datamodel_code_generator/reference.py @@ -1223,10 +1223,9 @@ def resolve_default_value( if not self.default_value_overrides: return original_default, has_default - if class_name: - scoped_key = f"{class_name}.{field_name}" - if scoped_key in self.default_value_overrides: - return self.default_value_overrides[scoped_key], True + scoped_key = f"{class_name}.{field_name}" + if scoped_key in self.default_value_overrides: + return self.default_value_overrides[scoped_key], True if field_name in self.default_value_overrides: return self.default_value_overrides[field_name], True From 9bf099862be4b76634e7c46712e3c67450d2d02b Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 14:54:22 +0000 Subject: [PATCH 06/10] DRY JSON config loading with _load_json_config helper --- src/datamodel_code_generator/__main__.py | 120 +++++++++++++---------- 1 file changed, 67 insertions(+), 53 deletions(-) diff --git a/src/datamodel_code_generator/__main__.py b/src/datamodel_code_generator/__main__.py index 2667068e3..d3757b6b4 100644 --- a/src/datamodel_code_generator/__main__.py +++ b/src/datamodel_code_generator/__main__.py @@ -39,7 +39,7 @@ import tempfile import warnings from collections import defaultdict -from collections.abc import Sequence # noqa: TC003 # pydantic needs it +from collections.abc import Callable, Sequence # noqa: TC003 # pydantic needs it from enum import IntEnum from io import TextIOBase from pathlib import Path @@ -854,6 +854,38 @@ def generate_cli_command(config: dict[str, TomlValue]) -> str: return " ".join(parts) + "\n" +def _load_json_config( + file_handle: TextIOBase | None, + name: str, + validator: Callable[[Any], str | None], +) -> tuple[dict[str, Any] | None, str | None]: + """Load and validate a JSON configuration file. + + Args: + file_handle: The file handle to read from, or None. + name: The name of the config for error messages. + validator: A function that validates the loaded data and returns an error message or None. + + Returns: + A tuple of (loaded_dict, error_message). If successful, error_message is None. + If file_handle is None, returns (None, None). + """ + if file_handle is None: + return None, None + + with file_handle as data: + try: + result = json.load(data) + except json.JSONDecodeError as e: + return None, f"Unable to load {name}: {e}" + + error = validator(result) + if error: + return None, f"Unable to load {name}: {error}" + + return result, None + + def run_generate_from_config( # noqa: PLR0913, PLR0917 config: Config, input_: Path | str | ParseResult, @@ -1173,63 +1205,45 @@ def main(args: Sequence[str] | None = None) -> Exit: # noqa: PLR0911, PLR0912, else: config.additional_imports = list(config.additional_imports) + additional_imports_from_template_data - if config.aliases is None: - aliases = None - else: - with config.aliases as data: - try: - aliases = json.load(data) - except json.JSONDecodeError as e: - print(f"Unable to load alias mapping: {e}", file=sys.stderr) # noqa: T201 - return Exit.ERROR - if not isinstance(aliases, dict) or not all( + def _validate_aliases(data: Any) -> str | None: + if not isinstance(data, dict) or not all( isinstance(k, str) and (isinstance(v, str) or (isinstance(v, list) and all(isinstance(i, str) for i in v))) - for k, v in aliases.items() + for k, v in data.items() ): - print( # noqa: T201 - "Alias mapping must be a JSON mapping with string keys and string or list of strings values " - '(e.g. {"from": "to", "field": ["alias1", "alias2"]})', - file=sys.stderr, + return ( + "must be a JSON mapping with string keys and string or list of strings values " + '(e.g. {"from": "to", "field": ["alias1", "alias2"]})' ) - return Exit.ERROR + return None - default_value_overrides: dict[str, Any] | None - if config.default_values is None: - default_value_overrides = None - else: - with config.default_values as data: - try: - default_value_overrides = json.load(data) - except json.JSONDecodeError as e: - print(f"Unable to load default values mapping: {e}", file=sys.stderr) # noqa: T201 - return Exit.ERROR - if not isinstance(default_value_overrides, dict): - print("Unable to load default values mapping: must be a JSON object", file=sys.stderr) # noqa: T201 - return Exit.ERROR - if not all(isinstance(k, str) for k in default_value_overrides): # pragma: no cover - print("Unable to load default values mapping: all keys must be strings", file=sys.stderr) # noqa: T201 - return Exit.ERROR # pragma: no cover + def _validate_string_key_dict(data: Any) -> str | None: + if not isinstance(data, dict) or not all(isinstance(k, str) for k in data): + return "must be a JSON object with string keys" + return None - if config.custom_formatters_kwargs is None: - custom_formatters_kwargs = None - else: - with config.custom_formatters_kwargs as data: - try: - custom_formatters_kwargs = json.load(data) - except json.JSONDecodeError as e: # pragma: no cover - print( # noqa: T201 - f"Unable to load custom_formatters_kwargs mapping: {e}", - file=sys.stderr, - ) - return Exit.ERROR - if not isinstance(custom_formatters_kwargs, dict) or not all( - isinstance(k, str) and isinstance(v, str) for k, v in custom_formatters_kwargs.items() - ): # pragma: no cover - print( # noqa: T201 - 'Custom formatters kwargs mapping must be a JSON string mapping (e.g. {"from": "to", ...})', - file=sys.stderr, - ) - return Exit.ERROR + def _validate_string_mapping(data: Any) -> str | None: + if not isinstance(data, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in data.items()): + return 'must be a JSON string mapping (e.g. {"key": "value", ...})' + return None + + aliases, error = _load_json_config(config.aliases, "alias mapping", _validate_aliases) + if error: + print(error, file=sys.stderr) # noqa: T201 + return Exit.ERROR + + default_value_overrides, error = _load_json_config( + config.default_values, "default values mapping", _validate_string_key_dict + ) + if error: + print(error, file=sys.stderr) # noqa: T201 + return Exit.ERROR + + custom_formatters_kwargs, error = _load_json_config( + config.custom_formatters_kwargs, "custom_formatters_kwargs mapping", _validate_string_mapping + ) + if error: + print(error, file=sys.stderr) # noqa: T201 + return Exit.ERROR if config.check: config_output = cast("Path", config.output) From afca73b005d94e020587af40bc0afe49a5743377 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 14:57:45 +0000 Subject: [PATCH 07/10] Add test for custom_formatters_kwargs validation error --- .../invalid_formatters_kwargs.json | 1 + tests/main/test_main_general.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 tests/data/default_values/invalid_formatters_kwargs.json diff --git a/tests/data/default_values/invalid_formatters_kwargs.json b/tests/data/default_values/invalid_formatters_kwargs.json new file mode 100644 index 000000000..ecbd3f09e --- /dev/null +++ b/tests/data/default_values/invalid_formatters_kwargs.json @@ -0,0 +1 @@ +{"key": 123} diff --git a/tests/main/test_main_general.py b/tests/main/test_main_general.py index a1dda5da7..80f2e7156 100644 --- a/tests/main/test_main_general.py +++ b/tests/main/test_main_general.py @@ -2207,3 +2207,19 @@ def test_default_values_non_dict(output_file: Path, capsys: pytest.CaptureFixtur capsys=capsys, expected_stderr_contains="Unable to load default values mapping: must be a JSON object", ) + + +def test_custom_formatters_kwargs_invalid(output_file: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Test --custom-formatters-kwargs with non-string values returns error.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "person.json", + output_path=output_file, + input_file_type="jsonschema", + extra_args=[ + "--custom-formatters-kwargs", + str(DEFAULT_VALUES_DATA_PATH / "invalid_formatters_kwargs.json"), + ], + expected_exit=Exit.ERROR, + capsys=capsys, + expected_stderr_contains="Unable to load custom_formatters_kwargs mapping: must be a JSON string mapping", + ) From 169bacff858cefcc501e785d1367ad6e7b7afa3d Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 15:15:00 +0000 Subject: [PATCH 08/10] Handle class_name=None edge case and add allOf inheritance test --- src/datamodel_code_generator/reference.py | 22 +++---------------- tests/data/default_values/allof_defaults.json | 4 ++++ .../jsonschema_default_values_allof.py | 21 ++++++++++++++++++ .../data/jsonschema/default_values_allof.json | 22 +++++++++++++++++++ tests/main/jsonschema/test_main_jsonschema.py | 16 ++++++++++++++ 5 files changed, 66 insertions(+), 19 deletions(-) create mode 100644 tests/data/default_values/allof_defaults.json create mode 100644 tests/data/expected/main/jsonschema/jsonschema_default_values_allof.py create mode 100644 tests/data/jsonschema/default_values_allof.json diff --git a/src/datamodel_code_generator/reference.py b/src/datamodel_code_generator/reference.py index 69533d592..c425d4576 100644 --- a/src/datamodel_code_generator/reference.py +++ b/src/datamodel_code_generator/reference.py @@ -1201,26 +1201,10 @@ def resolve_default_value( field_name: str, original_default: Any, has_default: bool, # noqa: FBT001 - class_name: str | None = None, + class_name: str | None, ) -> tuple[Any, bool]: - """Resolve default value for a field, applying overrides if configured. - - Supports hierarchical override resolution with the following priority: - 1. Scoped overrides (ClassName.field_name) - class-level specificity - 2. Flat overrides (field_name) - applies to all occurrences - - Args: - field_name: The original field name from the schema. - original_default: The default value from the schema. - has_default: Whether the schema defined a default value. - class_name: Optional class name for scoped override resolution. - - Returns: - A tuple of (effective_default, effective_has_default) where: - - effective_default: The resolved default value to use. - - effective_has_default: Whether a default value is now present. - """ - if not self.default_value_overrides: + """Resolve default value for a field, applying overrides if configured.""" + if not self.default_value_overrides or class_name is None: return original_default, has_default scoped_key = f"{class_name}.{field_name}" diff --git a/tests/data/default_values/allof_defaults.json b/tests/data/default_values/allof_defaults.json new file mode 100644 index 000000000..ee6f95405 --- /dev/null +++ b/tests/data/default_values/allof_defaults.json @@ -0,0 +1,4 @@ +{ + "Child.child_field": "child_default", + "base_field": "base_default" +} diff --git a/tests/data/expected/main/jsonschema/jsonschema_default_values_allof.py b/tests/data/expected/main/jsonschema/jsonschema_default_values_allof.py new file mode 100644 index 000000000..f4a6fa338 --- /dev/null +++ b/tests/data/expected/main/jsonschema/jsonschema_default_values_allof.py @@ -0,0 +1,21 @@ +# generated by datamodel-codegen: +# filename: default_values_allof.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel + + +class Model(BaseModel): + __root__: Any + + +class Base(BaseModel): + base_field: str | None = 'base_default' + + +class Child(Base): + child_field: str | None = 'child_default' diff --git a/tests/data/jsonschema/default_values_allof.json b/tests/data/jsonschema/default_values_allof.json new file mode 100644 index 000000000..10784b02f --- /dev/null +++ b/tests/data/jsonschema/default_values_allof.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Base": { + "type": "object", + "properties": { + "base_field": {"type": "string"} + } + }, + "Child": { + "allOf": [ + {"$ref": "#/definitions/Base"}, + { + "type": "object", + "properties": { + "child_field": {"type": "string"} + } + } + ] + } + } +} diff --git a/tests/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index 46bc4b103..bb177ea31 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -7483,6 +7483,22 @@ def test_main_jsonschema_default_values_override(output_file: Path) -> None: ) +def test_main_jsonschema_default_values_allof(output_file: Path) -> None: + """Test default value overrides with allOf inheritance.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "default_values_allof.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="jsonschema_default_values_allof.py", + extra_args=[ + "--use-default", + "--default-values", + str(DEFAULT_VALUES_DATA_PATH / "allof_defaults.json"), + ], + ) + + def test_ref_nullable_only_no_duplicate_model(output_file: Path) -> None: """Test that $ref + nullable: true does not create duplicate models. From 2ec4de8e72bb50966e61577cbf8880330f206ecb Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 15:28:14 +0000 Subject: [PATCH 09/10] Fix flat overrides skipped when class_name is None --- src/datamodel_code_generator/reference.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/datamodel_code_generator/reference.py b/src/datamodel_code_generator/reference.py index c425d4576..b8b340360 100644 --- a/src/datamodel_code_generator/reference.py +++ b/src/datamodel_code_generator/reference.py @@ -1204,12 +1204,13 @@ def resolve_default_value( class_name: str | None, ) -> tuple[Any, bool]: """Resolve default value for a field, applying overrides if configured.""" - if not self.default_value_overrides or class_name is None: + if not self.default_value_overrides: return original_default, has_default - scoped_key = f"{class_name}.{field_name}" - if scoped_key in self.default_value_overrides: - return self.default_value_overrides[scoped_key], True + if class_name is not None: + scoped_key = f"{class_name}.{field_name}" + if scoped_key in self.default_value_overrides: + return self.default_value_overrides[scoped_key], True if field_name in self.default_value_overrides: return self.default_value_overrides[field_name], True From 0f88967e4acf514676cf1377818f3f72ba310a87 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Fri, 2 Jan 2026 16:08:29 +0000 Subject: [PATCH 10/10] Simplify resolve_default_value scoped key logic --- src/datamodel_code_generator/reference.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/datamodel_code_generator/reference.py b/src/datamodel_code_generator/reference.py index b8b340360..019601148 100644 --- a/src/datamodel_code_generator/reference.py +++ b/src/datamodel_code_generator/reference.py @@ -1207,10 +1207,9 @@ def resolve_default_value( if not self.default_value_overrides: return original_default, has_default - if class_name is not None: - scoped_key = f"{class_name}.{field_name}" - if scoped_key in self.default_value_overrides: - return self.default_value_overrides[scoped_key], True + scoped_key = f"{class_name}.{field_name}" if class_name else None + if scoped_key and scoped_key in self.default_value_overrides: + return self.default_value_overrides[scoped_key], True if field_name in self.default_value_overrides: return self.default_value_overrides[field_name], True