Skip to content

Commit a2a4d74

Browse files
authored
Add support for multiple aliases using Pydantic v2 AliasChoices (#2845)
* Add support for multiple aliases using Pydantic v2 AliasChoices * Add e2e tests for 100% diff coverage * Add tests for empty list aliases to cover partial branches
1 parent 7e00d0e commit a2a4d74

29 files changed

Lines changed: 675 additions & 20 deletions

src/datamodel_code_generator/__main__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,10 +1401,12 @@ def main(args: Sequence[str] | None = None) -> Exit: # noqa: PLR0911, PLR0912,
14011401
print(f"Unable to load alias mapping: {e}", file=sys.stderr) # noqa: T201
14021402
return Exit.ERROR
14031403
if not isinstance(aliases, dict) or not all(
1404-
isinstance(k, str) and isinstance(v, str) for k, v in aliases.items()
1404+
isinstance(k, str) and (isinstance(v, str) or (isinstance(v, list) and all(isinstance(i, str) for i in v)))
1405+
for k, v in aliases.items()
14051406
):
14061407
print( # noqa: T201
1407-
'Alias mapping must be a JSON string mapping (e.g. {"from": "to", ...})',
1408+
"Alias mapping must be a JSON mapping with string keys and string or list of strings values "
1409+
'(e.g. {"from": "to", "field": ["alias1", "alias2"]})',
14081410
file=sys.stderr,
14091411
)
14101412
return Exit.ERROR

src/datamodel_code_generator/arguments.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -774,7 +774,8 @@ def start_section(self, heading: str | None) -> None:
774774
"Flat: {'field': 'alias'} applies to all occurrences. "
775775
"Scoped: {'ClassName.field': 'alias'} applies to specific class. "
776776
"Priority: scoped > flat. "
777-
"Example: {'User.name': 'user_name', 'Address.name': 'addr_name', 'id': 'id_'}",
777+
"Multiple aliases (Pydantic v2 only): {'field': ['alias1', 'alias2']} uses AliasChoices for validation. "
778+
"Example: {'User.name': 'user_name', 'id': 'id_', 'field': ['my-field', 'my_field']}",
778779
type=Path,
779780
)
780781
template_options.add_argument(

src/datamodel_code_generator/model/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ class Config:
167167
default: Optional[Any] = None # noqa: UP045
168168
required: bool = False
169169
alias: Optional[str] = None # noqa: UP045
170+
validation_aliases: Optional[list[str]] = None # noqa: UP045 # Multiple aliases for Pydantic v2 AliasChoices
170171
data_type: DataType
171172
constraints: Any = None
172173
strip_default_none: bool = False

src/datamodel_code_generator/model/pydantic_v2/base_model.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
DataModelField as DataModelFieldV1,
2525
)
2626
from datamodel_code_generator.model.pydantic_v2.imports import IMPORT_BASE_MODEL, IMPORT_CONFIG_DICT
27+
from datamodel_code_generator.types import chain_as_tuple
2728
from datamodel_code_generator.util import field_validator, model_validate, model_validator
2829

2930
if TYPE_CHECKING:
@@ -32,6 +33,18 @@
3233
from datamodel_code_generator.reference import Reference
3334

3435

36+
class _RawRepr:
37+
"""Wrapper to prevent repr() from adding quotes around a value."""
38+
39+
__slots__ = ("value",)
40+
41+
def __init__(self, value: str) -> None:
42+
self.value = value
43+
44+
def __repr__(self) -> str:
45+
return self.value
46+
47+
3548
class Constraints(_Constraints):
3649
"""Pydantic v2 field constraints with pattern support."""
3750

@@ -137,6 +150,14 @@ def _process_data_in_str(self, data: dict[str, Any]) -> None:
137150
else:
138151
data.pop("union_mode")
139152

153+
# Handle multiple aliases using AliasChoices (Pydantic v2 feature)
154+
if self.validation_aliases:
155+
# Remove single alias if present (validation_aliases takes precedence)
156+
data.pop("alias", None)
157+
# Format as AliasChoices(...) - use _RawRepr to prevent double-quoting
158+
aliases_repr = ", ".join(repr(a) for a in self.validation_aliases)
159+
data["validation_alias"] = _RawRepr(f"AliasChoices({aliases_repr})")
160+
140161
# **extra is not supported in pydantic 2.0
141162
json_schema_extra = {k: v for k, v in data.items() if k not in self._DEFAULT_FIELD_KEYS}
142163
if json_schema_extra:
@@ -150,6 +171,16 @@ def _process_annotated_field_arguments( # noqa: PLR6301
150171
) -> list[str]:
151172
return field_arguments
152173

174+
@property
175+
def imports(self) -> tuple[Import, ...]:
176+
"""Get all required imports including AliasChoices if needed."""
177+
base_imports = super().imports
178+
if self.validation_aliases:
179+
from datamodel_code_generator.model.pydantic_v2.imports import IMPORT_ALIAS_CHOICES # noqa: PLC0415
180+
181+
return chain_as_tuple(base_imports, (IMPORT_ALIAS_CHOICES,))
182+
return base_imports
183+
153184

154185
class ConfigAttribute(NamedTuple):
155186
"""Configuration attribute mapping for ConfigDict conversion."""

src/datamodel_code_generator/model/pydantic_v2/imports.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
IMPORT_BASE_MODEL = Import.from_full_path("pydantic.BaseModel")
1111
IMPORT_CONFIG_DICT = Import.from_full_path("pydantic.ConfigDict")
12+
IMPORT_ALIAS_CHOICES = Import.from_full_path("pydantic.AliasChoices")
1213
IMPORT_AWARE_DATETIME = Import.from_full_path("pydantic.AwareDatetime")
1314
IMPORT_NAIVE_DATETIME = Import.from_full_path("pydantic.NaiveDatetime")
1415
IMPORT_PAST_DATETIME = Import.from_full_path("pydantic.PastDatetime")

src/datamodel_code_generator/parser/base.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1489,12 +1489,20 @@ def check_paths(
14891489
new_data_type = self._create_discriminator_data_type(
14901490
enum_from_base, type_names, discriminator_model, imports
14911491
)
1492+
# Handle multiple aliases (Pydantic v2 AliasChoices)
1493+
single_alias: str | None = None
1494+
validation_aliases: list[str] | None = None
1495+
if isinstance(alias, list):
1496+
validation_aliases = alias
1497+
else:
1498+
single_alias = alias
14921499
discriminator_model.fields.append(
14931500
self.data_model_field_type(
14941501
name=field_name,
14951502
data_type=new_data_type,
14961503
required=True,
1497-
alias=alias,
1504+
alias=single_alias,
1505+
validation_aliases=validation_aliases,
14981506
)
14991507
)
15001508
has_imported_literal = any(import_ == IMPORT_LITERAL for import_ in imports)

src/datamodel_code_generator/parser/graphql.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,7 @@ def parse_enum_as_enum_class(self, enum_object: graphql.GraphQLEnumType) -> None
559559
def parse_field(
560560
self,
561561
field_name: str,
562-
alias: str | None,
562+
alias: str | list[str] | None,
563563
field: graphql.GraphQLField | graphql.GraphQLInputField,
564564
) -> DataModelFieldBase:
565565
"""Parse a GraphQL field and return a data model field."""
@@ -604,13 +604,21 @@ def parse_field(
604604
if field.description is not None: # pragma: no cover
605605
extras["description"] = field.description
606606

607+
# Handle multiple aliases (Pydantic v2 AliasChoices)
608+
single_alias: str | None = None
609+
validation_aliases: list[str] | None = None
610+
if isinstance(alias, list):
611+
validation_aliases = alias
612+
else:
613+
single_alias = alias
607614
return self.data_model_field_type(
608615
name=field_name,
609616
default=default,
610617
data_type=final_data_type,
611618
required=required,
612619
extras=extras,
613-
alias=alias,
620+
alias=single_alias,
621+
validation_aliases=validation_aliases,
614622
strip_default_none=self.strip_default_none,
615623
use_annotated=self.use_annotated,
616624
use_serialize_as_any=self.use_serialize_as_any,

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,7 +1144,7 @@ def get_object_field( # noqa: PLR0913
11441144
field: JsonSchemaObject,
11451145
required: bool,
11461146
field_type: DataType,
1147-
alias: str | None,
1147+
alias: str | list[str] | None,
11481148
original_field_name: str | None,
11491149
) -> DataModelFieldBase:
11501150
"""Create a data model field from a JSON Schema object field."""
@@ -1155,12 +1155,20 @@ def get_object_field( # noqa: PLR0913
11551155
if constraints and self._is_fixed_length_tuple(field):
11561156
constraints.pop("minItems", None)
11571157
constraints.pop("maxItems", None)
1158+
# Handle multiple aliases (Pydantic v2 AliasChoices)
1159+
single_alias: str | None = None
1160+
validation_aliases: list[str] | None = None
1161+
if isinstance(alias, list):
1162+
validation_aliases = alias
1163+
else:
1164+
single_alias = alias
11581165
return self.data_model_field_type(
11591166
name=field_name,
11601167
default=field.default,
11611168
data_type=field_type,
11621169
required=required,
1163-
alias=alias,
1170+
alias=single_alias,
1171+
validation_aliases=validation_aliases,
11641172
constraints=constraints,
11651173
nullable=field.nullable
11661174
if self.strict_nullable and field.nullable is not None
@@ -2171,7 +2179,7 @@ def _parse_object_common_part( # noqa: PLR0912, PLR0913, PLR0915
21712179

21722180
return self.data_type(reference=reference)
21732181

2174-
def _parse_all_of_item( # noqa: PLR0912, PLR0913, PLR0917
2182+
def _parse_all_of_item( # noqa: PLR0912, PLR0913, PLR0915, PLR0917
21752183
self,
21762184
name: str,
21772185
obj: JsonSchemaObject,
@@ -2242,12 +2250,20 @@ def _parse_all_of_item( # noqa: PLR0912, PLR0913, PLR0917
22422250
data_type = self._get_inherited_field_type(request, base_classes)
22432251
if data_type is None:
22442252
data_type = DataType(type=ANY, import_=IMPORT_ANY)
2253+
# Handle multiple aliases (Pydantic v2 AliasChoices)
2254+
single_alias: str | None = None
2255+
validation_aliases: list[str] | None = None
2256+
if isinstance(alias, list):
2257+
validation_aliases = alias
2258+
else:
2259+
single_alias = alias
22452260
fields.append(
22462261
self.data_model_field_type(
22472262
name=field_name,
22482263
required=True,
22492264
original_name=request,
2250-
alias=alias,
2265+
alias=single_alias,
2266+
validation_aliases=validation_aliases,
22512267
data_type=data_type,
22522268
)
22532269
)
@@ -2417,14 +2433,22 @@ def parse_object_fields(
24172433
exclude_field_names.add(field_name)
24182434

24192435
if isinstance(field, bool):
2436+
# Handle multiple aliases (Pydantic v2 AliasChoices)
2437+
single_alias: str | None = None
2438+
validation_aliases: list[str] | None = None
2439+
if isinstance(alias, list):
2440+
validation_aliases = alias
2441+
else:
2442+
single_alias = alias
24202443
fields.append(
24212444
self.data_model_field_type(
24222445
name=field_name,
24232446
data_type=self.data_type_manager.get_data_type(
24242447
Types.any,
24252448
),
24262449
required=False if self.force_optional_for_required_fields else original_field_name in requires,
2427-
alias=alias,
2450+
alias=single_alias,
2451+
validation_aliases=validation_aliases,
24282452
strip_default_none=self.strip_default_none,
24292453
use_annotated=self.use_annotated,
24302454
use_field_description=self.use_field_description,

src/datamodel_code_generator/parser/openapi.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -682,7 +682,7 @@ def _get_model_name(cls, path_name: str, method: str, suffix: str) -> str:
682682
camel_path_name = snake_to_upper_camel(normalized)
683683
return f"{camel_path_name}{method.capitalize()}{suffix}"
684684

685-
def parse_all_parameters(
685+
def parse_all_parameters( # noqa: PLR0912
686686
self,
687687
name: str,
688688
parameters: list[ReferenceObject | ParameterObject],
@@ -751,13 +751,21 @@ def parse_all_parameters(
751751
data_type = self.data_type(data_types=data_types)
752752
# multiple data_type parse as non-constraints field
753753
object_schema = None
754+
# Handle multiple aliases (Pydantic v2 AliasChoices)
755+
single_alias: str | None = None
756+
validation_aliases: list[str] | None = None
757+
if isinstance(alias, list):
758+
validation_aliases = alias
759+
else:
760+
single_alias = alias
754761
fields.append(
755762
self.data_model_field_type(
756763
name=field_name,
757764
default=object_schema.default if object_schema else None,
758765
data_type=data_type,
759766
required=parameter.required,
760-
alias=alias,
767+
alias=single_alias,
768+
validation_aliases=validation_aliases,
761769
constraints=model_dump(object_schema, exclude_none=True)
762770
if object_schema and self.is_constraints_field(object_schema)
763771
else None,

src/datamodel_code_generator/reference.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ class FieldNameResolver:
225225

226226
def __init__( # noqa: PLR0913, PLR0917
227227
self,
228-
aliases: Mapping[str, str] | None = None,
228+
aliases: Mapping[str, str | list[str]] | None = None,
229229
snake_case_field: bool = False, # noqa: FBT001, FBT002
230230
empty_field_name: str | None = None,
231231
original_delimiter: str | None = None,
@@ -235,7 +235,7 @@ def __init__( # noqa: PLR0913, PLR0917
235235
no_alias: bool = False, # noqa: FBT001, FBT002
236236
) -> None:
237237
"""Initialize field name resolver with transformation options."""
238-
self.aliases: Mapping[str, str] = {} if aliases is None else {**aliases}
238+
self.aliases: Mapping[str, str | list[str]] = {} if aliases is None else {**aliases}
239239
self.empty_field_name: str = empty_field_name or "_"
240240
self.snake_case_field = snake_case_field
241241
self.original_delimiter: str | None = original_delimiter
@@ -306,7 +306,7 @@ def get_valid_field_name_and_alias(
306306
excludes: set[str] | None = None,
307307
path: list[str] | None = None,
308308
class_name: str | None = None,
309-
) -> tuple[str, str | None]:
309+
) -> tuple[str, str | list[str] | None]:
310310
"""Get valid field name and original alias if different.
311311
312312
Supports hierarchical alias resolution with the following priority:
@@ -318,15 +318,33 @@ def get_valid_field_name_and_alias(
318318
excludes: Set of names to avoid when generating valid names.
319319
path: Unused, kept for backward compatibility.
320320
class_name: Optional class name for scoped alias resolution.
321+
322+
Returns:
323+
A tuple of (python_field_name, alias_or_aliases) where:
324+
- python_field_name: The valid Python identifier to use as the field name.
325+
- alias_or_aliases: None if no alias needed, str for single alias,
326+
or list[str] for multiple aliases (Pydantic v2 AliasChoices).
321327
"""
322328
del path
323329
if class_name:
324330
scoped_key = f"{class_name}.{field_name}"
325331
if scoped_key in self.aliases:
326-
return self.aliases[scoped_key], field_name
332+
alias_value = self.aliases[scoped_key]
333+
if isinstance(alias_value, list) and alias_value:
334+
# Multiple aliases: validate first alias as field name, return all aliases including original
335+
valid_name = self.get_valid_name(alias_value[0], excludes=excludes)
336+
return valid_name, [field_name, *alias_value]
337+
if isinstance(alias_value, str):
338+
return alias_value, field_name
327339

328340
if field_name in self.aliases:
329-
return self.aliases[field_name], field_name
341+
alias_value = self.aliases[field_name]
342+
if isinstance(alias_value, list) and alias_value:
343+
# Multiple aliases: validate first alias as field name, return all aliases including original
344+
valid_name = self.get_valid_name(alias_value[0], excludes=excludes)
345+
return valid_name, [field_name, *alias_value]
346+
if isinstance(alias_value, str):
347+
return alias_value, field_name
330348

331349
valid_name = self.get_valid_name(field_name, excludes=excludes)
332350
return (
@@ -1064,7 +1082,7 @@ def get_valid_field_name_and_alias(
10641082
model_type: ModelType = ModelType.PYDANTIC,
10651083
path: list[str] | None = None,
10661084
class_name: str | None = None,
1067-
) -> tuple[str, str | None]:
1085+
) -> tuple[str, str | list[str] | None]:
10681086
"""Get a valid field name and alias for the specified model type.
10691087
10701088
Args:
@@ -1075,7 +1093,10 @@ def get_valid_field_name_and_alias(
10751093
class_name: Optional class name for scoped alias resolution.
10761094
10771095
Returns:
1078-
A tuple of (valid_field_name, alias_or_none).
1096+
A tuple of (python_field_name, alias_or_aliases) where:
1097+
- python_field_name: The valid Python identifier to use as the field name.
1098+
- alias_or_aliases: None if no alias needed, str for single alias,
1099+
or list[str] for multiple aliases (Pydantic v2 AliasChoices).
10791100
"""
10801101
del path
10811102
return self.field_name_resolvers[model_type].get_valid_field_name_and_alias(

0 commit comments

Comments
 (0)