From 2dde9ff515a31abee5d546f1945e248134fbac8d Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Thu, 10 Sep 2026 15:31:09 -0700 Subject: [PATCH] fix(http-client-python): preserve single-member union aliases Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...ingle-union-aliases-2026-09-10-15-30-27.md | 7 +++ .../codegen/serializers/unions_serializer.py | 5 ++ .../pygen/codegen/templates/unions.py.jinja2 | 2 +- .../generator/pygen/preprocess/__init__.py | 6 ++- .../tests/unit/test_typeddict.py | 41 ++++++++++++++++- .../tests/unit/test_typeddict_overloads.py | 46 +++++++++++++++++++ 6 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 .chronus/changes/l0lawrence-single-union-aliases-2026-09-10-15-30-27.md diff --git a/.chronus/changes/l0lawrence-single-union-aliases-2026-09-10-15-30-27.md b/.chronus/changes/l0lawrence-single-union-aliases-2026-09-10-15-30-27.md new file mode 100644 index 00000000000..1cecf4411f8 --- /dev/null +++ b/.chronus/changes/l0lawrence-single-union-aliases-2026-09-10-15-30-27.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-client-python" +--- + +Fix named single-member unions to emit valid Python type aliases without generating lone overloads. diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/unions_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/unions_serializer.py index 28d606ccf01..b0c7ce09a57 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/unions_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/unions_serializer.py @@ -21,6 +21,11 @@ def __init__( def imports(self) -> FileImport: file_import = FileImport(self.code_model) if self.code_model.named_unions: + file_import.add_submodule_import( + "typing", + "TypeAlias", + ImportType.STDLIB, + ) file_import.add_submodule_import( "typing", "Union", diff --git a/packages/http-client-python/generator/pygen/codegen/templates/unions.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/unions.py.jinja2 index 19435f14e88..44908503e8c 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/unions.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/unions.py.jinja2 @@ -5,5 +5,5 @@ {{ imports }} {% for nu in code_model.named_unions %} -{{nu.name}} = {{nu.type_definition()}} +{{nu.name}}: TypeAlias = {{nu.type_definition()}} {% endfor %} diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index d79fcb42235..1a5f815e030 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -95,12 +95,14 @@ def add_overloads_for_body_param(yaml_data: dict[str, Any], skip_single_body_jso raw-JSON overload is kept, matching pre-TypedDict behavior). """ body_parameter = yaml_data["bodyParameter"] + body_types = body_parameter["type"].get("types", []) if not ( body_parameter["type"]["type"] == "combined" - and len(yaml_data["bodyParameter"]["type"]["types"]) > len(yaml_data["overloads"]) + and len(body_types) > 1 + and len(body_types) > len(yaml_data["overloads"]) ): return - for body_type in body_parameter["type"]["types"]: + for body_type in body_types: if any(o for o in yaml_data["overloads"] if id(o["bodyParameter"]["type"]) == id(body_type)): continue if body_type.get("type") == "model" and body_type.get("base") == "json": diff --git a/packages/http-client-python/tests/unit/test_typeddict.py b/packages/http-client-python/tests/unit/test_typeddict.py index 7b8b8472706..d0817d6e230 100644 --- a/packages/http-client-python/tests/unit/test_typeddict.py +++ b/packages/http-client-python/tests/unit/test_typeddict.py @@ -8,7 +8,7 @@ from jinja2 import PackageLoader, Environment -from pygen.codegen.models import CodeModel, JSONModelType, DPGModelType, build_type +from pygen.codegen.models import CodeModel, CombinedType, JSONModelType, DPGModelType, build_type from pygen.codegen.models.imports import ImportType, FileImport, TypingSection from pygen.codegen.models.model_type import TypedDictModelType from pygen.codegen.models.property import Property @@ -522,6 +522,45 @@ def test_unions_serializer_no_unions(): assert "Union" not in output +def test_unions_serializer_single_member_alias(): + """A named single-member union must remain a valid static type alias.""" + code_model = _make_code_model(models_mode="dpg") + model = _make_model(code_model, "GenerateVoiceAgentRequest", model_cls=DPGModelType) + named_union = CombinedType( + {"type": "combined", "name": "GenerateAgentRequest"}, + code_model, + [model], + ) + code_model.named_unions = [named_union] + + output = UnionsSerializer(code_model=code_model, env=_make_env()).serialize() + + assert "from typing import TYPE_CHECKING, TypeAlias, Union" in output + assert 'GenerateAgentRequest: TypeAlias = "_models.GenerateVoiceAgentRequest"' in output + assert named_union.type_annotation() == '"_unions.GenerateAgentRequest"' + + +def test_unions_serializer_multiple_member_alias(): + """A named multi-member union remains a Union type alias.""" + code_model = _make_code_model(models_mode="dpg") + voice_model = _make_model(code_model, "GenerateVoiceAgentRequest", model_cls=DPGModelType) + text_model = _make_model(code_model, "GenerateTextAgentRequest", model_cls=DPGModelType) + named_union = CombinedType( + {"type": "combined", "name": "GenerateAgentRequest"}, + code_model, + [voice_model, text_model], + ) + code_model.named_unions = [named_union] + + output = UnionsSerializer(code_model=code_model, env=_make_env()).serialize() + + assert "from typing import TYPE_CHECKING, TypeAlias, Union" in output + assert ( + 'GenerateAgentRequest: TypeAlias = Union["_models.GenerateVoiceAgentRequest", ' + '"_models.GenerateTextAgentRequest"]' in output + ) + + # ---------- typed-dict-only ---------- diff --git a/packages/http-client-python/tests/unit/test_typeddict_overloads.py b/packages/http-client-python/tests/unit/test_typeddict_overloads.py index 0ce1ae24ede..e4298f84083 100644 --- a/packages/http-client-python/tests/unit/test_typeddict_overloads.py +++ b/packages/http-client-python/tests/unit/test_typeddict_overloads.py @@ -72,6 +72,52 @@ def _json_model_operation() -> tuple[dict, dict, dict]: return code_model, yaml_data, model_type +def _named_union_operation(member_count: int) -> dict: + member_types = [{"type": "string"}, {"type": "integer"}] + union_type = { + "type": "combined", + "name": "GenerateAgentRequest", + "types": member_types[:member_count], + } + return { + "name": "generate", + "bodyParameter": { + "wireName": "body", + "clientName": "body", + "location": "body", + "optional": False, + "implementation": "Method", + "contentTypes": ["application/json"], + "type": union_type, + }, + "parameters": [_content_type_param()], + "overloads": [], + "responses": [], + "exceptions": [], + } + + +def test_named_single_member_union_emits_no_overload(): + """The implementation annotation carries the alias without an invalid lone overload.""" + yaml_data = _named_union_operation(member_count=1) + named_union = yaml_data["bodyParameter"]["type"] + + add_overloads_for_body_param(yaml_data) + + assert yaml_data["overloads"] == [] + assert yaml_data["bodyParameter"]["type"] is named_union + assert yaml_data["bodyParameter"]["type"]["name"] == "GenerateAgentRequest" + + +def test_named_multiple_member_union_emits_variant_overloads(): + """Multi-member named unions keep one overload per variant.""" + yaml_data = _named_union_operation(member_count=2) + + add_overloads_for_body_param(yaml_data) + + assert len(yaml_data["overloads"]) == 2 + + def test_typeddict_only_single_body_emits_no_overload(): """A lone TypedDict body variant must NOT produce a single ``@overload``.""" plugin = _plugin("typeddict")