diff --git a/src/datamodel_code_generator/__main__.py b/src/datamodel_code_generator/__main__.py index 7d3d2c1b6..73060d905 100644 --- a/src/datamodel_code_generator/__main__.py +++ b/src/datamodel_code_generator/__main__.py @@ -632,12 +632,29 @@ def _serialize_python_type(tp: type) -> str | None: Returns None if the type doesn't need to be preserved (e.g., standard dict, list). """ + import types # noqa: PLC0415 from typing import get_args, get_origin # noqa: PLC0415 origin = get_origin(tp) args = get_args(tp) preserved_origins = _get_preserved_type_origins() + # Handle types.UnionType (X | Y syntax) in Python 3.10-3.13 + # In Python 3.10-3.13, get_origin(X | Y) returns types.UnionType which is distinct from typing.Union + # In Python 3.14+, types.UnionType is the same as typing.Union, so this check is skipped + from typing import Union # noqa: PLC0415 + + if ( + hasattr(types, "UnionType") + and types.UnionType is not Union # Only applies to Python 3.10-3.13 + and origin is types.UnionType + ): + if args: + nested = [_serialize_python_type(a) for a in args] + if any(n is not None for n in nested): + return " | ".join(n or _simple_type_name(a) for n, a in zip(nested, args, strict=False)) + return None # pragma: no cover + if origin in preserved_origins: type_name = preserved_origins[origin] if args: diff --git a/tests/data/python/input_model/pydantic_models.py b/tests/data/python/input_model/pydantic_models.py index 5bc4fed1a..ddcfb167f 100644 --- a/tests/data/python/input_model/pydantic_models.py +++ b/tests/data/python/input_model/pydantic_models.py @@ -33,6 +33,7 @@ class ModelWithPythonTypes(BaseModel): nested_in_list: list[Set[int]] optional_set: Optional[Set[str]] nullable_frozenset: Union[None, FrozenSet[str]] + optional_mapping: Mapping[str, str] | None class RecursiveNode(BaseModel): diff --git a/tests/test_input_model.py b/tests/test_input_model.py index c99f0dab5..28716deb2 100644 --- a/tests/test_input_model.py +++ b/tests/test_input_model.py @@ -590,3 +590,17 @@ def test_input_model_union_none_frozenset(tmp_path: Path) -> None: output_path=tmp_path / "output.py", expected_output_contains=["frozenset[str] | None", "nullable_frozenset:"], ) + + +@SKIP_PYDANTIC_V1 +def test_input_model_optional_mapping_union_syntax(tmp_path: Path) -> None: + """Test that Mapping[str, str] | None using | syntax is preserved correctly. + + In Python 3.10-3.13, get_origin(X | Y) returns types.UnionType. + This test verifies that types.UnionType is handled correctly in those versions. + """ + run_input_model_and_assert( + input_model="tests.data.python.input_model.pydantic_models:ModelWithPythonTypes", + output_path=tmp_path / "output.py", + expected_output_contains=["Mapping[str, str] | None", "optional_mapping:"], + )