@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
|
@@ -5,9 +5,9 @@ $ tsup src/index.ts --format esm --sourcemap --dts && tsc --emitDeclarationOnly
|
|
|
5
5
|
[34mCLI[39m tsup v8.5.1
|
|
6
6
|
[34mCLI[39m Target: esnext
|
|
7
7
|
[34mESM[39m Build start
|
|
8
|
-
[32mESM[39m [1mdist/index.js [22m[
|
|
9
|
-
[32mESM[39m [1mdist/index.js.map [22m[
|
|
10
|
-
[32mESM[39m ⚡️ Build success in
|
|
8
|
+
[32mESM[39m [1mdist/index.js [22m[32m52.19 KB[39m
|
|
9
|
+
[32mESM[39m [1mdist/index.js.map [22m[32m110.13 KB[39m
|
|
10
|
+
[32mESM[39m ⚡️ Build success in 233ms
|
|
11
11
|
[34mDTS[39m Build start
|
|
12
|
-
[32mDTS[39m ⚡️ Build success in
|
|
12
|
+
[32mDTS[39m ⚡️ Build success in 5411ms
|
|
13
13
|
[32mDTS[39m [1mdist/index.d.ts [22m[32m1.10 KB[39m
|
|
@@ -3,22 +3,22 @@ $ vitest run --coverage
|
|
|
3
3
|
[1m[30m[46m RUN [49m[39m[22m [36mv4.1.5 [39m[90m/home/runner/work/ContractKit/ContractKit/packages/plugin-python[39m
|
|
4
4
|
[2mCoverage enabled with [22m[33mv8[39m
|
|
5
5
|
|
|
6
|
-
[32m✓[39m tests/codegen-models.test.ts [2m([22m[2m36 tests[22m[2m)[22m[32m
|
|
7
|
-
[32m✓[39m tests/codegen-client.test.ts [2m([22m[
|
|
6
|
+
[32m✓[39m tests/codegen-models.test.ts [2m([22m[2m36 tests[22m[2m)[22m[32m 50[2mms[22m[39m
|
|
7
|
+
[32m✓[39m tests/codegen-client.test.ts [2m([22m[2m35 tests[22m[2m)[22m[32m 115[2mms[22m[39m
|
|
8
8
|
|
|
9
9
|
[2m Test Files [22m [1m[32m2 passed[39m[22m[90m (2)[39m
|
|
10
|
-
[2m Tests [22m [1m[
|
|
11
|
-
[2m Start at [22m
|
|
12
|
-
[2m Duration [22m 5.
|
|
10
|
+
[2m Tests [22m [1m[32m71 passed[39m[22m[90m (71)[39m
|
|
11
|
+
[2m Start at [22m 16:09:45
|
|
12
|
+
[2m Duration [22m 5.54s[2m (transform 2.13s, setup 0ms, import 7.77s, tests 164ms, environment 0ms)[22m
|
|
13
13
|
|
|
14
14
|
[34m % [39m[2mCoverage report from [22m[33mv8[39m
|
|
15
15
|
-------------------|---------|----------|---------|---------|-------------------
|
|
16
16
|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
|
|
17
17
|
-------------------|---------|----------|---------|---------|-------------------
|
|
18
|
-
All files |
|
|
19
|
-
src |
|
|
20
|
-
...gen-client.ts |
|
|
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.
|
|
23
|
-
helpers.ts | 86.
|
|
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,27 @@
|
|
|
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
|
+
|
|
3
25
|
## 0.11.8
|
|
4
26
|
|
|
5
27
|
### 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
|
|
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
|
|
package/dist/codegen-client.d.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
433
|
-
return (
|
|
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
|
-
|
|
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 (
|
|
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
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
|
509
|
-
const
|
|
510
|
-
const
|
|
511
|
-
const
|
|
512
|
-
const
|
|
513
|
-
const
|
|
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 ?
|
|
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 (
|
|
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(
|
|
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
|
|
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(
|
|
761
|
+
lines.push(`${indent}${varName}: ${typeName} = {}`);
|
|
599
762
|
for (const h of headers) {
|
|
600
763
|
const pyName = toPythonFieldName(h.name);
|
|
601
|
-
lines.push(
|
|
602
|
-
lines.push(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|