@typespec/http-client-python 0.30.0 → 0.30.1-alpha.20260603.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -75,6 +75,13 @@ New-Item -ItemType Directory -Force -Path "$outputPath/packages" | Out-Null
75
75
  $emitterVersion = node -p -e "require('$packageRoot/package.json').version"
76
76
  Write-Host "Package version: $emitterVersion"
77
77
 
78
+ # Stamp prerelease version if BuildNumber is provided
79
+ if ($BuildNumber) {
80
+ $versionTag = $Prerelease ? "-alpha" : "-beta"
81
+ $emitterVersion = "$emitterVersion$versionTag.$BuildNumber"
82
+ Write-Host "Stamped prerelease version: $emitterVersion"
83
+ }
84
+
78
85
  Push-Location "$packageRoot"
79
86
  try {
80
87
  # Step 1: Build the emitter and generator
@@ -87,12 +94,20 @@ try {
87
94
  Invoke-LoggedCommand "npm run lint" -GroupOutput
88
95
  }
89
96
 
90
- # Step 3: Create npm package
97
+ # Step 3: Stamp version in package.json if prerelease
98
+ if ($BuildNumber) {
99
+ Write-Host "`n=== Updating package.json version to $emitterVersion ===" -ForegroundColor Cyan
100
+ $packageJson = Get-Content -Raw "package.json" | ConvertFrom-Json -AsHashtable
101
+ $packageJson.version = $emitterVersion
102
+ $packageJson | ConvertTo-Json -Depth 100 | Out-File -Path "package.json" -Encoding utf8 -NoNewline -Force
103
+ }
104
+
105
+ # Step 4: Create npm package
91
106
  Write-Host "`n=== Creating npm package ===" -ForegroundColor Cyan
92
107
  Invoke-LoggedCommand "npm pack"
93
108
  Copy-Item "typespec-http-client-python-$emitterVersion.tgz" -Destination "$outputPath/packages"
94
109
 
95
- # Step 4: Verify package can be installed
110
+ # Step 5: Verify package can be installed
96
111
  Write-Host "`n=== Verifying package installation ===" -ForegroundColor Cyan
97
112
  Invoke-LoggedCommand "npm install typespec-http-client-python-$emitterVersion.tgz" -GroupOutput
98
113
 
@@ -716,15 +716,31 @@ export async function prepareBaselineOfGeneratedCode(generatedFolder: string): P
716
716
  run(`git fetch --depth 1 origin ${branch}`);
717
717
  run(`git checkout FETCH_HEAD`);
718
718
 
719
+ // we don't copy whole generated folder, just the specific subfolders needed for tests
720
+ // to verify correct preservation/deletion of files and folders during regeneration,
721
+ // to avoid accidentally including any manually edited code that might be in the repo
722
+ // and cause confusion when it doesn't get updated during regeneration
723
+ const legacyCodePathNeededForTests = [
724
+ "azure/authentication-api-key",
725
+ "unbranded/authentication-api-key",
726
+ "azure/authentication-union",
727
+ "azure/generation-subdir",
728
+ "azure/generation-subdir2",
729
+ "unbranded/generation-subdir",
730
+ "unbranded/generation-subdir2",
731
+ ];
732
+
719
733
  const sourceRoot = join(tempDir, ...sourceSubdir.split("/"));
720
- for (const flavor of ["azure", "unbranded"]) {
721
- const src = join(sourceRoot, flavor);
722
- const dest = join(testsGeneratedDir, flavor);
734
+ for (const subPath of legacyCodePathNeededForTests) {
735
+ const segments = subPath.split("/");
736
+ const src = join(sourceRoot, ...segments);
737
+ const dest = join(testsGeneratedDir, ...segments);
723
738
  if (!existsSync(src)) {
724
739
  console.warn(pc.yellow(`Baseline folder not found: ${src}`));
725
740
  continue;
726
741
  }
727
- console.log(pc.dim(`Copying ${flavor}/ -> ${dest}`));
742
+ console.log(pc.dim(`Copying ${subPath} -> ${dest}`));
743
+ await mkdir(dirname(dest), { recursive: true });
728
744
  await cp(src, dest, { recursive: true });
729
745
  }
730
746
 
@@ -242,6 +242,7 @@ class GeneralSerializer(BaseSerializer):
242
242
  ImportType.LOCAL,
243
243
  )
244
244
  file_import.add_import("json", ImportType.STDLIB)
245
+ file_import.add_import("os", ImportType.STDLIB)
245
246
 
246
247
  return template.render(
247
248
  code_model=self.code_model,
@@ -76,23 +76,60 @@ def serialize_multipart_data_entry(data_entry: Any) -> Any:
76
76
  return json.dumps(data_entry, cls=SdkJSONEncoder, exclude_readonly=True)
77
77
  return data_entry
78
78
 
79
+ def _normalize_multipart_file_entry(field_name: str, entry: Any, index: int) -> Any:
80
+ """Ensure a multipart file entry carries a filename for Content-Disposition.
81
+
82
+ Servers distinguish file parts from plain form fields by the presence of
83
+ ``filename=`` in the ``Content-Disposition`` header. When callers pass
84
+ bare bytes/str/IO the HTTP client omits the filename and the server may
85
+ reject the upload. This helper wraps bare values into a (filename, content)
86
+ tuple, deriving the name from IO.name when available.
87
+
88
+ :param str field_name: The multipart field name used as a filename fallback.
89
+ :param entry: The user-provided file entry (tuple, bytes, str, or IO).
90
+ :type entry: any
91
+ :param int index: The positional index of the entry within the field, used
92
+ to disambiguate fallback filenames when multiple entries are provided.
93
+ :return: Either the original tuple entry, or a ``(filename, content)`` tuple
94
+ wrapping the bare value.
95
+ :rtype: any
96
+ """
97
+ if isinstance(entry, tuple):
98
+ return entry
99
+ filename: Optional[str] = None
100
+ name_attr = getattr(entry, "name", None)
101
+ if isinstance(name_attr, str) and name_attr:
102
+ filename = os.path.basename(name_attr)
103
+ if not filename:
104
+ filename = f"{field_name}_{index}" if index else field_name
105
+
106
+ # Return a 3-tuple with an explicit "application/octet-stream" content type.
107
+ # A 2-tuple (filename, content) would leave the part's Content-Type unset, and
108
+ # the sdk core library only defaults to "application/octet-stream" for bare
109
+ # (non-tuple) values - a tuple bypasses that default and falls back to the
110
+ # HTTP "text/plain" default instead. Setting it explicitly preserves the
111
+ # pre-existing behavior for bare bytes/IO across all transports.
112
+ return (filename, entry, "application/octet-stream")
113
+
79
114
  def prepare_multipart_form_data(
80
115
  body: Mapping[str, Any], multipart_fields: list[str], data_fields: list[str]
81
116
  ) -> list[FileType]:
82
117
  files: list[FileType] = []
83
- for multipart_field in multipart_fields:
84
- multipart_entry = body.get(multipart_field)
85
- if isinstance(multipart_entry, list):
86
- files.extend([(multipart_field, e) for e in multipart_entry ])
87
- elif multipart_entry:
88
- files.append((multipart_field, multipart_entry))
89
118
 
90
- # if files is empty, sdk core library can't handle multipart/form-data correctly, so
91
- # we put data fields into files with filename as None to avoid that scenario.
119
+ # Data fields first so streaming server-side parsers see metadata before
120
+ # binary file parts.
92
121
  for data_field in data_fields:
93
122
  data_entry = body.get(data_field)
94
123
  if data_entry:
95
124
  files.append((data_field, str(serialize_multipart_data_entry(data_entry))))
96
125
 
126
+ for multipart_field in multipart_fields:
127
+ multipart_entry = body.get(multipart_field)
128
+ if isinstance(multipart_entry, list):
129
+ for idx, e in enumerate(multipart_entry):
130
+ files.append((multipart_field, _normalize_multipart_file_entry(multipart_field, e, idx)))
131
+ elif multipart_entry is not None:
132
+ files.append((multipart_field, _normalize_multipart_file_entry(multipart_field, multipart_entry, 0)))
133
+
97
134
  return files
98
135
  {% endif %}
@@ -158,6 +158,8 @@ CLOUD_SETTING = {
158
158
  "isTypingOnly": True,
159
159
  },
160
160
  }
161
+ STANDARD_IF_MATCH_WIRE_NAME = "if-match"
162
+ STANDARD_IF_NONE_MATCH_WIRE_NAME = "if-none-match"
161
163
 
162
164
 
163
165
  def get_wire_name_lower(parameter: dict[str, Any]) -> str:
@@ -169,6 +171,103 @@ def _get_etag_role(parameter: dict[str, Any]) -> Optional[str]:
169
171
  return parameter.get("etagRole")
170
172
 
171
173
 
174
+ def _pick_etag_slot(candidates: list[dict[str, Any]], standard_wire_name: str) -> Optional[dict[str, Any]]:
175
+ """Choose which etag-typed header should be promoted to the etag/match_condition slot.
176
+
177
+ When more than one etag-typed header is present in an operation, prefer the
178
+ standard If-Match/If-None-Match header (matched on wireName). Otherwise
179
+ fall back to the first candidate. Returns None if there are no candidates.
180
+ """
181
+ if not candidates:
182
+ return None
183
+ for c in candidates:
184
+ if get_wire_name_lower(c) == standard_wire_name:
185
+ return c
186
+ return candidates[0]
187
+
188
+
189
+ def _resolve_etag_pair(
190
+ if_match_candidates: list[dict[str, Any]],
191
+ if_none_match_candidates: list[dict[str, Any]],
192
+ ) -> tuple[Optional[dict[str, Any]], Optional[dict[str, Any]]]:
193
+ """Select and reconcile the etag header pair for an operation.
194
+
195
+ When multiple etag-typed headers are present, prefer the standard
196
+ If-Match / If-None-Match pair. Synthesize a missing partner when only
197
+ one side is present, and strip etagRole from non-selected candidates.
198
+
199
+ Returns (property_if_match, property_if_none_match) — both None when
200
+ there are no etag candidates.
201
+ """
202
+ property_if_match = _pick_etag_slot(if_match_candidates, STANDARD_IF_MATCH_WIRE_NAME)
203
+ property_if_none_match = _pick_etag_slot(if_none_match_candidates, STANDARD_IF_NONE_MATCH_WIRE_NAME)
204
+
205
+ # Ensure the promoted pair come from the same family. When one slot is
206
+ # standard and the other custom (cross-family), replace the custom slot
207
+ # with a synthetic standard partner. Also synthesize the missing partner
208
+ # when only one side is present.
209
+ if property_if_match and property_if_none_match:
210
+ match_is_std = get_wire_name_lower(property_if_match) == STANDARD_IF_MATCH_WIRE_NAME
211
+ none_match_is_std = get_wire_name_lower(property_if_none_match) == STANDARD_IF_NONE_MATCH_WIRE_NAME
212
+ if match_is_std and not none_match_is_std:
213
+ property_if_none_match = property_if_match.copy()
214
+ property_if_none_match["wireName"] = STANDARD_IF_NONE_MATCH_WIRE_NAME
215
+ property_if_none_match["etagRole"] = "ifNoneMatch"
216
+ elif none_match_is_std and not match_is_std:
217
+ property_if_match = property_if_none_match.copy()
218
+ property_if_match["wireName"] = STANDARD_IF_MATCH_WIRE_NAME
219
+ property_if_match["etagRole"] = "ifMatch"
220
+ elif not property_if_match and property_if_none_match:
221
+ property_if_match = property_if_none_match.copy()
222
+ property_if_match["wireName"] = STANDARD_IF_MATCH_WIRE_NAME
223
+ property_if_match["etagRole"] = "ifMatch"
224
+ elif property_if_match and not property_if_none_match:
225
+ property_if_none_match = property_if_match.copy()
226
+ property_if_none_match["wireName"] = STANDARD_IF_NONE_MATCH_WIRE_NAME
227
+ property_if_none_match["etagRole"] = "ifNoneMatch"
228
+
229
+ for c in if_match_candidates:
230
+ if c is not property_if_match:
231
+ c.pop("etagRole", None)
232
+ for c in if_none_match_candidates:
233
+ if c is not property_if_none_match:
234
+ c.pop("etagRole", None)
235
+
236
+ return property_if_match, property_if_none_match
237
+
238
+
239
+ def _process_operation_etag_headers(
240
+ operation: dict[str, Any],
241
+ client: dict[str, Any],
242
+ version_tolerant: bool,
243
+ ) -> None:
244
+ """Collect etag candidates from *operation*, resolve the promoted pair,
245
+ and update *operation* / *client* accordingly."""
246
+ if_match_candidates: list[dict[str, Any]] = []
247
+ if_none_match_candidates: list[dict[str, Any]] = []
248
+ for p in operation["parameters"]:
249
+ wire_name_lower = get_wire_name_lower(p)
250
+ if p["location"] == "header" and wire_name_lower == "client-request-id":
251
+ client["requestIdHeaderName"] = wire_name_lower
252
+ if version_tolerant and p["location"] == "header":
253
+ role = _get_etag_role(p)
254
+ if role == "ifMatch":
255
+ if_match_candidates.append(p)
256
+ elif role == "ifNoneMatch":
257
+ if_none_match_candidates.append(p)
258
+
259
+ property_if_match, property_if_none_match = _resolve_etag_pair(if_match_candidates, if_none_match_candidates)
260
+
261
+ if property_if_match and property_if_none_match:
262
+ etag_params = {id(property_if_match), id(property_if_none_match)}
263
+ operation["parameters"] = [item for item in operation["parameters"] if id(item) not in etag_params] + [
264
+ property_if_match,
265
+ property_if_none_match,
266
+ ]
267
+ operation["hasEtag"] = True
268
+ client["hasEtag"] = True
269
+
270
+
172
271
  def headers_convert(yaml_data: dict[str, Any], replace_data: Any) -> None:
173
272
  if isinstance(replace_data, dict):
174
273
  for k, v in replace_data.items():
@@ -313,40 +412,7 @@ class PreProcessPlugin(YamlUpdatePlugin):
313
412
  yaml_data["builderPadName"] = to_snake_case(prop_name)
314
413
  for og in yaml_data.get("operationGroups", []):
315
414
  for o in og["operations"]:
316
- property_if_match = None
317
- property_if_none_match = None
318
- for p in o["parameters"]:
319
- wire_name_lower = get_wire_name_lower(p)
320
- if p["location"] == "header" and wire_name_lower == "client-request-id":
321
- yaml_data["requestIdHeaderName"] = wire_name_lower
322
- if self.version_tolerant and p["location"] == "header":
323
- role = _get_etag_role(p)
324
- if role == "ifMatch" and not property_if_match:
325
- property_if_match = p
326
- elif role == "ifNoneMatch" and not property_if_none_match:
327
- property_if_none_match = p
328
- # pylint: disable=line-too-long
329
- # some service(e.g. https://github.com/Azure/azure-rest-api-specs/blob/main/specification/cosmos-db/data-plane/Microsoft.Tables/preview/2019-02-02/table.json)
330
- # only has one, so we need to add "if-none-match" or "if-match" if it's missing
331
- if not property_if_match and property_if_none_match:
332
- property_if_match = property_if_none_match.copy()
333
- property_if_match["wireName"] = "if-match"
334
- property_if_match["etagRole"] = "ifMatch"
335
- if not property_if_none_match and property_if_match:
336
- property_if_none_match = property_if_match.copy()
337
- property_if_none_match["wireName"] = "if-none-match"
338
- property_if_none_match["etagRole"] = "ifNoneMatch"
339
-
340
- if property_if_match and property_if_none_match:
341
- # arrange if-match and if-none-match to the end of parameters
342
- etag_params = {id(property_if_match), id(property_if_none_match)}
343
- o["parameters"] = [item for item in o["parameters"] if id(item) not in etag_params] + [
344
- property_if_match,
345
- property_if_none_match,
346
- ]
347
-
348
- o["hasEtag"] = True
349
- yaml_data["hasEtag"] = True
415
+ _process_operation_etag_headers(o, yaml_data, self.version_tolerant)
350
416
 
351
417
  # add client signature cloud_setting for arm
352
418
  if self.azure_arm and yaml_data["parameters"]:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": "1",
3
- "pip_version": "26.1.1",
3
+ "pip_version": "26.1.2",
4
4
  "install": [
5
5
  {
6
6
  "download_info": {
@@ -242,6 +242,7 @@ class GeneralSerializer(BaseSerializer):
242
242
  ImportType.LOCAL,
243
243
  )
244
244
  file_import.add_import("json", ImportType.STDLIB)
245
+ file_import.add_import("os", ImportType.STDLIB)
245
246
 
246
247
  return template.render(
247
248
  code_model=self.code_model,
@@ -76,23 +76,60 @@ def serialize_multipart_data_entry(data_entry: Any) -> Any:
76
76
  return json.dumps(data_entry, cls=SdkJSONEncoder, exclude_readonly=True)
77
77
  return data_entry
78
78
 
79
+ def _normalize_multipart_file_entry(field_name: str, entry: Any, index: int) -> Any:
80
+ """Ensure a multipart file entry carries a filename for Content-Disposition.
81
+
82
+ Servers distinguish file parts from plain form fields by the presence of
83
+ ``filename=`` in the ``Content-Disposition`` header. When callers pass
84
+ bare bytes/str/IO the HTTP client omits the filename and the server may
85
+ reject the upload. This helper wraps bare values into a (filename, content)
86
+ tuple, deriving the name from IO.name when available.
87
+
88
+ :param str field_name: The multipart field name used as a filename fallback.
89
+ :param entry: The user-provided file entry (tuple, bytes, str, or IO).
90
+ :type entry: any
91
+ :param int index: The positional index of the entry within the field, used
92
+ to disambiguate fallback filenames when multiple entries are provided.
93
+ :return: Either the original tuple entry, or a ``(filename, content)`` tuple
94
+ wrapping the bare value.
95
+ :rtype: any
96
+ """
97
+ if isinstance(entry, tuple):
98
+ return entry
99
+ filename: Optional[str] = None
100
+ name_attr = getattr(entry, "name", None)
101
+ if isinstance(name_attr, str) and name_attr:
102
+ filename = os.path.basename(name_attr)
103
+ if not filename:
104
+ filename = f"{field_name}_{index}" if index else field_name
105
+
106
+ # Return a 3-tuple with an explicit "application/octet-stream" content type.
107
+ # A 2-tuple (filename, content) would leave the part's Content-Type unset, and
108
+ # the sdk core library only defaults to "application/octet-stream" for bare
109
+ # (non-tuple) values - a tuple bypasses that default and falls back to the
110
+ # HTTP "text/plain" default instead. Setting it explicitly preserves the
111
+ # pre-existing behavior for bare bytes/IO across all transports.
112
+ return (filename, entry, "application/octet-stream")
113
+
79
114
  def prepare_multipart_form_data(
80
115
  body: Mapping[str, Any], multipart_fields: list[str], data_fields: list[str]
81
116
  ) -> list[FileType]:
82
117
  files: list[FileType] = []
83
- for multipart_field in multipart_fields:
84
- multipart_entry = body.get(multipart_field)
85
- if isinstance(multipart_entry, list):
86
- files.extend([(multipart_field, e) for e in multipart_entry ])
87
- elif multipart_entry:
88
- files.append((multipart_field, multipart_entry))
89
118
 
90
- # if files is empty, sdk core library can't handle multipart/form-data correctly, so
91
- # we put data fields into files with filename as None to avoid that scenario.
119
+ # Data fields first so streaming server-side parsers see metadata before
120
+ # binary file parts.
92
121
  for data_field in data_fields:
93
122
  data_entry = body.get(data_field)
94
123
  if data_entry:
95
124
  files.append((data_field, str(serialize_multipart_data_entry(data_entry))))
96
125
 
126
+ for multipart_field in multipart_fields:
127
+ multipart_entry = body.get(multipart_field)
128
+ if isinstance(multipart_entry, list):
129
+ for idx, e in enumerate(multipart_entry):
130
+ files.append((multipart_field, _normalize_multipart_file_entry(multipart_field, e, idx)))
131
+ elif multipart_entry is not None:
132
+ files.append((multipart_field, _normalize_multipart_file_entry(multipart_field, multipart_entry, 0)))
133
+
97
134
  return files
98
135
  {% endif %}
@@ -158,6 +158,8 @@ CLOUD_SETTING = {
158
158
  "isTypingOnly": True,
159
159
  },
160
160
  }
161
+ STANDARD_IF_MATCH_WIRE_NAME = "if-match"
162
+ STANDARD_IF_NONE_MATCH_WIRE_NAME = "if-none-match"
161
163
 
162
164
 
163
165
  def get_wire_name_lower(parameter: dict[str, Any]) -> str:
@@ -169,6 +171,103 @@ def _get_etag_role(parameter: dict[str, Any]) -> Optional[str]:
169
171
  return parameter.get("etagRole")
170
172
 
171
173
 
174
+ def _pick_etag_slot(candidates: list[dict[str, Any]], standard_wire_name: str) -> Optional[dict[str, Any]]:
175
+ """Choose which etag-typed header should be promoted to the etag/match_condition slot.
176
+
177
+ When more than one etag-typed header is present in an operation, prefer the
178
+ standard If-Match/If-None-Match header (matched on wireName). Otherwise
179
+ fall back to the first candidate. Returns None if there are no candidates.
180
+ """
181
+ if not candidates:
182
+ return None
183
+ for c in candidates:
184
+ if get_wire_name_lower(c) == standard_wire_name:
185
+ return c
186
+ return candidates[0]
187
+
188
+
189
+ def _resolve_etag_pair(
190
+ if_match_candidates: list[dict[str, Any]],
191
+ if_none_match_candidates: list[dict[str, Any]],
192
+ ) -> tuple[Optional[dict[str, Any]], Optional[dict[str, Any]]]:
193
+ """Select and reconcile the etag header pair for an operation.
194
+
195
+ When multiple etag-typed headers are present, prefer the standard
196
+ If-Match / If-None-Match pair. Synthesize a missing partner when only
197
+ one side is present, and strip etagRole from non-selected candidates.
198
+
199
+ Returns (property_if_match, property_if_none_match) — both None when
200
+ there are no etag candidates.
201
+ """
202
+ property_if_match = _pick_etag_slot(if_match_candidates, STANDARD_IF_MATCH_WIRE_NAME)
203
+ property_if_none_match = _pick_etag_slot(if_none_match_candidates, STANDARD_IF_NONE_MATCH_WIRE_NAME)
204
+
205
+ # Ensure the promoted pair come from the same family. When one slot is
206
+ # standard and the other custom (cross-family), replace the custom slot
207
+ # with a synthetic standard partner. Also synthesize the missing partner
208
+ # when only one side is present.
209
+ if property_if_match and property_if_none_match:
210
+ match_is_std = get_wire_name_lower(property_if_match) == STANDARD_IF_MATCH_WIRE_NAME
211
+ none_match_is_std = get_wire_name_lower(property_if_none_match) == STANDARD_IF_NONE_MATCH_WIRE_NAME
212
+ if match_is_std and not none_match_is_std:
213
+ property_if_none_match = property_if_match.copy()
214
+ property_if_none_match["wireName"] = STANDARD_IF_NONE_MATCH_WIRE_NAME
215
+ property_if_none_match["etagRole"] = "ifNoneMatch"
216
+ elif none_match_is_std and not match_is_std:
217
+ property_if_match = property_if_none_match.copy()
218
+ property_if_match["wireName"] = STANDARD_IF_MATCH_WIRE_NAME
219
+ property_if_match["etagRole"] = "ifMatch"
220
+ elif not property_if_match and property_if_none_match:
221
+ property_if_match = property_if_none_match.copy()
222
+ property_if_match["wireName"] = STANDARD_IF_MATCH_WIRE_NAME
223
+ property_if_match["etagRole"] = "ifMatch"
224
+ elif property_if_match and not property_if_none_match:
225
+ property_if_none_match = property_if_match.copy()
226
+ property_if_none_match["wireName"] = STANDARD_IF_NONE_MATCH_WIRE_NAME
227
+ property_if_none_match["etagRole"] = "ifNoneMatch"
228
+
229
+ for c in if_match_candidates:
230
+ if c is not property_if_match:
231
+ c.pop("etagRole", None)
232
+ for c in if_none_match_candidates:
233
+ if c is not property_if_none_match:
234
+ c.pop("etagRole", None)
235
+
236
+ return property_if_match, property_if_none_match
237
+
238
+
239
+ def _process_operation_etag_headers(
240
+ operation: dict[str, Any],
241
+ client: dict[str, Any],
242
+ version_tolerant: bool,
243
+ ) -> None:
244
+ """Collect etag candidates from *operation*, resolve the promoted pair,
245
+ and update *operation* / *client* accordingly."""
246
+ if_match_candidates: list[dict[str, Any]] = []
247
+ if_none_match_candidates: list[dict[str, Any]] = []
248
+ for p in operation["parameters"]:
249
+ wire_name_lower = get_wire_name_lower(p)
250
+ if p["location"] == "header" and wire_name_lower == "client-request-id":
251
+ client["requestIdHeaderName"] = wire_name_lower
252
+ if version_tolerant and p["location"] == "header":
253
+ role = _get_etag_role(p)
254
+ if role == "ifMatch":
255
+ if_match_candidates.append(p)
256
+ elif role == "ifNoneMatch":
257
+ if_none_match_candidates.append(p)
258
+
259
+ property_if_match, property_if_none_match = _resolve_etag_pair(if_match_candidates, if_none_match_candidates)
260
+
261
+ if property_if_match and property_if_none_match:
262
+ etag_params = {id(property_if_match), id(property_if_none_match)}
263
+ operation["parameters"] = [item for item in operation["parameters"] if id(item) not in etag_params] + [
264
+ property_if_match,
265
+ property_if_none_match,
266
+ ]
267
+ operation["hasEtag"] = True
268
+ client["hasEtag"] = True
269
+
270
+
172
271
  def headers_convert(yaml_data: dict[str, Any], replace_data: Any) -> None:
173
272
  if isinstance(replace_data, dict):
174
273
  for k, v in replace_data.items():
@@ -313,40 +412,7 @@ class PreProcessPlugin(YamlUpdatePlugin):
313
412
  yaml_data["builderPadName"] = to_snake_case(prop_name)
314
413
  for og in yaml_data.get("operationGroups", []):
315
414
  for o in og["operations"]:
316
- property_if_match = None
317
- property_if_none_match = None
318
- for p in o["parameters"]:
319
- wire_name_lower = get_wire_name_lower(p)
320
- if p["location"] == "header" and wire_name_lower == "client-request-id":
321
- yaml_data["requestIdHeaderName"] = wire_name_lower
322
- if self.version_tolerant and p["location"] == "header":
323
- role = _get_etag_role(p)
324
- if role == "ifMatch" and not property_if_match:
325
- property_if_match = p
326
- elif role == "ifNoneMatch" and not property_if_none_match:
327
- property_if_none_match = p
328
- # pylint: disable=line-too-long
329
- # some service(e.g. https://github.com/Azure/azure-rest-api-specs/blob/main/specification/cosmos-db/data-plane/Microsoft.Tables/preview/2019-02-02/table.json)
330
- # only has one, so we need to add "if-none-match" or "if-match" if it's missing
331
- if not property_if_match and property_if_none_match:
332
- property_if_match = property_if_none_match.copy()
333
- property_if_match["wireName"] = "if-match"
334
- property_if_match["etagRole"] = "ifMatch"
335
- if not property_if_none_match and property_if_match:
336
- property_if_none_match = property_if_match.copy()
337
- property_if_none_match["wireName"] = "if-none-match"
338
- property_if_none_match["etagRole"] = "ifNoneMatch"
339
-
340
- if property_if_match and property_if_none_match:
341
- # arrange if-match and if-none-match to the end of parameters
342
- etag_params = {id(property_if_match), id(property_if_none_match)}
343
- o["parameters"] = [item for item in o["parameters"] if id(item) not in etag_params] + [
344
- property_if_match,
345
- property_if_none_match,
346
- ]
347
-
348
- o["hasEtag"] = True
349
- yaml_data["hasEtag"] = True
415
+ _process_operation_etag_headers(o, yaml_data, self.version_tolerant)
350
416
 
351
417
  # add client signature cloud_setting for arm
352
418
  if self.azure_arm and yaml_data["parameters"]:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typespec/http-client-python",
3
- "version": "0.30.0",
3
+ "version": "0.30.1-alpha.20260603.2",
4
4
  "author": "Microsoft Corporation",
5
5
  "description": "TypeSpec emitter for Python SDKs",
6
6
  "homepage": "https://typespec.io",
@@ -126,4 +126,4 @@
126
126
  "typescript-eslint": "^8.49.0",
127
127
  "vitest": "^4.0.15"
128
128
  }
129
- }
129
+ }