@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.
@@ -5,9 +5,9 @@ $ tsup src/index.ts --format esm --sourcemap --dts && tsc --emitDeclarationOnly
5
5
  CLI tsup v8.5.1
6
6
  CLI Target: esnext
7
7
  ESM Build start
8
- ESM dist/index.js 43.30 KB
9
- ESM dist/index.js.map 92.85 KB
10
- ESM ⚡️ Build success in 274ms
8
+ ESM dist/index.js 52.19 KB
9
+ ESM dist/index.js.map 110.13 KB
10
+ ESM ⚡️ Build success in 233ms
11
11
  DTS Build start
12
- DTS ⚡️ Build success in 4814ms
12
+ DTS ⚡️ Build success in 5411ms
13
13
  DTS dist/index.d.ts 1.10 KB
@@ -3,22 +3,22 @@ $ vitest run --coverage
3
3
   RUN  v4.1.5 /home/runner/work/ContractKit/ContractKit/packages/plugin-python
4
4
  Coverage enabled with v8
5
5
 
6
- ✓ tests/codegen-client.test.ts (30 tests) 70ms
7
- ✓ tests/codegen-models.test.ts (36 tests) 177ms
6
+ ✓ tests/codegen-models.test.ts (36 tests) 50ms
7
+ ✓ tests/codegen-client.test.ts (35 tests) 115ms
8
8
 
9
9
   Test Files  2 passed (2)
10
-  Tests  66 passed (66)
11
-  Start at  17:06:40
12
-  Duration  5.70s (transform 2.27s, setup 0ms, import 7.55s, tests 246ms, environment 0ms)
10
+  Tests  71 passed (71)
11
+  Start at  16:09:45
12
+  Duration  5.54s (transform 2.13s, setup 0ms, import 7.77s, tests 164ms, environment 0ms)
13
13
 
14
14
   % Coverage report from v8
15
15
  -------------------|---------|----------|---------|---------|-------------------
16
16
  File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
17
17
  -------------------|---------|----------|---------|---------|-------------------
18
- All files | 83.57 | 78.28 | 86.31 | 85.13 |
19
- src | 83.33 | 78.6 | 84.28 | 84.61 |
20
- ...gen-client.ts | 83.18 | 78.96 | 87.17 | 83.56 | ...02-522,555-556
18
+ All files | 85.63 | 79.49 | 88.39 | 86.87 |
19
+ src | 85.54 | 79.57 | 87.35 | 86.52 |
20
+ ...gen-client.ts | 86.69 | 80.31 | 91.07 | 86.84 | ...96-716,749-750
21
21
  ...gen-models.ts | 83.53 | 78.06 | 80.64 | 85.97 | ...59,373-375,379
22
- tests | 86.66 | 74.19 | 92 | 92.1 |
23
- helpers.ts | 86.66 | 74.19 | 92 | 92.1 | 137-141,167
22
+ tests | 86.95 | 78.37 | 92 | 92.3 |
23
+ helpers.ts | 86.95 | 78.37 | 92 | 92.3 | 137-141,170
24
24
  -------------------|---------|----------|---------|---------|-------------------
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # @contractkit/contractkit-plugin-python
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 85d7566: Let an operation emit more than one status, and a status serve more than one content type.
8
+
9
+ A status code could previously declare only one mime — the parser warned `Duplicate response body` and dropped the rest — and the generated router pinned both `ctx.status` and `ctx.type` to whichever response happened to be listed first with a body. An endpoint serving several formats had to declare one lying mime and let browsers sniff, and a service had no way to say which status it produced.
10
+
11
+ A status now holds every declared `mime: Type` line (`OpResponseNode.bodies`). When there is more than one, the service picks at runtime and the router sets `ctx.type` from the returned `contentType`. When an operation produces more than one status, the service returns a union discriminated on `status` and the handler switches on it, so each status writes only its own headers, mime and body. Both SDKs mirror the router: the TypeScript and Python clients return a matching union, report which mime came back, and pass the non-2xx statuses they expect to the shared fetch so a declared `304` no longer surfaces as an error. `SdkError` now takes a body type parameter, and each operation exports a `…ErrorBody` alias for the statuses that stay on the throw path.
12
+
13
+ Which statuses the service produces is derived from the declaration: **a status is emitted if it has a block, or is 2xx.** An empty block (`304: {}`) says the service returns that status carrying nothing; a bare `304:` says it is documented and something else produces it. `404(documented): { … }` is the one modifier, forcing a block-carrying status back out.
14
+
15
+ Two long-standing bugs in the same area go with it. The formatter deleted any comment written inside a `response` block — above a status code, above a mime line, above a `headers:` block, or before a closing brace — so `pnpm format` silently threw away the notes explaining why a contract looks the way it does; all four positions now round-trip. And the generated router declared a `_ZodBinary` helper for a binary _response_ body, which is a plain `Buffer` annotation with no schema behind it, leaving an unused const that tripped `noUnusedLocals` downstream; helpers are now chosen from the code that was actually generated, the same way imports already were.
16
+
17
+ **If you are already on a pre-release version, two things change under you.** A contract that declares a body on an error status — `404: { application/json: Problem }` alongside a `200` — now returns it from the service instead of throwing, and the SDK return type becomes a union; add `(documented)` to that status to keep the previous behaviour. Contracts whose error statuses are bodyless (`400:`, `404:`) are unaffected. Anything reading the AST directly should move from `OpResponseNode.contentType`/`bodyType` to `bodies`, which replaces them.
18
+
19
+ ### Patch Changes
20
+
21
+ - Updated dependencies [85d7566]
22
+ - Updated dependencies [90d19ee]
23
+ - @contractkit/core@0.25.0
24
+
25
+ ## 0.11.8
26
+
27
+ ### Patch Changes
28
+
29
+ - Updated dependencies [23e4beb]
30
+ - @contractkit/core@0.24.0
31
+
3
32
  ## 0.11.7
4
33
 
5
34
  ### Patch Changes
package/README.md CHANGED
@@ -50,6 +50,8 @@ Each `contract` declaration becomes a Pydantic v2 `BaseModel`. Contracts that ha
50
50
 
51
51
  Each operation file with at least one public operation generates a client class. Methods correspond to HTTP verbs and are named from the `sdk:` field in the `.ck` source. Request and response bodies are typed with the generated Pydantic models.
52
52
 
53
+ A method returns its body directly when the operation has one response a caller can receive. When it has several, the return type is a union of per-status `TypedDict`s keyed on a `Literal` status, and when one status declares several content types the result carries the `content_type` that actually came back.
54
+
53
55
  ### Aggregator (`__init__.py`)
54
56
 
55
57
  The aggregator class (named from `packageName`) instantiates all client classes and exposes them as attributes. Pass the base URL and optional headers at construction time:
@@ -63,7 +65,9 @@ payment = sdk.payments.get_payment(id="pay_123")
63
65
 
64
66
  ### Base client (`_base_client.py`)
65
67
 
66
- Provides `BaseClient` (wraps `httpx.Client`) and `SdkError` (raised on non-2xx responses). All generated client classes inherit from `BaseClient`.
68
+ Provides `BaseClient` (wraps `httpx.Client`) and `SdkError`. All generated client classes inherit from `BaseClient`.
69
+
70
+ `SdkError` is raised for any response at or above 400 **except** the statuses an operation declares as values, which each method passes as `expect_statuses`. A `304` produced by conditional-GET middleware, or an error status the service returns deliberately, therefore comes back as a normal return value rather than an exception.
67
71
 
68
72
  ## Runtime dependencies
69
73
 
@@ -26,5 +26,5 @@ export declare function generatePythonClient(root: OpRootNode, opts?: ClientCode
26
26
  export declare function deriveClientClassName(file: string): string;
27
27
  export declare function deriveClientModuleName(file: string): string;
28
28
  export declare function deriveClientPropertyName(file: string): string;
29
- export declare const BASE_CLIENT_PY = "# Auto-generated by contractkit-plugin-python-sdk. Do not edit manually.\nfrom __future__ import annotations\n\nimport httpx\nfrom typing import Any\n\n\nclass SdkError(Exception):\n def __init__(self, status: int, status_text: str, body: Any):\n super().__init__(f\"{status} {status_text}\")\n self.status = status\n self.status_text = status_text\n self.body = body\n\n\nclass BaseClient:\n def __init__(self, base_url: str, headers: dict[str, str] | None = None):\n self._base_url = base_url.rstrip(\"/\")\n self._headers = headers or {}\n self._http = httpx.AsyncClient()\n\n async def _fetch(\n self,\n path: str,\n *,\n method: str,\n body: Any = None,\n params: dict | None = None,\n extra_headers: dict | None = None,\n content_type: str | None = None,\n body_kind: str = \"json\",\n response_kind: str = \"json\",\n ) -> Any:\n result, _ = await self._fetch_with_headers(\n path,\n method=method,\n body=body,\n params=params,\n extra_headers=extra_headers,\n content_type=content_type,\n body_kind=body_kind,\n response_kind=response_kind,\n )\n return result\n\n async def _fetch_with_headers(\n self,\n path: str,\n *,\n method: str,\n body: Any = None,\n params: dict | None = None,\n extra_headers: dict | None = None,\n content_type: str | None = None,\n body_kind: str = \"json\",\n response_kind: str = \"json\",\n ) -> tuple[Any, dict[str, str]]:\n headers = {**self._headers, **(extra_headers or {})}\n if body is not None:\n headers[\"Content-Type\"] = content_type or \"application/json\"\n # body_kind controls how httpx serializes the request body:\n # \"json\" \u2014 body is a JSON-serializable object, sent via httpx's json= kwarg\n # \"text\"/\"binary\" \u2014 body is a raw str/bytes payload, sent via content= unchanged\n request_kwargs: dict[str, Any] = {\"method\": method, \"url\": f\"{self._base_url}{path}\", \"params\": params, \"headers\": headers}\n if body is not None:\n if body_kind == \"json\":\n request_kwargs[\"json\"] = body\n else:\n request_kwargs[\"content\"] = body\n response = await self._http.request(**request_kwargs)\n if not response.is_success:\n try:\n error_body = response.json()\n except Exception:\n error_body = response.text\n raise SdkError(response.status_code, response.reason_phrase, error_body)\n # HTTP headers are case-insensitive \u2014 normalize to lowercase keys for stable lookup.\n response_headers = {k.lower(): v for k, v in response.headers.items()}\n if response.status_code == 204 or not response.content:\n return None, response_headers\n if response_kind == \"text\":\n return response.text, response_headers\n if response_kind == \"binary\":\n return response.content, response_headers\n return response.json(), response_headers\n";
29
+ export declare const BASE_CLIENT_PY = "# Auto-generated by contractkit-plugin-python-sdk. Do not edit manually.\nfrom __future__ import annotations\n\nimport httpx\nfrom typing import Any\n\n\nclass SdkError(Exception):\n def __init__(self, status: int, status_text: str, body: Any):\n super().__init__(f\"{status} {status_text}\")\n self.status = status\n self.status_text = status_text\n self.body = body\n\n\nclass BaseClient:\n def __init__(self, base_url: str, headers: dict[str, str] | None = None):\n self._base_url = base_url.rstrip(\"/\")\n self._headers = headers or {}\n self._http = httpx.AsyncClient()\n self._last_status = 0\n self._last_content_type = \"\"\n\n async def _fetch(\n self,\n path: str,\n *,\n method: str,\n body: Any = None,\n params: dict | None = None,\n extra_headers: dict | None = None,\n content_type: str | None = None,\n body_kind: str = \"json\",\n response_kind: str = \"json\",\n expect_statuses: tuple[int, ...] = (),\n ) -> Any:\n result, _ = await self._fetch_with_headers(\n path,\n method=method,\n body=body,\n params=params,\n extra_headers=extra_headers,\n content_type=content_type,\n body_kind=body_kind,\n response_kind=response_kind,\n expect_statuses=expect_statuses,\n )\n return result\n\n async def _fetch_full(\n self,\n path: str,\n *,\n method: str,\n body: Any = None,\n params: dict | None = None,\n extra_headers: dict | None = None,\n content_type: str | None = None,\n body_kind: str = \"json\",\n response_kind: str = \"json\",\n expect_statuses: tuple[int, ...] = (),\n ) -> tuple[int, str, Any, dict[str, str]]:\n \"\"\"Like _fetch_with_headers, but also reports the status and content type.\n\n Used by operations that declare more than one status, or more than one mime for a\n status, where the caller cannot know which it got without being told.\n \"\"\"\n result, headers = await self._fetch_with_headers(\n path,\n method=method,\n body=body,\n params=params,\n extra_headers=extra_headers,\n content_type=content_type,\n body_kind=body_kind,\n response_kind=response_kind,\n expect_statuses=expect_statuses,\n )\n return self._last_status, self._last_content_type, result, headers\n\n async def _fetch_with_headers(\n self,\n path: str,\n *,\n method: str,\n body: Any = None,\n params: dict | None = None,\n extra_headers: dict | None = None,\n content_type: str | None = None,\n body_kind: str = \"json\",\n response_kind: str = \"json\",\n expect_statuses: tuple[int, ...] = (),\n ) -> tuple[Any, dict[str, str]]:\n headers = {**self._headers, **(extra_headers or {})}\n if body is not None:\n headers[\"Content-Type\"] = content_type or \"application/json\"\n # body_kind controls how httpx serializes the request body:\n # \"json\" \u2014 body is a JSON-serializable object, sent via httpx's json= kwarg\n # \"text\"/\"binary\" \u2014 body is a raw str/bytes payload, sent via content= unchanged\n request_kwargs: dict[str, Any] = {\"method\": method, \"url\": f\"{self._base_url}{path}\", \"params\": params, \"headers\": headers}\n if body is not None:\n if body_kind == \"json\":\n request_kwargs[\"json\"] = body\n else:\n request_kwargs[\"content\"] = body\n response = await self._http.request(**request_kwargs)\n # expect_statuses carries the codes this operation declares as values rather than\n # errors \u2014 a 304 from conditional-GET middleware, or an error status the service\n # returns deliberately. Anything else outside 2xx still raises.\n if not response.is_success and response.status_code not in expect_statuses:\n try:\n error_body = response.json()\n except Exception:\n error_body = response.text\n raise SdkError(response.status_code, response.reason_phrase, error_body)\n # HTTP headers are case-insensitive \u2014 normalize to lowercase keys for stable lookup.\n response_headers = {k.lower(): v for k, v in response.headers.items()}\n self._last_status = response.status_code\n self._last_content_type = response.headers.get(\"content-type\", \"\").split(\";\")[0].strip()\n if response.status_code == 204 or not response.content:\n return None, response_headers\n # \"auto\" is for a status declaring several mimes that do not read the same way: the\n # response itself is the only thing that knows which one came back.\n kind = response_kind\n if kind == \"auto\":\n ct = self._last_content_type\n if ct.startswith(\"text/\"):\n kind = \"text\"\n elif ct == \"application/json\" or ct.endswith(\"+json\"):\n kind = \"json\"\n else:\n kind = \"binary\"\n if kind == \"text\":\n return response.text, response_headers\n if kind == \"binary\":\n return response.content, response_headers\n return response.json(), response_headers\n";
30
30
  //# sourceMappingURL=codegen-client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"codegen-client.d.ts","sourceRoot":"","sources":["../src/codegen-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAqF,MAAM,mBAAmB,CAAC;AAMvI,MAAM,WAAW,oBAAoB;IACjC,0EAA0E;IAC1E,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvC,0FAA0F;IAC1F,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,kDAAkD;IAClD,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B;;;;OAIG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,UAAU,EAAE,eAAe,UAAQ,GAAG,OAAO,CAOtF;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,UAAU,EAAE,IAAI,GAAE,oBAAyB,GAAG,MAAM,CA0G9F;AA2YD,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQ3D;AAED,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAG7D;AAmCD,eAAO,MAAM,cAAc,wuGAqF1B,CAAC"}
1
+ {"version":3,"file":"codegen-client.d.ts","sourceRoot":"","sources":["../src/codegen-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACR,UAAU,EAQb,MAAM,mBAAmB,CAAC;AAqD3B,MAAM,WAAW,oBAAoB;IACjC,0EAA0E;IAC1E,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvC,0FAA0F;IAC1F,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,kDAAkD;IAClD,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B;;;;OAIG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,UAAU,EAAE,eAAe,UAAQ,GAAG,OAAO,CAOtF;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,UAAU,EAAE,IAAI,GAAE,oBAAyB,GAAG,MAAM,CA+I9F;AAgfD,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQ3D;AAED,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAG7D;AAmCD,eAAO,MAAM,cAAc,6+KAyI1B,CAAC"}
package/dist/index.js CHANGED
@@ -386,7 +386,50 @@ function deriveModelsModuleName(file) {
386
386
  __name(deriveModelsModuleName, "deriveModelsModuleName");
387
387
 
388
388
  // src/codegen-client.ts
389
- import { resolveModifiers, classifyContentType } from "@contractkit/core";
389
+ import { resolveModifiers, classifyContentType, observableResponses } from "@contractkit/core";
390
+ function headersClassName(methodBase, statusCode) {
391
+ return statusCode === void 0 ? `${methodBase}Headers` : `${methodBase}${statusCode}Headers`;
392
+ }
393
+ __name(headersClassName, "headersClassName");
394
+ function responseClassName(methodBase, statusCode) {
395
+ return statusCode === void 0 ? `${methodBase}Response` : `${methodBase}${statusCode}Response`;
396
+ }
397
+ __name(responseClassName, "responseClassName");
398
+ function pyBodyType(body, modelsWithInput) {
399
+ const category = classifyContentType(body.contentType);
400
+ if (category === "text") return "str";
401
+ if (category === "binary") return "bytes";
402
+ return renderPyType(body.bodyType, modelsWithInput);
403
+ }
404
+ __name(pyBodyType, "pyBodyType");
405
+ function pyDataAnnotation(bodies, modelsWithInput) {
406
+ const types = [
407
+ ...new Set(bodies.map((b) => pyBodyType(b, modelsWithInput)))
408
+ ];
409
+ return types.join(" | ");
410
+ }
411
+ __name(pyDataAnnotation, "pyDataAnnotation");
412
+ function pyContentTypeAnnotation(bodies) {
413
+ return `Literal[${bodies.map((b) => JSON.stringify(b.contentType)).join(", ")}]`;
414
+ }
415
+ __name(pyContentTypeAnnotation, "pyContentTypeAnnotation");
416
+ function responseShape(op) {
417
+ const observable = observableResponses(op);
418
+ if (observable.length > 1) return {
419
+ kind: "multiStatus",
420
+ responses: observable
421
+ };
422
+ const resp = observable[0];
423
+ if (resp && resp.bodies.length > 1) return {
424
+ kind: "multiMime",
425
+ resp
426
+ };
427
+ return {
428
+ kind: "simple",
429
+ resp
430
+ };
431
+ }
432
+ __name(responseShape, "responseShape");
390
433
  function hasPublicOperations(root, includeInternal = false) {
391
434
  for (const route of root.routes) {
392
435
  for (const op of route.operations) {
@@ -428,14 +471,23 @@ function generatePythonClient(root, opts = {}) {
428
471
  });
429
472
  }
430
473
  }
474
+ const opShapes = new Map(publicOps.map(({ op }) => [
475
+ op,
476
+ responseShape(op)
477
+ ]));
431
478
  const opsWithRespHeaders = publicOps.filter(({ op }) => {
432
- const primary = op.responses.find((r) => r.bodyType) ?? op.responses[0];
433
- return (primary?.headers?.length ?? 0) > 0;
479
+ const shape = opShapes.get(op);
480
+ if (shape.kind === "multiStatus") return shape.responses.some((r) => (r.headers?.length ?? 0) > 0);
481
+ return (shape.resp?.headers?.length ?? 0) > 0;
434
482
  });
435
- if (needsAny || opsWithRespHeaders.length > 0) {
483
+ const opsWithResponseDict = publicOps.filter(({ op }) => opShapes.get(op).kind !== "simple");
484
+ const needsTypedDict = opsWithRespHeaders.length > 0 || opsWithResponseDict.length > 0;
485
+ const needsLiteral = opsWithResponseDict.length > 0;
486
+ if (needsAny || needsTypedDict) {
436
487
  const typingImports = [];
437
488
  if (needsAny) typingImports.push("Any");
438
- if (opsWithRespHeaders.length > 0) typingImports.push("TypedDict");
489
+ if (needsLiteral) typingImports.push("Literal");
490
+ if (needsTypedDict) typingImports.push("TypedDict");
439
491
  lines.push(`from typing import ${typingImports.join(", ")}`);
440
492
  }
441
493
  lines.push("from ._base_client import BaseClient, SdkError # noqa: F401");
@@ -461,15 +513,50 @@ function generatePythonClient(root, opts = {}) {
461
513
  lines.push(`from ${mod} import ${sorted}`);
462
514
  }
463
515
  for (const { route, op } of opsWithRespHeaders) {
464
- const primary = op.responses.find((r) => r.bodyType) ?? op.responses[0];
465
- const className = `${snakeToPascal(deriveMethodName(op, route))}Headers`;
466
- lines.push("");
467
- lines.push("");
468
- lines.push(`class ${className}(TypedDict, total=False):`);
469
- for (const h of primary.headers) {
470
- const pyName = toPythonFieldName(h.name);
471
- const tag = h.optional ? "optional" : "required";
472
- lines.push(` ${pyName}: str # ${h.name} (${tag})`);
516
+ const shape = opShapes.get(op);
517
+ const base = snakeToPascal(deriveMethodName(op, route));
518
+ const targets = shape.kind === "multiStatus" ? shape.responses.filter((r) => (r.headers?.length ?? 0) > 0).map((r) => ({
519
+ name: headersClassName(base, r.statusCode),
520
+ resp: r
521
+ })) : [
522
+ {
523
+ name: headersClassName(base),
524
+ resp: shape.resp
525
+ }
526
+ ];
527
+ for (const { name, resp } of targets) {
528
+ lines.push("");
529
+ lines.push("");
530
+ lines.push(`class ${name}(TypedDict, total=False):`);
531
+ for (const h of resp.headers) {
532
+ const pyName = toPythonFieldName(h.name);
533
+ const tag = h.optional ? "optional" : "required";
534
+ lines.push(` ${pyName}: str # ${h.name} (${tag})`);
535
+ }
536
+ }
537
+ }
538
+ for (const { route, op } of opsWithResponseDict) {
539
+ const shape = opShapes.get(op);
540
+ const base = snakeToPascal(deriveMethodName(op, route));
541
+ const targets = shape.kind === "multiStatus" ? shape.responses : shape.kind === "multiMime" ? [
542
+ shape.resp
543
+ ] : [];
544
+ for (const resp of targets) {
545
+ lines.push("");
546
+ lines.push("");
547
+ lines.push(`class ${responseClassName(base, shape.kind === "multiStatus" ? resp.statusCode : void 0)}(TypedDict):`);
548
+ if (shape.kind === "multiStatus") lines.push(` status: Literal[${resp.statusCode}]`);
549
+ const bodies = resp.bodies;
550
+ if (bodies.length > 0) {
551
+ lines.push(` content_type: ${pyContentTypeAnnotation(bodies)}`);
552
+ lines.push(` data: ${pyDataAnnotation(bodies, opts.modelsWithInput)}`);
553
+ }
554
+ if ((resp.headers?.length ?? 0) > 0) {
555
+ lines.push(` headers: ${headersClassName(base, shape.kind === "multiStatus" ? resp.statusCode : void 0)}`);
556
+ }
557
+ if (shape.kind !== "multiStatus" && bodies.length === 0 && (resp.headers?.length ?? 0) === 0) {
558
+ lines.push(" pass");
559
+ }
473
560
  }
474
561
  }
475
562
  lines.push("");
@@ -505,16 +592,19 @@ function generateMethod(route, op, opts) {
505
592
  return `${p.name}: ${p.type}`;
506
593
  });
507
594
  const paramStr = allParams.length > 0 ? `, ${allParams.join(", ")}` : "";
508
- const primaryResponse = op.responses.find((r) => r.bodyType) ?? op.responses[0];
509
- const isVoid = !primaryResponse?.bodyType;
510
- const respCategory = primaryResponse?.contentType ? classifyContentType(primaryResponse.contentType) : "json";
511
- const dataType = isVoid ? "None" : respCategory === "text" ? "str" : respCategory === "binary" ? "bytes" : renderPyType(primaryResponse.bodyType, modelsWithInput);
512
- const isModelReturn = !isVoid && respCategory === "json" && isModelRef(primaryResponse.bodyType, modelsWithInput);
513
- const isListModelReturn = !isVoid && respCategory === "json" && isListModelRef(primaryResponse.bodyType, modelsWithInput);
595
+ const shape = responseShape(op);
596
+ const methodBase = snakeToPascal(methodName);
597
+ const primaryResponse = shape.kind === "multiStatus" ? void 0 : shape.resp;
598
+ const primaryBodies = primaryResponse ? primaryResponse.bodies : [];
599
+ const isVoid = primaryBodies.length === 0;
600
+ const respCategory = primaryBodies[0] ? classifyContentType(primaryBodies[0].contentType) : "json";
601
+ const dataType = isVoid ? "None" : pyBodyType(primaryBodies[0], modelsWithInput);
602
+ const isModelReturn = !isVoid && respCategory === "json" && isModelRef(primaryBodies[0].bodyType, modelsWithInput);
603
+ const isListModelReturn = !isVoid && respCategory === "json" && isListModelRef(primaryBodies[0].bodyType, modelsWithInput);
514
604
  const respHeaders = primaryResponse?.headers ?? [];
515
605
  const hasRespHeaders = respHeaders.length > 0;
516
- const headersTypeName = hasRespHeaders ? `${snakeToPascal(methodName)}Headers` : "";
517
- const returnType = hasRespHeaders ? isVoid ? headersTypeName : `tuple[${dataType}, ${headersTypeName}]` : dataType;
606
+ const headersTypeName = hasRespHeaders ? headersClassName(methodBase) : "";
607
+ const returnType = shape.kind === "multiStatus" ? shape.responses.map((r) => responseClassName(methodBase, r.statusCode)).join(" | ") : shape.kind === "multiMime" ? responseClassName(methodBase) : hasRespHeaders ? isVoid ? headersTypeName : `tuple[${dataType}, ${headersTypeName}]` : dataType;
518
608
  const desc = op.description ?? route.description;
519
609
  if (op.name || desc) {
520
610
  lines.push(` async def ${methodName}(${selfParam}${paramStr}) -> ${returnType}:`);
@@ -552,9 +642,19 @@ function generateMethod(route, op, opts) {
552
642
  fetchKwargs.push(`body_kind="${reqCategory}"`);
553
643
  }
554
644
  }
555
- if (respCategory === "text" || respCategory === "binary") {
645
+ if (shape.kind !== "simple") {
646
+ fetchKwargs.push(`response_kind="auto"`);
647
+ } else if (respCategory === "text" || respCategory === "binary") {
556
648
  fetchKwargs.push(`response_kind="${respCategory}"`);
557
649
  }
650
+ const observable = shape.kind === "multiStatus" ? shape.responses : shape.resp ? [
651
+ shape.resp
652
+ ] : [];
653
+ const expectStatuses = observable.filter((r) => r.statusCode < 200 || r.statusCode >= 300).map((r) => r.statusCode);
654
+ if (expectStatuses.length > 0) {
655
+ const tuple = expectStatuses.length === 1 ? `(${expectStatuses[0]},)` : `(${expectStatuses.join(", ")})`;
656
+ fetchKwargs.push(`expect_statuses=${tuple}`);
657
+ }
558
658
  if (hasQuery) {
559
659
  fetchKwargs.push("params=query");
560
660
  }
@@ -562,6 +662,11 @@ function generateMethod(route, op, opts) {
562
662
  fetchKwargs.push("extra_headers=custom_headers");
563
663
  }
564
664
  const kwargsStr = fetchKwargs.length > 1 ? fetchKwargs.join(", ") : fetchKwargs[0] ?? "";
665
+ if (shape.kind !== "simple") {
666
+ lines.push(` _status, _content_type, result, _response_headers = await self._fetch_full(${urlExpr}, ${kwargsStr})`);
667
+ lines.push(...buildMultiReturnLines(shape, methodBase, modelsWithInput));
668
+ return lines;
669
+ }
565
670
  if (hasRespHeaders) {
566
671
  lines.push(` result, _response_headers = await self._fetch_with_headers(${urlExpr}, ${kwargsStr})`);
567
672
  lines.push(...buildHeadersDictLines(respHeaders, headersTypeName));
@@ -577,7 +682,7 @@ function generateMethod(route, op, opts) {
577
682
  } else {
578
683
  let dataExpr;
579
684
  if (isListModelReturn) {
580
- const innerType = getListItemType(primaryResponse.bodyType, modelsWithInput);
685
+ const innerType = getListItemType(primaryBodies[0].bodyType, modelsWithInput);
581
686
  dataExpr = `[${innerType}.model_validate(item) for item in result]`;
582
687
  } else if (isModelReturn) {
583
688
  dataExpr = `${dataType}.model_validate(result)`;
@@ -593,13 +698,71 @@ function generateMethod(route, op, opts) {
593
698
  return lines;
594
699
  }
595
700
  __name(generateMethod, "generateMethod");
596
- function buildHeadersDictLines(headers, typeName) {
701
+ function pyDataExpr(body, modelsWithInput) {
702
+ if (classifyContentType(body.contentType) !== "json") return "result";
703
+ if (isListModelRef(body.bodyType, modelsWithInput)) {
704
+ return `[${getListItemType(body.bodyType, modelsWithInput)}.model_validate(item) for item in result]`;
705
+ }
706
+ if (isModelRef(body.bodyType, modelsWithInput)) {
707
+ return `${renderPyType(body.bodyType, modelsWithInput)}.model_validate(result)`;
708
+ }
709
+ return "result";
710
+ }
711
+ __name(pyDataExpr, "pyDataExpr");
712
+ function buildMultiReturnLines(shape, methodBase, modelsWithInput) {
713
+ const lines = [];
714
+ const returnFor = /* @__PURE__ */ __name((resp, body, indent, includeStatus, headersVar) => {
715
+ const entries = [];
716
+ if (includeStatus) entries.push(`"status": ${resp.statusCode}`);
717
+ if (body) {
718
+ entries.push(`"content_type": ${JSON.stringify(body.contentType)}`);
719
+ entries.push(`"data": ${pyDataExpr(body, modelsWithInput)}`);
720
+ }
721
+ if (headersVar) entries.push(`"headers": ${headersVar}`);
722
+ return [
723
+ `${indent}return {${entries.length > 0 ? ` ${entries.join(", ")} ` : ""}}`
724
+ ];
725
+ }, "returnFor");
726
+ const emitStatus = /* @__PURE__ */ __name((resp, indent, includeStatus) => {
727
+ const out = [];
728
+ let headersVar;
729
+ if ((resp.headers?.length ?? 0) > 0) {
730
+ headersVar = includeStatus ? `headers_${resp.statusCode}` : "headers";
731
+ const typeName = headersClassName(methodBase, includeStatus ? resp.statusCode : void 0);
732
+ out.push(...buildHeadersDictLines(resp.headers, typeName, indent, headersVar));
733
+ }
734
+ const bodies = resp.bodies;
735
+ if (bodies.length <= 1) {
736
+ out.push(...returnFor(resp, bodies[0], indent, includeStatus, headersVar));
737
+ return out;
738
+ }
739
+ for (const body of bodies.slice(1)) {
740
+ out.push(`${indent}if _content_type == ${JSON.stringify(body.contentType)}:`);
741
+ out.push(...returnFor(resp, body, `${indent} `, includeStatus, headersVar));
742
+ }
743
+ out.push(...returnFor(resp, bodies[0], indent, includeStatus, headersVar));
744
+ return out;
745
+ }, "emitStatus");
746
+ if (shape.kind === "multiMime") {
747
+ lines.push(...emitStatus(shape.resp, " ", false));
748
+ return lines;
749
+ }
750
+ const [fallback, ...rest] = shape.responses;
751
+ for (const resp of rest) {
752
+ lines.push(` if _status == ${resp.statusCode}:`);
753
+ lines.push(...emitStatus(resp, " ", true));
754
+ }
755
+ lines.push(...emitStatus(fallback, " ", true));
756
+ return lines;
757
+ }
758
+ __name(buildMultiReturnLines, "buildMultiReturnLines");
759
+ function buildHeadersDictLines(headers, typeName, indent = " ", varName = "headers") {
597
760
  const lines = [];
598
- lines.push(` headers: ${typeName} = {}`);
761
+ lines.push(`${indent}${varName}: ${typeName} = {}`);
599
762
  for (const h of headers) {
600
763
  const pyName = toPythonFieldName(h.name);
601
- lines.push(` if ${JSON.stringify(h.name.toLowerCase())} in _response_headers:`);
602
- lines.push(` headers[${JSON.stringify(pyName)}] = _response_headers[${JSON.stringify(h.name.toLowerCase())}]`);
764
+ lines.push(`${indent}if ${JSON.stringify(h.name.toLowerCase())} in _response_headers:`);
765
+ lines.push(`${indent} ${varName}[${JSON.stringify(pyName)}] = _response_headers[${JSON.stringify(h.name.toLowerCase())}]`);
603
766
  }
604
767
  return lines;
605
768
  }
@@ -743,7 +906,7 @@ function collectReferencedModels(root, modelsWithInput, includeInternal = false)
743
906
  for (const body of op.request.bodies) collectTypeRefs(body.bodyType, refs, modelsWithInput, true);
744
907
  }
745
908
  for (const resp of op.responses) {
746
- if (resp.bodyType) collectTypeRefs(resp.bodyType, refs, modelsWithInput, false);
909
+ for (const body of resp.bodies) collectTypeRefs(body.bodyType, refs, modelsWithInput, false);
747
910
  }
748
911
  if (op.query) collectParamSourceRefs(op.query, refs, modelsWithInput);
749
912
  if (op.headers) collectParamSourceRefs(op.headers, refs, modelsWithInput);
@@ -893,6 +1056,8 @@ class BaseClient:
893
1056
  self._base_url = base_url.rstrip("/")
894
1057
  self._headers = headers or {}
895
1058
  self._http = httpx.AsyncClient()
1059
+ self._last_status = 0
1060
+ self._last_content_type = ""
896
1061
 
897
1062
  async def _fetch(
898
1063
  self,
@@ -905,6 +1070,7 @@ class BaseClient:
905
1070
  content_type: str | None = None,
906
1071
  body_kind: str = "json",
907
1072
  response_kind: str = "json",
1073
+ expect_statuses: tuple[int, ...] = (),
908
1074
  ) -> Any:
909
1075
  result, _ = await self._fetch_with_headers(
910
1076
  path,
@@ -915,9 +1081,41 @@ class BaseClient:
915
1081
  content_type=content_type,
916
1082
  body_kind=body_kind,
917
1083
  response_kind=response_kind,
1084
+ expect_statuses=expect_statuses,
918
1085
  )
919
1086
  return result
920
1087
 
1088
+ async def _fetch_full(
1089
+ self,
1090
+ path: str,
1091
+ *,
1092
+ method: str,
1093
+ body: Any = None,
1094
+ params: dict | None = None,
1095
+ extra_headers: dict | None = None,
1096
+ content_type: str | None = None,
1097
+ body_kind: str = "json",
1098
+ response_kind: str = "json",
1099
+ expect_statuses: tuple[int, ...] = (),
1100
+ ) -> tuple[int, str, Any, dict[str, str]]:
1101
+ """Like _fetch_with_headers, but also reports the status and content type.
1102
+
1103
+ Used by operations that declare more than one status, or more than one mime for a
1104
+ status, where the caller cannot know which it got without being told.
1105
+ """
1106
+ result, headers = await self._fetch_with_headers(
1107
+ path,
1108
+ method=method,
1109
+ body=body,
1110
+ params=params,
1111
+ extra_headers=extra_headers,
1112
+ content_type=content_type,
1113
+ body_kind=body_kind,
1114
+ response_kind=response_kind,
1115
+ expect_statuses=expect_statuses,
1116
+ )
1117
+ return self._last_status, self._last_content_type, result, headers
1118
+
921
1119
  async def _fetch_with_headers(
922
1120
  self,
923
1121
  path: str,
@@ -929,6 +1127,7 @@ class BaseClient:
929
1127
  content_type: str | None = None,
930
1128
  body_kind: str = "json",
931
1129
  response_kind: str = "json",
1130
+ expect_statuses: tuple[int, ...] = (),
932
1131
  ) -> tuple[Any, dict[str, str]]:
933
1132
  headers = {**self._headers, **(extra_headers or {})}
934
1133
  if body is not None:
@@ -943,7 +1142,10 @@ class BaseClient:
943
1142
  else:
944
1143
  request_kwargs["content"] = body
945
1144
  response = await self._http.request(**request_kwargs)
946
- if not response.is_success:
1145
+ # expect_statuses carries the codes this operation declares as values rather than
1146
+ # errors \u2014 a 304 from conditional-GET middleware, or an error status the service
1147
+ # returns deliberately. Anything else outside 2xx still raises.
1148
+ if not response.is_success and response.status_code not in expect_statuses:
947
1149
  try:
948
1150
  error_body = response.json()
949
1151
  except Exception:
@@ -951,11 +1153,24 @@ class BaseClient:
951
1153
  raise SdkError(response.status_code, response.reason_phrase, error_body)
952
1154
  # HTTP headers are case-insensitive \u2014 normalize to lowercase keys for stable lookup.
953
1155
  response_headers = {k.lower(): v for k, v in response.headers.items()}
1156
+ self._last_status = response.status_code
1157
+ self._last_content_type = response.headers.get("content-type", "").split(";")[0].strip()
954
1158
  if response.status_code == 204 or not response.content:
955
1159
  return None, response_headers
956
- if response_kind == "text":
1160
+ # "auto" is for a status declaring several mimes that do not read the same way: the
1161
+ # response itself is the only thing that knows which one came back.
1162
+ kind = response_kind
1163
+ if kind == "auto":
1164
+ ct = self._last_content_type
1165
+ if ct.startswith("text/"):
1166
+ kind = "text"
1167
+ elif ct == "application/json" or ct.endswith("+json"):
1168
+ kind = "json"
1169
+ else:
1170
+ kind = "binary"
1171
+ if kind == "text":
957
1172
  return response.text, response_headers
958
- if response_kind == "binary":
1173
+ if kind == "binary":
959
1174
  return response.content, response_headers
960
1175
  return response.json(), response_headers
961
1176
  `;
@@ -1176,7 +1391,7 @@ function collectOpRootModelRefs(root, modelMap) {
1176
1391
  for (const body of op.request.bodies) seeds.push(body.bodyType);
1177
1392
  }
1178
1393
  for (const resp of op.responses) {
1179
- if (resp.bodyType) seeds.push(resp.bodyType);
1394
+ for (const body of resp.bodies) seeds.push(body.bodyType);
1180
1395
  if (resp.headers) {
1181
1396
  for (const h of resp.headers) seeds.push(h.type);
1182
1397
  }