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..30a45960e55 --- /dev/null +++ b/.chronus/changes/fix-required-etag-header-2026-09-14.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-client-python" +--- + +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 1a5f815e030..1e0a8a82579 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": @@ -275,7 +272,6 @@ def _process_operation_etag_headers( 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] + [ @@ -286,6 +282,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 +618,16 @@ 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) + 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, + 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..319b2311e76 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,49 @@ 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([]) + 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 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"]] == [ + "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")