From e78334682329f502afeb006fb315eac8cc31e16b Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 14 Sep 2026 11:11:34 -0700 Subject: [PATCH 1/6] fix(python): handle required etag headers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../fix-required-etag-header-2026-09-14.md | 8 ++++++++ .../http-client-python/emitter/src/http.ts | 6 +++++- .../emitter/test/http.test.ts | 19 +++++++++++++++++++ ...ecial_headers_conditional_request_async.py | 5 +++++ ...ure_special_headers_conditional_request.py | 4 ++++ 5 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 .chronus/changes/fix-required-etag-header-2026-09-14.md create mode 100644 packages/http-client-python/emitter/test/http.test.ts diff --git a/.chronus/changes/fix-required-etag-header-2026-09-14.md b/.chronus/changes/fix-required-etag-header-2026-09-14.md new file mode 100644 index 00000000000..46d5b15cc0d --- /dev/null +++ b/.chronus/changes/fix-required-etag-header-2026-09-14.md @@ -0,0 +1,8 @@ +--- +changeKind: fix +packages: + - "@typespec/http-client-python" +--- + +Generate required `If-Match` headers as direct `if_match` parameters instead of +the optional `etag` and `match_condition` convenience API. diff --git a/packages/http-client-python/emitter/src/http.ts b/packages/http-client-python/emitter/src/http.ts index e1fe7efb351..4831c8e9474 100644 --- a/packages/http-client-python/emitter/src/http.ts +++ b/packages/http-client-python/emitter/src/http.ts @@ -206,7 +206,11 @@ function isEtagType(type: SdkType): boolean { ); } -function getEtagRole(parameter: SdkHeaderParameter): string | undefined { +export function getEtagRole(parameter: SdkHeaderParameter): string | undefined { + // The etag/match_condition convenience API can omit or redirect the wire + // header, so it is only valid when the TypeSpec header itself is optional. + if (!parameter.optional) return undefined; + const name = parameter.name.toLowerCase(); const wire = parameter.serializedName.toLowerCase(); // Standard If-Match / If-None-Match headers work with any type diff --git a/packages/http-client-python/emitter/test/http.test.ts b/packages/http-client-python/emitter/test/http.test.ts new file mode 100644 index 00000000000..536d57d50c8 --- /dev/null +++ b/packages/http-client-python/emitter/test/http.test.ts @@ -0,0 +1,19 @@ +import type { SdkHeaderParameter } from "@azure-tools/typespec-client-generator-core"; +import { strictEqual } from "assert"; +import { describe, it } from "vitest"; +import { getEtagRole } from "../src/http.js"; + +function createHeaderParameter(optional: boolean): SdkHeaderParameter { + return { + name: "ifMatch", + serializedName: "If-Match", + optional, + } as SdkHeaderParameter; +} + +describe("typespec-python: HTTP parameters", () => { + it("only promotes optional If-Match headers to the conditional convenience API", () => { + strictEqual(getEtagRole(createHeaderParameter(true)), "ifMatch"); + strictEqual(getEtagRole(createHeaderParameter(false)), undefined); + }); +}); diff --git a/packages/http-client-python/tests/mock_api/azure/asynctests/test_azure_special_headers_conditional_request_async.py b/packages/http-client-python/tests/mock_api/azure/asynctests/test_azure_special_headers_conditional_request_async.py index 264cc71cdb3..e1f7ae821b9 100644 --- a/packages/http-client-python/tests/mock_api/azure/asynctests/test_azure_special_headers_conditional_request_async.py +++ b/packages/http-client-python/tests/mock_api/azure/asynctests/test_azure_special_headers_conditional_request_async.py @@ -21,6 +21,11 @@ async def test_post_if_match(client: ConditionalRequestClient): await client.post_if_match(etag="valid", match_condition=MatchConditions.IfNotModified) +@pytest.mark.asyncio +async def test_post_required_if_match(client: ConditionalRequestClient): + await client.post_required_if_match(if_match='"required"') + + @pytest.mark.asyncio async def test_post_if_none_match(client: ConditionalRequestClient): await client.post_if_none_match(etag="invalid", match_condition=MatchConditions.IfModified) diff --git a/packages/http-client-python/tests/mock_api/azure/test_azure_special_headers_conditional_request.py b/packages/http-client-python/tests/mock_api/azure/test_azure_special_headers_conditional_request.py index dacd7ba07f0..a54cc1baa75 100644 --- a/packages/http-client-python/tests/mock_api/azure/test_azure_special_headers_conditional_request.py +++ b/packages/http-client-python/tests/mock_api/azure/test_azure_special_headers_conditional_request.py @@ -19,6 +19,10 @@ def test_post_if_match(client: ConditionalRequestClient): client.post_if_match(etag="valid", match_condition=MatchConditions.IfNotModified) +def test_post_required_if_match(client: ConditionalRequestClient): + client.post_required_if_match(if_match='"required"') + + def test_post_if_none_match(client: ConditionalRequestClient): client.post_if_none_match(etag="invalid", match_condition=MatchConditions.IfModified) From e28762d9882dc15d251abc7958f380364e5f9e2e Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 14 Sep 2026 12:53:49 -0700 Subject: [PATCH 2/6] test(python): remove unavailable required etag scenario Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../test_azure_special_headers_conditional_request_async.py | 5 ----- .../azure/test_azure_special_headers_conditional_request.py | 4 ---- 2 files changed, 9 deletions(-) diff --git a/packages/http-client-python/tests/mock_api/azure/asynctests/test_azure_special_headers_conditional_request_async.py b/packages/http-client-python/tests/mock_api/azure/asynctests/test_azure_special_headers_conditional_request_async.py index e1f7ae821b9..264cc71cdb3 100644 --- a/packages/http-client-python/tests/mock_api/azure/asynctests/test_azure_special_headers_conditional_request_async.py +++ b/packages/http-client-python/tests/mock_api/azure/asynctests/test_azure_special_headers_conditional_request_async.py @@ -21,11 +21,6 @@ async def test_post_if_match(client: ConditionalRequestClient): await client.post_if_match(etag="valid", match_condition=MatchConditions.IfNotModified) -@pytest.mark.asyncio -async def test_post_required_if_match(client: ConditionalRequestClient): - await client.post_required_if_match(if_match='"required"') - - @pytest.mark.asyncio async def test_post_if_none_match(client: ConditionalRequestClient): await client.post_if_none_match(etag="invalid", match_condition=MatchConditions.IfModified) diff --git a/packages/http-client-python/tests/mock_api/azure/test_azure_special_headers_conditional_request.py b/packages/http-client-python/tests/mock_api/azure/test_azure_special_headers_conditional_request.py index a54cc1baa75..dacd7ba07f0 100644 --- a/packages/http-client-python/tests/mock_api/azure/test_azure_special_headers_conditional_request.py +++ b/packages/http-client-python/tests/mock_api/azure/test_azure_special_headers_conditional_request.py @@ -19,10 +19,6 @@ def test_post_if_match(client: ConditionalRequestClient): client.post_if_match(etag="valid", match_condition=MatchConditions.IfNotModified) -def test_post_required_if_match(client: ConditionalRequestClient): - client.post_required_if_match(if_match='"required"') - - def test_post_if_none_match(client: ConditionalRequestClient): client.post_if_none_match(etag="invalid", match_condition=MatchConditions.IfModified) From f0f45252b859561bd4eceb6e784fdee00fca15e1 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 15 Sep 2026 09:43:01 -0700 Subject: [PATCH 3/6] fix(python): process etags in nested operation groups Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../fix-required-etag-header-2026-09-14.md | 3 +- .../http-client-python/emitter/src/http.ts | 6 +- .../emitter/test/http.test.ts | 19 ---- .../generator/pygen/preprocess/__init__.py | 23 ++++- .../tests/unit/test_preprocess_etag.py | 99 ++++++++++++------- 5 files changed, 83 insertions(+), 67 deletions(-) delete mode 100644 packages/http-client-python/emitter/test/http.test.ts diff --git a/.chronus/changes/fix-required-etag-header-2026-09-14.md b/.chronus/changes/fix-required-etag-header-2026-09-14.md index 46d5b15cc0d..30a45960e55 100644 --- a/.chronus/changes/fix-required-etag-header-2026-09-14.md +++ b/.chronus/changes/fix-required-etag-header-2026-09-14.md @@ -4,5 +4,4 @@ packages: - "@typespec/http-client-python" --- -Generate required `If-Match` headers as direct `if_match` parameters instead of -the optional `etag` and `match_condition` convenience API. +Generate complete ETag handling for operations in nested operation groups. diff --git a/packages/http-client-python/emitter/src/http.ts b/packages/http-client-python/emitter/src/http.ts index 4831c8e9474..e1fe7efb351 100644 --- a/packages/http-client-python/emitter/src/http.ts +++ b/packages/http-client-python/emitter/src/http.ts @@ -206,11 +206,7 @@ function isEtagType(type: SdkType): boolean { ); } -export function getEtagRole(parameter: SdkHeaderParameter): string | undefined { - // The etag/match_condition convenience API can omit or redirect the wire - // header, so it is only valid when the TypeSpec header itself is optional. - if (!parameter.optional) return undefined; - +function getEtagRole(parameter: SdkHeaderParameter): string | undefined { const name = parameter.name.toLowerCase(); const wire = parameter.serializedName.toLowerCase(); // Standard If-Match / If-None-Match headers work with any type diff --git a/packages/http-client-python/emitter/test/http.test.ts b/packages/http-client-python/emitter/test/http.test.ts deleted file mode 100644 index 536d57d50c8..00000000000 --- a/packages/http-client-python/emitter/test/http.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { SdkHeaderParameter } from "@azure-tools/typespec-client-generator-core"; -import { strictEqual } from "assert"; -import { describe, it } from "vitest"; -import { getEtagRole } from "../src/http.js"; - -function createHeaderParameter(optional: boolean): SdkHeaderParameter { - return { - name: "ifMatch", - serializedName: "If-Match", - optional, - } as SdkHeaderParameter; -} - -describe("typespec-python: HTTP parameters", () => { - it("only promotes optional If-Match headers to the conditional convenience API", () => { - strictEqual(getEtagRole(createHeaderParameter(true)), "ifMatch"); - strictEqual(getEtagRole(createHeaderParameter(false)), undefined); - }); -}); diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index 1a5f815e030..0a93215f211 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -286,6 +286,21 @@ def _process_operation_etag_headers( client["hasEtag"] = True +def _process_operation_group_etag_headers( + operation_groups: list[dict[str, Any]], + client: dict[str, Any], + version_tolerant: bool, +) -> None: + for operation_group in operation_groups: + for operation in operation_group.get("operations", []): + _process_operation_etag_headers(operation, client, version_tolerant) + _process_operation_group_etag_headers( + operation_group.get("operationGroups", []), + client, + version_tolerant, + ) + + def headers_convert(yaml_data: dict[str, Any], replace_data: Any) -> None: if isinstance(replace_data, dict): for k, v in replace_data.items(): @@ -607,9 +622,11 @@ def update_client(self, yaml_data: dict[str, Any]) -> None: if prop_name.endswith("Client"): prop_name = prop_name[: len(prop_name) - len("Client")] yaml_data["builderPadName"] = to_snake_case(prop_name) - for og in yaml_data.get("operationGroups", []): - for o in og["operations"]: - _process_operation_etag_headers(o, yaml_data, self.version_tolerant) + _process_operation_group_etag_headers( + yaml_data.get("operationGroups", []), + yaml_data, + self.version_tolerant, + ) # add client signature cloud_setting for arm if self.azure_arm and yaml_data["parameters"]: 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 f631f71ba51..69038b61f34 100644 --- a/packages/http-client-python/tests/unit/test_preprocess_etag.py +++ b/packages/http-client-python/tests/unit/test_preprocess_etag.py @@ -20,12 +20,18 @@ def _plugin() -> PreProcessPlugin: ) -def _header_param(client_name: str, wire_name: str, etag_role: str | None) -> dict: +def _header_param( + client_name: str, + wire_name: str, + etag_role: str | None, + *, + optional: bool = True, +) -> dict: p: dict = { "clientName": client_name, "wireName": wire_name, "location": "header", - "optional": True, + "optional": optional, "implementation": "Method", "type": {"type": "string"}, } @@ -59,6 +65,47 @@ 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.""" + if_match = _header_param( + "if_match", + "If-Match", + "ifMatch", + optional=False, + ) + 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" + + for parameter in operation["parameters"]: + plugin.update_parameter(parameter) + assert [parameter["clientName"] for parameter in operation["parameters"]] == [ + "etag", + "match_condition", + ] + + 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") @@ -78,9 +125,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) @@ -98,12 +143,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") @@ -128,15 +169,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) @@ -153,9 +188,7 @@ def test_synthetic_partner_still_works_with_only_one_custom_etag(): 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" - ) + source_match = _header_param("source_if_match", "x-ms-source-if-match", "ifMatch") client = _client_yaml([source_match]) _plugin().update_client(client) @@ -175,12 +208,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]) @@ -193,9 +222,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 @@ -214,9 +241,7 @@ def test_standard_if_match_not_paired_with_custom_if_none_match(): 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() @@ -239,9 +264,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(): From d30cd1fac7cacc64ceb044ceebd5ee8d1a0e86ce Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 15 Sep 2026 09:43:43 -0700 Subject: [PATCH 4/6] chore(python): keep etag test diff focused Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../tests/unit/test_preprocess_etag.py | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) 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 69038b61f34..2f1cdaf9b15 100644 --- a/packages/http-client-python/tests/unit/test_preprocess_etag.py +++ b/packages/http-client-python/tests/unit/test_preprocess_etag.py @@ -125,7 +125,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) @@ -143,8 +145,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") @@ -169,9 +175,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) @@ -188,7 +200,9 @@ def test_synthetic_partner_still_works_with_only_one_custom_etag(): 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") + source_match = _header_param( + "source_if_match", "x-ms-source-if-match", "ifMatch" + ) client = _client_yaml([source_match]) _plugin().update_client(client) @@ -208,8 +222,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]) @@ -222,7 +240,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 @@ -241,7 +261,9 @@ def test_standard_if_match_not_paired_with_custom_if_none_match(): 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() @@ -264,7 +286,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(): From d88ac8362c336f43e90c5caaa7adf7980f289ade Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 15 Sep 2026 11:18:32 -0700 Subject: [PATCH 5/6] Default match conditions for required ETag headers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../fix-required-etag-header-2026-09-14.md | 3 +- .../generator/pygen/preprocess/__init__.py | 18 +++- .../tests/unit/test_preprocess_etag.py | 93 +++++++++++++++---- 3 files changed, 89 insertions(+), 25 deletions(-) diff --git a/.chronus/changes/fix-required-etag-header-2026-09-14.md b/.chronus/changes/fix-required-etag-header-2026-09-14.md index 30a45960e55..dcfc261e072 100644 --- a/.chronus/changes/fix-required-etag-header-2026-09-14.md +++ b/.chronus/changes/fix-required-etag-header-2026-09-14.md @@ -4,4 +4,5 @@ packages: - "@typespec/http-client-python" --- -Generate complete ETag handling for operations in nested operation groups. +Generate complete ETag handling for operations in nested operation groups and +default synthesized match conditions for required ETag headers. diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index 0a93215f211..609dc4c36b3 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -264,9 +264,6 @@ def _process_operation_etag_headers( if_match_candidates: list[dict[str, Any]] = [] if_none_match_candidates: list[dict[str, Any]] = [] for p in operation["parameters"]: - wire_name_lower = get_wire_name_lower(p) - if p["location"] == "header" and wire_name_lower == "client-request-id": - client["requestIdHeaderName"] = wire_name_lower if version_tolerant and p["location"] == "header": role = _get_etag_role(p) if role == "ifMatch": @@ -274,9 +271,17 @@ 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) + default_match_condition = None + if len(if_match_candidates) == 1 and not if_none_match_candidates and not if_match_candidates[0]["optional"]: + default_match_condition = "MatchConditions.IfNotModified" + elif len(if_none_match_candidates) == 1 and not if_match_candidates and not if_none_match_candidates[0]["optional"]: + default_match_condition = "MatchConditions.IfModified" + 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: + if default_match_condition: + property_if_none_match["clientDefaultValue"] = default_match_condition + 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, @@ -622,6 +627,11 @@ def update_client(self, yaml_data: dict[str, Any]) -> None: if prop_name.endswith("Client"): prop_name = prop_name[: len(prop_name) - len("Client")] yaml_data["builderPadName"] = to_snake_case(prop_name) + for operation_group in yaml_data.get("operationGroups", []): + for operation in operation_group.get("operations", []): + for parameter in operation["parameters"]: + if parameter["location"] == "header" and get_wire_name_lower(parameter) == "client-request-id": + yaml_data["requestIdHeaderName"] = "client-request-id" _process_operation_group_etag_headers( yaml_data.get("operationGroups", []), yaml_data, 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 2f1cdaf9b15..ad7b85e2dd4 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 @@ -65,38 +67,51 @@ def _get_op(client: dict) -> dict: return client["operationGroups"][0]["operations"][0] -def test_etag_headers_in_nested_operation_group_are_processed(): +@pytest.mark.parametrize( + ("client_name", "wire_name", "etag_role", "match_condition_default"), + [ + ("if_match", "If-Match", "ifMatch", "MatchConditions.IfNotModified"), + ("if_none_match", "If-None-Match", "ifNoneMatch", "MatchConditions.IfModified"), + ], +) +def test_etag_headers_in_nested_operation_group_are_processed( + client_name: str, + wire_name: str, + etag_role: str, + match_condition_default: str, +): """Nested ETag operations get their partner parameter and enable client helpers.""" - if_match = _header_param( - "if_match", - "If-Match", - "ifMatch", + etag_parameter = _header_param( + client_name, + wire_name, + etag_role, optional=False, ) operation = { "name": "remove", - "parameters": [if_match], + "parameters": [etag_parameter], } client = _client_yaml([]) - client["operationGroups"] = [ - { - "operations": [], - "operationGroups": [ - { - "operations": [operation], - } - ], - } - ] + parent_group = { + "operations": [], + "operationGroups": [ + { + "operations": [operation], + } + ], + } + client["operationGroups"] = [parent_group] plugin = _plugin() plugin.update_client(client) assert client["hasEtag"] is True + assert "hasEtag" not in parent_group assert operation["hasEtag"] is True assert len(operation["parameters"]) == 2 assert operation["parameters"][0]["etagRole"] == "ifMatch" assert operation["parameters"][1]["etagRole"] == "ifNoneMatch" + assert operation["parameters"][1]["clientDefaultValue"] == match_condition_default for parameter in operation["parameters"]: plugin.update_parameter(parameter) @@ -260,9 +275,12 @@ def test_standard_if_match_not_paired_with_custom_if_none_match(): 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") + if_match = _header_param("if_match", "If-Match", "ifMatch", optional=False) source_none = _header_param( - "source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch" + "source_if_none_match", + "x-ms-source-if-none-match", + "ifNoneMatch", + optional=False, ) client = _client_yaml([if_match, source_none]) @@ -278,6 +296,7 @@ def test_standard_if_match_not_paired_with_custom_if_none_match(): assert last_two[0]["wireName"] == "If-Match" assert last_two[1]["etagRole"] == "ifNoneMatch" assert last_two[1]["wireName"] == "if-none-match" # synthetic + assert "clientDefaultValue" not in last_two[1] # The custom header should NOT have been promoted — etagRole stripped. assert "etagRole" not in source_none @@ -298,8 +317,18 @@ def test_standard_if_none_match_not_paired_with_custom_if_match(): The custom header should be demoted; the standard If-None-Match gets a 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") + source_match = _header_param( + "source_if_match", + "x-ms-source-if-match", + "ifMatch", + optional=False, + ) + if_none_match = _header_param( + "if_none_match", + "If-None-Match", + "ifNoneMatch", + optional=False, + ) client = _client_yaml([source_match, if_none_match]) plugin = _plugin() @@ -313,5 +342,29 @@ def test_standard_if_none_match_not_paired_with_custom_if_match(): assert last_two[0]["wireName"] == "if-match" # synthetic assert last_two[1]["etagRole"] == "ifNoneMatch" assert last_two[1]["wireName"] == "If-None-Match" + assert "clientDefaultValue" not in last_two[1] assert "etagRole" not in source_match + + +def test_required_match_condition_default_not_added_for_multiple_candidates(): + """A default is only safe when exactly one conditional header is declared.""" + first_match = _header_param( + "first_if_match", + "x-ms-first-if-match", + "ifMatch", + optional=False, + ) + second_match = _header_param( + "second_if_match", + "x-ms-second-if-match", + "ifMatch", + optional=False, + ) + client = _client_yaml([first_match, second_match]) + + _plugin().update_client(client) + + synthetic_none_match = _get_op(client)["parameters"][-1] + assert synthetic_none_match["etagRole"] == "ifNoneMatch" + assert "clientDefaultValue" not in synthetic_none_match From af3c0916619393e04cd844de77c0de2b8d0d73c3 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Wed, 16 Sep 2026 09:29:39 -0700 Subject: [PATCH 6/6] Remove required ETag match condition defaults Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../fix-required-etag-header-2026-09-14.md | 3 +- .../generator/pygen/preprocess/__init__.py | 9 --- .../tests/unit/test_preprocess_etag.py | 75 +++---------------- 3 files changed, 13 insertions(+), 74 deletions(-) diff --git a/.chronus/changes/fix-required-etag-header-2026-09-14.md b/.chronus/changes/fix-required-etag-header-2026-09-14.md index dcfc261e072..30a45960e55 100644 --- a/.chronus/changes/fix-required-etag-header-2026-09-14.md +++ b/.chronus/changes/fix-required-etag-header-2026-09-14.md @@ -4,5 +4,4 @@ packages: - "@typespec/http-client-python" --- -Generate complete ETag handling for operations in nested operation groups and -default synthesized match conditions for required ETag headers. +Generate complete ETag handling for operations in nested operation groups. diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index 609dc4c36b3..1e0a8a82579 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -271,17 +271,8 @@ def _process_operation_etag_headers( elif role == "ifNoneMatch": if_none_match_candidates.append(p) - default_match_condition = None - if len(if_match_candidates) == 1 and not if_none_match_candidates and not if_match_candidates[0]["optional"]: - default_match_condition = "MatchConditions.IfNotModified" - elif len(if_none_match_candidates) == 1 and not if_match_candidates and not if_none_match_candidates[0]["optional"]: - default_match_condition = "MatchConditions.IfModified" - 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: - if default_match_condition: - property_if_none_match["clientDefaultValue"] = default_match_condition - 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, 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 ad7b85e2dd4..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,29 +65,17 @@ def _get_op(client: dict) -> dict: return client["operationGroups"][0]["operations"][0] -@pytest.mark.parametrize( - ("client_name", "wire_name", "etag_role", "match_condition_default"), - [ - ("if_match", "If-Match", "ifMatch", "MatchConditions.IfNotModified"), - ("if_none_match", "If-None-Match", "ifNoneMatch", "MatchConditions.IfModified"), - ], -) -def test_etag_headers_in_nested_operation_group_are_processed( - client_name: str, - wire_name: str, - etag_role: str, - match_condition_default: str, -): +def test_etag_headers_in_nested_operation_group_are_processed(): """Nested ETag operations get their partner parameter and enable client helpers.""" - etag_parameter = _header_param( - client_name, - wire_name, - etag_role, + if_match = _header_param( + "if_match", + "If-Match", + "ifMatch", optional=False, ) operation = { "name": "remove", - "parameters": [etag_parameter], + "parameters": [if_match], } client = _client_yaml([]) parent_group = { @@ -111,7 +97,8 @@ def test_etag_headers_in_nested_operation_group_are_processed( assert len(operation["parameters"]) == 2 assert operation["parameters"][0]["etagRole"] == "ifMatch" assert operation["parameters"][1]["etagRole"] == "ifNoneMatch" - assert operation["parameters"][1]["clientDefaultValue"] == match_condition_default + 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) @@ -275,12 +262,9 @@ def test_standard_if_match_not_paired_with_custom_if_none_match(): 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", optional=False) + if_match = _header_param("if_match", "If-Match", "ifMatch") source_none = _header_param( - "source_if_none_match", - "x-ms-source-if-none-match", - "ifNoneMatch", - optional=False, + "source_if_none_match", "x-ms-source-if-none-match", "ifNoneMatch" ) client = _client_yaml([if_match, source_none]) @@ -296,7 +280,6 @@ def test_standard_if_match_not_paired_with_custom_if_none_match(): assert last_two[0]["wireName"] == "If-Match" assert last_two[1]["etagRole"] == "ifNoneMatch" assert last_two[1]["wireName"] == "if-none-match" # synthetic - assert "clientDefaultValue" not in last_two[1] # The custom header should NOT have been promoted — etagRole stripped. assert "etagRole" not in source_none @@ -317,18 +300,8 @@ def test_standard_if_none_match_not_paired_with_custom_if_match(): The custom header should be demoted; the standard If-None-Match gets a synthetic If-Match partner. """ - source_match = _header_param( - "source_if_match", - "x-ms-source-if-match", - "ifMatch", - optional=False, - ) - if_none_match = _header_param( - "if_none_match", - "If-None-Match", - "ifNoneMatch", - optional=False, - ) + 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") client = _client_yaml([source_match, if_none_match]) plugin = _plugin() @@ -342,29 +315,5 @@ def test_standard_if_none_match_not_paired_with_custom_if_match(): assert last_two[0]["wireName"] == "if-match" # synthetic assert last_two[1]["etagRole"] == "ifNoneMatch" assert last_two[1]["wireName"] == "If-None-Match" - assert "clientDefaultValue" not in last_two[1] assert "etagRole" not in source_match - - -def test_required_match_condition_default_not_added_for_multiple_candidates(): - """A default is only safe when exactly one conditional header is declared.""" - first_match = _header_param( - "first_if_match", - "x-ms-first-if-match", - "ifMatch", - optional=False, - ) - second_match = _header_param( - "second_if_match", - "x-ms-second-if-match", - "ifMatch", - optional=False, - ) - client = _client_yaml([first_match, second_match]) - - _plugin().update_client(client) - - synthetic_none_match = _get_op(client)["parameters"][-1] - assert synthetic_none_match["etagRole"] == "ifNoneMatch" - assert "clientDefaultValue" not in synthetic_none_match