@contractkit/plugin-python 0.11.8 → 0.12.0
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/.turbo/turbo-build$colon$ci.log +4 -4
- package/.turbo/turbo-test$colon$ci.log +10 -10
- package/CHANGELOG.md +22 -0
- package/README.md +5 -1
- package/dist/codegen-client.d.ts +1 -1
- package/dist/codegen-client.d.ts.map +1 -1
- package/dist/index.js +248 -33
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/codegen-client.ts +286 -40
- package/src/index.ts +1 -1
- package/tests/codegen-client.test.ts +97 -2
- package/tests/helpers.ts +4 -1
package/src/codegen-client.ts
CHANGED
|
@@ -1,7 +1,63 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
OpRootNode,
|
|
3
|
+
OpRouteNode,
|
|
4
|
+
OpOperationNode,
|
|
5
|
+
OpResponseNode,
|
|
6
|
+
OpResponseBodyNode,
|
|
7
|
+
OpResponseHeaderNode,
|
|
8
|
+
ContractTypeNode,
|
|
9
|
+
ParamSource,
|
|
10
|
+
} from '@contractkit/core';
|
|
11
|
+
import { resolveModifiers, classifyContentType, observableResponses } from '@contractkit/core';
|
|
3
12
|
import { renderPyType, toPythonFieldName } from './codegen-models.js';
|
|
4
13
|
|
|
14
|
+
// ─── Response shape ────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How a method reports what came back, mirroring the TypeScript SDK.
|
|
18
|
+
*
|
|
19
|
+
* `simple` is the overwhelmingly common case and generates exactly what it always has: the body
|
|
20
|
+
* itself, or a `(data, headers)` tuple. The other two exist because the caller cannot otherwise
|
|
21
|
+
* know which status or which mime it received.
|
|
22
|
+
*/
|
|
23
|
+
type PyResponseShape =
|
|
24
|
+
| { kind: 'simple'; resp?: OpResponseNode }
|
|
25
|
+
| { kind: 'multiMime'; resp: OpResponseNode }
|
|
26
|
+
| { kind: 'multiStatus'; responses: OpResponseNode[] };
|
|
27
|
+
|
|
28
|
+
function headersClassName(methodBase: string, statusCode?: number): string {
|
|
29
|
+
return statusCode === undefined ? `${methodBase}Headers` : `${methodBase}${statusCode}Headers`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function responseClassName(methodBase: string, statusCode?: number): string {
|
|
33
|
+
return statusCode === undefined ? `${methodBase}Response` : `${methodBase}${statusCode}Response`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The Python type of one response body — non-JSON mimes ignore the schema, as elsewhere. */
|
|
37
|
+
function pyBodyType(body: OpResponseBodyNode, modelsWithInput?: Set<string>): string {
|
|
38
|
+
const category = classifyContentType(body.contentType);
|
|
39
|
+
if (category === 'text') return 'str';
|
|
40
|
+
if (category === 'binary') return 'bytes';
|
|
41
|
+
return renderPyType(body.bodyType, modelsWithInput);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function pyDataAnnotation(bodies: OpResponseBodyNode[], modelsWithInput?: Set<string>): string {
|
|
45
|
+
const types = [...new Set(bodies.map(b => pyBodyType(b, modelsWithInput)))];
|
|
46
|
+
return types.join(' | ');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pyContentTypeAnnotation(bodies: OpResponseBodyNode[]): string {
|
|
50
|
+
return `Literal[${bodies.map(b => JSON.stringify(b.contentType)).join(', ')}]`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function responseShape(op: OpOperationNode): PyResponseShape {
|
|
54
|
+
const observable = observableResponses(op);
|
|
55
|
+
if (observable.length > 1) return { kind: 'multiStatus', responses: observable };
|
|
56
|
+
const resp = observable[0];
|
|
57
|
+
if (resp && resp.bodies.length > 1) return { kind: 'multiMime', resp };
|
|
58
|
+
return { kind: 'simple', resp };
|
|
59
|
+
}
|
|
60
|
+
|
|
5
61
|
// ─── Public entry point ────────────────────────────────────────────────────
|
|
6
62
|
|
|
7
63
|
export interface ClientCodegenOptions {
|
|
@@ -73,15 +129,21 @@ export function generatePythonClient(root: OpRootNode, opts: ClientCodegenOption
|
|
|
73
129
|
publicOps.push({ route, op });
|
|
74
130
|
}
|
|
75
131
|
}
|
|
132
|
+
const opShapes = new Map<OpOperationNode, PyResponseShape>(publicOps.map(({ op }) => [op, responseShape(op)]));
|
|
76
133
|
const opsWithRespHeaders = publicOps.filter(({ op }) => {
|
|
77
|
-
const
|
|
78
|
-
return (
|
|
134
|
+
const shape = opShapes.get(op)!;
|
|
135
|
+
if (shape.kind === 'multiStatus') return shape.responses.some(r => (r.headers?.length ?? 0) > 0);
|
|
136
|
+
return (shape.resp?.headers?.length ?? 0) > 0;
|
|
79
137
|
});
|
|
138
|
+
const opsWithResponseDict = publicOps.filter(({ op }) => opShapes.get(op)!.kind !== 'simple');
|
|
139
|
+
const needsTypedDict = opsWithRespHeaders.length > 0 || opsWithResponseDict.length > 0;
|
|
140
|
+
const needsLiteral = opsWithResponseDict.length > 0;
|
|
80
141
|
|
|
81
|
-
if (needsAny ||
|
|
142
|
+
if (needsAny || needsTypedDict) {
|
|
82
143
|
const typingImports: string[] = [];
|
|
83
144
|
if (needsAny) typingImports.push('Any');
|
|
84
|
-
if (
|
|
145
|
+
if (needsLiteral) typingImports.push('Literal');
|
|
146
|
+
if (needsTypedDict) typingImports.push('TypedDict');
|
|
85
147
|
lines.push(`from typing import ${typingImports.join(', ')}`);
|
|
86
148
|
}
|
|
87
149
|
lines.push('from ._base_client import BaseClient, SdkError # noqa: F401');
|
|
@@ -105,17 +167,48 @@ export function generatePythonClient(root: OpRootNode, opts: ClientCodegenOption
|
|
|
105
167
|
lines.push(`from ${mod} import ${sorted}`);
|
|
106
168
|
}
|
|
107
169
|
|
|
108
|
-
// Per-method response-header TypedDicts.
|
|
170
|
+
// Per-method response-header TypedDicts. Multi-status methods get one per status, since
|
|
171
|
+
// each status declares its own headers.
|
|
109
172
|
for (const { route, op } of opsWithRespHeaders) {
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
lines.push(
|
|
173
|
+
const shape = opShapes.get(op)!;
|
|
174
|
+
const base = snakeToPascal(deriveMethodName(op, route));
|
|
175
|
+
const targets =
|
|
176
|
+
shape.kind === 'multiStatus'
|
|
177
|
+
? shape.responses.filter(r => (r.headers?.length ?? 0) > 0).map(r => ({ name: headersClassName(base, r.statusCode), resp: r }))
|
|
178
|
+
: [{ name: headersClassName(base), resp: shape.resp! }];
|
|
179
|
+
for (const { name, resp } of targets) {
|
|
180
|
+
lines.push('');
|
|
181
|
+
lines.push('');
|
|
182
|
+
lines.push(`class ${name}(TypedDict, total=False):`);
|
|
183
|
+
for (const h of resp.headers!) {
|
|
184
|
+
const pyName = toPythonFieldName(h.name);
|
|
185
|
+
const tag = h.optional ? 'optional' : 'required';
|
|
186
|
+
lines.push(` ${pyName}: str # ${h.name} (${tag})`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Per-method response TypedDicts for the shapes that have to report status or mime.
|
|
192
|
+
for (const { route, op } of opsWithResponseDict) {
|
|
193
|
+
const shape = opShapes.get(op)!;
|
|
194
|
+
const base = snakeToPascal(deriveMethodName(op, route));
|
|
195
|
+
const targets: OpResponseNode[] = shape.kind === 'multiStatus' ? shape.responses : shape.kind === 'multiMime' ? [shape.resp] : [];
|
|
196
|
+
for (const resp of targets) {
|
|
197
|
+
lines.push('');
|
|
198
|
+
lines.push('');
|
|
199
|
+
lines.push(`class ${responseClassName(base, shape.kind === 'multiStatus' ? resp.statusCode : undefined)}(TypedDict):`);
|
|
200
|
+
if (shape.kind === 'multiStatus') lines.push(` status: Literal[${resp.statusCode}]`);
|
|
201
|
+
const bodies = resp.bodies;
|
|
202
|
+
if (bodies.length > 0) {
|
|
203
|
+
lines.push(` content_type: ${pyContentTypeAnnotation(bodies)}`);
|
|
204
|
+
lines.push(` data: ${pyDataAnnotation(bodies, opts.modelsWithInput)}`);
|
|
205
|
+
}
|
|
206
|
+
if ((resp.headers?.length ?? 0) > 0) {
|
|
207
|
+
lines.push(` headers: ${headersClassName(base, shape.kind === 'multiStatus' ? resp.statusCode : undefined)}`);
|
|
208
|
+
}
|
|
209
|
+
if (shape.kind !== 'multiStatus' && bodies.length === 0 && (resp.headers?.length ?? 0) === 0) {
|
|
210
|
+
lines.push(' pass');
|
|
211
|
+
}
|
|
119
212
|
}
|
|
120
213
|
}
|
|
121
214
|
|
|
@@ -168,22 +261,31 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, opts: ClientCod
|
|
|
168
261
|
const paramStr = allParams.length > 0 ? `, ${allParams.join(', ')}` : '';
|
|
169
262
|
|
|
170
263
|
// Return type — non-JSON responses ignore the schema and return raw bytes/str.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const isModelReturn = !isVoid && respCategory === 'json' && isModelRef(
|
|
182
|
-
const isListModelReturn = !isVoid && respCategory === 'json' && isListModelRef(
|
|
264
|
+
//
|
|
265
|
+
// `observableResponses` is shared with the Koa router and the TypeScript SDK, so all three
|
|
266
|
+
// agree on which statuses are values and which are errors.
|
|
267
|
+
const shape = responseShape(op);
|
|
268
|
+
const methodBase = snakeToPascal(methodName);
|
|
269
|
+
const primaryResponse = shape.kind === 'multiStatus' ? undefined : shape.resp;
|
|
270
|
+
const primaryBodies = primaryResponse ? primaryResponse.bodies : [];
|
|
271
|
+
const isVoid = primaryBodies.length === 0;
|
|
272
|
+
const respCategory = primaryBodies[0] ? classifyContentType(primaryBodies[0].contentType) : 'json';
|
|
273
|
+
const dataType = isVoid ? 'None' : pyBodyType(primaryBodies[0]!, modelsWithInput);
|
|
274
|
+
const isModelReturn = !isVoid && respCategory === 'json' && isModelRef(primaryBodies[0]!.bodyType, modelsWithInput);
|
|
275
|
+
const isListModelReturn = !isVoid && respCategory === 'json' && isListModelRef(primaryBodies[0]!.bodyType, modelsWithInput);
|
|
183
276
|
const respHeaders = primaryResponse?.headers ?? [];
|
|
184
277
|
const hasRespHeaders = respHeaders.length > 0;
|
|
185
|
-
const headersTypeName = hasRespHeaders ?
|
|
186
|
-
const returnType =
|
|
278
|
+
const headersTypeName = hasRespHeaders ? headersClassName(methodBase) : '';
|
|
279
|
+
const returnType =
|
|
280
|
+
shape.kind === 'multiStatus'
|
|
281
|
+
? shape.responses.map(r => responseClassName(methodBase, r.statusCode)).join(' | ')
|
|
282
|
+
: shape.kind === 'multiMime'
|
|
283
|
+
? responseClassName(methodBase)
|
|
284
|
+
: hasRespHeaders
|
|
285
|
+
? isVoid
|
|
286
|
+
? headersTypeName
|
|
287
|
+
: `tuple[${dataType}, ${headersTypeName}]`
|
|
288
|
+
: dataType;
|
|
187
289
|
|
|
188
290
|
// Description
|
|
189
291
|
const desc = op.description ?? route.description;
|
|
@@ -234,10 +336,21 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, opts: ClientCod
|
|
|
234
336
|
fetchKwargs.push(`body_kind="${reqCategory}"`);
|
|
235
337
|
}
|
|
236
338
|
}
|
|
237
|
-
if (
|
|
339
|
+
if (shape.kind !== 'simple') {
|
|
340
|
+
// Several mimes or statuses in play — only the response knows how to read itself.
|
|
341
|
+
fetchKwargs.push(`response_kind="auto"`);
|
|
342
|
+
} else if (respCategory === 'text' || respCategory === 'binary') {
|
|
238
343
|
fetchKwargs.push(`response_kind="${respCategory}"`);
|
|
239
344
|
}
|
|
240
345
|
|
|
346
|
+
const observable = shape.kind === 'multiStatus' ? shape.responses : shape.resp ? [shape.resp] : [];
|
|
347
|
+
const expectStatuses = observable.filter(r => r.statusCode < 200 || r.statusCode >= 300).map(r => r.statusCode);
|
|
348
|
+
if (expectStatuses.length > 0) {
|
|
349
|
+
// A single-element tuple needs the trailing comma or Python reads it as parentheses.
|
|
350
|
+
const tuple = expectStatuses.length === 1 ? `(${expectStatuses[0]},)` : `(${expectStatuses.join(', ')})`;
|
|
351
|
+
fetchKwargs.push(`expect_statuses=${tuple}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
241
354
|
if (hasQuery) {
|
|
242
355
|
fetchKwargs.push('params=query');
|
|
243
356
|
}
|
|
@@ -248,6 +361,12 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, opts: ClientCod
|
|
|
248
361
|
|
|
249
362
|
const kwargsStr = fetchKwargs.length > 1 ? fetchKwargs.join(', ') : (fetchKwargs[0] ?? '');
|
|
250
363
|
|
|
364
|
+
if (shape.kind !== 'simple') {
|
|
365
|
+
lines.push(` _status, _content_type, result, _response_headers = await self._fetch_full(${urlExpr}, ${kwargsStr})`);
|
|
366
|
+
lines.push(...buildMultiReturnLines(shape, methodBase, modelsWithInput));
|
|
367
|
+
return lines;
|
|
368
|
+
}
|
|
369
|
+
|
|
251
370
|
if (hasRespHeaders) {
|
|
252
371
|
lines.push(` result, _response_headers = await self._fetch_with_headers(${urlExpr}, ${kwargsStr})`);
|
|
253
372
|
lines.push(...buildHeadersDictLines(respHeaders, headersTypeName));
|
|
@@ -264,7 +383,7 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, opts: ClientCod
|
|
|
264
383
|
} else {
|
|
265
384
|
let dataExpr: string;
|
|
266
385
|
if (isListModelReturn) {
|
|
267
|
-
const innerType = getListItemType(
|
|
386
|
+
const innerType = getListItemType(primaryBodies[0]!.bodyType, modelsWithInput);
|
|
268
387
|
dataExpr = `[${innerType}.model_validate(item) for item in result]`;
|
|
269
388
|
} else if (isModelReturn) {
|
|
270
389
|
dataExpr = `${dataType}.model_validate(result)`;
|
|
@@ -281,14 +400,89 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, opts: ClientCod
|
|
|
281
400
|
return lines;
|
|
282
401
|
}
|
|
283
402
|
|
|
403
|
+
/** The expression that turns a decoded body into the declared type, validating models. */
|
|
404
|
+
function pyDataExpr(body: OpResponseBodyNode, modelsWithInput?: Set<string>): string {
|
|
405
|
+
if (classifyContentType(body.contentType) !== 'json') return 'result';
|
|
406
|
+
if (isListModelRef(body.bodyType, modelsWithInput)) {
|
|
407
|
+
return `[${getListItemType(body.bodyType, modelsWithInput)}.model_validate(item) for item in result]`;
|
|
408
|
+
}
|
|
409
|
+
if (isModelRef(body.bodyType, modelsWithInput)) {
|
|
410
|
+
return `${renderPyType(body.bodyType, modelsWithInput)}.model_validate(result)`;
|
|
411
|
+
}
|
|
412
|
+
return 'result';
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Return statements for a method that reports its status or mime.
|
|
417
|
+
*
|
|
418
|
+
* Dispatch is a chain of `if`s on the runtime status (or content type), with the first declared
|
|
419
|
+
* alternative as the fall-through so the function always returns.
|
|
420
|
+
*/
|
|
421
|
+
function buildMultiReturnLines(
|
|
422
|
+
shape: Extract<PyResponseShape, { kind: 'multiMime' | 'multiStatus' }>,
|
|
423
|
+
methodBase: string,
|
|
424
|
+
modelsWithInput?: Set<string>,
|
|
425
|
+
): string[] {
|
|
426
|
+
const lines: string[] = [];
|
|
427
|
+
|
|
428
|
+
const returnFor = (resp: OpResponseNode, body: OpResponseBodyNode | undefined, indent: string, includeStatus: boolean, headersVar?: string): string[] => {
|
|
429
|
+
const entries: string[] = [];
|
|
430
|
+
if (includeStatus) entries.push(`"status": ${resp.statusCode}`);
|
|
431
|
+
if (body) {
|
|
432
|
+
entries.push(`"content_type": ${JSON.stringify(body.contentType)}`);
|
|
433
|
+
entries.push(`"data": ${pyDataExpr(body, modelsWithInput)}`);
|
|
434
|
+
}
|
|
435
|
+
if (headersVar) entries.push(`"headers": ${headersVar}`);
|
|
436
|
+
return [`${indent}return {${entries.length > 0 ? ` ${entries.join(', ')} ` : ''}}`];
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
/** One status, dispatching over its mimes when they do not all decode the same way. */
|
|
440
|
+
const emitStatus = (resp: OpResponseNode, indent: string, includeStatus: boolean): string[] => {
|
|
441
|
+
const out: string[] = [];
|
|
442
|
+
// Built once per status, under a name of its own: two statuses in the same method can
|
|
443
|
+
// declare different headers, and Python has no block scope to keep them apart.
|
|
444
|
+
let headersVar: string | undefined;
|
|
445
|
+
if ((resp.headers?.length ?? 0) > 0) {
|
|
446
|
+
headersVar = includeStatus ? `headers_${resp.statusCode}` : 'headers';
|
|
447
|
+
const typeName = headersClassName(methodBase, includeStatus ? resp.statusCode : undefined);
|
|
448
|
+
out.push(...buildHeadersDictLines(resp.headers!, typeName, indent, headersVar));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const bodies = resp.bodies;
|
|
452
|
+
if (bodies.length <= 1) {
|
|
453
|
+
out.push(...returnFor(resp, bodies[0], indent, includeStatus, headersVar));
|
|
454
|
+
return out;
|
|
455
|
+
}
|
|
456
|
+
for (const body of bodies.slice(1)) {
|
|
457
|
+
out.push(`${indent}if _content_type == ${JSON.stringify(body.contentType)}:`);
|
|
458
|
+
out.push(...returnFor(resp, body, `${indent} `, includeStatus, headersVar));
|
|
459
|
+
}
|
|
460
|
+
out.push(...returnFor(resp, bodies[0], indent, includeStatus, headersVar));
|
|
461
|
+
return out;
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
if (shape.kind === 'multiMime') {
|
|
465
|
+
lines.push(...emitStatus(shape.resp, ' ', false));
|
|
466
|
+
return lines;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const [fallback, ...rest] = shape.responses;
|
|
470
|
+
for (const resp of rest) {
|
|
471
|
+
lines.push(` if _status == ${resp.statusCode}:`);
|
|
472
|
+
lines.push(...emitStatus(resp, ' ', true));
|
|
473
|
+
}
|
|
474
|
+
lines.push(...emitStatus(fallback!, ' ', true));
|
|
475
|
+
return lines;
|
|
476
|
+
}
|
|
477
|
+
|
|
284
478
|
/** Build the lines that construct a TypedDict literal of declared response headers. */
|
|
285
|
-
function buildHeadersDictLines(headers: OpResponseHeaderNode[], typeName: string): string[] {
|
|
479
|
+
function buildHeadersDictLines(headers: OpResponseHeaderNode[], typeName: string, indent = ' ', varName = 'headers'): string[] {
|
|
286
480
|
const lines: string[] = [];
|
|
287
|
-
lines.push(
|
|
481
|
+
lines.push(`${indent}${varName}: ${typeName} = {}`);
|
|
288
482
|
for (const h of headers) {
|
|
289
483
|
const pyName = toPythonFieldName(h.name);
|
|
290
|
-
lines.push(
|
|
291
|
-
lines.push(
|
|
484
|
+
lines.push(`${indent}if ${JSON.stringify(h.name.toLowerCase())} in _response_headers:`);
|
|
485
|
+
lines.push(`${indent} ${varName}[${JSON.stringify(pyName)}] = _response_headers[${JSON.stringify(h.name.toLowerCase())}]`);
|
|
292
486
|
}
|
|
293
487
|
return lines;
|
|
294
488
|
}
|
|
@@ -438,7 +632,7 @@ function collectReferencedModels(root: OpRootNode, modelsWithInput?: Set<string>
|
|
|
438
632
|
for (const body of op.request.bodies) collectTypeRefs(body.bodyType, refs, modelsWithInput, true);
|
|
439
633
|
}
|
|
440
634
|
for (const resp of op.responses) {
|
|
441
|
-
|
|
635
|
+
for (const body of resp.bodies) collectTypeRefs(body.bodyType, refs, modelsWithInput, false);
|
|
442
636
|
}
|
|
443
637
|
if (op.query) collectParamSourceRefs(op.query, refs, modelsWithInput);
|
|
444
638
|
if (op.headers) collectParamSourceRefs(op.headers, refs, modelsWithInput);
|
|
@@ -609,6 +803,8 @@ class BaseClient:
|
|
|
609
803
|
self._base_url = base_url.rstrip("/")
|
|
610
804
|
self._headers = headers or {}
|
|
611
805
|
self._http = httpx.AsyncClient()
|
|
806
|
+
self._last_status = 0
|
|
807
|
+
self._last_content_type = ""
|
|
612
808
|
|
|
613
809
|
async def _fetch(
|
|
614
810
|
self,
|
|
@@ -621,6 +817,7 @@ class BaseClient:
|
|
|
621
817
|
content_type: str | None = None,
|
|
622
818
|
body_kind: str = "json",
|
|
623
819
|
response_kind: str = "json",
|
|
820
|
+
expect_statuses: tuple[int, ...] = (),
|
|
624
821
|
) -> Any:
|
|
625
822
|
result, _ = await self._fetch_with_headers(
|
|
626
823
|
path,
|
|
@@ -631,9 +828,41 @@ class BaseClient:
|
|
|
631
828
|
content_type=content_type,
|
|
632
829
|
body_kind=body_kind,
|
|
633
830
|
response_kind=response_kind,
|
|
831
|
+
expect_statuses=expect_statuses,
|
|
634
832
|
)
|
|
635
833
|
return result
|
|
636
834
|
|
|
835
|
+
async def _fetch_full(
|
|
836
|
+
self,
|
|
837
|
+
path: str,
|
|
838
|
+
*,
|
|
839
|
+
method: str,
|
|
840
|
+
body: Any = None,
|
|
841
|
+
params: dict | None = None,
|
|
842
|
+
extra_headers: dict | None = None,
|
|
843
|
+
content_type: str | None = None,
|
|
844
|
+
body_kind: str = "json",
|
|
845
|
+
response_kind: str = "json",
|
|
846
|
+
expect_statuses: tuple[int, ...] = (),
|
|
847
|
+
) -> tuple[int, str, Any, dict[str, str]]:
|
|
848
|
+
"""Like _fetch_with_headers, but also reports the status and content type.
|
|
849
|
+
|
|
850
|
+
Used by operations that declare more than one status, or more than one mime for a
|
|
851
|
+
status, where the caller cannot know which it got without being told.
|
|
852
|
+
"""
|
|
853
|
+
result, headers = await self._fetch_with_headers(
|
|
854
|
+
path,
|
|
855
|
+
method=method,
|
|
856
|
+
body=body,
|
|
857
|
+
params=params,
|
|
858
|
+
extra_headers=extra_headers,
|
|
859
|
+
content_type=content_type,
|
|
860
|
+
body_kind=body_kind,
|
|
861
|
+
response_kind=response_kind,
|
|
862
|
+
expect_statuses=expect_statuses,
|
|
863
|
+
)
|
|
864
|
+
return self._last_status, self._last_content_type, result, headers
|
|
865
|
+
|
|
637
866
|
async def _fetch_with_headers(
|
|
638
867
|
self,
|
|
639
868
|
path: str,
|
|
@@ -645,6 +874,7 @@ class BaseClient:
|
|
|
645
874
|
content_type: str | None = None,
|
|
646
875
|
body_kind: str = "json",
|
|
647
876
|
response_kind: str = "json",
|
|
877
|
+
expect_statuses: tuple[int, ...] = (),
|
|
648
878
|
) -> tuple[Any, dict[str, str]]:
|
|
649
879
|
headers = {**self._headers, **(extra_headers or {})}
|
|
650
880
|
if body is not None:
|
|
@@ -659,7 +889,10 @@ class BaseClient:
|
|
|
659
889
|
else:
|
|
660
890
|
request_kwargs["content"] = body
|
|
661
891
|
response = await self._http.request(**request_kwargs)
|
|
662
|
-
|
|
892
|
+
# expect_statuses carries the codes this operation declares as values rather than
|
|
893
|
+
# errors — a 304 from conditional-GET middleware, or an error status the service
|
|
894
|
+
# returns deliberately. Anything else outside 2xx still raises.
|
|
895
|
+
if not response.is_success and response.status_code not in expect_statuses:
|
|
663
896
|
try:
|
|
664
897
|
error_body = response.json()
|
|
665
898
|
except Exception:
|
|
@@ -667,11 +900,24 @@ class BaseClient:
|
|
|
667
900
|
raise SdkError(response.status_code, response.reason_phrase, error_body)
|
|
668
901
|
# HTTP headers are case-insensitive — normalize to lowercase keys for stable lookup.
|
|
669
902
|
response_headers = {k.lower(): v for k, v in response.headers.items()}
|
|
903
|
+
self._last_status = response.status_code
|
|
904
|
+
self._last_content_type = response.headers.get("content-type", "").split(";")[0].strip()
|
|
670
905
|
if response.status_code == 204 or not response.content:
|
|
671
906
|
return None, response_headers
|
|
672
|
-
|
|
907
|
+
# "auto" is for a status declaring several mimes that do not read the same way: the
|
|
908
|
+
# response itself is the only thing that knows which one came back.
|
|
909
|
+
kind = response_kind
|
|
910
|
+
if kind == "auto":
|
|
911
|
+
ct = self._last_content_type
|
|
912
|
+
if ct.startswith("text/"):
|
|
913
|
+
kind = "text"
|
|
914
|
+
elif ct == "application/json" or ct.endswith("+json"):
|
|
915
|
+
kind = "json"
|
|
916
|
+
else:
|
|
917
|
+
kind = "binary"
|
|
918
|
+
if kind == "text":
|
|
673
919
|
return response.text, response_headers
|
|
674
|
-
if
|
|
920
|
+
if kind == "binary":
|
|
675
921
|
return response.content, response_headers
|
|
676
922
|
return response.json(), response_headers
|
|
677
923
|
`;
|
package/src/index.ts
CHANGED
|
@@ -293,7 +293,7 @@ function collectOpRootModelRefs(root: OpRootNode, modelMap: Map<string, ModelNod
|
|
|
293
293
|
for (const body of op.request.bodies) seeds.push(body.bodyType);
|
|
294
294
|
}
|
|
295
295
|
for (const resp of op.responses) {
|
|
296
|
-
|
|
296
|
+
for (const body of resp.bodies) seeds.push(body.bodyType);
|
|
297
297
|
if (resp.headers) {
|
|
298
298
|
for (const h of resp.headers) seeds.push(h.type);
|
|
299
299
|
}
|
|
@@ -296,6 +296,99 @@ describe('generatePythonClient', () => {
|
|
|
296
296
|
expect(output).toContain('body=body.model_dump(mode="json")');
|
|
297
297
|
});
|
|
298
298
|
|
|
299
|
+
describe('observable-set returns', () => {
|
|
300
|
+
const artBodies = [
|
|
301
|
+
{ contentType: 'image/png', bodyType: scalarType('binary') },
|
|
302
|
+
{ contentType: 'image/jpeg', bodyType: scalarType('binary') },
|
|
303
|
+
];
|
|
304
|
+
|
|
305
|
+
it('leaves the common success-plus-bodyless-errors method alone', () => {
|
|
306
|
+
const root = opRoot([
|
|
307
|
+
opRoute('/pets', [
|
|
308
|
+
opOperation('get', { sdk: 'listPets', responses: [opResponse(200, 'Pet', 'application/json'), opResponse(404)] }),
|
|
309
|
+
]),
|
|
310
|
+
]);
|
|
311
|
+
const output = generatePythonClient(root);
|
|
312
|
+
expect(output).toContain('-> Pet:');
|
|
313
|
+
expect(output).toContain('return Pet.model_validate(result)');
|
|
314
|
+
expect(output).not.toContain('expect_statuses');
|
|
315
|
+
expect(output).not.toContain('_fetch_full');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('reports which mime came back when a status declares several', () => {
|
|
319
|
+
const root = opRoot([
|
|
320
|
+
opRoute('/art', [
|
|
321
|
+
opOperation('get', { sdk: 'getArt', responses: [{ statusCode: 200, hasBlock: true, bodies: artBodies }] }),
|
|
322
|
+
]),
|
|
323
|
+
]);
|
|
324
|
+
const output = generatePythonClient(root);
|
|
325
|
+
expect(output).toContain('class GetArtResponse(TypedDict):');
|
|
326
|
+
expect(output).toContain(' content_type: Literal["image/png", "image/jpeg"]');
|
|
327
|
+
expect(output).toContain(' data: bytes');
|
|
328
|
+
expect(output).toContain('-> GetArtResponse:');
|
|
329
|
+
expect(output).toContain('response_kind="auto"');
|
|
330
|
+
expect(output).toContain('if _content_type == "image/jpeg":');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('returns a union over every status a client can receive', () => {
|
|
334
|
+
const root = opRoot([
|
|
335
|
+
opRoute('/art', [
|
|
336
|
+
opOperation('get', {
|
|
337
|
+
sdk: 'getArt',
|
|
338
|
+
responses: [opResponse(200, 'Art', 'application/json'), opResponse(304), opResponse(404)],
|
|
339
|
+
}),
|
|
340
|
+
]),
|
|
341
|
+
]);
|
|
342
|
+
const output = generatePythonClient(root);
|
|
343
|
+
expect(output).toContain('class GetArt200Response(TypedDict):');
|
|
344
|
+
expect(output).toContain(' status: Literal[200]');
|
|
345
|
+
expect(output).toContain('class GetArt304Response(TypedDict):');
|
|
346
|
+
expect(output).toContain('-> GetArt200Response | GetArt304Response:');
|
|
347
|
+
expect(output).toContain('expect_statuses=(304,)');
|
|
348
|
+
expect(output).toContain('if _status == 304:');
|
|
349
|
+
// The bare 404 still raises SdkError, so it is not a member.
|
|
350
|
+
expect(output).not.toContain('GetArt404Response');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('stops raising for a status declared as a value rather than an error', () => {
|
|
354
|
+
const root = opRoot([
|
|
355
|
+
opRoute('/pets', [
|
|
356
|
+
opOperation('get', {
|
|
357
|
+
sdk: 'getPet',
|
|
358
|
+
responses: [opResponse(200, 'Pet', 'application/json'), opResponse(422, 'Problem', 'application/json')],
|
|
359
|
+
}),
|
|
360
|
+
]),
|
|
361
|
+
]);
|
|
362
|
+
const output = generatePythonClient(root);
|
|
363
|
+
expect(output).toContain('expect_statuses=(422,)');
|
|
364
|
+
expect(output).toContain('if _status == 422:');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('gives each status its own headers dict, since Python has no block scope', () => {
|
|
368
|
+
const root = opRoot([
|
|
369
|
+
opRoute('/art', [
|
|
370
|
+
opOperation('get', {
|
|
371
|
+
sdk: 'getArt',
|
|
372
|
+
responses: [
|
|
373
|
+
{ statusCode: 200, hasBlock: true, bodies: artBodies, headers: [{ name: 'etag', optional: true, type: scalarType('string') }] },
|
|
374
|
+
{
|
|
375
|
+
statusCode: 202,
|
|
376
|
+
hasBlock: true,
|
|
377
|
+
bodies: [{ contentType: 'application/json', bodyType: refType('JobRef') }],
|
|
378
|
+
headers: [{ name: 'retry-after', optional: false, type: scalarType('string') }],
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
}),
|
|
382
|
+
]),
|
|
383
|
+
]);
|
|
384
|
+
const output = generatePythonClient(root);
|
|
385
|
+
expect(output).toContain('class GetArt200Headers(TypedDict, total=False):');
|
|
386
|
+
expect(output).toContain('class GetArt202Headers(TypedDict, total=False):');
|
|
387
|
+
expect(output).toContain('headers_200: GetArt200Headers = {}');
|
|
388
|
+
expect(output).toContain('headers_202: GetArt202Headers = {}');
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
299
392
|
describe('response headers', () => {
|
|
300
393
|
it('emits a TypedDict and tuple return type when response declares headers', () => {
|
|
301
394
|
const root = opRoot([
|
|
@@ -305,8 +398,8 @@ describe('generatePythonClient', () => {
|
|
|
305
398
|
responses: [
|
|
306
399
|
{
|
|
307
400
|
statusCode: 200,
|
|
308
|
-
|
|
309
|
-
bodyType: { kind: 'ref', name: 'Transfer' },
|
|
401
|
+
hasBlock: true,
|
|
402
|
+
bodies: [{ contentType: 'application/json', bodyType: { kind: 'ref', name: 'Transfer' } }],
|
|
310
403
|
headers: [
|
|
311
404
|
{ name: 'preference-applied', optional: true, type: scalarType('string') },
|
|
312
405
|
{ name: 'etag', optional: false, type: scalarType('string') },
|
|
@@ -336,6 +429,8 @@ describe('generatePythonClient', () => {
|
|
|
336
429
|
responses: [
|
|
337
430
|
{
|
|
338
431
|
statusCode: 204,
|
|
432
|
+
hasBlock: true,
|
|
433
|
+
bodies: [],
|
|
339
434
|
headers: [{ name: 'x-deleted-at', optional: false, type: scalarType('string') }],
|
|
340
435
|
},
|
|
341
436
|
],
|
package/tests/helpers.ts
CHANGED
|
@@ -149,7 +149,10 @@ export function opRequest(bodyType: string | ContractTypeNode, contentType: stri
|
|
|
149
149
|
export function opResponse(statusCode: number, bodyType?: string | ContractTypeNode, contentType?: string): OpResponseNode {
|
|
150
150
|
const bt: ContractTypeNode | undefined =
|
|
151
151
|
bodyType === undefined ? undefined : typeof bodyType === 'string' ? parseBodyTypeString(bodyType) : bodyType;
|
|
152
|
-
|
|
152
|
+
// A body with no explicit mime defaults to JSON, matching how every plugin used to read
|
|
153
|
+
// the old singular contentType field.
|
|
154
|
+
const bodies = bt === undefined ? [] : [{ contentType: contentType ?? 'application/json', bodyType: bt }];
|
|
155
|
+
return { statusCode, bodies, ...(bt !== undefined ? { hasBlock: true } : {}) };
|
|
153
156
|
}
|
|
154
157
|
|
|
155
158
|
function parseBodyTypeString(s: string): ContractTypeNode {
|