From 2dde9ff515a31abee5d546f1945e248134fbac8d Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Thu, 10 Sep 2026 15:31:09 -0700 Subject: [PATCH 1/9] 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") From 2a5a2e41819ca59b88cb4f15d01776cf06b0a985 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Fri, 18 Sep 2026 09:59:29 -0700 Subject: [PATCH 2/9] fix(http-client-python): correct union overload regressions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...ingle-union-aliases-2026-09-10-15-30-27.md | 7 -- ...ython-union-etag-regressions-2026-09-18.md | 7 ++ .../codegen/serializers/unions_serializer.py | 17 +++- .../pygen/codegen/templates/unions.py.jinja2 | 2 +- .../generator/pygen/preprocess/__init__.py | 36 ++++++--- .../tests/unit/test_preprocess_etag.py | 78 +++++++++++++++++-- .../tests/unit/test_typeddict.py | 21 +++++ .../tests/unit/test_typeddict_overloads.py | 49 +++++++++++- 8 files changed, 185 insertions(+), 32 deletions(-) delete mode 100644 .chronus/changes/l0lawrence-single-union-aliases-2026-09-10-15-30-27.md create mode 100644 .chronus/changes/python-union-etag-regressions-2026-09-18.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 deleted file mode 100644 index 1cecf4411f8..00000000000 --- a/.chronus/changes/l0lawrence-single-union-aliases-2026-09-10-15-30-27.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -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/.chronus/changes/python-union-etag-regressions-2026-09-18.md b/.chronus/changes/python-union-etag-regressions-2026-09-18.md new file mode 100644 index 00000000000..13a6abdf464 --- /dev/null +++ b/.chronus/changes/python-union-etag-regressions-2026-09-18.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-client-python" +--- + +Prevent duplicate named union aliases, preserve parameter types in generated body overloads, and keep required conditional headers direct. 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 b0c7ce09a57..fdf597b6160 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 @@ -3,7 +3,7 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- -from ..models import CodeModel +from ..models import CodeModel, CombinedType from ..models.imports import FileImport, ImportType from ..models.utils import NamespaceType from .import_serializer import FileImportSerializer @@ -18,9 +18,19 @@ def __init__( ): super().__init__(code_model=code_model, env=env) + @property + def named_unions(self) -> list[CombinedType]: + result: list[CombinedType] = [] + seen_names: set[str] = set() + for union in self.code_model.named_unions: + if union.name and union.name not in seen_names: + result.append(union) + seen_names.add(union.name) + return result + def imports(self) -> FileImport: file_import = FileImport(self.code_model) - if self.code_model.named_unions: + if self.named_unions: file_import.add_submodule_import( "typing", "TypeAlias", @@ -31,7 +41,7 @@ def imports(self) -> FileImport: "Union", ImportType.STDLIB, ) - for nu in self.code_model.named_unions: + for nu in self.named_unions: file_import.merge( nu.imports( serialize_namespace=self.serialize_namespace, @@ -44,6 +54,7 @@ def serialize(self) -> str: template = self.env.get_template("unions.py.jinja2") return template.render( code_model=self.code_model, + named_unions=self.named_unions, imports=FileImportSerializer(self.imports()), serializer=self, ) 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 44908503e8c..b2a1bb74a02 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 @@ -4,6 +4,6 @@ {% endif %} {{ imports }} -{% for nu in code_model.named_unions %} +{% for nu in named_unions %} {{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 1e0a8a82579..291e5db4488 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -54,13 +54,14 @@ def add_overload(yaml_data: dict[str, Any], body_type: dict[str, Any], for_flatt if yaml_data.get("initialOperation"): overload["initialOperation"] = yaml_data["initialOperation"] + # Reattach shared type objects before filtering parameters so positional + # alignment with the original operation is preserved. + for overload_p, original_p in zip(overload["parameters"], yaml_data["parameters"]): + overload_p["type"] = original_p["type"] if for_flatten_params: overload["bodyParameter"]["flattened"] = True else: overload["parameters"] = [p for p in overload["parameters"] if not p.get("inFlattenedBody")] - # for yaml sync, we need to make sure all of the responses, parameters, and exceptions' types have the same yaml id - for overload_p, original_p in zip(overload["parameters"], yaml_data["parameters"]): - overload_p["type"] = original_p["type"] update_overload_section(overload, yaml_data, "responses") update_overload_section(overload, yaml_data, "exceptions") @@ -271,15 +272,26 @@ def _process_operation_etag_headers( elif role == "ifNoneMatch": if_none_match_candidates.append(p) - property_if_match, property_if_none_match = _resolve_etag_pair(if_match_candidates, if_none_match_candidates) - if property_if_match and property_if_none_match: - etag_params = {id(property_if_match), id(property_if_none_match)} - operation["parameters"] = [item for item in operation["parameters"] if id(item) not in etag_params] + [ - property_if_match, - property_if_none_match, - ] - operation["hasEtag"] = True - client["hasEtag"] = True + etag_candidates = if_match_candidates + if_none_match_candidates + if any(not parameter.get("optional", False) for parameter in etag_candidates): + # A required conditional header fixes the header choice and requires + # its value. Keep it direct instead of introducing the optional + # etag/MatchConditions convenience API. + for parameter in etag_candidates: + parameter.pop("etagRole", None) + else: + property_if_match, property_if_none_match = _resolve_etag_pair(if_match_candidates, if_none_match_candidates) + if property_if_match and property_if_none_match: + etag_params = {id(property_if_match), id(property_if_none_match)} + operation["parameters"] = [item for item in operation["parameters"] if id(item) not in etag_params] + [ + property_if_match, + property_if_none_match, + ] + operation["hasEtag"] = True + client["hasEtag"] = True + + for overload in operation.get("overloads", []): + _process_operation_etag_headers(overload, client, version_tolerant) def _process_operation_group_etag_headers( diff --git a/packages/http-client-python/tests/unit/test_preprocess_etag.py b/packages/http-client-python/tests/unit/test_preprocess_etag.py index 319b2311e76..d1a09230255 100644 --- a/packages/http-client-python/tests/unit/test_preprocess_etag.py +++ b/packages/http-client-python/tests/unit/test_preprocess_etag.py @@ -65,8 +65,8 @@ def _get_op(client: dict) -> dict: return client["operationGroups"][0]["operations"][0] -def test_etag_headers_in_nested_operation_group_are_processed(): - """Nested ETag operations get their partner parameter and enable client helpers.""" +def test_required_etag_header_in_nested_operation_group_remains_direct(): + """A required explicit conditional header does not gain MatchConditions.""" if_match = _header_param( "if_match", "If-Match", @@ -91,15 +91,42 @@ def test_etag_headers_in_nested_operation_group_are_processed(): plugin = _plugin() plugin.update_client(client) - assert client["hasEtag"] is True + assert "hasEtag" not in client assert "hasEtag" not in parent_group + assert "hasEtag" not in operation + assert operation["parameters"] == [if_match] + assert "etagRole" not in if_match + + plugin.update_parameter(if_match) + assert if_match["clientName"] == "if_match" + assert if_match["type"] == {"type": "string"} + + +def test_optional_etag_header_in_nested_operation_group_uses_match_conditions(): + """The optional ETag convenience API remains available in nested groups.""" + if_match = _header_param("if_match", "If-Match", "ifMatch") + operation = { + "name": "remove", + "parameters": [if_match], + } + client = _client_yaml([]) + client["operationGroups"] = [ + { + "operations": [], + "operationGroups": [ + { + "operations": [operation], + } + ], + } + ] + + plugin = _plugin() + plugin.update_client(client) + + assert client["hasEtag"] is True assert operation["hasEtag"] is True assert len(operation["parameters"]) == 2 - assert operation["parameters"][0]["etagRole"] == "ifMatch" - assert operation["parameters"][1]["etagRole"] == "ifNoneMatch" - assert all(parameter["optional"] is False for parameter in operation["parameters"]) - assert all("clientDefaultValue" not in parameter for parameter in operation["parameters"]) - for parameter in operation["parameters"]: plugin.update_parameter(parameter) assert [parameter["clientName"] for parameter in operation["parameters"]] == [ @@ -108,6 +135,41 @@ def test_etag_headers_in_nested_operation_group_are_processed(): ] +def test_required_etag_roles_are_removed_from_existing_overloads(): + """Required conditional headers stay direct in body overloads as well.""" + operation_header = _header_param( + "if_match", + "If-Match", + "ifMatch", + optional=False, + ) + overload_header = _header_param( + "if_match", + "If-Match", + "ifMatch", + optional=False, + ) + operation = { + "name": "update", + "parameters": [operation_header], + "overloads": [ + { + "name": "update", + "parameters": [overload_header], + } + ], + } + client = _client_yaml([]) + client["operationGroups"][0]["operations"] = [operation] + + _plugin().update_client(client) + + assert "etagRole" not in operation_header + assert "etagRole" not in overload_header + assert "hasEtag" not in operation + assert "hasEtag" not in operation["overloads"][0] + + def test_etag_role_preserved_when_only_standard_pair_present(): """Standard If-Match/If-None-Match keep their etagRole.""" if_match = _header_param("if_match", "If-Match", "ifMatch") diff --git a/packages/http-client-python/tests/unit/test_typeddict.py b/packages/http-client-python/tests/unit/test_typeddict.py index d0817d6e230..dafe1a9e503 100644 --- a/packages/http-client-python/tests/unit/test_typeddict.py +++ b/packages/http-client-python/tests/unit/test_typeddict.py @@ -561,6 +561,27 @@ def test_unions_serializer_multiple_member_alias(): ) +def test_unions_serializer_deduplicates_named_aliases(): + """Equivalent named-union copies produce one alias declaration.""" + code_model = _make_code_model(models_mode="dpg") + voice_model = _make_model(code_model, "GenerateVoiceAgentRequest", model_cls=DPGModelType) + first = CombinedType( + {"type": "combined", "name": "GenerateAgentRequest"}, + code_model, + [voice_model], + ) + duplicate = CombinedType( + {"type": "combined", "name": "GenerateAgentRequest"}, + code_model, + [voice_model], + ) + code_model.named_unions = [first, duplicate] + + output = UnionsSerializer(code_model=code_model, env=_make_env()).serialize() + + assert output.count("GenerateAgentRequest: TypeAlias =") == 1 + + # ---------- 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 e4298f84083..e98682bbf79 100644 --- a/packages/http-client-python/tests/unit/test_typeddict_overloads.py +++ b/packages/http-client-python/tests/unit/test_typeddict_overloads.py @@ -11,7 +11,7 @@ ``Single overload definition, multiple required``. The preprocess plugin must instead keep the body as a plain single type so no ``@overload`` is emitted. """ -from pygen.preprocess import PreProcessPlugin, add_overloads_for_body_param +from pygen.preprocess import PreProcessPlugin, add_overload, add_overloads_for_body_param def _plugin(models_mode: str, generate_typeddict: bool = True) -> PreProcessPlugin: @@ -118,6 +118,53 @@ def test_named_multiple_member_union_emits_variant_overloads(): assert len(yaml_data["overloads"]) == 2 +def test_add_overload_preserves_types_after_filtering_flattened_parameters(): + """Filtering a flattened parameter must not shift later parameter types.""" + yaml_data = _named_union_operation(member_count=2) + flattened_type = {"type": "string", "name": "FlattenedType"} + etag_type = {"type": "string", "name": "EtagType"} + match_condition_type = {"type": "sdkcore", "name": "MatchConditions"} + yaml_data["parameters"].extend( + [ + { + "wireName": "flattened", + "clientName": "flattened", + "location": "body", + "optional": True, + "implementation": "Method", + "inFlattenedBody": True, + "type": flattened_type, + }, + { + "wireName": "If-Match", + "clientName": "etag", + "location": "header", + "optional": True, + "implementation": "Method", + "type": etag_type, + }, + { + "wireName": "If-None-Match", + "clientName": "match_condition", + "location": "header", + "optional": True, + "implementation": "Method", + "type": match_condition_type, + }, + ] + ) + + overload = add_overload(yaml_data, yaml_data["bodyParameter"]["type"]["types"][0]) + + assert [parameter["clientName"] for parameter in overload["parameters"]] == [ + "content_type", + "etag", + "match_condition", + ] + assert overload["parameters"][1]["type"] is etag_type + assert overload["parameters"][2]["type"] is match_condition_type + + def test_typeddict_only_single_body_emits_no_overload(): """A lone TypedDict body variant must NOT produce a single ``@overload``.""" plugin = _plugin("typeddict") From e33cb6355be4375550e5ce08b85ff046d52884da Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Fri, 18 Sep 2026 10:13:18 -0700 Subject: [PATCH 3/9] fix(http-client-python): retain match conditions for etags Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...ython-union-etag-regressions-2026-09-18.md | 2 +- .../generator/pygen/preprocess/__init__.py | 26 ++++------- .../tests/unit/test_preprocess_etag.py | 44 ++++++++++++------- 3 files changed, 38 insertions(+), 34 deletions(-) diff --git a/.chronus/changes/python-union-etag-regressions-2026-09-18.md b/.chronus/changes/python-union-etag-regressions-2026-09-18.md index 13a6abdf464..efdcf391874 100644 --- a/.chronus/changes/python-union-etag-regressions-2026-09-18.md +++ b/.chronus/changes/python-union-etag-regressions-2026-09-18.md @@ -4,4 +4,4 @@ packages: - "@typespec/http-client-python" --- -Prevent duplicate named union aliases, preserve parameter types in generated body overloads, and keep required conditional headers direct. +Prevent duplicate named union aliases and preserve ETag and match-condition parameter types in generated body overloads. diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index 291e5db4488..44a89b23340 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -272,23 +272,15 @@ def _process_operation_etag_headers( elif role == "ifNoneMatch": if_none_match_candidates.append(p) - etag_candidates = if_match_candidates + if_none_match_candidates - if any(not parameter.get("optional", False) for parameter in etag_candidates): - # A required conditional header fixes the header choice and requires - # its value. Keep it direct instead of introducing the optional - # etag/MatchConditions convenience API. - for parameter in etag_candidates: - parameter.pop("etagRole", None) - else: - property_if_match, property_if_none_match = _resolve_etag_pair(if_match_candidates, if_none_match_candidates) - if property_if_match and property_if_none_match: - etag_params = {id(property_if_match), id(property_if_none_match)} - operation["parameters"] = [item for item in operation["parameters"] if id(item) not in etag_params] + [ - property_if_match, - property_if_none_match, - ] - operation["hasEtag"] = True - client["hasEtag"] = True + property_if_match, property_if_none_match = _resolve_etag_pair(if_match_candidates, if_none_match_candidates) + if property_if_match and property_if_none_match: + etag_params = {id(property_if_match), id(property_if_none_match)} + operation["parameters"] = [item for item in operation["parameters"] if id(item) not in etag_params] + [ + property_if_match, + property_if_none_match, + ] + operation["hasEtag"] = True + client["hasEtag"] = True for overload in operation.get("overloads", []): _process_operation_etag_headers(overload, client, version_tolerant) diff --git a/packages/http-client-python/tests/unit/test_preprocess_etag.py b/packages/http-client-python/tests/unit/test_preprocess_etag.py index d1a09230255..476f839ddbd 100644 --- a/packages/http-client-python/tests/unit/test_preprocess_etag.py +++ b/packages/http-client-python/tests/unit/test_preprocess_etag.py @@ -65,8 +65,8 @@ def _get_op(client: dict) -> dict: return client["operationGroups"][0]["operations"][0] -def test_required_etag_header_in_nested_operation_group_remains_direct(): - """A required explicit conditional header does not gain MatchConditions.""" +def test_required_etag_header_in_nested_operation_group_uses_match_conditions(): + """Nested required ETag operations get the complete convenience pair.""" if_match = _header_param( "if_match", "If-Match", @@ -91,15 +91,22 @@ def test_required_etag_header_in_nested_operation_group_remains_direct(): plugin = _plugin() plugin.update_client(client) - assert "hasEtag" not in client + assert client["hasEtag"] is True assert "hasEtag" not in parent_group - assert "hasEtag" not in operation - assert operation["parameters"] == [if_match] - assert "etagRole" not in if_match - - plugin.update_parameter(if_match) - assert if_match["clientName"] == "if_match" - assert if_match["type"] == {"type": "string"} + assert operation["hasEtag"] is True + assert len(operation["parameters"]) == 2 + assert all(parameter["optional"] is False for parameter in operation["parameters"]) + for parameter in operation["parameters"]: + plugin.update_parameter(parameter) + assert [parameter["clientName"] for parameter in operation["parameters"]] == [ + "etag", + "match_condition", + ] + assert operation["parameters"][0]["type"] == {"type": "string"} + assert operation["parameters"][1]["type"] == { + "type": "sdkcore", + "name": "MatchConditions", + } def test_optional_etag_header_in_nested_operation_group_uses_match_conditions(): @@ -135,8 +142,8 @@ def test_optional_etag_header_in_nested_operation_group_uses_match_conditions(): ] -def test_required_etag_roles_are_removed_from_existing_overloads(): - """Required conditional headers stay direct in body overloads as well.""" +def test_required_etag_roles_are_processed_in_existing_overloads(): + """Required body overloads receive the same ETag convenience pair.""" operation_header = _header_param( "if_match", "If-Match", @@ -164,10 +171,15 @@ def test_required_etag_roles_are_removed_from_existing_overloads(): _plugin().update_client(client) - assert "etagRole" not in operation_header - assert "etagRole" not in overload_header - assert "hasEtag" not in operation - assert "hasEtag" not in operation["overloads"][0] + assert operation["hasEtag"] is True + assert operation["overloads"][0]["hasEtag"] is True + for target in (operation, operation["overloads"][0]): + assert len(target["parameters"]) == 2 + assert [parameter["etagRole"] for parameter in target["parameters"]] == [ + "ifMatch", + "ifNoneMatch", + ] + assert all(parameter["optional"] is False for parameter in target["parameters"]) def test_etag_role_preserved_when_only_standard_pair_present(): From 0f0a68299691a03b328a4c2e6794b492ee4e9b09 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Fri, 18 Sep 2026 10:34:30 -0700 Subject: [PATCH 4/9] test(http-client-python): harden union etag coverage Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../codegen/serializers/unions_serializer.py | 12 ++- .../tests/unit/test_preprocess_etag.py | 13 ++- .../tests/unit/test_typeddict.py | 34 ++++++- .../tests/unit/test_typeddict_overloads.py | 99 ++++++++++++++++++- 4 files changed, 144 insertions(+), 14 deletions(-) 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 fdf597b6160..81c231b2274 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,11 +21,17 @@ def __init__( @property def named_unions(self) -> list[CombinedType]: result: list[CombinedType] = [] - seen_names: set[str] = set() + definitions: dict[str, str] = {} for union in self.code_model.named_unions: - if union.name and union.name not in seen_names: + if not union.name: + continue + definition = union.type_definition() + if union.name in definitions: + if definitions[union.name] != definition: + raise ValueError(f"Conflicting definitions for named union {union.name}") + else: result.append(union) - seen_names.add(union.name) + definitions[union.name] = definition return result def imports(self) -> FileImport: diff --git a/packages/http-client-python/tests/unit/test_preprocess_etag.py b/packages/http-client-python/tests/unit/test_preprocess_etag.py index 476f839ddbd..99bb672b1ff 100644 --- a/packages/http-client-python/tests/unit/test_preprocess_etag.py +++ b/packages/http-client-python/tests/unit/test_preprocess_etag.py @@ -4,6 +4,8 @@ # license information. # -------------------------------------------------------------------------- """Tests for etag-typed header handling in the preprocess plugin.""" +import pytest + from pygen.preprocess import PreProcessPlugin @@ -142,19 +144,20 @@ def test_optional_etag_header_in_nested_operation_group_uses_match_conditions(): ] -def test_required_etag_roles_are_processed_in_existing_overloads(): - """Required body overloads receive the same ETag convenience pair.""" +@pytest.mark.parametrize("optional", [False, True]) +def test_etag_roles_are_processed_in_existing_overloads(optional: bool): + """Required and optional body overloads receive the ETag convenience pair.""" operation_header = _header_param( "if_match", "If-Match", "ifMatch", - optional=False, + optional=optional, ) overload_header = _header_param( "if_match", "If-Match", "ifMatch", - optional=False, + optional=optional, ) operation = { "name": "update", @@ -179,7 +182,7 @@ def test_required_etag_roles_are_processed_in_existing_overloads(): "ifMatch", "ifNoneMatch", ] - assert all(parameter["optional"] is False for parameter in target["parameters"]) + assert all(parameter["optional"] is optional for parameter in target["parameters"]) def test_etag_role_preserved_when_only_standard_pair_present(): diff --git a/packages/http-client-python/tests/unit/test_typeddict.py b/packages/http-client-python/tests/unit/test_typeddict.py index dafe1a9e503..62e8315ab6f 100644 --- a/packages/http-client-python/tests/unit/test_typeddict.py +++ b/packages/http-client-python/tests/unit/test_typeddict.py @@ -7,6 +7,7 @@ """Tests for TypedDict generation, unions generation, and models-mode interactions.""" from jinja2 import PackageLoader, Environment +import pytest from pygen.codegen.models import CodeModel, CombinedType, JSONModelType, DPGModelType, build_type from pygen.codegen.models.imports import ImportType, FileImport, TypingSection @@ -561,19 +562,22 @@ def test_unions_serializer_multiple_member_alias(): ) -def test_unions_serializer_deduplicates_named_aliases(): - """Equivalent named-union copies produce one alias declaration.""" +@pytest.mark.parametrize("member_count", [1, 2]) +def test_unions_serializer_deduplicates_named_aliases(member_count: int): + """Equivalent single- and multi-member copies produce one alias declaration.""" 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) + members = [voice_model, text_model][:member_count] first = CombinedType( {"type": "combined", "name": "GenerateAgentRequest"}, code_model, - [voice_model], + members, ) duplicate = CombinedType( {"type": "combined", "name": "GenerateAgentRequest"}, code_model, - [voice_model], + members.copy(), ) code_model.named_unions = [first, duplicate] @@ -582,6 +586,28 @@ def test_unions_serializer_deduplicates_named_aliases(): assert output.count("GenerateAgentRequest: TypeAlias =") == 1 +def test_unions_serializer_rejects_conflicting_duplicate_aliases(): + """One Python alias name cannot silently represent different unions.""" + 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) + code_model.named_unions = [ + CombinedType( + {"type": "combined", "name": "GenerateAgentRequest"}, + code_model, + [voice_model], + ), + CombinedType( + {"type": "combined", "name": "GenerateAgentRequest"}, + code_model, + [text_model], + ), + ] + + with pytest.raises(ValueError, match="Conflicting definitions for named union GenerateAgentRequest"): + UnionsSerializer(code_model=code_model, env=_make_env()).serialize() + + # ---------- 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 e98682bbf79..095abc1c0fb 100644 --- a/packages/http-client-python/tests/unit/test_typeddict_overloads.py +++ b/packages/http-client-python/tests/unit/test_typeddict_overloads.py @@ -11,6 +11,8 @@ ``Single overload definition, multiple required``. The preprocess plugin must instead keep the body as a plain single type so no ``@overload`` is emitted. """ +import pytest + from pygen.preprocess import PreProcessPlugin, add_overload, add_overloads_for_body_param @@ -97,25 +99,71 @@ def _named_union_operation(member_count: int) -> dict: } -def test_named_single_member_union_emits_no_overload(): +def _etag_parameters(*, optional: bool) -> tuple[list[dict], dict, dict]: + etag_type = {"type": "string"} + match_condition_type = {"type": "sdkcore", "name": "MatchConditions"} + return ( + [ + { + "wireName": "If-Match", + "clientName": "etag", + "location": "header", + "optional": optional, + "implementation": "Method", + "type": etag_type, + }, + { + "wireName": "If-None-Match", + "clientName": "match_condition", + "location": "header", + "optional": optional, + "implementation": "Method", + "type": match_condition_type, + }, + ], + etag_type, + match_condition_type, + ) + + +@pytest.mark.parametrize("optional", [False, True]) +def test_named_single_member_union_emits_no_overload(optional: bool): """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"] + etag_parameters, etag_type, match_condition_type = _etag_parameters(optional=optional) + yaml_data["parameters"].extend(etag_parameters) 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" + assert yaml_data["parameters"][-2]["type"] is etag_type + assert yaml_data["parameters"][-1]["type"] is match_condition_type + assert all(parameter["optional"] is optional for parameter in yaml_data["parameters"][-2:]) -def test_named_multiple_member_union_emits_variant_overloads(): +@pytest.mark.parametrize("optional", [False, True]) +def test_named_multiple_member_union_emits_variant_overloads(optional: bool): """Multi-member named unions keep one overload per variant.""" yaml_data = _named_union_operation(member_count=2) + named_union = yaml_data["bodyParameter"]["type"] + etag_parameters, etag_type, match_condition_type = _etag_parameters(optional=optional) + yaml_data["parameters"].extend(etag_parameters) add_overloads_for_body_param(yaml_data) assert len(yaml_data["overloads"]) == 2 + assert yaml_data["bodyParameter"]["type"] is named_union + assert all( + overload["bodyParameter"]["type"] is member_type + for overload, member_type in zip(yaml_data["overloads"], named_union["types"]) + ) + for overload in yaml_data["overloads"]: + assert overload["parameters"][-2]["type"] is etag_type + assert overload["parameters"][-1]["type"] is match_condition_type + assert all(parameter["optional"] is optional for parameter in overload["parameters"][-2:]) def test_add_overload_preserves_types_after_filtering_flattened_parameters(): @@ -165,6 +213,53 @@ def test_add_overload_preserves_types_after_filtering_flattened_parameters(): assert overload["parameters"][2]["type"] is match_condition_type +@pytest.mark.parametrize("optional", [False, True]) +def test_spread_body_overloads_preserve_etag_parameter_types(optional: bool): + """Flattened, JSON, and binary overloads keep the shared ETag pair.""" + plugin = _plugin("dpg") + cross_language_id = "Contoso.TelephonyTransferTargets" + original = _dpg_body_parameter("TelephonyTransferTargets", cross_language_id)["type"] + spread_body = _json_spread_body_parameter("ReplaceTransferTargetsRequest", cross_language_id) + flattened_type = {"type": "list", "elementType": {"type": "string"}} + flattened_parameter = { + "wireName": "transfer_targets", + "clientName": "transfer_targets", + "location": "body", + "optional": False, + "implementation": "Method", + "inFlattenedBody": True, + "type": flattened_type, + } + etag_parameters, etag_type, match_condition_type = _etag_parameters(optional=optional) + yaml_data = { + "name": "replace", + "bodyParameter": spread_body, + "parameters": [_content_type_param(), flattened_parameter, *etag_parameters], + "overloads": [], + "responses": [], + "exceptions": [], + } + code_model = {"types": [original, spread_body["type"]]} + + skip_single_body_json = plugin.add_body_param_type(code_model, spread_body) + add_overloads_for_body_param(yaml_data, skip_single_body_json=skip_single_body_json) + + assert len(yaml_data["overloads"]) == 3 + flattened_overloads = [ + overload for overload in yaml_data["overloads"] if overload["bodyParameter"].get("flattened") + ] + assert len(flattened_overloads) == 1 + assert flattened_overloads[0]["parameters"][1]["type"] is flattened_type + for overload in yaml_data["overloads"]: + parameters_by_name = {parameter["clientName"]: parameter for parameter in overload["parameters"]} + assert parameters_by_name["etag"]["type"] is etag_type + assert parameters_by_name["match_condition"]["type"] is match_condition_type + assert parameters_by_name["etag"]["optional"] is optional + assert parameters_by_name["match_condition"]["optional"] is optional + if not overload["bodyParameter"].get("flattened"): + assert "transfer_targets" not in parameters_by_name + + def test_typeddict_only_single_body_emits_no_overload(): """A lone TypedDict body variant must NOT produce a single ``@overload``.""" plugin = _plugin("typeddict") From 35a7d1c29a9517dc36e3c540185f5f86c35fe94b Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Fri, 18 Sep 2026 10:52:36 -0700 Subject: [PATCH 5/9] fix(http-client-python): avoid synthetic etag headers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...ython-union-etag-regressions-2026-09-18.md | 2 +- .../generator/pygen/preprocess/__init__.py | 34 +++++++------ .../tests/unit/test_preprocess_etag.py | 51 ++++++++++++------- .../tests/unit/test_typeddict_overloads.py | 8 +-- 4 files changed, 55 insertions(+), 40 deletions(-) diff --git a/.chronus/changes/python-union-etag-regressions-2026-09-18.md b/.chronus/changes/python-union-etag-regressions-2026-09-18.md index efdcf391874..66c44130928 100644 --- a/.chronus/changes/python-union-etag-regressions-2026-09-18.md +++ b/.chronus/changes/python-union-etag-regressions-2026-09-18.md @@ -4,4 +4,4 @@ packages: - "@typespec/http-client-python" --- -Prevent duplicate named union aliases and preserve ETag and match-condition parameter types in generated body overloads. +Prevent duplicate named union aliases and preserve ETag and match-condition parameter types in generated body overloads without emitting undeclared conditional headers. diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index 44a89b23340..7daa38941bc 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -205,6 +205,16 @@ def _pick_etag_slot(candidates: list[dict[str, Any]], standard_wire_name: str) - return candidates[0] +def _make_non_wire_etag_companion(source: dict[str, Any], replacement: dict[str, Any]) -> dict[str, Any]: + """Create the missing convenience parameter without inventing a request header.""" + companion = source.copy() + companion.update(replacement) + companion["wireName"] = "" + companion["location"] = "keyword" + companion.pop("etagRole", None) + return companion + + def _resolve_etag_pair( if_match_candidates: list[dict[str, Any]], if_none_match_candidates: list[dict[str, Any]], @@ -212,8 +222,8 @@ def _resolve_etag_pair( """Select and reconcile the etag header pair for an operation. When multiple etag-typed headers are present, prefer the standard - If-Match / If-None-Match pair. Synthesize a missing partner when only - one side is present, and strip etagRole from non-selected candidates. + If-Match / If-None-Match pair. Add a non-wire convenience parameter when + only one side is present, and strip etagRole from non-selected candidates. Returns (property_if_match, property_if_none_match) — both None when there are no etag candidates. @@ -223,27 +233,19 @@ def _resolve_etag_pair( # Ensure the promoted pair come from the same family. When one slot is # standard and the other custom (cross-family), replace the custom slot - # with a synthetic standard partner. Also synthesize the missing partner - # when only one side is present. + # with a non-wire convenience parameter. Also add the missing API parameter + # when only one side is present without inventing a request header. if property_if_match and property_if_none_match: match_is_std = get_wire_name_lower(property_if_match) == STANDARD_IF_MATCH_WIRE_NAME none_match_is_std = get_wire_name_lower(property_if_none_match) == STANDARD_IF_NONE_MATCH_WIRE_NAME if match_is_std and not none_match_is_std: - property_if_none_match = property_if_match.copy() - property_if_none_match["wireName"] = STANDARD_IF_NONE_MATCH_WIRE_NAME - property_if_none_match["etagRole"] = "ifNoneMatch" + property_if_none_match = _make_non_wire_etag_companion(property_if_match, ETAG_NONE_MATCH_DATA) elif none_match_is_std and not match_is_std: - property_if_match = property_if_none_match.copy() - property_if_match["wireName"] = STANDARD_IF_MATCH_WIRE_NAME - property_if_match["etagRole"] = "ifMatch" + property_if_match = _make_non_wire_etag_companion(property_if_none_match, ETAG_MATCH_DATA) elif not property_if_match and property_if_none_match: - property_if_match = property_if_none_match.copy() - property_if_match["wireName"] = STANDARD_IF_MATCH_WIRE_NAME - property_if_match["etagRole"] = "ifMatch" + property_if_match = _make_non_wire_etag_companion(property_if_none_match, ETAG_MATCH_DATA) elif property_if_match and not property_if_none_match: - property_if_none_match = property_if_match.copy() - property_if_none_match["wireName"] = STANDARD_IF_NONE_MATCH_WIRE_NAME - property_if_none_match["etagRole"] = "ifNoneMatch" + property_if_none_match = _make_non_wire_etag_companion(property_if_match, ETAG_NONE_MATCH_DATA) for c in if_match_candidates: if c is not property_if_match: diff --git a/packages/http-client-python/tests/unit/test_preprocess_etag.py b/packages/http-client-python/tests/unit/test_preprocess_etag.py index 99bb672b1ff..cb4dddcdf48 100644 --- a/packages/http-client-python/tests/unit/test_preprocess_etag.py +++ b/packages/http-client-python/tests/unit/test_preprocess_etag.py @@ -98,6 +98,11 @@ def test_required_etag_header_in_nested_operation_group_uses_match_conditions(): assert operation["hasEtag"] is True assert len(operation["parameters"]) == 2 assert all(parameter["optional"] is False for parameter in operation["parameters"]) + assert operation["parameters"][0]["wireName"] == "If-Match" + assert operation["parameters"][0]["etagRole"] == "ifMatch" + assert operation["parameters"][1]["wireName"] == "" + assert operation["parameters"][1]["location"] == "keyword" + assert "etagRole" not in operation["parameters"][1] for parameter in operation["parameters"]: plugin.update_parameter(parameter) assert [parameter["clientName"] for parameter in operation["parameters"]] == [ @@ -136,6 +141,9 @@ def test_optional_etag_header_in_nested_operation_group_uses_match_conditions(): assert client["hasEtag"] is True assert operation["hasEtag"] is True assert len(operation["parameters"]) == 2 + assert operation["parameters"][0]["wireName"] == "If-Match" + assert operation["parameters"][1]["wireName"] == "" + assert operation["parameters"][1]["location"] == "keyword" for parameter in operation["parameters"]: plugin.update_parameter(parameter) assert [parameter["clientName"] for parameter in operation["parameters"]] == [ @@ -178,10 +186,11 @@ def test_etag_roles_are_processed_in_existing_overloads(optional: bool): assert operation["overloads"][0]["hasEtag"] is True for target in (operation, operation["overloads"][0]): assert len(target["parameters"]) == 2 - assert [parameter["etagRole"] for parameter in target["parameters"]] == [ - "ifMatch", - "ifNoneMatch", - ] + assert target["parameters"][0]["etagRole"] == "ifMatch" + assert target["parameters"][0]["wireName"] == "If-Match" + assert "etagRole" not in target["parameters"][1] + assert target["parameters"][1]["wireName"] == "" + assert target["parameters"][1]["location"] == "keyword" assert all(parameter["optional"] is optional for parameter in target["parameters"]) @@ -274,11 +283,8 @@ def test_first_custom_pair_chosen_when_multiple_custom_pairs_present(): assert "etagRole" not in source_none -def test_synthetic_partner_still_works_with_only_one_custom_etag(): - """When only a single custom etag header is present (no partner), the existing - synthetic-partner code path still creates a matching ifNoneMatch (or ifMatch) - copy. The fix must not regress this behavior. - """ +def test_single_custom_etag_gets_non_wire_match_condition_companion(): + """A lone custom If-Match gets API convenience without inventing a wire header.""" source_match = _header_param( "source_if_match", "x-ms-source-if-match", "ifMatch" ) @@ -288,10 +294,13 @@ def test_synthetic_partner_still_works_with_only_one_custom_etag(): op = _get_op(client) assert op.get("hasEtag") is True - # The original custom param plus a synthetic partner are pushed to the end. last_two = op["parameters"][-2:] assert last_two[0]["etagRole"] == "ifMatch" - assert last_two[1]["etagRole"] == "ifNoneMatch" + assert last_two[0]["wireName"] == "x-ms-source-if-match" + assert last_two[1]["clientName"] == "match_condition" + assert last_two[1]["wireName"] == "" + assert last_two[1]["location"] == "keyword" + assert "etagRole" not in last_two[1] def test_full_update_yaml_does_not_collide_client_names(): @@ -336,8 +345,8 @@ def test_standard_if_match_not_paired_with_custom_if_none_match(): to the match_condition slot, pairing a standard header with a custom one from a different family. - The fix demotes the custom header (strips etagRole) so the standard - If-Match gets a synthetic If-None-Match partner instead. + The fix demotes the custom header (strips etagRole) and gives the standard + If-Match a non-wire match_condition companion instead. """ if_match = _header_param("if_match", "If-Match", "ifMatch") source_none = _header_param( @@ -351,12 +360,14 @@ def test_standard_if_match_not_paired_with_custom_if_none_match(): op = _get_op(client) assert op.get("hasEtag") is True - # The standard If-Match should be promoted and paired with a synthetic partner. + # The standard If-Match should be promoted with a non-wire companion. last_two = op["parameters"][-2:] assert last_two[0]["etagRole"] == "ifMatch" assert last_two[0]["wireName"] == "If-Match" - assert last_two[1]["etagRole"] == "ifNoneMatch" - assert last_two[1]["wireName"] == "if-none-match" # synthetic + assert last_two[1]["clientName"] == "match_condition" + assert last_two[1]["wireName"] == "" + assert last_two[1]["location"] == "keyword" + assert "etagRole" not in last_two[1] # The custom header should NOT have been promoted — etagRole stripped. assert "etagRole" not in source_none @@ -375,7 +386,7 @@ def test_standard_if_none_match_not_paired_with_custom_if_match(): (no standard If-Match, no custom If-None-Match). The custom header should be demoted; the standard If-None-Match gets a - synthetic If-Match partner. + non-wire etag companion. """ source_match = _header_param("source_if_match", "x-ms-source-if-match", "ifMatch") if_none_match = _header_param("if_none_match", "If-None-Match", "ifNoneMatch") @@ -388,8 +399,10 @@ def test_standard_if_none_match_not_paired_with_custom_if_match(): assert op.get("hasEtag") is True last_two = op["parameters"][-2:] - assert last_two[0]["etagRole"] == "ifMatch" - assert last_two[0]["wireName"] == "if-match" # synthetic + assert last_two[0]["clientName"] == "etag" + assert last_two[0]["wireName"] == "" + assert last_two[0]["location"] == "keyword" + assert "etagRole" not in last_two[0] assert last_two[1]["etagRole"] == "ifNoneMatch" assert last_two[1]["wireName"] == "If-None-Match" 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 095abc1c0fb..80e07cea21f 100644 --- a/packages/http-client-python/tests/unit/test_typeddict_overloads.py +++ b/packages/http-client-python/tests/unit/test_typeddict_overloads.py @@ -113,9 +113,9 @@ def _etag_parameters(*, optional: bool) -> tuple[list[dict], dict, dict]: "type": etag_type, }, { - "wireName": "If-None-Match", + "wireName": "", "clientName": "match_condition", - "location": "header", + "location": "keyword", "optional": optional, "implementation": "Method", "type": match_condition_type, @@ -192,9 +192,9 @@ def test_add_overload_preserves_types_after_filtering_flattened_parameters(): "type": etag_type, }, { - "wireName": "If-None-Match", + "wireName": "", "clientName": "match_condition", - "location": "header", + "location": "keyword", "optional": True, "implementation": "Method", "type": match_condition_type, From 400d8582464801ac8a868f44eff3fcd5d38540f4 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Fri, 18 Sep 2026 11:46:48 -0700 Subject: [PATCH 6/9] fix(http-client-python): keep synthetic parameters keyword-only Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../generator/pygen/codegen/models/parameter.py | 2 ++ .../tests/unit/test_parameter_ordering.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/http-client-python/generator/pygen/codegen/models/parameter.py b/packages/http-client-python/generator/pygen/codegen/models/parameter.py index 0a7aeb95c64..e423fc9b1b5 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/parameter.py +++ b/packages/http-client-python/generator/pygen/codegen/models/parameter.py @@ -350,6 +350,8 @@ def method_location( # pylint: disable=too-many-return-statements if self.in_overload: return ParameterMethodLocation.KEYWORD_ONLY return ParameterMethodLocation.KWARG + if self.location == ParameterLocation.KEYWORD: + return ParameterMethodLocation.KEYWORD_ONLY query_or_header = self.location in ( ParameterLocation.HEADER, ParameterLocation.QUERY, diff --git a/packages/http-client-python/tests/unit/test_parameter_ordering.py b/packages/http-client-python/tests/unit/test_parameter_ordering.py index f8df39fcdc9..6ad28dee3d5 100644 --- a/packages/http-client-python/tests/unit/test_parameter_ordering.py +++ b/packages/http-client-python/tests/unit/test_parameter_ordering.py @@ -4,6 +4,7 @@ # license information. # -------------------------------------------------------------------------- from pygen.codegen.models import Parameter, AnyType, CodeModel, StringType +from pygen.codegen.models.parameter import ParameterMethodLocation from pygen.codegen.models.parameter_list import ParameterList @@ -55,6 +56,22 @@ def get_parameter(name, required, default_value=None, type=None): ) +def test_non_wire_keyword_parameter_is_keyword_only(): + parameter = Parameter( + yaml_data={ + "wireName": "", + "clientName": "match_condition", + "location": "keyword", + "optional": False, + "implementation": "Method", + }, + code_model=get_code_model(), + type=AnyType(yaml_data={"type": "any"}, code_model=get_code_model()), + ) + + assert parameter.method_location == ParameterMethodLocation.KEYWORD_ONLY + + def test_sort_parameters_with_default_value_from_schema(): type = StringType( yaml_data={"clientDefaultValue": "this_is_the_default", "type": "str"}, From 2180e9653da4dedbc1c67392308110b175c8af4d Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Fri, 18 Sep 2026 12:34:54 -0700 Subject: [PATCH 7/9] refactor(http-client-python): simplify named union dedup to first-wins Collapse duplicate named unions by alias name using an insertion-ordered dict instead of raising on conflicting definitions. Update the test to assert a single alias is emitted (first wins). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../codegen/serializers/unions_serializer.py | 18 ++++++------------ .../tests/unit/test_typeddict.py | 9 +++++---- 2 files changed, 11 insertions(+), 16 deletions(-) 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 81c231b2274..545d08f8e52 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 @@ -20,19 +20,13 @@ def __init__( @property def named_unions(self) -> list[CombinedType]: - result: list[CombinedType] = [] - definitions: dict[str, str] = {} + # The same named union can reach codegen as multiple objects, so collapse + # by emitted alias name (keeping insertion order) to avoid duplicate aliases. + deduped: dict[str, CombinedType] = {} for union in self.code_model.named_unions: - if not union.name: - continue - definition = union.type_definition() - if union.name in definitions: - if definitions[union.name] != definition: - raise ValueError(f"Conflicting definitions for named union {union.name}") - else: - result.append(union) - definitions[union.name] = definition - return result + if union.name: + deduped.setdefault(union.name, union) + return list(deduped.values()) def imports(self) -> FileImport: file_import = FileImport(self.code_model) diff --git a/packages/http-client-python/tests/unit/test_typeddict.py b/packages/http-client-python/tests/unit/test_typeddict.py index 62e8315ab6f..60e3114e810 100644 --- a/packages/http-client-python/tests/unit/test_typeddict.py +++ b/packages/http-client-python/tests/unit/test_typeddict.py @@ -586,8 +586,8 @@ def test_unions_serializer_deduplicates_named_aliases(member_count: int): assert output.count("GenerateAgentRequest: TypeAlias =") == 1 -def test_unions_serializer_rejects_conflicting_duplicate_aliases(): - """One Python alias name cannot silently represent different unions.""" +def test_unions_serializer_collapses_same_name_aliases(): + """Two unions sharing an alias name emit a single declaration (first wins).""" 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) @@ -604,8 +604,9 @@ def test_unions_serializer_rejects_conflicting_duplicate_aliases(): ), ] - with pytest.raises(ValueError, match="Conflicting definitions for named union GenerateAgentRequest"): - UnionsSerializer(code_model=code_model, env=_make_env()).serialize() + output = UnionsSerializer(code_model=code_model, env=_make_env()).serialize() + + assert output.count("GenerateAgentRequest: TypeAlias =") == 1 # ---------- typed-dict-only ---------- From 5cec66e6c4d80e88ff82f2939a4590c08bc54328 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Fri, 18 Sep 2026 13:22:21 -0700 Subject: [PATCH 8/9] fix(http-client-python): emit paired etag header with canonical casing Synthesize the missing ETag partner as a real wire header with canonical HTTP casing (If-Match/If-None-Match) instead of a non-wire keyword parameter. At most one side serializes per call, gated by match_condition, so no undeclared header is sent. Remove the now-dead per-overload etag recursion (overloads inherit the reconciled pair via add_overload's deep copy) and revert the keyword-only method_location special case. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...ython-union-etag-regressions-2026-09-18.md | 2 +- .../pygen/codegen/models/parameter.py | 2 - .../generator/pygen/preprocess/__init__.py | 46 ++++--- .../unit/test_etag_header_serialization.py | 77 +++++++++++ .../tests/unit/test_parameter_ordering.py | 17 --- .../tests/unit/test_preprocess_etag.py | 128 +++++------------- .../tests/unit/test_typeddict_overloads.py | 10 +- 7 files changed, 145 insertions(+), 137 deletions(-) create mode 100644 packages/http-client-python/tests/unit/test_etag_header_serialization.py diff --git a/.chronus/changes/python-union-etag-regressions-2026-09-18.md b/.chronus/changes/python-union-etag-regressions-2026-09-18.md index 66c44130928..e2673e85432 100644 --- a/.chronus/changes/python-union-etag-regressions-2026-09-18.md +++ b/.chronus/changes/python-union-etag-regressions-2026-09-18.md @@ -4,4 +4,4 @@ packages: - "@typespec/http-client-python" --- -Prevent duplicate named union aliases and preserve ETag and match-condition parameter types in generated body overloads without emitting undeclared conditional headers. +Prevent duplicate named union aliases, and preserve ETag and match-condition parameter types in generated body overloads while emitting the paired conditional header with its canonical HTTP casing (`If-Match` / `If-None-Match`). diff --git a/packages/http-client-python/generator/pygen/codegen/models/parameter.py b/packages/http-client-python/generator/pygen/codegen/models/parameter.py index e423fc9b1b5..0a7aeb95c64 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/parameter.py +++ b/packages/http-client-python/generator/pygen/codegen/models/parameter.py @@ -350,8 +350,6 @@ def method_location( # pylint: disable=too-many-return-statements if self.in_overload: return ParameterMethodLocation.KEYWORD_ONLY return ParameterMethodLocation.KWARG - if self.location == ParameterLocation.KEYWORD: - return ParameterMethodLocation.KEYWORD_ONLY query_or_header = self.location in ( ParameterLocation.HEADER, ParameterLocation.QUERY, diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index 7daa38941bc..b5398d7577d 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -179,6 +179,9 @@ def update_paging_response(yaml_data: dict[str, Any]) -> None: } STANDARD_IF_MATCH_WIRE_NAME = "if-match" STANDARD_IF_NONE_MATCH_WIRE_NAME = "if-none-match" +# Canonical header casing used when synthesizing the missing side of the pair. +STANDARD_IF_MATCH_HEADER_NAME = "If-Match" +STANDARD_IF_NONE_MATCH_HEADER_NAME = "If-None-Match" def get_wire_name_lower(parameter: dict[str, Any]) -> str: @@ -205,13 +208,17 @@ def _pick_etag_slot(candidates: list[dict[str, Any]], standard_wire_name: str) - return candidates[0] -def _make_non_wire_etag_companion(source: dict[str, Any], replacement: dict[str, Any]) -> dict[str, Any]: - """Create the missing convenience parameter without inventing a request header.""" +def _make_wire_etag_companion(source: dict[str, Any], replacement: dict[str, Any], wire_name: str) -> dict[str, Any]: + """Create the missing side of the etag pair as a properly-cased wire header. + + The companion keeps ``location: "header"`` so it is emitted as a real request + header, and takes its client-facing shape (``clientName``/``etagRole``/``type``) + from *replacement*. Only ``wire_name`` fixes the header casing (e.g. + ``If-None-Match``) that the raw copy would otherwise inherit from *source*. + """ companion = source.copy() companion.update(replacement) - companion["wireName"] = "" - companion["location"] = "keyword" - companion.pop("etagRole", None) + companion["wireName"] = wire_name return companion @@ -222,8 +229,9 @@ def _resolve_etag_pair( """Select and reconcile the etag header pair for an operation. When multiple etag-typed headers are present, prefer the standard - If-Match / If-None-Match pair. Add a non-wire convenience parameter when - only one side is present, and strip etagRole from non-selected candidates. + If-Match / If-None-Match pair. Synthesize the missing side as a properly + cased wire header when only one side is present, and strip etagRole from + non-selected candidates. Returns (property_if_match, property_if_none_match) — both None when there are no etag candidates. @@ -233,19 +241,28 @@ def _resolve_etag_pair( # Ensure the promoted pair come from the same family. When one slot is # standard and the other custom (cross-family), replace the custom slot - # with a non-wire convenience parameter. Also add the missing API parameter - # when only one side is present without inventing a request header. + # with the standard wire header. Also synthesize the missing side when only + # one side is present so the client always exposes the etag/match_condition + # pair. if property_if_match and property_if_none_match: match_is_std = get_wire_name_lower(property_if_match) == STANDARD_IF_MATCH_WIRE_NAME none_match_is_std = get_wire_name_lower(property_if_none_match) == STANDARD_IF_NONE_MATCH_WIRE_NAME if match_is_std and not none_match_is_std: - property_if_none_match = _make_non_wire_etag_companion(property_if_match, ETAG_NONE_MATCH_DATA) + property_if_none_match = _make_wire_etag_companion( + property_if_match, ETAG_NONE_MATCH_DATA, STANDARD_IF_NONE_MATCH_HEADER_NAME + ) elif none_match_is_std and not match_is_std: - property_if_match = _make_non_wire_etag_companion(property_if_none_match, ETAG_MATCH_DATA) + property_if_match = _make_wire_etag_companion( + property_if_none_match, ETAG_MATCH_DATA, STANDARD_IF_MATCH_HEADER_NAME + ) elif not property_if_match and property_if_none_match: - property_if_match = _make_non_wire_etag_companion(property_if_none_match, ETAG_MATCH_DATA) + property_if_match = _make_wire_etag_companion( + property_if_none_match, ETAG_MATCH_DATA, STANDARD_IF_MATCH_HEADER_NAME + ) elif property_if_match and not property_if_none_match: - property_if_none_match = _make_non_wire_etag_companion(property_if_match, ETAG_NONE_MATCH_DATA) + property_if_none_match = _make_wire_etag_companion( + property_if_match, ETAG_NONE_MATCH_DATA, STANDARD_IF_NONE_MATCH_HEADER_NAME + ) for c in if_match_candidates: if c is not property_if_match: @@ -284,9 +301,6 @@ def _process_operation_etag_headers( operation["hasEtag"] = True client["hasEtag"] = True - for overload in operation.get("overloads", []): - _process_operation_etag_headers(overload, client, version_tolerant) - def _process_operation_group_etag_headers( operation_groups: list[dict[str, Any]], diff --git a/packages/http-client-python/tests/unit/test_etag_header_serialization.py b/packages/http-client-python/tests/unit/test_etag_header_serialization.py new file mode 100644 index 00000000000..fe4c22a4762 --- /dev/null +++ b/packages/http-client-python/tests/unit/test_etag_header_serialization.py @@ -0,0 +1,77 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +"""Serializer-level tests for etag header emission. + +These lock in the *shipped* request-builder output for the etag/match_condition +pair: the conditional header must be serialized with its canonical HTTP casing +(``If-Match`` / ``If-None-Match``) rather than a lowercased or empty wire name. +""" +from pygen.codegen.models import CodeModel, StringType +from pygen.codegen.models.request_builder_parameter import RequestBuilderParameter +from pygen.codegen.serializers.parameter_serializer import ParameterSerializer + + +def _code_model() -> CodeModel: + return CodeModel( + { + "clients": [ + { + "name": "client", + "namespace": "blah", + "moduleName": "blah", + "parameters": [], + "url": "", + "operationGroups": [], + } + ], + "namespace": "namespace", + }, + options={ + "show-send-request": True, + "builders-visibility": "public", + "show-operations": True, + "models-mode": "dpg", + "only-path-and-body-params-positional": True, + }, + ) + + +def _etag_header(*, wire_name: str, etag_role: str) -> RequestBuilderParameter: + code_model = _code_model() + return RequestBuilderParameter( + yaml_data={ + "wireName": wire_name, + "clientName": "etag" if etag_role == "ifMatch" else "match_condition", + "location": "header", + "optional": True, + "implementation": "Method", + "inOverload": False, + "inOverloaded": False, + "etagRole": etag_role, + }, + code_model=code_model, + type=StringType(yaml_data={"type": "str"}, code_model=code_model), + ) + + +def test_if_none_match_header_serialized_with_canonical_casing(): + """The synthesized If-None-Match companion emits a properly-cased header.""" + param = _etag_header(wire_name="If-None-Match", etag_role="ifNoneMatch") + lines = ParameterSerializer("").serialize_query_header(param, "headers", "_SERIALIZER", is_legacy=False) + assert lines[0] == "if_none_match = prep_if_none_match(etag, match_condition)" + assert lines[1] == "if if_none_match is not None:" + assert '_headers["If-None-Match"]' in lines[2] + # Never emit the lowercased or empty wire name that caused the original defect. + assert not any('_headers["if-none-match"]' in line or '_headers[""]' in line for line in lines) + + +def test_if_match_header_serialized_with_canonical_casing(): + """The declared/synthesized If-Match header emits a properly-cased header.""" + param = _etag_header(wire_name="If-Match", etag_role="ifMatch") + lines = ParameterSerializer("").serialize_query_header(param, "headers", "_SERIALIZER", is_legacy=False) + assert lines[0] == "if_match = prep_if_match(etag, match_condition)" + assert lines[1] == "if if_match is not None:" + assert '_headers["If-Match"]' in lines[2] diff --git a/packages/http-client-python/tests/unit/test_parameter_ordering.py b/packages/http-client-python/tests/unit/test_parameter_ordering.py index 6ad28dee3d5..f8df39fcdc9 100644 --- a/packages/http-client-python/tests/unit/test_parameter_ordering.py +++ b/packages/http-client-python/tests/unit/test_parameter_ordering.py @@ -4,7 +4,6 @@ # license information. # -------------------------------------------------------------------------- from pygen.codegen.models import Parameter, AnyType, CodeModel, StringType -from pygen.codegen.models.parameter import ParameterMethodLocation from pygen.codegen.models.parameter_list import ParameterList @@ -56,22 +55,6 @@ def get_parameter(name, required, default_value=None, type=None): ) -def test_non_wire_keyword_parameter_is_keyword_only(): - parameter = Parameter( - yaml_data={ - "wireName": "", - "clientName": "match_condition", - "location": "keyword", - "optional": False, - "implementation": "Method", - }, - code_model=get_code_model(), - type=AnyType(yaml_data={"type": "any"}, code_model=get_code_model()), - ) - - assert parameter.method_location == ParameterMethodLocation.KEYWORD_ONLY - - def test_sort_parameters_with_default_value_from_schema(): type = StringType( yaml_data={"clientDefaultValue": "this_is_the_default", "type": "str"}, diff --git a/packages/http-client-python/tests/unit/test_preprocess_etag.py b/packages/http-client-python/tests/unit/test_preprocess_etag.py index cb4dddcdf48..d869a8a4284 100644 --- a/packages/http-client-python/tests/unit/test_preprocess_etag.py +++ b/packages/http-client-python/tests/unit/test_preprocess_etag.py @@ -100,9 +100,9 @@ def test_required_etag_header_in_nested_operation_group_uses_match_conditions(): assert all(parameter["optional"] is False for parameter in operation["parameters"]) assert operation["parameters"][0]["wireName"] == "If-Match" assert operation["parameters"][0]["etagRole"] == "ifMatch" - assert operation["parameters"][1]["wireName"] == "" - assert operation["parameters"][1]["location"] == "keyword" - assert "etagRole" not in operation["parameters"][1] + assert operation["parameters"][1]["wireName"] == "If-None-Match" + assert operation["parameters"][1]["location"] == "header" + assert operation["parameters"][1]["etagRole"] == "ifNoneMatch" for parameter in operation["parameters"]: plugin.update_parameter(parameter) assert [parameter["clientName"] for parameter in operation["parameters"]] == [ @@ -142,8 +142,8 @@ def test_optional_etag_header_in_nested_operation_group_uses_match_conditions(): assert operation["hasEtag"] is True assert len(operation["parameters"]) == 2 assert operation["parameters"][0]["wireName"] == "If-Match" - assert operation["parameters"][1]["wireName"] == "" - assert operation["parameters"][1]["location"] == "keyword" + assert operation["parameters"][1]["wireName"] == "If-None-Match" + assert operation["parameters"][1]["location"] == "header" for parameter in operation["parameters"]: plugin.update_parameter(parameter) assert [parameter["clientName"] for parameter in operation["parameters"]] == [ @@ -152,48 +152,6 @@ def test_optional_etag_header_in_nested_operation_group_uses_match_conditions(): ] -@pytest.mark.parametrize("optional", [False, True]) -def test_etag_roles_are_processed_in_existing_overloads(optional: bool): - """Required and optional body overloads receive the ETag convenience pair.""" - operation_header = _header_param( - "if_match", - "If-Match", - "ifMatch", - optional=optional, - ) - overload_header = _header_param( - "if_match", - "If-Match", - "ifMatch", - optional=optional, - ) - operation = { - "name": "update", - "parameters": [operation_header], - "overloads": [ - { - "name": "update", - "parameters": [overload_header], - } - ], - } - client = _client_yaml([]) - client["operationGroups"][0]["operations"] = [operation] - - _plugin().update_client(client) - - assert operation["hasEtag"] is True - assert operation["overloads"][0]["hasEtag"] is True - for target in (operation, operation["overloads"][0]): - assert len(target["parameters"]) == 2 - assert target["parameters"][0]["etagRole"] == "ifMatch" - assert target["parameters"][0]["wireName"] == "If-Match" - assert "etagRole" not in target["parameters"][1] - assert target["parameters"][1]["wireName"] == "" - assert target["parameters"][1]["location"] == "keyword" - assert all(parameter["optional"] is optional for parameter in target["parameters"]) - - def test_etag_role_preserved_when_only_standard_pair_present(): """Standard If-Match/If-None-Match keep their etagRole.""" if_match = _header_param("if_match", "If-Match", "ifMatch") @@ -213,9 +171,7 @@ def test_etag_role_preserved_when_only_standard_pair_present(): def test_etag_role_preserved_when_only_custom_pair_present(): """Custom etag headers alone are promoted to the etag/match_condition slot.""" source_match = _header_param("source_if_match", "x-ms-source-if-match", "ifMatch") - source_none = _header_param( - "source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch" - ) + source_none = _header_param("source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch") client = _client_yaml([source_match, source_none]) _plugin().update_client(client) @@ -233,12 +189,8 @@ def test_standard_etag_wins_over_custom_when_both_present(): Regression test for PR #10494 which caused operations like Storage's copyFromUrl to emit two parameters named "etag" and two named "match_condition". """ - source_match = _header_param( - "source_if_match", "x-ms-source-if-match", "ifMatch" - ) - source_none = _header_param( - "source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch" - ) + source_match = _header_param("source_if_match", "x-ms-source-if-match", "ifMatch") + source_none = _header_param("source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch") if_match = _header_param("if_match", "If-Match", "ifMatch") if_none_match = _header_param("if_none_match", "If-None-Match", "ifNoneMatch") @@ -263,15 +215,9 @@ def test_standard_etag_wins_over_custom_when_both_present(): def test_first_custom_pair_chosen_when_multiple_custom_pairs_present(): """With multiple custom etag pairs and no standard pair, the first candidate wins.""" blob_match = _header_param("blob_if_match", "x-ms-blob-if-match", "ifMatch") - blob_none = _header_param( - "blob_if_none_match", "x-ms-blob-if-none-match", "ifNoneMatch" - ) - source_match = _header_param( - "source_if_match", "x-ms-source-if-match", "ifMatch" - ) - source_none = _header_param( - "source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch" - ) + blob_none = _header_param("blob_if_none_match", "x-ms-blob-if-none-match", "ifNoneMatch") + source_match = _header_param("source_if_match", "x-ms-source-if-match", "ifMatch") + source_none = _header_param("source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch") client = _client_yaml([blob_match, blob_none, source_match, source_none]) _plugin().update_client(client) @@ -283,11 +229,9 @@ def test_first_custom_pair_chosen_when_multiple_custom_pairs_present(): assert "etagRole" not in source_none -def test_single_custom_etag_gets_non_wire_match_condition_companion(): - """A lone custom If-Match gets API convenience without inventing a wire header.""" - source_match = _header_param( - "source_if_match", "x-ms-source-if-match", "ifMatch" - ) +def test_single_custom_etag_gets_wire_match_condition_companion(): + """A lone custom If-Match gets a properly-cased If-None-Match wire companion.""" + source_match = _header_param("source_if_match", "x-ms-source-if-match", "ifMatch") client = _client_yaml([source_match]) _plugin().update_client(client) @@ -298,9 +242,9 @@ def test_single_custom_etag_gets_non_wire_match_condition_companion(): assert last_two[0]["etagRole"] == "ifMatch" assert last_two[0]["wireName"] == "x-ms-source-if-match" assert last_two[1]["clientName"] == "match_condition" - assert last_two[1]["wireName"] == "" - assert last_two[1]["location"] == "keyword" - assert "etagRole" not in last_two[1] + assert last_two[1]["wireName"] == "If-None-Match" + assert last_two[1]["location"] == "header" + assert last_two[1]["etagRole"] == "ifNoneMatch" def test_full_update_yaml_does_not_collide_client_names(): @@ -310,12 +254,8 @@ def test_full_update_yaml_does_not_collide_client_names(): Without the fix, both source_if_match and if_match end up with clientName="etag", and both source_if_none_match and if_none_match end up with clientName="match_condition". """ - source_match = _header_param( - "source_if_match", "x-ms-source-if-match", "ifMatch" - ) - source_none = _header_param( - "source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch" - ) + source_match = _header_param("source_if_match", "x-ms-source-if-match", "ifMatch") + source_none = _header_param("source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch") if_match = _header_param("if_match", "If-Match", "ifMatch") if_none_match = _header_param("if_none_match", "If-None-Match", "ifNoneMatch") client = _client_yaml([source_match, source_none, if_match, if_none_match]) @@ -328,9 +268,7 @@ def test_full_update_yaml_does_not_collide_client_names(): plugin.update_parameter(p) client_names = [p["clientName"] for p in op["parameters"]] - assert len(client_names) == len(set(client_names)), ( - f"Duplicate clientNames after preprocess: {client_names}" - ) + assert len(client_names) == len(set(client_names)), f"Duplicate clientNames after preprocess: {client_names}" # The standard pair was promoted; the custom pair retains its natural names. assert "etag" in client_names assert "match_condition" in client_names @@ -346,12 +284,10 @@ def test_standard_if_match_not_paired_with_custom_if_none_match(): from a different family. The fix demotes the custom header (strips etagRole) and gives the standard - If-Match a non-wire match_condition companion instead. + If-Match a properly-cased If-None-Match wire companion instead. """ if_match = _header_param("if_match", "If-Match", "ifMatch") - source_none = _header_param( - "source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch" - ) + source_none = _header_param("source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch") client = _client_yaml([if_match, source_none]) plugin = _plugin() @@ -360,14 +296,14 @@ def test_standard_if_match_not_paired_with_custom_if_none_match(): op = _get_op(client) assert op.get("hasEtag") is True - # The standard If-Match should be promoted with a non-wire companion. + # The standard If-Match should be promoted with a wire companion. last_two = op["parameters"][-2:] assert last_two[0]["etagRole"] == "ifMatch" assert last_two[0]["wireName"] == "If-Match" assert last_two[1]["clientName"] == "match_condition" - assert last_two[1]["wireName"] == "" - assert last_two[1]["location"] == "keyword" - assert "etagRole" not in last_two[1] + assert last_two[1]["wireName"] == "If-None-Match" + assert last_two[1]["location"] == "header" + assert last_two[1]["etagRole"] == "ifNoneMatch" # The custom header should NOT have been promoted — etagRole stripped. assert "etagRole" not in source_none @@ -376,9 +312,7 @@ def test_standard_if_match_not_paired_with_custom_if_none_match(): for p in op["parameters"]: plugin.update_parameter(p) client_names = [p["clientName"] for p in op["parameters"]] - assert len(client_names) == len(set(client_names)), ( - f"Duplicate clientNames: {client_names}" - ) + assert len(client_names) == len(set(client_names)), f"Duplicate clientNames: {client_names}" def test_standard_if_none_match_not_paired_with_custom_if_match(): @@ -386,7 +320,7 @@ def test_standard_if_none_match_not_paired_with_custom_if_match(): (no standard If-Match, no custom If-None-Match). The custom header should be demoted; the standard If-None-Match gets a - non-wire etag companion. + properly-cased If-Match wire companion. """ source_match = _header_param("source_if_match", "x-ms-source-if-match", "ifMatch") if_none_match = _header_param("if_none_match", "If-None-Match", "ifNoneMatch") @@ -400,9 +334,9 @@ def test_standard_if_none_match_not_paired_with_custom_if_match(): last_two = op["parameters"][-2:] assert last_two[0]["clientName"] == "etag" - assert last_two[0]["wireName"] == "" - assert last_two[0]["location"] == "keyword" - assert "etagRole" not in last_two[0] + assert last_two[0]["wireName"] == "If-Match" + assert last_two[0]["location"] == "header" + assert last_two[0]["etagRole"] == "ifMatch" assert last_two[1]["etagRole"] == "ifNoneMatch" assert last_two[1]["wireName"] == "If-None-Match" 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 80e07cea21f..5686169c1d0 100644 --- a/packages/http-client-python/tests/unit/test_typeddict_overloads.py +++ b/packages/http-client-python/tests/unit/test_typeddict_overloads.py @@ -113,9 +113,10 @@ def _etag_parameters(*, optional: bool) -> tuple[list[dict], dict, dict]: "type": etag_type, }, { - "wireName": "", + "wireName": "If-None-Match", "clientName": "match_condition", - "location": "keyword", + "location": "header", + "etagRole": "ifNoneMatch", "optional": optional, "implementation": "Method", "type": match_condition_type, @@ -192,9 +193,10 @@ def test_add_overload_preserves_types_after_filtering_flattened_parameters(): "type": etag_type, }, { - "wireName": "", + "wireName": "If-None-Match", "clientName": "match_condition", - "location": "keyword", + "location": "header", + "etagRole": "ifNoneMatch", "optional": True, "implementation": "Method", "type": match_condition_type, From cd1ce57eca7c1b26a0a34535c24d9642e0402b1c Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Fri, 18 Sep 2026 13:32:57 -0700 Subject: [PATCH 9/9] fix(http-client-python): drop etag casing change, keep overload type fix Scope the PR to two fixes: deduplicate named union aliases, and reattach shared parameter type objects before filtering flattened parameters in add_overload so trailing parameter types are not shifted. Removes the earlier conditional-header casing synthesis change and its tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...ython-union-etag-regressions-2026-09-18.md | 2 +- .../generator/pygen/preprocess/__init__.py | 51 +++---- .../unit/test_etag_header_serialization.py | 77 ----------- .../tests/unit/test_preprocess_etag.py | 128 +++++++----------- 4 files changed, 69 insertions(+), 189 deletions(-) delete mode 100644 packages/http-client-python/tests/unit/test_etag_header_serialization.py diff --git a/.chronus/changes/python-union-etag-regressions-2026-09-18.md b/.chronus/changes/python-union-etag-regressions-2026-09-18.md index e2673e85432..3c2739a2ccf 100644 --- a/.chronus/changes/python-union-etag-regressions-2026-09-18.md +++ b/.chronus/changes/python-union-etag-regressions-2026-09-18.md @@ -4,4 +4,4 @@ packages: - "@typespec/http-client-python" --- -Prevent duplicate named union aliases, and preserve ETag and match-condition parameter types in generated body overloads while emitting the paired conditional header with its canonical HTTP casing (`If-Match` / `If-None-Match`). +Prevent duplicate named union aliases, and preserve parameter types in generated body overloads when a flattened parameter is filtered out. diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index b5398d7577d..4537139def1 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -179,9 +179,6 @@ def update_paging_response(yaml_data: dict[str, Any]) -> None: } STANDARD_IF_MATCH_WIRE_NAME = "if-match" STANDARD_IF_NONE_MATCH_WIRE_NAME = "if-none-match" -# Canonical header casing used when synthesizing the missing side of the pair. -STANDARD_IF_MATCH_HEADER_NAME = "If-Match" -STANDARD_IF_NONE_MATCH_HEADER_NAME = "If-None-Match" def get_wire_name_lower(parameter: dict[str, Any]) -> str: @@ -208,20 +205,6 @@ def _pick_etag_slot(candidates: list[dict[str, Any]], standard_wire_name: str) - return candidates[0] -def _make_wire_etag_companion(source: dict[str, Any], replacement: dict[str, Any], wire_name: str) -> dict[str, Any]: - """Create the missing side of the etag pair as a properly-cased wire header. - - The companion keeps ``location: "header"`` so it is emitted as a real request - header, and takes its client-facing shape (``clientName``/``etagRole``/``type``) - from *replacement*. Only ``wire_name`` fixes the header casing (e.g. - ``If-None-Match``) that the raw copy would otherwise inherit from *source*. - """ - companion = source.copy() - companion.update(replacement) - companion["wireName"] = wire_name - return companion - - def _resolve_etag_pair( if_match_candidates: list[dict[str, Any]], if_none_match_candidates: list[dict[str, Any]], @@ -229,9 +212,8 @@ def _resolve_etag_pair( """Select and reconcile the etag header pair for an operation. When multiple etag-typed headers are present, prefer the standard - If-Match / If-None-Match pair. Synthesize the missing side as a properly - cased wire header when only one side is present, and strip etagRole from - non-selected candidates. + If-Match / If-None-Match pair. Synthesize a missing partner when only + one side is present, and strip etagRole from non-selected candidates. Returns (property_if_match, property_if_none_match) — both None when there are no etag candidates. @@ -241,28 +223,27 @@ def _resolve_etag_pair( # Ensure the promoted pair come from the same family. When one slot is # standard and the other custom (cross-family), replace the custom slot - # with the standard wire header. Also synthesize the missing side when only - # one side is present so the client always exposes the etag/match_condition - # pair. + # with a synthetic standard partner. Also synthesize the missing partner + # when only one side is present. if property_if_match and property_if_none_match: match_is_std = get_wire_name_lower(property_if_match) == STANDARD_IF_MATCH_WIRE_NAME none_match_is_std = get_wire_name_lower(property_if_none_match) == STANDARD_IF_NONE_MATCH_WIRE_NAME if match_is_std and not none_match_is_std: - property_if_none_match = _make_wire_etag_companion( - property_if_match, ETAG_NONE_MATCH_DATA, STANDARD_IF_NONE_MATCH_HEADER_NAME - ) + property_if_none_match = property_if_match.copy() + property_if_none_match["wireName"] = STANDARD_IF_NONE_MATCH_WIRE_NAME + property_if_none_match["etagRole"] = "ifNoneMatch" elif none_match_is_std and not match_is_std: - property_if_match = _make_wire_etag_companion( - property_if_none_match, ETAG_MATCH_DATA, STANDARD_IF_MATCH_HEADER_NAME - ) + property_if_match = property_if_none_match.copy() + property_if_match["wireName"] = STANDARD_IF_MATCH_WIRE_NAME + property_if_match["etagRole"] = "ifMatch" elif not property_if_match and property_if_none_match: - property_if_match = _make_wire_etag_companion( - property_if_none_match, ETAG_MATCH_DATA, STANDARD_IF_MATCH_HEADER_NAME - ) + property_if_match = property_if_none_match.copy() + property_if_match["wireName"] = STANDARD_IF_MATCH_WIRE_NAME + property_if_match["etagRole"] = "ifMatch" elif property_if_match and not property_if_none_match: - property_if_none_match = _make_wire_etag_companion( - property_if_match, ETAG_NONE_MATCH_DATA, STANDARD_IF_NONE_MATCH_HEADER_NAME - ) + property_if_none_match = property_if_match.copy() + property_if_none_match["wireName"] = STANDARD_IF_NONE_MATCH_WIRE_NAME + property_if_none_match["etagRole"] = "ifNoneMatch" for c in if_match_candidates: if c is not property_if_match: diff --git a/packages/http-client-python/tests/unit/test_etag_header_serialization.py b/packages/http-client-python/tests/unit/test_etag_header_serialization.py deleted file mode 100644 index fe4c22a4762..00000000000 --- a/packages/http-client-python/tests/unit/test_etag_header_serialization.py +++ /dev/null @@ -1,77 +0,0 @@ -# ------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -------------------------------------------------------------------------- -"""Serializer-level tests for etag header emission. - -These lock in the *shipped* request-builder output for the etag/match_condition -pair: the conditional header must be serialized with its canonical HTTP casing -(``If-Match`` / ``If-None-Match``) rather than a lowercased or empty wire name. -""" -from pygen.codegen.models import CodeModel, StringType -from pygen.codegen.models.request_builder_parameter import RequestBuilderParameter -from pygen.codegen.serializers.parameter_serializer import ParameterSerializer - - -def _code_model() -> CodeModel: - return CodeModel( - { - "clients": [ - { - "name": "client", - "namespace": "blah", - "moduleName": "blah", - "parameters": [], - "url": "", - "operationGroups": [], - } - ], - "namespace": "namespace", - }, - options={ - "show-send-request": True, - "builders-visibility": "public", - "show-operations": True, - "models-mode": "dpg", - "only-path-and-body-params-positional": True, - }, - ) - - -def _etag_header(*, wire_name: str, etag_role: str) -> RequestBuilderParameter: - code_model = _code_model() - return RequestBuilderParameter( - yaml_data={ - "wireName": wire_name, - "clientName": "etag" if etag_role == "ifMatch" else "match_condition", - "location": "header", - "optional": True, - "implementation": "Method", - "inOverload": False, - "inOverloaded": False, - "etagRole": etag_role, - }, - code_model=code_model, - type=StringType(yaml_data={"type": "str"}, code_model=code_model), - ) - - -def test_if_none_match_header_serialized_with_canonical_casing(): - """The synthesized If-None-Match companion emits a properly-cased header.""" - param = _etag_header(wire_name="If-None-Match", etag_role="ifNoneMatch") - lines = ParameterSerializer("").serialize_query_header(param, "headers", "_SERIALIZER", is_legacy=False) - assert lines[0] == "if_none_match = prep_if_none_match(etag, match_condition)" - assert lines[1] == "if if_none_match is not None:" - assert '_headers["If-None-Match"]' in lines[2] - # Never emit the lowercased or empty wire name that caused the original defect. - assert not any('_headers["if-none-match"]' in line or '_headers[""]' in line for line in lines) - - -def test_if_match_header_serialized_with_canonical_casing(): - """The declared/synthesized If-Match header emits a properly-cased header.""" - param = _etag_header(wire_name="If-Match", etag_role="ifMatch") - lines = ParameterSerializer("").serialize_query_header(param, "headers", "_SERIALIZER", is_legacy=False) - assert lines[0] == "if_match = prep_if_match(etag, match_condition)" - assert lines[1] == "if if_match is not None:" - assert '_headers["If-Match"]' in lines[2] diff --git a/packages/http-client-python/tests/unit/test_preprocess_etag.py b/packages/http-client-python/tests/unit/test_preprocess_etag.py index d869a8a4284..319b2311e76 100644 --- a/packages/http-client-python/tests/unit/test_preprocess_etag.py +++ b/packages/http-client-python/tests/unit/test_preprocess_etag.py @@ -4,8 +4,6 @@ # license information. # -------------------------------------------------------------------------- """Tests for etag-typed header handling in the preprocess plugin.""" -import pytest - from pygen.preprocess import PreProcessPlugin @@ -67,8 +65,8 @@ def _get_op(client: dict) -> dict: return client["operationGroups"][0]["operations"][0] -def test_required_etag_header_in_nested_operation_group_uses_match_conditions(): - """Nested required ETag operations get the complete convenience pair.""" +def test_etag_headers_in_nested_operation_group_are_processed(): + """Nested ETag operations get their partner parameter and enable client helpers.""" if_match = _header_param( "if_match", "If-Match", @@ -97,53 +95,11 @@ def test_required_etag_header_in_nested_operation_group_uses_match_conditions(): assert "hasEtag" not in parent_group assert operation["hasEtag"] is True assert len(operation["parameters"]) == 2 - assert all(parameter["optional"] is False for parameter in operation["parameters"]) - assert operation["parameters"][0]["wireName"] == "If-Match" assert operation["parameters"][0]["etagRole"] == "ifMatch" - assert operation["parameters"][1]["wireName"] == "If-None-Match" - assert operation["parameters"][1]["location"] == "header" assert operation["parameters"][1]["etagRole"] == "ifNoneMatch" - for parameter in operation["parameters"]: - plugin.update_parameter(parameter) - assert [parameter["clientName"] for parameter in operation["parameters"]] == [ - "etag", - "match_condition", - ] - assert operation["parameters"][0]["type"] == {"type": "string"} - assert operation["parameters"][1]["type"] == { - "type": "sdkcore", - "name": "MatchConditions", - } - - -def test_optional_etag_header_in_nested_operation_group_uses_match_conditions(): - """The optional ETag convenience API remains available in nested groups.""" - if_match = _header_param("if_match", "If-Match", "ifMatch") - operation = { - "name": "remove", - "parameters": [if_match], - } - client = _client_yaml([]) - client["operationGroups"] = [ - { - "operations": [], - "operationGroups": [ - { - "operations": [operation], - } - ], - } - ] - - plugin = _plugin() - plugin.update_client(client) + assert all(parameter["optional"] is False for parameter in operation["parameters"]) + assert all("clientDefaultValue" not in parameter for parameter in operation["parameters"]) - assert client["hasEtag"] is True - assert operation["hasEtag"] is True - assert len(operation["parameters"]) == 2 - assert operation["parameters"][0]["wireName"] == "If-Match" - assert operation["parameters"][1]["wireName"] == "If-None-Match" - assert operation["parameters"][1]["location"] == "header" for parameter in operation["parameters"]: plugin.update_parameter(parameter) assert [parameter["clientName"] for parameter in operation["parameters"]] == [ @@ -171,7 +127,9 @@ def test_etag_role_preserved_when_only_standard_pair_present(): def test_etag_role_preserved_when_only_custom_pair_present(): """Custom etag headers alone are promoted to the etag/match_condition slot.""" source_match = _header_param("source_if_match", "x-ms-source-if-match", "ifMatch") - source_none = _header_param("source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch") + source_none = _header_param( + "source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch" + ) client = _client_yaml([source_match, source_none]) _plugin().update_client(client) @@ -189,8 +147,12 @@ def test_standard_etag_wins_over_custom_when_both_present(): Regression test for PR #10494 which caused operations like Storage's copyFromUrl to emit two parameters named "etag" and two named "match_condition". """ - source_match = _header_param("source_if_match", "x-ms-source-if-match", "ifMatch") - source_none = _header_param("source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch") + source_match = _header_param( + "source_if_match", "x-ms-source-if-match", "ifMatch" + ) + source_none = _header_param( + "source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch" + ) if_match = _header_param("if_match", "If-Match", "ifMatch") if_none_match = _header_param("if_none_match", "If-None-Match", "ifNoneMatch") @@ -215,9 +177,15 @@ def test_standard_etag_wins_over_custom_when_both_present(): def test_first_custom_pair_chosen_when_multiple_custom_pairs_present(): """With multiple custom etag pairs and no standard pair, the first candidate wins.""" blob_match = _header_param("blob_if_match", "x-ms-blob-if-match", "ifMatch") - blob_none = _header_param("blob_if_none_match", "x-ms-blob-if-none-match", "ifNoneMatch") - source_match = _header_param("source_if_match", "x-ms-source-if-match", "ifMatch") - source_none = _header_param("source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch") + blob_none = _header_param( + "blob_if_none_match", "x-ms-blob-if-none-match", "ifNoneMatch" + ) + source_match = _header_param( + "source_if_match", "x-ms-source-if-match", "ifMatch" + ) + source_none = _header_param( + "source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch" + ) client = _client_yaml([blob_match, blob_none, source_match, source_none]) _plugin().update_client(client) @@ -229,21 +197,23 @@ def test_first_custom_pair_chosen_when_multiple_custom_pairs_present(): assert "etagRole" not in source_none -def test_single_custom_etag_gets_wire_match_condition_companion(): - """A lone custom If-Match gets a properly-cased If-None-Match wire companion.""" - source_match = _header_param("source_if_match", "x-ms-source-if-match", "ifMatch") +def test_synthetic_partner_still_works_with_only_one_custom_etag(): + """When only a single custom etag header is present (no partner), the existing + synthetic-partner code path still creates a matching ifNoneMatch (or ifMatch) + copy. The fix must not regress this behavior. + """ + source_match = _header_param( + "source_if_match", "x-ms-source-if-match", "ifMatch" + ) client = _client_yaml([source_match]) _plugin().update_client(client) op = _get_op(client) assert op.get("hasEtag") is True + # The original custom param plus a synthetic partner are pushed to the end. last_two = op["parameters"][-2:] assert last_two[0]["etagRole"] == "ifMatch" - assert last_two[0]["wireName"] == "x-ms-source-if-match" - assert last_two[1]["clientName"] == "match_condition" - assert last_two[1]["wireName"] == "If-None-Match" - assert last_two[1]["location"] == "header" assert last_two[1]["etagRole"] == "ifNoneMatch" @@ -254,8 +224,12 @@ def test_full_update_yaml_does_not_collide_client_names(): Without the fix, both source_if_match and if_match end up with clientName="etag", and both source_if_none_match and if_none_match end up with clientName="match_condition". """ - source_match = _header_param("source_if_match", "x-ms-source-if-match", "ifMatch") - source_none = _header_param("source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch") + source_match = _header_param( + "source_if_match", "x-ms-source-if-match", "ifMatch" + ) + source_none = _header_param( + "source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch" + ) if_match = _header_param("if_match", "If-Match", "ifMatch") if_none_match = _header_param("if_none_match", "If-None-Match", "ifNoneMatch") client = _client_yaml([source_match, source_none, if_match, if_none_match]) @@ -268,7 +242,9 @@ def test_full_update_yaml_does_not_collide_client_names(): plugin.update_parameter(p) client_names = [p["clientName"] for p in op["parameters"]] - assert len(client_names) == len(set(client_names)), f"Duplicate clientNames after preprocess: {client_names}" + assert len(client_names) == len(set(client_names)), ( + f"Duplicate clientNames after preprocess: {client_names}" + ) # The standard pair was promoted; the custom pair retains its natural names. assert "etag" in client_names assert "match_condition" in client_names @@ -283,11 +259,13 @@ def test_standard_if_match_not_paired_with_custom_if_none_match(): to the match_condition slot, pairing a standard header with a custom one from a different family. - The fix demotes the custom header (strips etagRole) and gives the standard - If-Match a properly-cased If-None-Match wire companion instead. + The fix demotes the custom header (strips etagRole) so the standard + If-Match gets a synthetic If-None-Match partner instead. """ if_match = _header_param("if_match", "If-Match", "ifMatch") - source_none = _header_param("source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch") + source_none = _header_param( + "source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch" + ) client = _client_yaml([if_match, source_none]) plugin = _plugin() @@ -296,14 +274,12 @@ def test_standard_if_match_not_paired_with_custom_if_none_match(): op = _get_op(client) assert op.get("hasEtag") is True - # The standard If-Match should be promoted with a wire companion. + # The standard If-Match should be promoted and paired with a synthetic partner. last_two = op["parameters"][-2:] assert last_two[0]["etagRole"] == "ifMatch" assert last_two[0]["wireName"] == "If-Match" - assert last_two[1]["clientName"] == "match_condition" - assert last_two[1]["wireName"] == "If-None-Match" - assert last_two[1]["location"] == "header" assert last_two[1]["etagRole"] == "ifNoneMatch" + assert last_two[1]["wireName"] == "if-none-match" # synthetic # The custom header should NOT have been promoted — etagRole stripped. assert "etagRole" not in source_none @@ -312,7 +288,9 @@ def test_standard_if_match_not_paired_with_custom_if_none_match(): for p in op["parameters"]: plugin.update_parameter(p) client_names = [p["clientName"] for p in op["parameters"]] - assert len(client_names) == len(set(client_names)), f"Duplicate clientNames: {client_names}" + assert len(client_names) == len(set(client_names)), ( + f"Duplicate clientNames: {client_names}" + ) def test_standard_if_none_match_not_paired_with_custom_if_match(): @@ -320,7 +298,7 @@ def test_standard_if_none_match_not_paired_with_custom_if_match(): (no standard If-Match, no custom If-None-Match). The custom header should be demoted; the standard If-None-Match gets a - properly-cased If-Match wire companion. + synthetic If-Match partner. """ source_match = _header_param("source_if_match", "x-ms-source-if-match", "ifMatch") if_none_match = _header_param("if_none_match", "If-None-Match", "ifNoneMatch") @@ -333,10 +311,8 @@ def test_standard_if_none_match_not_paired_with_custom_if_match(): assert op.get("hasEtag") is True last_two = op["parameters"][-2:] - assert last_two[0]["clientName"] == "etag" - assert last_two[0]["wireName"] == "If-Match" - assert last_two[0]["location"] == "header" assert last_two[0]["etagRole"] == "ifMatch" + assert last_two[0]["wireName"] == "if-match" # synthetic assert last_two[1]["etagRole"] == "ifNoneMatch" assert last_two[1]["wireName"] == "If-None-Match"