Skip to content

Commit 05e8cf5

Browse files
Add scoped alias support for class-specific field renaming (#2599)
* Add support for field aliases with scoped and flat formats in model generation * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add aliases data path and update test to use it for hierarchical aliases --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent cd0c2d2 commit 05e8cf5

14 files changed

Lines changed: 385 additions & 13 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,11 @@ Model customization:
507507
--use-title-as-name use titles as class names of models
508508

509509
Template customization:
510-
--aliases ALIASES Alias mapping file
510+
--aliases ALIASES Alias mapping file (JSON) for renaming fields. Supports hierarchical
511+
formats: Flat: {''field'': ''alias''} applies to all occurrences.
512+
Scoped: {''ClassName.field'': ''alias''} applies to specific class.
513+
Priority: scoped > flat. Example: {''User.name'': ''user_name'',
514+
''Address.name'': ''addr_name'', ''id'': ''id_''}
511515
--custom-file-header CUSTOM_FILE_HEADER
512516
Custom file header
513517
--custom-file-header-path CUSTOM_FILE_HEADER_PATH

docs/aliases.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Field Aliases
2+
3+
The `--aliases` option allows you to rename fields in the generated models. This is useful when you want to use different Python field names than those defined in the schema while preserving the original names as serialization aliases.
4+
5+
## Basic Usage
6+
7+
```bash
8+
datamodel-codegen --input schema.json --output model.py --aliases aliases.json
9+
```
10+
11+
## Alias File Format
12+
13+
The alias file is a JSON file that maps original field names to their Python aliases.
14+
15+
### Flat Format (Traditional)
16+
17+
The simplest format applies aliases to all fields with the matching name, regardless of which class they belong to:
18+
19+
```json
20+
{
21+
"id": "id_",
22+
"type": "type_",
23+
"class": "class_"
24+
}
25+
```
26+
27+
This will rename all fields named `id` to `id_`, all fields named `type` to `type_`, etc.
28+
29+
### Scoped Format (Class-Specific)
30+
31+
When you have the same field name in multiple classes but want different aliases for each, use the scoped format with `ClassName.field`:
32+
33+
```json
34+
{
35+
"User.name": "user_name",
36+
"Address.name": "address_name",
37+
"name": "default_name"
38+
}
39+
```
40+
41+
**Priority**: Scoped aliases take priority over flat aliases. In the example above:
42+
- `User.name` will be renamed to `user_name`
43+
- `Address.name` will be renamed to `address_name`
44+
- Any other class with a `name` field will use `default_name`
45+
46+
## Example
47+
48+
### Input Schema
49+
50+
```json
51+
{
52+
"type": "object",
53+
"title": "Root",
54+
"properties": {
55+
"name": {"type": "string"},
56+
"user": {
57+
"type": "object",
58+
"title": "User",
59+
"properties": {
60+
"name": {"type": "string"},
61+
"id": {"type": "integer"}
62+
}
63+
},
64+
"address": {
65+
"type": "object",
66+
"title": "Address",
67+
"properties": {
68+
"name": {"type": "string"},
69+
"city": {"type": "string"}
70+
}
71+
}
72+
}
73+
}
74+
```
75+
76+
### Alias File
77+
78+
```json
79+
{
80+
"Root.name": "root_name",
81+
"User.name": "user_name",
82+
"Address.name": "address_name"
83+
}
84+
```
85+
86+
### Generated Output
87+
88+
```python
89+
from pydantic import BaseModel, Field
90+
91+
class User(BaseModel):
92+
user_name: str | None = Field(None, alias='name')
93+
id: int | None = None
94+
95+
class Address(BaseModel):
96+
address_name: str | None = Field(None, alias='name')
97+
city: str | None = None
98+
99+
class Root(BaseModel):
100+
root_name: str | None = Field(None, alias='name')
101+
user: User | None = None
102+
address: Address | None = None
103+
```
104+
105+
## Notes
106+
107+
- The `ClassName` in scoped format must match the generated Python class name (after title conversion)
108+
- When using `--use-title-as-name`, the class name is derived from the `title` property in the schema
109+
- Aliases are applied during code generation, so the original field names are preserved as Pydantic `alias` values for proper serialization/deserialization

docs/index.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,11 @@ Model customization:
499499
--use-title-as-name use titles as class names of models
500500

501501
Template customization:
502-
--aliases ALIASES Alias mapping file
502+
--aliases ALIASES Alias mapping file (JSON) for renaming fields. Supports hierarchical
503+
formats: Flat: {''field'': ''alias''} applies to all occurrences.
504+
Scoped: {''ClassName.field'': ''alias''} applies to specific class.
505+
Priority: scoped > flat. Example: {''User.name'': ''user_name'',
506+
''Address.name'': ''addr_name'', ''id'': ''id_''}
503507
--custom-file-header CUSTOM_FILE_HEADER
504508
Custom file header
505509
--custom-file-header-path CUSTOM_FILE_HEADER_PATH

src/datamodel_code_generator/arguments.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -541,7 +541,12 @@ def start_section(self, heading: str | None) -> None:
541541
# ======================================================================================
542542
template_options.add_argument(
543543
"--aliases",
544-
help="Alias mapping file",
544+
help="Alias mapping file (JSON) for renaming fields. "
545+
"Supports hierarchical formats: "
546+
"Flat: {'field': 'alias'} applies to all occurrences. "
547+
"Scoped: {'ClassName.field': 'alias'} applies to specific class. "
548+
"Priority: scoped > flat. "
549+
"Example: {'User.name': 'user_name', 'Address.name': 'addr_name', 'id': 'id_'}",
545550
type=Path,
546551
)
547552
template_options.add_argument(

src/datamodel_code_generator/parser/graphql.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,9 @@ def parse_object_like(
510510

511511
for field_name, field in obj.fields.items():
512512
field_name_, alias = self.model_resolver.get_valid_field_name_and_alias(
513-
field_name, excludes=exclude_field_names
513+
field_name,
514+
excludes=exclude_field_names,
515+
class_name=obj.name,
514516
)
515517
exclude_field_names.add(field_name_)
516518

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -933,7 +933,10 @@ def _parse_object_common_part( # noqa: PLR0913, PLR0917
933933
if obj.properties:
934934
fields.extend(
935935
self.parse_object_fields(
936-
obj, path, get_module_name(name, None, treat_dot_as_module=self.treat_dot_as_module)
936+
obj,
937+
path,
938+
get_module_name(name, None, treat_dot_as_module=self.treat_dot_as_module),
939+
class_name=name,
937940
)
938941
)
939942
# ignore an undetected object
@@ -1004,6 +1007,7 @@ def _parse_all_of_item( # noqa: PLR0913, PLR0917
10041007
all_of_item,
10051008
path,
10061009
module_name,
1010+
class_name=name,
10071011
)
10081012

10091013
if object_fields:
@@ -1107,6 +1111,7 @@ def parse_object_fields(
11071111
obj: JsonSchemaObject,
11081112
path: list[str],
11091113
module_name: Optional[str] = None, # noqa: UP045
1114+
class_name: Optional[str] = None, # noqa: UP045
11101115
) -> list[DataModelFieldBase]:
11111116
"""Parse object properties into a list of data model fields."""
11121117
properties: dict[str, JsonSchemaObject | bool] = {} if obj.properties is None else obj.properties
@@ -1116,7 +1121,9 @@ def parse_object_fields(
11161121
exclude_field_names: set[str] = set()
11171122
for original_field_name, field in properties.items():
11181123
field_name, alias = self.model_resolver.get_valid_field_name_and_alias(
1119-
original_field_name, excludes=exclude_field_names
1124+
original_field_name,
1125+
excludes=exclude_field_names,
1126+
class_name=class_name,
11201127
)
11211128
modular_name = f"{module_name}.{field_name}" if module_name else field_name
11221129

@@ -1188,7 +1195,10 @@ def parse_object(
11881195
class_name = reference.name
11891196
self.set_title(reference.path, obj)
11901197
fields = self.parse_object_fields(
1191-
obj, path, get_module_name(class_name, None, treat_dot_as_module=self.treat_dot_as_module)
1198+
obj,
1199+
path,
1200+
get_module_name(class_name, None, treat_dot_as_module=self.treat_dot_as_module),
1201+
class_name=class_name,
11921202
)
11931203
if fields or not isinstance(obj.additionalProperties, JsonSchemaObject):
11941204
data_model_type_class = self.data_model_type

src/datamodel_code_generator/parser/openapi.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,9 +393,10 @@ def parse_object_fields(
393393
obj: JsonSchemaObject,
394394
path: list[str],
395395
module_name: Optional[str] = None, # noqa: UP045
396+
class_name: Optional[str] = None, # noqa: UP045
396397
) -> list[DataModelFieldBase]:
397398
"""Parse object fields, adding discriminator info for allOf polymorphism."""
398-
fields = super().parse_object_fields(obj, path, module_name)
399+
fields = super().parse_object_fields(obj, path, module_name, class_name=class_name)
399400
properties = obj.properties or {}
400401

401402
result_fields: list[DataModelFieldBase] = []
@@ -550,7 +551,9 @@ def parse_all_parameters(
550551
raise Exception(msg) # noqa: TRY002
551552

552553
field_name, alias = self.model_resolver.get_valid_field_name_and_alias(
553-
field_name=parameter_name, excludes=exclude_field_names
554+
field_name=parameter_name,
555+
excludes=exclude_field_names,
556+
class_name=name,
554557
)
555558
if parameter.schema_:
556559
fields.append(

src/datamodel_code_generator/reference.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -283,11 +283,33 @@ def get_valid_name( # noqa: PLR0912
283283
return new_name
284284

285285
def get_valid_field_name_and_alias(
286-
self, field_name: str, excludes: set[str] | None = None
286+
self,
287+
field_name: str,
288+
excludes: set[str] | None = None,
289+
path: list[str] | None = None,
290+
class_name: str | None = None,
287291
) -> tuple[str, str | None]:
288-
"""Get valid field name and original alias if different."""
292+
"""Get valid field name and original alias if different.
293+
294+
Supports hierarchical alias resolution with the following priority:
295+
1. Scoped aliases (ClassName.field_name) - class-level specificity
296+
2. Flat aliases (field_name) - applies to all occurrences
297+
298+
Args:
299+
field_name: The original field name from the schema.
300+
excludes: Set of names to avoid when generating valid names.
301+
path: Unused, kept for backward compatibility.
302+
class_name: Optional class name for scoped alias resolution.
303+
"""
304+
del path
305+
if class_name:
306+
scoped_key = f"{class_name}.{field_name}"
307+
if scoped_key in self.aliases:
308+
return self.aliases[scoped_key], field_name
309+
289310
if field_name in self.aliases:
290311
return self.aliases[field_name], field_name
312+
291313
valid_name = self.get_valid_name(field_name, excludes=excludes)
292314
return (
293315
valid_name,
@@ -767,9 +789,25 @@ def get_valid_field_name_and_alias(
767789
field_name: str,
768790
excludes: set[str] | None = None,
769791
model_type: ModelType = ModelType.PYDANTIC,
792+
path: list[str] | None = None,
793+
class_name: str | None = None,
770794
) -> tuple[str, str | None]:
771-
"""Get a valid field name and alias for the specified model type."""
772-
return self.field_name_resolvers[model_type].get_valid_field_name_and_alias(field_name, excludes)
795+
"""Get a valid field name and alias for the specified model type.
796+
797+
Args:
798+
field_name: The original field name from the schema.
799+
excludes: Set of names to avoid when generating valid names.
800+
model_type: The type of model (PYDANTIC, ENUM, or CLASS).
801+
path: Unused, kept for backward compatibility.
802+
class_name: Optional class name for scoped alias resolution.
803+
804+
Returns:
805+
A tuple of (valid_field_name, alias_or_none).
806+
"""
807+
del path
808+
return self.field_name_resolvers[model_type].get_valid_field_name_and_alias(
809+
field_name, excludes, class_name=class_name
810+
)
773811

774812

775813
def _get_inflect_engine() -> inflect.engine:
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"Root.name": "root_name",
3+
"User.name": "user_name",
4+
"Address.name": "address_name"
5+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# generated by datamodel-codegen:
2+
# filename: hierarchical_aliases.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Optional
8+
9+
from pydantic import BaseModel, Field
10+
11+
12+
class User(BaseModel):
13+
user_name: Optional[str] = Field(None, alias='name')
14+
id: Optional[int] = None
15+
16+
17+
class Address(BaseModel):
18+
address_name: Optional[str] = Field(None, alias='name')
19+
city: Optional[str] = None
20+
21+
22+
class Root(BaseModel):
23+
root_name: Optional[str] = Field(None, alias='name')
24+
user: Optional[User] = Field(None, title='User')
25+
address: Optional[Address] = Field(None, title='Address')

0 commit comments

Comments
 (0)