Skip to content

Commit 689cc79

Browse files
Add --default-values CLI option for overriding field defaults (#2897)
* Add --default-values CLI option for overriding field defaults * docs: update CLI reference documentation and prompt data 🤖 Generated by GitHub Actions * Remove line comments and add flat key test coverage * Remove line comments from parser files * Simplify resolve_default_value by removing redundant class_name check * DRY JSON config loading with _load_json_config helper * Add test for custom_formatters_kwargs validation error * Handle class_name=None edge case and add allOf inheritance test * Fix flat overrides skipped when class_name is None * Simplify resolve_default_value scoped key logic --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 99aac7b commit 689cc79

38 files changed

Lines changed: 597 additions & 58 deletions

docs/cli-reference/field-customization.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
|--------|-------------|
77
| [`--aliases`](#aliases) | Apply custom field and class name aliases from JSON file. |
88
| [`--capitalize-enum-members`](#capitalize-enum-members) | Capitalize enum member names to UPPER_CASE format. |
9+
| [`--default-values`](#default-values) | Override field default values from external JSON file. |
910
| [`--empty-enum-field-name`](#empty-enum-field-name) | Name for empty string enum field values. |
1011
| [`--extra-fields`](#extra-fields) | Configure how generated models handle extra fields not defin... |
1112
| [`--field-constraints`](#field-constraints) | Generate Field() with validation constraints from schema. |
@@ -557,6 +558,74 @@ naming conventions for constants.
557558

558559
---
559560

561+
## `--default-values` {#default-values}
562+
563+
Override field default values from external JSON file.
564+
565+
The `--default-values` option allows specifying default values for fields via a JSON file.
566+
Supports scoped format (ClassName.field) for hierarchical overrides.
567+
568+
!!! tip "Usage"
569+
570+
```bash
571+
datamodel-codegen --input schema.json --default-values default_values/scoped_defaults.json # (1)!
572+
```
573+
574+
1. :material-arrow-left: `--default-values` - the option documented here
575+
576+
??? example "Examples"
577+
578+
**Input Schema:**
579+
580+
```json
581+
{
582+
"$schema": "http://json-schema.org/draft-07/schema#",
583+
"definitions": {
584+
"User": {
585+
"type": "object",
586+
"properties": {
587+
"name": {
588+
"type": "string"
589+
},
590+
"status": {
591+
"type": "string"
592+
},
593+
"page": {
594+
"type": "integer"
595+
}
596+
},
597+
"required": ["name"]
598+
}
599+
}
600+
}
601+
```
602+
603+
**Output:**
604+
605+
```python
606+
# generated by datamodel-codegen:
607+
# filename: default_values_override.json
608+
# timestamp: 2019-07-26T00:00:00+00:00
609+
610+
from __future__ import annotations
611+
612+
from typing import Any
613+
614+
from pydantic import BaseModel
615+
616+
617+
class Model(BaseModel):
618+
__root__: Any
619+
620+
621+
class User(BaseModel):
622+
name: str
623+
status: str | None = 'active'
624+
page: int | None = 1
625+
```
626+
627+
---
628+
560629
## `--empty-enum-field-name` {#empty-enum-field-name}
561630

562631
Name for empty string enum field values.

docs/cli-reference/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ This documentation is auto-generated from test cases.
1010
|----------|---------|-------------|
1111
| 📁 [Base Options](base-options.md) | 7 | Input/output configuration |
1212
| 🔧 [Typing Customization](typing-customization.md) | 27 | Type annotation and import behavior |
13-
| 🏷️ [Field Customization](field-customization.md) | 22 | Field naming and docstring behavior |
13+
| 🏷️ [Field Customization](field-customization.md) | 23 | Field naming and docstring behavior |
1414
| 🏗️ [Model Customization](model-customization.md) | 39 | Model generation behavior |
1515
| 🎨 [Template Customization](template-customization.md) | 18 | Output formatting and custom rendering |
1616
| 📘 [OpenAPI-only Options](openapi-only-options.md) | 7 | OpenAPI-specific features |
@@ -61,6 +61,7 @@ This documentation is auto-generated from test cases.
6161

6262
- [`--dataclass-arguments`](model-customization.md#dataclass-arguments)
6363
- [`--debug`](utility-options.md#debug)
64+
- [`--default-values`](field-customization.md#default-values)
6465
- [`--disable-appending-item-suffix`](template-customization.md#disable-appending-item-suffix)
6566
- [`--disable-future-imports`](typing-customization.md#disable-future-imports)
6667
- [`--disable-timestamp`](template-customization.md#disable-timestamp)

docs/cli-reference/quick-reference.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ datamodel-codegen [OPTIONS]
6262
|--------|-------------|
6363
| [`--aliases`](field-customization.md#aliases) | Apply custom field and class name aliases from JSON file. |
6464
| [`--capitalize-enum-members`](field-customization.md#capitalize-enum-members) | Capitalize enum member names to UPPER_CASE format. |
65+
| [`--default-values`](field-customization.md#default-values) | Override field default values from external JSON file. |
6566
| [`--empty-enum-field-name`](field-customization.md#empty-enum-field-name) | Name for empty string enum field values. |
6667
| [`--extra-fields`](field-customization.md#extra-fields) | Configure how generated models handle extra fields not defined in schema. |
6768
| [`--field-constraints`](field-customization.md#field-constraints) | Generate Field() with validation constraints from schema. |
@@ -232,6 +233,7 @@ All options sorted alphabetically:
232233
- [`--custom-template-dir`](template-customization.md#custom-template-dir) - Use custom Jinja2 templates for model generation.
233234
- [`--dataclass-arguments`](model-customization.md#dataclass-arguments) - Customize dataclass decorator arguments via JSON dictionary.
234235
- [`--debug`](utility-options.md#debug) - Show debug messages during code generation
236+
- [`--default-values`](field-customization.md#default-values) - Override field default values from external JSON file.
235237
- [`--disable-appending-item-suffix`](template-customization.md#disable-appending-item-suffix) - Disable appending 'Item' suffix to array item types.
236238
- [`--disable-future-imports`](typing-customization.md#disable-future-imports) - Prevent automatic addition of __future__ imports in generate...
237239
- [`--disable-timestamp`](template-customization.md#disable-timestamp) - Disable timestamp in generated file header for reproducible ...

src/datamodel_code_generator/__main__.py

Lines changed: 76 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
import tempfile
4040
import warnings
4141
from collections import defaultdict
42-
from collections.abc import Sequence # noqa: TC003 # pydantic needs it
42+
from collections.abc import Callable, Sequence # noqa: TC003 # pydantic needs it
4343
from enum import IntEnum
4444
from io import TextIOBase
4545
from pathlib import Path
@@ -176,7 +176,7 @@ def get_fields(cls) -> dict[str, Any]:
176176
"""Get model fields."""
177177
return cls.__fields__
178178

179-
@field_validator("aliases", "extra_template_data", "custom_formatters_kwargs", mode="before")
179+
@field_validator("aliases", "extra_template_data", "custom_formatters_kwargs", "default_values", mode="before")
180180
def validate_file(cls, value: Any) -> TextIOBase | None: # noqa: N805
181181
"""Validate and open file path."""
182182
if value is None: # pragma: no cover
@@ -503,6 +503,7 @@ def validate_class_name_affix_scope(cls, v: str | ClassNameAffixScope | None) ->
503503
snake_case_field: bool = False
504504
strip_default_none: bool = False
505505
aliases: Optional[TextIOBase] = None # noqa: UP045
506+
default_values: Optional[TextIOBase] = None # noqa: UP045
506507
disable_timestamp: bool = False
507508
enable_version_header: bool = False
508509
enable_command_header: bool = False
@@ -854,6 +855,38 @@ def generate_cli_command(config: dict[str, TomlValue]) -> str:
854855
return " ".join(parts) + "\n"
855856

856857

858+
def _load_json_config(
859+
file_handle: TextIOBase | None,
860+
name: str,
861+
validator: Callable[[Any], str | None],
862+
) -> tuple[dict[str, Any] | None, str | None]:
863+
"""Load and validate a JSON configuration file.
864+
865+
Args:
866+
file_handle: The file handle to read from, or None.
867+
name: The name of the config for error messages.
868+
validator: A function that validates the loaded data and returns an error message or None.
869+
870+
Returns:
871+
A tuple of (loaded_dict, error_message). If successful, error_message is None.
872+
If file_handle is None, returns (None, None).
873+
"""
874+
if file_handle is None:
875+
return None, None
876+
877+
with file_handle as data:
878+
try:
879+
result = json.load(data)
880+
except json.JSONDecodeError as e:
881+
return None, f"Unable to load {name}: {e}"
882+
883+
error = validator(result)
884+
if error:
885+
return None, f"Unable to load {name}: {error}"
886+
887+
return result, None
888+
889+
857890
def run_generate_from_config( # noqa: PLR0913, PLR0917
858891
config: Config,
859892
input_: Path | str | ParseResult,
@@ -863,6 +896,7 @@ def run_generate_from_config( # noqa: PLR0913, PLR0917
863896
command_line: str | None,
864897
custom_formatters_kwargs: dict[str, str] | None,
865898
settings_path: Path | None = None,
899+
default_value_overrides: dict[str, Any] | None = None,
866900
) -> None:
867901
"""Run code generation with the given config and parameters."""
868902
result = generate(
@@ -990,6 +1024,7 @@ def run_generate_from_config( # noqa: PLR0913, PLR0917
9901024
all_exports_collision_strategy=config.all_exports_collision_strategy,
9911025
field_type_collision_strategy=config.field_type_collision_strategy,
9921026
module_split_mode=config.module_split_mode,
1027+
default_value_overrides=default_value_overrides,
9931028
)
9941029

9951030
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,
11751210
else:
11761211
config.additional_imports = list(config.additional_imports) + additional_imports_from_template_data
11771212

1178-
if config.aliases is None:
1179-
aliases = None
1180-
else:
1181-
with config.aliases as data:
1182-
try:
1183-
aliases = json.load(data)
1184-
except json.JSONDecodeError as e:
1185-
print(f"Unable to load alias mapping: {e}", file=sys.stderr) # noqa: T201
1186-
return Exit.ERROR
1187-
if not isinstance(aliases, dict) or not all(
1213+
def _validate_aliases(data: Any) -> str | None:
1214+
if not isinstance(data, dict) or not all(
11881215
isinstance(k, str) and (isinstance(v, str) or (isinstance(v, list) and all(isinstance(i, str) for i in v)))
1189-
for k, v in aliases.items()
1216+
for k, v in data.items()
11901217
):
1191-
print( # noqa: T201
1192-
"Alias mapping must be a JSON mapping with string keys and string or list of strings values "
1193-
'(e.g. {"from": "to", "field": ["alias1", "alias2"]})',
1194-
file=sys.stderr,
1218+
return (
1219+
"must be a JSON mapping with string keys and string or list of strings values "
1220+
'(e.g. {"from": "to", "field": ["alias1", "alias2"]})'
11951221
)
1196-
return Exit.ERROR
1222+
return None
11971223

1198-
if config.custom_formatters_kwargs is None:
1199-
custom_formatters_kwargs = None
1200-
else:
1201-
with config.custom_formatters_kwargs as data:
1202-
try:
1203-
custom_formatters_kwargs = json.load(data)
1204-
except json.JSONDecodeError as e: # pragma: no cover
1205-
print( # noqa: T201
1206-
f"Unable to load custom_formatters_kwargs mapping: {e}",
1207-
file=sys.stderr,
1208-
)
1209-
return Exit.ERROR
1210-
if not isinstance(custom_formatters_kwargs, dict) or not all(
1211-
isinstance(k, str) and isinstance(v, str) for k, v in custom_formatters_kwargs.items()
1212-
): # pragma: no cover
1213-
print( # noqa: T201
1214-
'Custom formatters kwargs mapping must be a JSON string mapping (e.g. {"from": "to", ...})',
1215-
file=sys.stderr,
1216-
)
1217-
return Exit.ERROR
1224+
def _validate_string_key_dict(data: Any) -> str | None:
1225+
if not isinstance(data, dict) or not all(isinstance(k, str) for k in data):
1226+
return "must be a JSON object with string keys"
1227+
return None
1228+
1229+
def _validate_string_mapping(data: Any) -> str | None:
1230+
if not isinstance(data, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in data.items()):
1231+
return 'must be a JSON string mapping (e.g. {"key": "value", ...})'
1232+
return None
1233+
1234+
aliases, error = _load_json_config(config.aliases, "alias mapping", _validate_aliases)
1235+
if error:
1236+
print(error, file=sys.stderr) # noqa: T201
1237+
return Exit.ERROR
1238+
1239+
default_value_overrides, error = _load_json_config(
1240+
config.default_values, "default values mapping", _validate_string_key_dict
1241+
)
1242+
if error:
1243+
print(error, file=sys.stderr) # noqa: T201
1244+
return Exit.ERROR
1245+
1246+
custom_formatters_kwargs, error = _load_json_config(
1247+
config.custom_formatters_kwargs, "custom_formatters_kwargs mapping", _validate_string_mapping
1248+
)
1249+
if error:
1250+
print(error, file=sys.stderr) # noqa: T201
1251+
return Exit.ERROR
12181252

12191253
if config.check:
12201254
config_output = cast("Path", config.output)
@@ -1260,6 +1294,7 @@ def main(args: Sequence[str] | None = None) -> Exit: # noqa: PLR0911, PLR0912,
12601294
command_line=shlex.join(["datamodel-codegen", *args]) if config.enable_command_header else None,
12611295
custom_formatters_kwargs=custom_formatters_kwargs,
12621296
settings_path=config.output,
1297+
default_value_overrides=default_value_overrides,
12631298
)
12641299
except InvalidClassNameError as e:
12651300
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,
13081343
try:
13091344
from datamodel_code_generator.watch import watch_and_regenerate # noqa: PLC0415
13101345

1311-
return watch_and_regenerate(config, extra_template_data, aliases, custom_formatters_kwargs)
1346+
return watch_and_regenerate(
1347+
config, extra_template_data, aliases, custom_formatters_kwargs, default_value_overrides
1348+
)
13121349
except Exception as e: # noqa: BLE001
13131350
print(str(e), file=sys.stderr) # noqa: T201
13141351
return Exit.ERROR

src/datamodel_code_generator/_types/generate_config_dict.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,4 @@ class GenerateConfigDict(TypedDict):
164164
all_exports_collision_strategy: NotRequired[AllExportsCollisionStrategy | None]
165165
field_type_collision_strategy: NotRequired[FieldTypeCollisionStrategy | None]
166166
module_split_mode: NotRequired[ModuleSplitMode | None]
167+
default_value_overrides: NotRequired[Mapping[str, Any] | None]

src/datamodel_code_generator/_types/parser_config_dicts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ class ParserConfigDict(TypedDict):
147147
read_only_write_only_model_type: NotRequired[ReadOnlyWriteOnlyModelType | None]
148148
field_type_collision_strategy: NotRequired[FieldTypeCollisionStrategy | None]
149149
target_pydantic_version: NotRequired[TargetPydanticVersion | None]
150+
default_value_overrides: NotRequired[Mapping[str, Any] | None]
150151

151152

152153
class GraphQLParserConfigDict(ParserConfigDict):

src/datamodel_code_generator/arguments.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,6 +826,18 @@ def start_section(self, heading: str | None) -> None:
826826
"Example: {'User.name': 'user_name', 'id': 'id_'} generates `id_: ... = Field(alias='id')`.",
827827
type=Path,
828828
)
829+
template_options.add_argument(
830+
"--default-values",
831+
help="Default value overrides file (JSON). "
832+
"Supports hierarchical formats: "
833+
"Flat: {'field': value} applies to all occurrences. "
834+
"Scoped: {'ClassName.field': value} applies to specific class. "
835+
"Priority: scoped > flat. "
836+
"Note: Scoped keys use the generated class name for JSON Schema/OpenAPI. "
837+
"Required fields remain required unless --use-default is also specified. "
838+
"Example: {'User.status': 'active', 'page': 1, 'limit': 10}",
839+
type=Path,
840+
)
829841
template_options.add_argument(
830842
"--custom-file-header",
831843
help="Custom file header",

src/datamodel_code_generator/cli_options.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ class CLIOptionMeta:
140140
"--empty-enum-field-name": CLIOptionMeta(name="--empty-enum-field-name", category=OptionCategory.FIELD),
141141
"--set-default-enum-member": CLIOptionMeta(name="--set-default-enum-member", category=OptionCategory.FIELD),
142142
"--aliases": CLIOptionMeta(name="--aliases", category=OptionCategory.FIELD),
143+
"--default-values": CLIOptionMeta(name="--default-values", category=OptionCategory.FIELD),
143144
"--no-alias": CLIOptionMeta(name="--no-alias", category=OptionCategory.FIELD),
144145
"--use-title-as-name": CLIOptionMeta(name="--use-title-as-name", category=OptionCategory.FIELD),
145146
"--use-schema-description": CLIOptionMeta(name="--use-schema-description", category=OptionCategory.FIELD),

src/datamodel_code_generator/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ class Config:
201201
all_exports_collision_strategy: AllExportsCollisionStrategy | None = None
202202
field_type_collision_strategy: FieldTypeCollisionStrategy | None = None
203203
module_split_mode: ModuleSplitMode | None = None
204+
default_value_overrides: Mapping[str, Any] | None = None
204205

205206

206207
class ParserConfig(BaseModel):
@@ -330,6 +331,7 @@ class Config:
330331
read_only_write_only_model_type: ReadOnlyWriteOnlyModelType | None = None
331332
field_type_collision_strategy: FieldTypeCollisionStrategy | None = None
332333
target_pydantic_version: TargetPydanticVersion | None = None
334+
default_value_overrides: Mapping[str, Any] | None = None
333335

334336

335337
class GraphQLParserConfig(ParserConfig):

src/datamodel_code_generator/parser/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -897,6 +897,7 @@ def __init__( # noqa: PLR0912, PLR0915
897897
class_name_suffix=config.class_name_suffix,
898898
class_name_affix_scope=config.class_name_affix_scope,
899899
skip_affix_for_root=config.class_name is not None,
900+
default_value_overrides=config.default_value_overrides,
900901
)
901902
self.class_name: str | None = config.class_name
902903
self.wrap_string_literal: bool | None = config.wrap_string_literal

0 commit comments

Comments
 (0)