@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.
- package/emitter/temp/tsconfig.tsbuildinfo +1 -1
- package/eng/scripts/Build-Packages.ps1 +17 -2
- package/eng/scripts/ci/regenerate-common.ts +20 -4
- package/eng/scripts/setup/__pycache__/package_manager.cpython-311.pyc +0 -0
- package/eng/scripts/setup/__pycache__/venvtools.cpython-311.pyc +0 -0
- package/generator/build/lib/pygen/codegen/serializers/general_serializer.py +1 -0
- package/generator/build/lib/pygen/codegen/templates/utils.py.jinja2 +45 -8
- package/generator/build/lib/pygen/preprocess/__init__.py +100 -34
- package/generator/component-detection-pip-report.json +1 -1
- package/generator/dist/pygen-0.1.0-py3-none-any.whl +0 -0
- package/generator/pygen/codegen/serializers/general_serializer.py +1 -0
- package/generator/pygen/codegen/templates/utils.py.jinja2 +45 -8
- package/generator/pygen/preprocess/__init__.py +100 -34
- package/package.json +2 -2
|
@@ -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:
|
|
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
|
|
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
|
|
721
|
-
const
|
|
722
|
-
const
|
|
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 ${
|
|
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
|
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
#
|
|
91
|
-
#
|
|
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
|
-
|
|
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"]:
|
|
Binary file
|
|
@@ -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
|
-
#
|
|
91
|
-
#
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|