Skip to content

Commit ae11c41

Browse files
authored
Add --use-generic-base-class option for DRY model config (#2726)
This option creates a shared base class (BaseModel/Struct) with common configuration instead of repeating model_config in every generated model. Features: - Supports pydantic v2 BaseModel and msgspec Struct - Works with module split mode (single BaseModel shared across modules) - Preserves schema inheritance relationships - Handles circular imports via _internal.py pattern
1 parent 7f1f7af commit ae11c41

54 files changed

Lines changed: 1215 additions & 13 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/cli-reference/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ This documentation is auto-generated from test cases.
1111
| 📁 [Base Options](base-options.md) | 5 | Input/output configuration |
1212
| 🔧 [Typing Customization](typing-customization.md) | 17 | Type annotation and import behavior |
1313
| 🏷️ [Field Customization](field-customization.md) | 20 | Field naming and docstring behavior |
14-
| 🏗️ [Model Customization](model-customization.md) | 27 | Model generation behavior |
14+
| 🏗️ [Model Customization](model-customization.md) | 28 | Model generation behavior |
1515
| 🎨 [Template Customization](template-customization.md) | 16 | Output formatting and custom rendering |
1616
| 📘 [OpenAPI-only Options](openapi-only-options.md) | 6 | OpenAPI-specific features |
1717
| ⚙️ [General Options](general-options.md) | 14 | Utilities and meta options |
@@ -167,6 +167,7 @@ This documentation is auto-generated from test cases.
167167
- [`--use-exact-imports`](template-customization.md#use-exact-imports)
168168
- [`--use-field-description`](field-customization.md#use-field-description)
169169
- [`--use-frozen-field`](model-customization.md#use-frozen-field)
170+
- [`--use-generic-base-class`](model-customization.md#use-generic-base-class)
170171
- [`--use-generic-container-types`](typing-customization.md#use-generic-container-types)
171172
- [`--use-inline-field-description`](field-customization.md#use-inline-field-description)
172173
- [`--use-non-positive-negative-number-constrained-types`](typing-customization.md#use-non-positive-negative-number-constrained-types)

docs/cli-reference/model-customization.md

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
| [`--use-default-factory-for-optional-nested-models`](#use-default-factory-for-optional-nested-models) | Generate default_factory for optional nested model fields. |
2929
| [`--use-default-kwarg`](#use-default-kwarg) | Use default= keyword argument instead of positional argument... |
3030
| [`--use-frozen-field`](#use-frozen-field) | Generate frozen (immutable) field definitions for readOnly p... |
31+
| [`--use-generic-base-class`](#use-generic-base-class) | Generate a shared base class with model configuration to avo... |
3132
| [`--use-one-literal-as-default`](#use-one-literal-as-default) | Use single literal value as default when enum has only one o... |
3233
| [`--use-serialize-as-any`](#use-serialize-as-any) | Wrap fields with subtypes in Pydantic's SerializeAsAny. |
3334
| [`--use-subclass-enum`](#use-subclass-enum) | Generate typed Enum subclasses for enums with specific field... |
@@ -5164,6 +5165,109 @@ The `--use-frozen-field` flag generates frozen field definitions:
51645165

51655166
---
51665167

5168+
## `--use-generic-base-class` {#use-generic-base-class}
5169+
5170+
Generate a shared base class with model configuration to avoid repetition (DRY).
5171+
5172+
!!! tip "Usage"
5173+
5174+
```bash
5175+
datamodel-codegen --input schema.json --extra-fields forbid --output-model-type pydantic_v2.BaseModel --use-generic-base-class # (1)!
5176+
```
5177+
5178+
1. :material-arrow-left: `--use-generic-base-class` - the option documented here
5179+
5180+
??? example "Examples"
5181+
5182+
**Input Schema:**
5183+
5184+
```json
5185+
{
5186+
"title": "Test",
5187+
"type": "object",
5188+
"required": [
5189+
"foo"
5190+
],
5191+
"properties": {
5192+
"foo": {
5193+
"type": "object",
5194+
"properties": {
5195+
"x": {
5196+
"type": "integer"
5197+
}
5198+
},
5199+
"additionalProperties": true
5200+
},
5201+
"bar": {
5202+
"type": "object",
5203+
"properties": {
5204+
"y": {
5205+
"type": "integer"
5206+
}
5207+
},
5208+
"additionalProperties": false
5209+
},
5210+
"baz": {
5211+
"type": "object",
5212+
"properties": {
5213+
"z": {
5214+
"type": "integer"
5215+
}
5216+
}
5217+
}
5218+
},
5219+
"additionalProperties": false
5220+
}
5221+
```
5222+
5223+
**Output:**
5224+
5225+
```python
5226+
# generated by datamodel-codegen:
5227+
# filename: extra_fields.json
5228+
# timestamp: 2019-07-26T00:00:00+00:00
5229+
5230+
from __future__ import annotations
5231+
5232+
from pydantic import BaseModel as _BaseModel
5233+
from pydantic import ConfigDict
5234+
5235+
5236+
class BaseModel(_BaseModel):
5237+
model_config = ConfigDict(
5238+
extra='forbid',
5239+
)
5240+
5241+
5242+
class Foo(BaseModel):
5243+
model_config = ConfigDict(
5244+
extra='allow',
5245+
)
5246+
x: int | None = None
5247+
5248+
5249+
class Bar(BaseModel):
5250+
model_config = ConfigDict(
5251+
extra='forbid',
5252+
)
5253+
y: int | None = None
5254+
5255+
5256+
class Baz(BaseModel):
5257+
z: int | None = None
5258+
5259+
5260+
class Test(BaseModel):
5261+
model_config = ConfigDict(
5262+
extra='forbid',
5263+
)
5264+
foo: Foo
5265+
bar: Bar | None = None
5266+
baz: Baz | None = None
5267+
```
5268+
5269+
---
5270+
51675271
## `--use-one-literal-as-default` {#use-one-literal-as-default}
51685272

51695273
Use single literal value as default when enum has only one option.

docs/cli-reference/quick-reference.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ datamodel-codegen [OPTIONS]
9797
| [`--use-default-factory-for-optional-nested-models`](model-customization.md#use-default-factory-for-optional-nested-models) | Generate default_factory for optional nested model fields. |
9898
| [`--use-default-kwarg`](model-customization.md#use-default-kwarg) | Use default= keyword argument instead of positional argument for fields with def... |
9999
| [`--use-frozen-field`](model-customization.md#use-frozen-field) | Generate frozen (immutable) field definitions for readOnly properties. |
100+
| [`--use-generic-base-class`](model-customization.md#use-generic-base-class) | Generate a shared base class with model configuration to avoid repetition (DRY).... |
100101
| [`--use-one-literal-as-default`](model-customization.md#use-one-literal-as-default) | Use single literal value as default when enum has only one option. |
101102
| [`--use-serialize-as-any`](model-customization.md#use-serialize-as-any) | Wrap fields with subtypes in Pydantic's SerializeAsAny. |
102103
| [`--use-subclass-enum`](model-customization.md#use-subclass-enum) | Generate typed Enum subclasses for enums with specific field types. |
@@ -260,6 +261,7 @@ All options sorted alphabetically:
260261
- [`--use-exact-imports`](template-customization.md#use-exact-imports) - Import exact types instead of modules.
261262
- [`--use-field-description`](field-customization.md#use-field-description) - Include schema descriptions as Field docstrings.
262263
- [`--use-frozen-field`](model-customization.md#use-frozen-field) - Generate frozen (immutable) field definitions for readOnly p...
264+
- [`--use-generic-base-class`](model-customization.md#use-generic-base-class) - Generate a shared base class with model configuration to avo...
263265
- [`--use-generic-container-types`](typing-customization.md#use-generic-container-types) - Use typing.Dict/List instead of dict/list for container type...
264266
- [`--use-inline-field-description`](field-customization.md#use-inline-field-description) - Add field descriptions as inline comments.
265267
- [`--use-non-positive-negative-number-constrained-types`](typing-customization.md#use-non-positive-negative-number-constrained-types) - Use NonPositive/NonNegative types for number constraints.

src/datamodel_code_generator/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,7 @@ def generate( # noqa: PLR0912, PLR0913, PLR0914, PLR0915
403403
allow_population_by_field_name: bool = False,
404404
allow_extra_fields: bool = False,
405405
extra_fields: str | None = None,
406+
use_generic_base_class: bool = False,
406407
apply_default_values_for_required_fields: bool = False,
407408
force_optional_for_required_fields: bool = False,
408409
class_name: str | None = None,
@@ -650,6 +651,7 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]:
650651
allow_population_by_field_name=allow_population_by_field_name,
651652
allow_extra_fields=allow_extra_fields,
652653
extra_fields=extra_fields,
654+
use_generic_base_class=use_generic_base_class,
653655
apply_default_values_for_required_fields=apply_default_values_for_required_fields,
654656
force_optional_for_required_fields=force_optional_for_required_fields,
655657
class_name=class_name,

src/datamodel_code_generator/__main__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ def validate_all_exports_collision_strategy(cls, values: dict[str, Any]) -> dict
391391
allow_population_by_field_name: bool = False
392392
allow_extra_fields: bool = False
393393
extra_fields: Optional[str] = None # noqa: UP045
394+
use_generic_base_class: bool = False
394395
use_default: bool = False
395396
force_optional: bool = False
396397
class_name: Optional[str] = None # noqa: UP045
@@ -696,6 +697,7 @@ def run_generate_from_config( # noqa: PLR0913, PLR0917
696697
allow_population_by_field_name=config.allow_population_by_field_name,
697698
allow_extra_fields=config.allow_extra_fields,
698699
extra_fields=config.extra_fields,
700+
use_generic_base_class=config.use_generic_base_class,
699701
apply_default_values_for_required_fields=config.use_default,
700702
force_optional_for_required_fields=config.force_optional,
701703
class_name=config.class_name,

src/datamodel_code_generator/arguments.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,13 @@ def start_section(self, heading: str | None) -> None:
269269
action="store_true",
270270
default=None,
271271
)
272+
model_options.add_argument(
273+
"--use-generic-base-class",
274+
help="Generate a shared base class with model configuration (e.g., extra='forbid') "
275+
"instead of repeating the configuration in each model. Keeps code DRY.",
276+
action="store_true",
277+
default=None,
278+
)
272279
model_options.add_argument(
273280
"--use-schema-description",
274281
help="Use schema description to populate class docstring",

src/datamodel_code_generator/cli_options.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ class CLIOptionMeta:
100100
"--use-one-literal-as-default": CLIOptionMeta(name="--use-one-literal-as-default", category=OptionCategory.MODEL),
101101
"--use-serialize-as-any": CLIOptionMeta(name="--use-serialize-as-any", category=OptionCategory.MODEL),
102102
"--skip-root-model": CLIOptionMeta(name="--skip-root-model", category=OptionCategory.MODEL),
103+
"--use-generic-base-class": CLIOptionMeta(name="--use-generic-base-class", category=OptionCategory.MODEL),
103104
# ==========================================================================
104105
# Field Customization
105106
# ==========================================================================

src/datamodel_code_generator/model/base.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
TEMPLATE_DIR: Path = Path(__file__).parents[0] / "template"
5050

5151
ALL_MODEL: str = "#all#"
52+
GENERIC_BASE_CLASS_PATH: str = "#/__datamodel_code_generator__/generic_base_class__"
53+
GENERIC_BASE_CLASS_NAME: str = "__generic_base_class__"
5254

5355

5456
def repr_set_sorted(value: set[Any]) -> str:
@@ -474,7 +476,8 @@ class DataModel(TemplateBase, Nullable, ABC): # noqa: PLR0904
474476
TEMPLATE_FILE_PATH: ClassVar[str] = ""
475477
BASE_CLASS: ClassVar[str] = ""
476478
DEFAULT_IMPORTS: ClassVar[tuple[Import, ...]] = ()
477-
IS_ALIAS: bool = False
479+
IS_ALIAS: ClassVar[bool] = False
480+
SUPPORTS_GENERIC_BASE_CLASS: ClassVar[bool] = True
478481
has_forward_reference: bool = False
479482

480483
def __init__( # noqa: PLR0913
@@ -707,6 +710,22 @@ def is_alias(self) -> bool:
707710
"""Whether is a type alias (i.e. not an instance of BaseModel/RootModel)."""
708711
return self.IS_ALIAS
709712

713+
@classmethod
714+
def create_base_class_model(
715+
cls,
716+
config: dict[str, Any], # noqa: ARG003
717+
reference: Reference, # noqa: ARG003
718+
custom_template_dir: Path | None = None, # noqa: ARG003
719+
keyword_only: bool = False, # noqa: ARG003, FBT001, FBT002
720+
treat_dot_as_module: bool = False, # noqa: ARG003, FBT001, FBT002
721+
) -> DataModel | None:
722+
"""Create a shared base class model for DRY configuration.
723+
724+
Returns the base model or None if not supported. Updates reference in place.
725+
Each model type should override this to provide appropriate implementation.
726+
"""
727+
return None
728+
710729
@property
711730
def nullable(self) -> bool:
712731
"""Check if this model is nullable."""

src/datamodel_code_generator/model/enum.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class Enum(DataModel):
4242
TEMPLATE_FILE_PATH: ClassVar[str] = "Enum.jinja2"
4343
BASE_CLASS: ClassVar[str] = "enum.Enum"
4444
DEFAULT_IMPORTS: ClassVar[tuple[Import, ...]] = (IMPORT_ENUM,)
45+
SUPPORTS_GENERIC_BASE_CLASS: ClassVar[bool] = False
4546

4647
def __init__( # noqa: PLR0913
4748
self,

src/datamodel_code_generator/model/msgspec.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@
2020
Import,
2121
)
2222
from datamodel_code_generator.model import DataModel, DataModelFieldBase
23-
from datamodel_code_generator.model.base import UNDEFINED
23+
from datamodel_code_generator.model.base import UNDEFINED, BaseClassDataType
2424
from datamodel_code_generator.model.imports import (
2525
IMPORT_MSGSPEC_CONVERT,
2626
IMPORT_MSGSPEC_FIELD,
2727
IMPORT_MSGSPEC_META,
28+
IMPORT_MSGSPEC_STRUCT,
2829
IMPORT_MSGSPEC_UNSET,
2930
IMPORT_MSGSPEC_UNSETTYPE,
3031
)
@@ -109,7 +110,18 @@ class Struct(DataModel):
109110

110111
TEMPLATE_FILE_PATH: ClassVar[str] = "msgspec.jinja2"
111112
BASE_CLASS: ClassVar[str] = "msgspec.Struct"
113+
BASE_CLASS_NAME: ClassVar[str] = "Struct"
114+
BASE_CLASS_ALIAS: ClassVar[str] = "_Struct"
112115
DEFAULT_IMPORTS: ClassVar[tuple[Import, ...]] = ()
116+
CONFIG_MAPPING: ClassVar[dict[tuple[str, Any], tuple[str, Any] | None]] = {
117+
("allow_mutation", False): ("frozen", True),
118+
("extra_fields", "forbid"): ("forbid_unknown_fields", True),
119+
("extra_fields", "allow"): None,
120+
("extra_fields", "ignore"): None,
121+
("allow_extra_fields", True): None,
122+
("allow_population_by_field_name", True): None,
123+
("use_attribute_docstrings", True): None,
124+
}
113125

114126
def __init__( # noqa: PLR0913
115127
self,
@@ -154,6 +166,46 @@ def add_base_class_kwarg(self, name: str, value: str) -> None:
154166
"""Add keyword argument to base class constructor."""
155167
self.extra_template_data["base_class_kwargs"][name] = value
156168

169+
@classmethod
170+
def create_base_class_model(
171+
cls,
172+
config: dict[str, Any],
173+
reference: Reference,
174+
custom_template_dir: Path | None = None,
175+
keyword_only: bool = False, # noqa: FBT001, FBT002
176+
treat_dot_as_module: bool = False, # noqa: FBT001, FBT002
177+
) -> Struct | None:
178+
"""Create a shared base class model for DRY configuration.
179+
180+
Creates a Struct that inherits from msgspec.Struct (aliased as _Struct)
181+
with the specified configuration. Updates the reference path and name in place.
182+
"""
183+
reference.path = f"#/{cls.BASE_CLASS_NAME}"
184+
reference.name = cls.BASE_CLASS_NAME
185+
186+
base_model = cls(
187+
reference=reference,
188+
fields=[],
189+
custom_template_dir=custom_template_dir,
190+
keyword_only=keyword_only,
191+
treat_dot_as_module=treat_dot_as_module,
192+
)
193+
194+
base_model.base_classes = [BaseClassDataType(type=cls.BASE_CLASS_ALIAS)]
195+
196+
for key, value in config.items():
197+
mapping_result = cls.CONFIG_MAPPING.get((key, value))
198+
if mapping_result is None:
199+
continue
200+
mapped_key, mapped_value = mapping_result
201+
base_model.add_base_class_kwarg(mapped_key, str(mapped_value))
202+
203+
base_model._additional_imports.append(
204+
Import(from_=IMPORT_MSGSPEC_STRUCT.from_, import_=IMPORT_MSGSPEC_STRUCT.import_, alias=cls.BASE_CLASS_ALIAS)
205+
)
206+
207+
return base_model
208+
157209

158210
class Constraints(_Constraints):
159211
"""Constraint model for msgspec fields."""

0 commit comments

Comments
 (0)