From abaf7622919c1957ba506f22b6aa860b9c168034 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Sun, 28 Dec 2025 18:45:55 +0000 Subject: [PATCH 1/3] Add support for multiple aliases using Pydantic v2 AliasChoices --- src/datamodel_code_generator/__main__.py | 6 ++- src/datamodel_code_generator/arguments.py | 3 +- src/datamodel_code_generator/model/base.py | 1 + .../model/pydantic_v2/base_model.py | 31 +++++++++++++ .../model/pydantic_v2/imports.py | 1 + src/datamodel_code_generator/parser/base.py | 10 ++++- .../parser/graphql.py | 12 ++++- .../parser/jsonschema.py | 34 +++++++++++--- .../parser/openapi.py | 12 ++++- src/datamodel_code_generator/reference.py | 35 ++++++++++++--- tests/data/aliases/multiple_aliases.json | 4 ++ ...jsonschema_multiple_aliases_pydantic_v2.py | 22 ++++++++++ tests/data/jsonschema/multiple_aliases.json | 25 +++++++++++ tests/main/jsonschema/test_main_jsonschema.py | 23 ++++++++++ tests/test_resolver.py | 44 +++++++++++++++++++ 15 files changed, 243 insertions(+), 20 deletions(-) create mode 100644 tests/data/aliases/multiple_aliases.json create mode 100644 tests/data/expected/main/jsonschema/jsonschema_multiple_aliases_pydantic_v2.py create mode 100644 tests/data/jsonschema/multiple_aliases.json diff --git a/src/datamodel_code_generator/__main__.py b/src/datamodel_code_generator/__main__.py index 900155896..7d3d2c1b6 100644 --- a/src/datamodel_code_generator/__main__.py +++ b/src/datamodel_code_generator/__main__.py @@ -1401,10 +1401,12 @@ def main(args: Sequence[str] | None = None) -> Exit: # noqa: PLR0911, PLR0912, print(f"Unable to load alias mapping: {e}", file=sys.stderr) # noqa: T201 return Exit.ERROR if not isinstance(aliases, dict) or not all( - isinstance(k, str) and isinstance(v, str) for k, v in aliases.items() + 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() ): print( # noqa: T201 - 'Alias mapping must be a JSON string mapping (e.g. {"from": "to", ...})', + "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 Exit.ERROR diff --git a/src/datamodel_code_generator/arguments.py b/src/datamodel_code_generator/arguments.py index c370595af..455bc31e4 100644 --- a/src/datamodel_code_generator/arguments.py +++ b/src/datamodel_code_generator/arguments.py @@ -774,7 +774,8 @@ def start_section(self, heading: str | None) -> None: "Flat: {'field': 'alias'} applies to all occurrences. " "Scoped: {'ClassName.field': 'alias'} applies to specific class. " "Priority: scoped > flat. " - "Example: {'User.name': 'user_name', 'Address.name': 'addr_name', 'id': 'id_'}", + "Multiple aliases (Pydantic v2 only): {'field': ['alias1', 'alias2']} uses AliasChoices for validation. " + "Example: {'User.name': 'user_name', 'id': 'id_', 'field': ['my-field', 'my_field']}", type=Path, ) template_options.add_argument( diff --git a/src/datamodel_code_generator/model/base.py b/src/datamodel_code_generator/model/base.py index a683a695c..f70c7134f 100644 --- a/src/datamodel_code_generator/model/base.py +++ b/src/datamodel_code_generator/model/base.py @@ -167,6 +167,7 @@ class Config: default: Optional[Any] = None # noqa: UP045 required: bool = False alias: Optional[str] = None # noqa: UP045 + validation_aliases: Optional[list[str]] = None # noqa: UP045 # Multiple aliases for Pydantic v2 AliasChoices data_type: DataType constraints: Any = None strip_default_none: bool = False diff --git a/src/datamodel_code_generator/model/pydantic_v2/base_model.py b/src/datamodel_code_generator/model/pydantic_v2/base_model.py index 9aac262df..9e946222b 100644 --- a/src/datamodel_code_generator/model/pydantic_v2/base_model.py +++ b/src/datamodel_code_generator/model/pydantic_v2/base_model.py @@ -24,6 +24,7 @@ DataModelField as DataModelFieldV1, ) from datamodel_code_generator.model.pydantic_v2.imports import IMPORT_BASE_MODEL, IMPORT_CONFIG_DICT +from datamodel_code_generator.types import chain_as_tuple from datamodel_code_generator.util import field_validator, model_validate, model_validator if TYPE_CHECKING: @@ -32,6 +33,18 @@ from datamodel_code_generator.reference import Reference +class _RawRepr: + """Wrapper to prevent repr() from adding quotes around a value.""" + + __slots__ = ("value",) + + def __init__(self, value: str) -> None: + self.value = value + + def __repr__(self) -> str: + return self.value + + class Constraints(_Constraints): """Pydantic v2 field constraints with pattern support.""" @@ -137,6 +150,14 @@ def _process_data_in_str(self, data: dict[str, Any]) -> None: else: data.pop("union_mode") + # Handle multiple aliases using AliasChoices (Pydantic v2 feature) + if self.validation_aliases: + # Remove single alias if present (validation_aliases takes precedence) + data.pop("alias", None) + # Format as AliasChoices(...) - use _RawRepr to prevent double-quoting + aliases_repr = ", ".join(repr(a) for a in self.validation_aliases) + data["validation_alias"] = _RawRepr(f"AliasChoices({aliases_repr})") + # **extra is not supported in pydantic 2.0 json_schema_extra = {k: v for k, v in data.items() if k not in self._DEFAULT_FIELD_KEYS} if json_schema_extra: @@ -150,6 +171,16 @@ def _process_annotated_field_arguments( # noqa: PLR6301 ) -> list[str]: return field_arguments + @property + def imports(self) -> tuple[Import, ...]: + """Get all required imports including AliasChoices if needed.""" + base_imports = super().imports + if self.validation_aliases: + from datamodel_code_generator.model.pydantic_v2.imports import IMPORT_ALIAS_CHOICES # noqa: PLC0415 + + return chain_as_tuple(base_imports, (IMPORT_ALIAS_CHOICES,)) + return base_imports + class ConfigAttribute(NamedTuple): """Configuration attribute mapping for ConfigDict conversion.""" diff --git a/src/datamodel_code_generator/model/pydantic_v2/imports.py b/src/datamodel_code_generator/model/pydantic_v2/imports.py index 7af781437..b1b799057 100644 --- a/src/datamodel_code_generator/model/pydantic_v2/imports.py +++ b/src/datamodel_code_generator/model/pydantic_v2/imports.py @@ -9,6 +9,7 @@ IMPORT_BASE_MODEL = Import.from_full_path("pydantic.BaseModel") IMPORT_CONFIG_DICT = Import.from_full_path("pydantic.ConfigDict") +IMPORT_ALIAS_CHOICES = Import.from_full_path("pydantic.AliasChoices") IMPORT_AWARE_DATETIME = Import.from_full_path("pydantic.AwareDatetime") IMPORT_NAIVE_DATETIME = Import.from_full_path("pydantic.NaiveDatetime") IMPORT_PAST_DATETIME = Import.from_full_path("pydantic.PastDatetime") diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index 156282152..3583b3c0f 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -1489,12 +1489,20 @@ def check_paths( new_data_type = self._create_discriminator_data_type( enum_from_base, type_names, discriminator_model, imports ) + # Handle multiple aliases (Pydantic v2 AliasChoices) + single_alias: str | None = None + validation_aliases: list[str] | None = None + if isinstance(alias, list): + validation_aliases = alias + else: + single_alias = alias discriminator_model.fields.append( self.data_model_field_type( name=field_name, data_type=new_data_type, required=True, - alias=alias, + alias=single_alias, + validation_aliases=validation_aliases, ) ) has_imported_literal = any(import_ == IMPORT_LITERAL for import_ in imports) diff --git a/src/datamodel_code_generator/parser/graphql.py b/src/datamodel_code_generator/parser/graphql.py index 1dcdc7304..b583399e6 100644 --- a/src/datamodel_code_generator/parser/graphql.py +++ b/src/datamodel_code_generator/parser/graphql.py @@ -559,7 +559,7 @@ def parse_enum_as_enum_class(self, enum_object: graphql.GraphQLEnumType) -> None def parse_field( self, field_name: str, - alias: str | None, + alias: str | list[str] | None, field: graphql.GraphQLField | graphql.GraphQLInputField, ) -> DataModelFieldBase: """Parse a GraphQL field and return a data model field.""" @@ -604,13 +604,21 @@ def parse_field( if field.description is not None: # pragma: no cover extras["description"] = field.description + # Handle multiple aliases (Pydantic v2 AliasChoices) + single_alias: str | None = None + validation_aliases: list[str] | None = None + if isinstance(alias, list): + validation_aliases = alias + else: + single_alias = alias return self.data_model_field_type( name=field_name, default=default, data_type=final_data_type, required=required, extras=extras, - alias=alias, + alias=single_alias, + validation_aliases=validation_aliases, strip_default_none=self.strip_default_none, use_annotated=self.use_annotated, use_serialize_as_any=self.use_serialize_as_any, diff --git a/src/datamodel_code_generator/parser/jsonschema.py b/src/datamodel_code_generator/parser/jsonschema.py index 311c5fa17..048e052ce 100644 --- a/src/datamodel_code_generator/parser/jsonschema.py +++ b/src/datamodel_code_generator/parser/jsonschema.py @@ -1107,7 +1107,7 @@ def get_object_field( # noqa: PLR0913 field: JsonSchemaObject, required: bool, field_type: DataType, - alias: str | None, + alias: str | list[str] | None, original_field_name: str | None, ) -> DataModelFieldBase: """Create a data model field from a JSON Schema object field.""" @@ -1118,12 +1118,20 @@ def get_object_field( # noqa: PLR0913 if constraints and self._is_fixed_length_tuple(field): constraints.pop("minItems", None) constraints.pop("maxItems", None) + # Handle multiple aliases (Pydantic v2 AliasChoices) + single_alias: str | None = None + validation_aliases: list[str] | None = None + if isinstance(alias, list): + validation_aliases = alias + else: + single_alias = alias return self.data_model_field_type( name=field_name, default=field.default, data_type=field_type, required=required, - alias=alias, + alias=single_alias, + validation_aliases=validation_aliases, constraints=constraints, nullable=field.nullable if self.strict_nullable and field.nullable is not None @@ -2024,7 +2032,7 @@ def _parse_object_common_part( # noqa: PLR0912, PLR0913, PLR0915 return self.data_type(reference=reference) - def _parse_all_of_item( # noqa: PLR0912, PLR0913, PLR0917 + def _parse_all_of_item( # noqa: PLR0912, PLR0913, PLR0915, PLR0917 self, name: str, obj: JsonSchemaObject, @@ -2095,12 +2103,20 @@ def _parse_all_of_item( # noqa: PLR0912, PLR0913, PLR0917 data_type = self._get_inherited_field_type(request, base_classes) if data_type is None: data_type = DataType(type=ANY, import_=IMPORT_ANY) + # Handle multiple aliases (Pydantic v2 AliasChoices) + single_alias: str | None = None + validation_aliases: list[str] | None = None + if isinstance(alias, list): + validation_aliases = alias + else: + single_alias = alias fields.append( self.data_model_field_type( name=field_name, required=True, original_name=request, - alias=alias, + alias=single_alias, + validation_aliases=validation_aliases, data_type=data_type, ) ) @@ -2270,6 +2286,13 @@ def parse_object_fields( exclude_field_names.add(field_name) if isinstance(field, bool): + # Handle multiple aliases (Pydantic v2 AliasChoices) + single_alias: str | None = None + validation_aliases: list[str] | None = None + if isinstance(alias, list): + validation_aliases = alias + else: + single_alias = alias fields.append( self.data_model_field_type( name=field_name, @@ -2277,7 +2300,8 @@ def parse_object_fields( Types.any, ), required=False if self.force_optional_for_required_fields else original_field_name in requires, - alias=alias, + alias=single_alias, + validation_aliases=validation_aliases, strip_default_none=self.strip_default_none, use_annotated=self.use_annotated, use_field_description=self.use_field_description, diff --git a/src/datamodel_code_generator/parser/openapi.py b/src/datamodel_code_generator/parser/openapi.py index bc33b2ce0..fae1af0a5 100644 --- a/src/datamodel_code_generator/parser/openapi.py +++ b/src/datamodel_code_generator/parser/openapi.py @@ -682,7 +682,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( + def parse_all_parameters( # noqa: PLR0912 self, name: str, parameters: list[ReferenceObject | ParameterObject], @@ -751,13 +751,21 @@ def parse_all_parameters( data_type = self.data_type(data_types=data_types) # multiple data_type parse as non-constraints field object_schema = None + # Handle multiple aliases (Pydantic v2 AliasChoices) + single_alias: str | None = None + validation_aliases: list[str] | None = None + if isinstance(alias, list): + validation_aliases = alias + else: + single_alias = alias fields.append( self.data_model_field_type( name=field_name, default=object_schema.default if object_schema else None, data_type=data_type, required=parameter.required, - alias=alias, + alias=single_alias, + validation_aliases=validation_aliases, constraints=model_dump(object_schema, exclude_none=True) if object_schema and self.is_constraints_field(object_schema) else None, diff --git a/src/datamodel_code_generator/reference.py b/src/datamodel_code_generator/reference.py index e5414094c..5f2b0a869 100644 --- a/src/datamodel_code_generator/reference.py +++ b/src/datamodel_code_generator/reference.py @@ -225,7 +225,7 @@ class FieldNameResolver: def __init__( # noqa: PLR0913, PLR0917 self, - aliases: Mapping[str, str] | None = None, + aliases: Mapping[str, str | list[str]] | None = None, snake_case_field: bool = False, # noqa: FBT001, FBT002 empty_field_name: str | None = None, original_delimiter: str | None = None, @@ -235,7 +235,7 @@ def __init__( # noqa: PLR0913, PLR0917 no_alias: bool = False, # noqa: FBT001, FBT002 ) -> None: """Initialize field name resolver with transformation options.""" - self.aliases: Mapping[str, str] = {} if aliases is None else {**aliases} + self.aliases: Mapping[str, str | list[str]] = {} if aliases is None else {**aliases} self.empty_field_name: str = empty_field_name or "_" self.snake_case_field = snake_case_field self.original_delimiter: str | None = original_delimiter @@ -306,7 +306,7 @@ def get_valid_field_name_and_alias( excludes: set[str] | None = None, path: list[str] | None = None, class_name: str | None = None, - ) -> tuple[str, str | None]: + ) -> tuple[str, str | list[str] | None]: """Get valid field name and original alias if different. Supports hierarchical alias resolution with the following priority: @@ -318,15 +318,33 @@ def get_valid_field_name_and_alias( excludes: Set of names to avoid when generating valid names. path: Unused, kept for backward compatibility. class_name: Optional class name for scoped alias resolution. + + Returns: + A tuple of (python_field_name, alias_or_aliases) where: + - python_field_name: The valid Python identifier to use as the field name. + - alias_or_aliases: None if no alias needed, str for single alias, + or list[str] for multiple aliases (Pydantic v2 AliasChoices). """ del path if class_name: scoped_key = f"{class_name}.{field_name}" if scoped_key in self.aliases: - return self.aliases[scoped_key], field_name + alias_value = self.aliases[scoped_key] + if isinstance(alias_value, list) and alias_value: + # Multiple aliases: validate first alias as field name, return all aliases including original + valid_name = self.get_valid_name(alias_value[0], excludes=excludes) + return valid_name, [field_name, *alias_value] + if isinstance(alias_value, str): + return alias_value, field_name if field_name in self.aliases: - return self.aliases[field_name], field_name + alias_value = self.aliases[field_name] + if isinstance(alias_value, list) and alias_value: + # Multiple aliases: validate first alias as field name, return all aliases including original + valid_name = self.get_valid_name(alias_value[0], excludes=excludes) + return valid_name, [field_name, *alias_value] + if isinstance(alias_value, str): + return alias_value, field_name valid_name = self.get_valid_name(field_name, excludes=excludes) return ( @@ -1064,7 +1082,7 @@ def get_valid_field_name_and_alias( model_type: ModelType = ModelType.PYDANTIC, path: list[str] | None = None, class_name: str | None = None, - ) -> tuple[str, str | None]: + ) -> tuple[str, str | list[str] | None]: """Get a valid field name and alias for the specified model type. Args: @@ -1075,7 +1093,10 @@ def get_valid_field_name_and_alias( class_name: Optional class name for scoped alias resolution. Returns: - A tuple of (valid_field_name, alias_or_none). + A tuple of (python_field_name, alias_or_aliases) where: + - python_field_name: The valid Python identifier to use as the field name. + - alias_or_aliases: None if no alias needed, str for single alias, + or list[str] for multiple aliases (Pydantic v2 AliasChoices). """ del path return self.field_name_resolvers[model_type].get_valid_field_name_and_alias( diff --git a/tests/data/aliases/multiple_aliases.json b/tests/data/aliases/multiple_aliases.json new file mode 100644 index 000000000..80fa1a0c5 --- /dev/null +++ b/tests/data/aliases/multiple_aliases.json @@ -0,0 +1,4 @@ +{ + "my_field": ["my-field", "myField"], + "User.user_name": ["user-name", "userName"] +} diff --git a/tests/data/expected/main/jsonschema/jsonschema_multiple_aliases_pydantic_v2.py b/tests/data/expected/main/jsonschema/jsonschema_multiple_aliases_pydantic_v2.py new file mode 100644 index 000000000..4d5d7053c --- /dev/null +++ b/tests/data/expected/main/jsonschema/jsonschema_multiple_aliases_pydantic_v2.py @@ -0,0 +1,22 @@ +# generated by datamodel-codegen: +# filename: multiple_aliases.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import AliasChoices, BaseModel, Field + + +class User(BaseModel): + user_name: str | None = Field( + None, validation_alias=AliasChoices('user_name', 'user-name', 'userName') + ) + id: int | None = None + + +class Root(BaseModel): + my_field: str | None = Field( + None, validation_alias=AliasChoices('my_field', 'my-field', 'myField') + ) + other_field: int | None = None + user: User | None = Field(None, title='User') diff --git a/tests/data/jsonschema/multiple_aliases.json b/tests/data/jsonschema/multiple_aliases.json new file mode 100644 index 000000000..2cde67f49 --- /dev/null +++ b/tests/data/jsonschema/multiple_aliases.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Root", + "type": "object", + "properties": { + "my_field": { + "type": "string" + }, + "other_field": { + "type": "integer" + }, + "user": { + "title": "User", + "type": "object", + "properties": { + "user_name": { + "type": "string" + }, + "id": { + "type": "integer" + } + } + } + } +} diff --git a/tests/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index 104849688..05ae31401 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -5456,6 +5456,29 @@ def test_main_jsonschema_hierarchical_aliases_scoped(output_file: Path) -> None: ) +@pytest.mark.cli_doc( + options=["--aliases"], + option_description="""Test multiple aliases with AliasChoices for Pydantic v2.""", + input_schema="jsonschema/multiple_aliases.json", + cli_args=["--aliases", "aliases/multiple_aliases.json", "--output-model-type", "pydantic_v2.BaseModel"], + golden_output="jsonschema/multiple_aliases_pydantic_v2.py", +) +def test_main_jsonschema_multiple_aliases_pydantic_v2(output_file: Path) -> None: + """Test multiple aliases with AliasChoices for Pydantic v2.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "multiple_aliases.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + extra_args=[ + "--aliases", + str(ALIASES_DATA_PATH / "multiple_aliases.json"), + "--output-model-type", + "pydantic_v2.BaseModel", + ], + ) + + def test_main_jsonschema_multiple_types_with_object(output_file: Path) -> None: """Test multiple types in array including object with properties generates Union type.""" run_main_and_assert( diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 0826f72c4..66be9f900 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -136,3 +136,47 @@ def test_hierarchical_path_parameter_backward_compatibility() -> None: field_name, alias = resolver.get_valid_field_name_and_alias("name", path=["root", "properties", "name"]) assert field_name == "name_alias" assert alias == "name" + + +def test_multiple_aliases_flat() -> None: + """Test multiple aliases return list including original field name.""" + resolver = FieldNameResolver(aliases={"my_field": ["my-field", "myField"]}) + field_name, aliases = resolver.get_valid_field_name_and_alias("my_field") + assert field_name == "my_field" # First alias validated to valid identifier + assert aliases == ["my_field", "my-field", "myField"] # Original + all aliases + + +def test_multiple_aliases_scoped() -> None: + """Test multiple aliases with scoped format (ClassName.field).""" + resolver = FieldNameResolver( + aliases={ + "User.name": ["user-name", "userName"], + "name": ["default-name", "defaultName"], + } + ) + + field_name, aliases = resolver.get_valid_field_name_and_alias("name", class_name="User") + assert field_name == "user_name" # Hyphen converted to valid identifier + assert aliases == ["name", "user-name", "userName"] + + field_name, aliases = resolver.get_valid_field_name_and_alias("name", class_name="Other") + assert field_name == "default_name" # Hyphen converted to valid identifier + assert aliases == ["name", "default-name", "defaultName"] + + +def test_multiple_aliases_mixed_with_single() -> None: + """Test mixing multiple aliases with single aliases.""" + resolver = FieldNameResolver( + aliases={ + "multi": ["alias1", "alias2"], + "single": "single_alias", + } + ) + + field_name, aliases = resolver.get_valid_field_name_and_alias("multi") + assert field_name == "alias1" + assert aliases == ["multi", "alias1", "alias2"] + + field_name, alias = resolver.get_valid_field_name_and_alias("single") + assert field_name == "single_alias" + assert alias == "single" From 426e715f6b6d5597b61b18972915b055c852992c Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Sun, 28 Dec 2025 19:15:39 +0000 Subject: [PATCH 2/3] Add e2e tests for 100% diff coverage --- .../discriminator_multiple_aliases.json | 3 + .../aliases/discriminator_no_literal.json | 3 + tests/data/aliases/multiple_aliases.json | 6 +- .../aliases/multiple_aliases_parameters.json | 6 ++ .../graphql_multiple_aliases_pydantic_v2.py | 35 ++++++++++++ ...criminator_multiple_aliases_pydantic_v2.py | 31 +++++++++++ ...no_literal_multiple_aliases_pydantic_v2.py | 27 +++++++++ ...jsonschema_multiple_aliases_pydantic_v2.py | 24 ++++++++ ...multiple_aliases_parameters_pydantic_v2.py | 36 ++++++++++++ tests/data/graphql/multiple-aliases.graphql | 7 +++ tests/data/graphql/multiple-aliases.json | 4 ++ .../discriminator_multiple_aliases.json | 48 ++++++++++++++++ .../jsonschema/discriminator_no_literal.json | 36 ++++++++++++ tests/data/jsonschema/multiple_aliases.json | 24 +++++++- .../openapi/multiple_aliases_parameters.yaml | 55 +++++++++++++++++++ tests/main/graphql/test_main_graphql.py | 21 +++++++ tests/main/jsonschema/test_main_jsonschema.py | 32 +++++++++++ tests/main/openapi/test_main_openapi.py | 22 ++++++++ 18 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 tests/data/aliases/discriminator_multiple_aliases.json create mode 100644 tests/data/aliases/discriminator_no_literal.json create mode 100644 tests/data/aliases/multiple_aliases_parameters.json create mode 100644 tests/data/expected/main/graphql/graphql_multiple_aliases_pydantic_v2.py create mode 100644 tests/data/expected/main/jsonschema/jsonschema_discriminator_multiple_aliases_pydantic_v2.py create mode 100644 tests/data/expected/main/jsonschema/jsonschema_discriminator_no_literal_multiple_aliases_pydantic_v2.py create mode 100644 tests/data/expected/main/openapi/openapi_multiple_aliases_parameters_pydantic_v2.py create mode 100644 tests/data/graphql/multiple-aliases.graphql create mode 100644 tests/data/graphql/multiple-aliases.json create mode 100644 tests/data/jsonschema/discriminator_multiple_aliases.json create mode 100644 tests/data/jsonschema/discriminator_no_literal.json create mode 100644 tests/data/openapi/multiple_aliases_parameters.yaml diff --git a/tests/data/aliases/discriminator_multiple_aliases.json b/tests/data/aliases/discriminator_multiple_aliases.json new file mode 100644 index 000000000..b888bd4a8 --- /dev/null +++ b/tests/data/aliases/discriminator_multiple_aliases.json @@ -0,0 +1,3 @@ +{ + "message_type": ["messageType", "message-type"] +} diff --git a/tests/data/aliases/discriminator_no_literal.json b/tests/data/aliases/discriminator_no_literal.json new file mode 100644 index 000000000..83044e5ff --- /dev/null +++ b/tests/data/aliases/discriminator_no_literal.json @@ -0,0 +1,3 @@ +{ + "pet_type": ["petType", "pet-type"] +} diff --git a/tests/data/aliases/multiple_aliases.json b/tests/data/aliases/multiple_aliases.json index 80fa1a0c5..c157e90aa 100644 --- a/tests/data/aliases/multiple_aliases.json +++ b/tests/data/aliases/multiple_aliases.json @@ -1,4 +1,8 @@ { "my_field": ["my-field", "myField"], - "User.user_name": ["user-name", "userName"] + "User.user_name": ["user-name", "userName"], + "base_field": ["baseField", "base-field"], + "extra_field": ["extraField", "extra-field"], + "any_value_field": ["anyValueField", "any-value-field"], + "inherited_required": ["inheritedRequired", "inherited-required"] } diff --git a/tests/data/aliases/multiple_aliases_parameters.json b/tests/data/aliases/multiple_aliases_parameters.json new file mode 100644 index 000000000..d163630b9 --- /dev/null +++ b/tests/data/aliases/multiple_aliases_parameters.json @@ -0,0 +1,6 @@ +{ + "page_size": ["pageSize", "page-size"], + "sort_order": ["sortOrder", "sort-order"], + "filter_options": ["filterOptions", "filter-options"], + "single_alias_field": "singleAliasField" +} diff --git a/tests/data/expected/main/graphql/graphql_multiple_aliases_pydantic_v2.py b/tests/data/expected/main/graphql/graphql_multiple_aliases_pydantic_v2.py new file mode 100644 index 000000000..94b25ab17 --- /dev/null +++ b/tests/data/expected/main/graphql/graphql_multiple_aliases_pydantic_v2.py @@ -0,0 +1,35 @@ +# generated by datamodel-codegen: +# filename: multiple-aliases.graphql +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Literal + +from pydantic import AliasChoices, BaseModel, Field +from typing_extensions import TypeAliasType + +Boolean = TypeAliasType("Boolean", bool) +""" +The `Boolean` scalar type represents `true` or `false`. +""" + + +DateTime = TypeAliasType("DateTime", str) + + +String = TypeAliasType("String", 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 Event(BaseModel): + end_date: DateTime = Field( + ..., validation_alias=AliasChoices('endDate', 'end_date', 'endDate') + ) + name: String + start_date: DateTime = Field( + ..., validation_alias=AliasChoices('startDate', 'start_date', 'startDate') + ) + typename__: Literal['Event'] | None = Field('Event', alias='__typename') diff --git a/tests/data/expected/main/jsonschema/jsonschema_discriminator_multiple_aliases_pydantic_v2.py b/tests/data/expected/main/jsonschema/jsonschema_discriminator_multiple_aliases_pydantic_v2.py new file mode 100644 index 000000000..1768e7f72 --- /dev/null +++ b/tests/data/expected/main/jsonschema/jsonschema_discriminator_multiple_aliases_pydantic_v2.py @@ -0,0 +1,31 @@ +# generated by datamodel-codegen: +# filename: discriminator_multiple_aliases.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Literal + +from pydantic import AliasChoices, BaseModel, Field + + +class SystemMessage(BaseModel): + messageType: Literal['system'] = Field( + ..., + validation_alias=AliasChoices('message_type', 'messageType', 'message-type'), + ) + content: str + + +class UserMessage(BaseModel): + messageType: Literal['user'] = Field( + ..., + validation_alias=AliasChoices('message_type', 'messageType', 'message-type'), + ) + content: str + + +class Model(BaseModel): + message: SystemMessage | UserMessage | None = Field( + None, discriminator='messageType' + ) diff --git a/tests/data/expected/main/jsonschema/jsonschema_discriminator_no_literal_multiple_aliases_pydantic_v2.py b/tests/data/expected/main/jsonschema/jsonschema_discriminator_no_literal_multiple_aliases_pydantic_v2.py new file mode 100644 index 000000000..b2a5dd511 --- /dev/null +++ b/tests/data/expected/main/jsonschema/jsonschema_discriminator_no_literal_multiple_aliases_pydantic_v2.py @@ -0,0 +1,27 @@ +# generated by datamodel-codegen: +# filename: discriminator_no_literal.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Literal + +from pydantic import AliasChoices, BaseModel, Field + + +class Dog(BaseModel): + bark: bool | None = None + petType: Literal['dog'] = Field( + ..., validation_alias=AliasChoices('pet_type', 'petType', 'pet-type') + ) + + +class Cat(BaseModel): + meow: bool | None = None + petType: Literal['cat'] = Field( + ..., validation_alias=AliasChoices('pet_type', 'petType', 'pet-type') + ) + + +class Model(BaseModel): + pet: Dog | Cat | None = Field(None, discriminator='petType') diff --git a/tests/data/expected/main/jsonschema/jsonschema_multiple_aliases_pydantic_v2.py b/tests/data/expected/main/jsonschema/jsonschema_multiple_aliases_pydantic_v2.py index 4d5d7053c..cd9248b59 100644 --- a/tests/data/expected/main/jsonschema/jsonschema_multiple_aliases_pydantic_v2.py +++ b/tests/data/expected/main/jsonschema/jsonschema_multiple_aliases_pydantic_v2.py @@ -4,6 +4,8 @@ from __future__ import annotations +from typing import Any + from pydantic import AliasChoices, BaseModel, Field @@ -14,9 +16,31 @@ class User(BaseModel): id: int | None = None +class MergedObj(BaseModel): + baseField: str | None = Field( + None, validation_alias=AliasChoices('base_field', 'baseField', 'base-field') + ) + inheritedRequired: Any = Field( + ..., + validation_alias=AliasChoices( + 'inherited_required', 'inheritedRequired', 'inherited-required' + ), + ) + extraField: int | None = Field( + None, validation_alias=AliasChoices('extra_field', 'extraField', 'extra-field') + ) + + class Root(BaseModel): my_field: str | None = Field( None, validation_alias=AliasChoices('my_field', 'my-field', 'myField') ) other_field: int | None = None user: User | None = Field(None, title='User') + merged_obj: MergedObj | None = None + anyValueField: Any | None = Field( + None, + validation_alias=AliasChoices( + 'any_value_field', 'anyValueField', 'any-value-field' + ), + ) diff --git a/tests/data/expected/main/openapi/openapi_multiple_aliases_parameters_pydantic_v2.py b/tests/data/expected/main/openapi/openapi_multiple_aliases_parameters_pydantic_v2.py new file mode 100644 index 000000000..f7dba8e46 --- /dev/null +++ b/tests/data/expected/main/openapi/openapi_multiple_aliases_parameters_pydantic_v2.py @@ -0,0 +1,36 @@ +# generated by datamodel-codegen: +# filename: multiple_aliases_parameters.yaml +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import AliasChoices, BaseModel, Field, RootModel + + +class Item(BaseModel): + id: int | None = None + name: str | None = None + + +class FilterOptions(BaseModel): + category: str | None = None + + +class ListItemsParametersQuery(BaseModel): + pageSize: int | None = Field( + None, validation_alias=AliasChoices('page_size', 'pageSize', 'page-size') + ) + sortOrder: str | None = Field( + None, validation_alias=AliasChoices('sort_order', 'sortOrder', 'sort-order') + ) + filterOptions: FilterOptions | None = Field( + None, + validation_alias=AliasChoices( + 'filter_options', 'filterOptions', 'filter-options' + ), + ) + singleAliasField: str | None = Field(None, alias='single_alias_field') + + +class ListItemsResponse(RootModel[list[Item]]): + root: list[Item] diff --git a/tests/data/graphql/multiple-aliases.graphql b/tests/data/graphql/multiple-aliases.graphql new file mode 100644 index 000000000..a2ba6df88 --- /dev/null +++ b/tests/data/graphql/multiple-aliases.graphql @@ -0,0 +1,7 @@ +scalar DateTime + +type Event { + name: String! + startDate: DateTime! + endDate: DateTime! +} diff --git a/tests/data/graphql/multiple-aliases.json b/tests/data/graphql/multiple-aliases.json new file mode 100644 index 000000000..de4087d77 --- /dev/null +++ b/tests/data/graphql/multiple-aliases.json @@ -0,0 +1,4 @@ +{ + "startDate": ["start_date", "startDate"], + "endDate": ["end_date", "endDate"] +} diff --git a/tests/data/jsonschema/discriminator_multiple_aliases.json b/tests/data/jsonschema/discriminator_multiple_aliases.json new file mode 100644 index 000000000..1644d145f --- /dev/null +++ b/tests/data/jsonschema/discriminator_multiple_aliases.json @@ -0,0 +1,48 @@ +{ + "$defs": { + "SystemMessage": { + "type": "object", + "properties": { + "message_type": { + "type": "string", + "const": "system", + "default": "system" + }, + "content": { + "type": "string" + } + }, + "required": ["message_type", "content"] + }, + "UserMessage": { + "type": "object", + "properties": { + "message_type": { + "type": "string", + "const": "user", + "default": "user" + }, + "content": { + "type": "string" + } + }, + "required": ["message_type", "content"] + } + }, + "type": "object", + "properties": { + "message": { + "discriminator": { + "propertyName": "message_type", + "mapping": { + "system": "#/$defs/SystemMessage", + "user": "#/$defs/UserMessage" + } + }, + "oneOf": [ + {"$ref": "#/$defs/SystemMessage"}, + {"$ref": "#/$defs/UserMessage"} + ] + } + } +} diff --git a/tests/data/jsonschema/discriminator_no_literal.json b/tests/data/jsonschema/discriminator_no_literal.json new file mode 100644 index 000000000..d30ad1695 --- /dev/null +++ b/tests/data/jsonschema/discriminator_no_literal.json @@ -0,0 +1,36 @@ +{ + "$defs": { + "Dog": { + "type": "object", + "properties": { + "bark": { + "type": "boolean" + } + } + }, + "Cat": { + "type": "object", + "properties": { + "meow": { + "type": "boolean" + } + } + } + }, + "type": "object", + "properties": { + "pet": { + "discriminator": { + "propertyName": "pet_type", + "mapping": { + "dog": "#/$defs/Dog", + "cat": "#/$defs/Cat" + } + }, + "oneOf": [ + {"$ref": "#/$defs/Dog"}, + {"$ref": "#/$defs/Cat"} + ] + } + } +} diff --git a/tests/data/jsonschema/multiple_aliases.json b/tests/data/jsonschema/multiple_aliases.json index 2cde67f49..253f670ae 100644 --- a/tests/data/jsonschema/multiple_aliases.json +++ b/tests/data/jsonschema/multiple_aliases.json @@ -20,6 +20,28 @@ "type": "integer" } } - } + }, + "merged_obj": { + "allOf": [ + { + "type": "object", + "properties": { + "base_field": { + "type": "string" + } + }, + "required": ["inherited_required"] + }, + { + "type": "object", + "properties": { + "extra_field": { + "type": "integer" + } + } + } + ] + }, + "any_value_field": true } } diff --git a/tests/data/openapi/multiple_aliases_parameters.yaml b/tests/data/openapi/multiple_aliases_parameters.yaml new file mode 100644 index 000000000..ea521d9f4 --- /dev/null +++ b/tests/data/openapi/multiple_aliases_parameters.yaml @@ -0,0 +1,55 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: Multiple Aliases Test +paths: + /items: + get: + summary: List items + operationId: listItems + parameters: + - name: page_size + in: query + required: false + schema: + type: integer + - name: sort_order + in: query + required: false + schema: + type: string + - name: filter_options + in: query + required: false + content: + application/json: + schema: + type: object + properties: + category: + type: string + - name: single_alias_field + in: query + required: false + content: + application/json: + schema: + type: string + responses: + '200': + description: A list of items + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Item" +components: + schemas: + Item: + type: object + properties: + id: + type: integer + name: + type: string diff --git a/tests/main/graphql/test_main_graphql.py b/tests/main/graphql/test_main_graphql.py index 46d41cc7f..32310d0ec 100644 --- a/tests/main/graphql/test_main_graphql.py +++ b/tests/main/graphql/test_main_graphql.py @@ -150,6 +150,27 @@ def test_main_graphql_field_aliases(output_file: Path) -> None: ) +@pytest.mark.skipif( + black.__version__.split(".")[0] == "19", + reason="Installed black doesn't support the old style", +) +def test_main_graphql_multiple_aliases_pydantic_v2(output_file: Path) -> None: + """Test GraphQL with multiple aliases using Pydantic v2 AliasChoices.""" + run_main_and_assert( + input_path=GRAPHQL_DATA_PATH / "multiple-aliases.graphql", + output_path=output_file, + input_file_type="graphql", + assert_func=assert_file_content, + expected_file="graphql_multiple_aliases_pydantic_v2.py", + extra_args=[ + "--aliases", + str(GRAPHQL_DATA_PATH / "multiple-aliases.json"), + "--output-model-type", + "pydantic_v2.BaseModel", + ], + ) + + @pytest.mark.skipif( black.__version__.split(".")[0] == "19", reason="Installed black doesn't support the old style", diff --git a/tests/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index 05ae31401..0b756e691 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -5479,6 +5479,38 @@ def test_main_jsonschema_multiple_aliases_pydantic_v2(output_file: Path) -> None ) +def test_main_jsonschema_discriminator_multiple_aliases_pydantic_v2(output_file: Path) -> None: + """Test discriminator with multiple aliases using AliasChoices for Pydantic v2.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "discriminator_multiple_aliases.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + extra_args=[ + "--aliases", + str(ALIASES_DATA_PATH / "discriminator_multiple_aliases.json"), + "--output-model-type", + "pydantic_v2.BaseModel", + ], + ) + + +def test_main_jsonschema_discriminator_no_literal_multiple_aliases_pydantic_v2(output_file: Path) -> None: + """Test discriminator without literal in child models using multiple aliases for Pydantic v2.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "discriminator_no_literal.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + extra_args=[ + "--aliases", + str(ALIASES_DATA_PATH / "discriminator_no_literal.json"), + "--output-model-type", + "pydantic_v2.BaseModel", + ], + ) + + def test_main_jsonschema_multiple_types_with_object(output_file: Path) -> None: """Test multiple types in array including object with properties generates Union type.""" run_main_and_assert( diff --git a/tests/main/openapi/test_main_openapi.py b/tests/main/openapi/test_main_openapi.py index c6d526938..5a273239f 100644 --- a/tests/main/openapi/test_main_openapi.py +++ b/tests/main/openapi/test_main_openapi.py @@ -931,6 +931,28 @@ def test_main_with_aliases(output_model: str, expected_output: str, output_file: ) +def test_main_multiple_aliases_parameters_pydantic_v2(output_file: Path) -> None: + """Test OpenAPI with multiple aliases for parameters using Pydantic v2 AliasChoices.""" + run_main_and_assert( + input_path=OPEN_API_DATA_PATH / "multiple_aliases_parameters.yaml", + output_path=output_file, + input_file_type="openapi", + assert_func=assert_file_content, + expected_file="openapi_multiple_aliases_parameters_pydantic_v2.py", + extra_args=[ + "--aliases", + str(DATA_PATH / "aliases" / "multiple_aliases_parameters.json"), + "--openapi-scopes", + "paths", + "schemas", + "parameters", + "--use-operation-id-as-name", + "--output-model-type", + "pydantic_v2.BaseModel", + ], + ) + + def test_main_with_bad_aliases(output_file: Path) -> None: """Test OpenAPI generation with invalid aliases file.""" run_main_and_assert( From 3c54f364ff20d66a49d4d414a9aed6d22d6ef929 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Sun, 28 Dec 2025 19:25:26 +0000 Subject: [PATCH 3/3] Add tests for empty list aliases to cover partial branches --- tests/test_resolver.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 66be9f900..eb5f83c72 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -180,3 +180,19 @@ def test_multiple_aliases_mixed_with_single() -> None: field_name, alias = resolver.get_valid_field_name_and_alias("single") assert field_name == "single_alias" assert alias == "single" + + +def test_empty_list_aliases_flat() -> None: + """Test empty list aliases are ignored and field is treated as no alias.""" + resolver = FieldNameResolver(aliases={"my_field": []}) + field_name, alias = resolver.get_valid_field_name_and_alias("my_field") + assert field_name == "my_field" + assert alias is None # Empty list is ignored + + +def test_empty_list_aliases_scoped() -> None: + """Test empty list aliases with scoped format are ignored.""" + resolver = FieldNameResolver(aliases={"User.name": []}) + field_name, alias = resolver.get_valid_field_name_and_alias("name", class_name="User") + assert field_name == "name" + assert alias is None # Empty list is ignored