@contractkit/plugin-python 0.11.7 → 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.
@@ -1,7 +1,63 @@
1
- import type { OpRootNode, OpRouteNode, OpOperationNode, OpResponseHeaderNode, ContractTypeNode, ParamSource } from '@contractkit/core';
2
- import { resolveModifiers, classifyContentType } from '@contractkit/core';
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 primary = op.responses.find(r => r.bodyType) ?? op.responses[0];
78
- return (primary?.headers?.length ?? 0) > 0;
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 || opsWithRespHeaders.length > 0) {
142
+ if (needsAny || needsTypedDict) {
82
143
  const typingImports: string[] = [];
83
144
  if (needsAny) typingImports.push('Any');
84
- if (opsWithRespHeaders.length > 0) typingImports.push('TypedDict');
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 primary = op.responses.find(r => r.bodyType) ?? op.responses[0]!;
111
- const className = `${snakeToPascal(deriveMethodName(op, route))}Headers`;
112
- lines.push('');
113
- lines.push('');
114
- lines.push(`class ${className}(TypedDict, total=False):`);
115
- for (const h of primary.headers!) {
116
- const pyName = toPythonFieldName(h.name);
117
- const tag = h.optional ? 'optional' : 'required';
118
- lines.push(` ${pyName}: str # ${h.name} (${tag})`);
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
- const primaryResponse = op.responses.find(r => r.bodyType) ?? op.responses[0];
172
- const isVoid = !primaryResponse?.bodyType;
173
- const respCategory = primaryResponse?.contentType ? classifyContentType(primaryResponse.contentType) : 'json';
174
- const dataType = isVoid
175
- ? 'None'
176
- : respCategory === 'text'
177
- ? 'str'
178
- : respCategory === 'binary'
179
- ? 'bytes'
180
- : renderPyType(primaryResponse!.bodyType!, modelsWithInput);
181
- const isModelReturn = !isVoid && respCategory === 'json' && isModelRef(primaryResponse!.bodyType!, modelsWithInput);
182
- const isListModelReturn = !isVoid && respCategory === 'json' && isListModelRef(primaryResponse!.bodyType!, modelsWithInput);
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 ? `${snakeToPascal(methodName)}Headers` : '';
186
- const returnType = hasRespHeaders ? (isVoid ? headersTypeName : `tuple[${dataType}, ${headersTypeName}]`) : dataType;
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 (respCategory === 'text' || respCategory === 'binary') {
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(primaryResponse!.bodyType!, modelsWithInput);
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(` headers: ${typeName} = {}`);
481
+ lines.push(`${indent}${varName}: ${typeName} = {}`);
288
482
  for (const h of headers) {
289
483
  const pyName = toPythonFieldName(h.name);
290
- lines.push(` if ${JSON.stringify(h.name.toLowerCase())} in _response_headers:`);
291
- lines.push(` headers[${JSON.stringify(pyName)}] = _response_headers[${JSON.stringify(h.name.toLowerCase())}]`);
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
- if (resp.bodyType) collectTypeRefs(resp.bodyType, refs, modelsWithInput, false);
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
- if not response.is_success:
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
- if response_kind == "text":
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 response_kind == "binary":
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
- if (resp.bodyType) seeds.push(resp.bodyType);
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
- contentType: 'application/json',
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
- return { statusCode, contentType, bodyType: bt };
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 {