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 5a1254285..102e6e927 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) | 7 | OpenAPI-specific features | @@ -61,6 +61,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 03e4e869d..00ad3a6d4 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. | @@ -232,6 +233,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 260320fd8..88732746e 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 @@ -176,7 +176,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 @@ -503,6 +503,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 @@ -854,6 +855,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, @@ -863,6 +896,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( @@ -990,6 +1024,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 @@ -1175,46 +1210,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 - 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_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 + + 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) @@ -1260,6 +1294,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 @@ -1308,7 +1343,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 9cc6e20af..7b4e753e3 100644 --- a/src/datamodel_code_generator/_types/generate_config_dict.py +++ b/src/datamodel_code_generator/_types/generate_config_dict.py @@ -164,3 +164,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 062b0cb83..0520866d8 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 f598b8d32..bf15579ee 100644 --- a/src/datamodel_code_generator/arguments.py +++ b/src/datamodel_code_generator/arguments.py @@ -826,6 +826,18 @@ def start_section(self, heading: str | None) -> None: "Example: {'User.name': 'user_name', 'id': 'id_'} generates `id_: ... = Field(alias='id')`.", 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 06b7712a9..499566664 100644 --- a/src/datamodel_code_generator/cli_options.py +++ b/src/datamodel_code_generator/cli_options.py @@ -140,6 +140,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 c0199f674..20d53aae5 100644 --- a/src/datamodel_code_generator/config.py +++ b/src/datamodel_code_generator/config.py @@ -201,6 +201,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): @@ -330,6 +331,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 653382699..a2c1b67e2 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 eaae0185c..347335ae3 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,18 @@ 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 + + effective_default, effective_has_default = self.model_resolver.resolve_default_value( + original_field_name, + default, + has_default, + class_name=class_name, + ) + + 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 +402,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 +416,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 +427,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) if not self.config.graphql_no_typename: diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index 1408c3f85..1e97f07ed 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -1037,8 +1037,13 @@ 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.""" + 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 @@ -1055,7 +1060,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, @@ -1063,7 +1068,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, @@ -1073,7 +1078,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"), @@ -2412,8 +2417,15 @@ def parse_object_fields( field_type = self.parse_item(modular_name, field, [*path, field_name]) + 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: @@ -2426,6 +2438,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 cc7bb328f..3a711183b 100644 --- a/src/datamodel_code_generator/parser/openapi.py +++ b/src/datamodel_code_generator/parser/openapi.py @@ -502,7 +502,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], @@ -535,14 +535,25 @@ def parse_all_parameters( # noqa: PLR0912 class_name=name, ) if parameter.schema_: + effective_default, effective_has_default = self.model_resolver.resolve_default_value( + parameter_name, + parameter.schema_.default, + parameter.schema_.has_default, + class_name=reference.name, + ) + effective_required = parameter.required + if self.apply_default_values_for_required_fields and effective_has_default: + effective_required = False fields.append( self.get_object_field( field_name=field_name, field=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: @@ -571,6 +582,17 @@ 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 + 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, + ) + 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 @@ -581,9 +603,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) @@ -593,9 +615,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, @@ -607,7 +627,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 61b67be36..61011973f 100644 --- a/src/datamodel_code_generator/prompt_data.py +++ b/src/datamodel_code_generator/prompt_data.py @@ -33,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.", diff --git a/src/datamodel_code_generator/reference.py b/src/datamodel_code_generator/reference.py index 966875c24..019601148 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,26 @@ 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, + ) -> tuple[Any, bool]: + """Resolve default value for a field, applying overrides if configured.""" + if not self.default_value_overrides: + return original_default, has_default + + 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 + + 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/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/default_values/graphql_user_defaults.json b/tests/data/default_values/graphql_user_defaults.json new file mode 100644 index 000000000..585d4227e --- /dev/null +++ b/tests/data/default_values/graphql_user_defaults.json @@ -0,0 +1,4 @@ +{ + "User.status": "active", + "name": "default_user" +} 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/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..52b082996 --- /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 | None = 'default_user' + 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 5e288ef79..745ca0693 100644 --- a/tests/data/expected/main/input_model/config_class.py +++ b/tests/data/expected/main/input_model/config_class.py @@ -239,3 +239,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_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/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_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/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 47a02ce87..0ed095fd9 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: @@ -785,6 +785,22 @@ def test_main_graphql_split_graphql_schemas(output_file: Path) -> None: ) +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"), + ], + ) + + @pytest.mark.cli_doc( options=["--graphql-no-typename"], option_description="""Exclude __typename field from generated GraphQL models. diff --git a/tests/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index 3b5c37656..bb177ea31 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, @@ -7457,6 +7458,47 @@ def test_x_python_type_union_anyof(output_file: Path) -> None: ) +@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"), + ], + ) + + +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. diff --git a/tests/main/openapi/test_main_openapi.py b/tests/main/openapi/test_main_openapi.py index da8ab32a3..555161073 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, @@ -4689,6 +4690,25 @@ def test_query_parameters_with_model_config(output_file: Path) -> None: ) +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", + ], + ) + + @pytest.mark.cli_doc( options=["--openapi-include-paths"], option_description="""Filter OpenAPI paths to include in model generation. diff --git a/tests/main/test_main_general.py b/tests/main/test_main_general.py index ed5088939..80f2e7156 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, @@ -2180,3 +2181,45 @@ 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", + ) + + +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", + ) diff --git a/tests/main/test_public_api_signature_baseline.py b/tests/main/test_public_api_signature_baseline.py index b6060c54f..22c206204 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, @@ -209,6 +210,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,