From f62d0d92b77e063ef57e3f6234544a59b13ab7b8 Mon Sep 17 00:00:00 2001 From: Calvin Grunewald Date: Fri, 12 Jun 2026 07:21:19 -0700 Subject: [PATCH 1/2] feat: generate sync python platform client --- package-lock.json | 2 +- .../__tests__/backends/python.test.ts | 194 +++++++++- packages/sdk-generator/package.json | 2 +- .../backends/contract-tests/python-emitter.ts | 30 +- .../src/backends/python/auth-emitter.ts | 84 ++++- .../src/backends/python/client-emitter.ts | 330 +++++++++++++++--- .../src/backends/python/index.ts | 22 +- .../src/backends/python/namespace-emitter.ts | 29 +- .../src/backends/python/resource-emitter.ts | 99 +++++- 9 files changed, 713 insertions(+), 79 deletions(-) diff --git a/package-lock.json b/package-lock.json index 928ad9c..eab915a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2879,7 +2879,7 @@ }, "packages/sdk-generator": { "name": "@archastro/sdk-generator", - "version": "0.3.2", + "version": "0.4.0", "license": "MIT", "dependencies": { "ajv": "^8.12.0", diff --git a/packages/sdk-generator/__tests__/backends/python.test.ts b/packages/sdk-generator/__tests__/backends/python.test.ts index c5499cc..4c931f1 100644 --- a/packages/sdk-generator/__tests__/backends/python.test.ts +++ b/packages/sdk-generator/__tests__/backends/python.test.ts @@ -243,10 +243,20 @@ describe("Python resource emitter", () => { it("generates resource classes", () => { expect(output).toContain("class TeamResource:"); expect(output).toContain("class MemberResource:"); + expect(output).toContain("class AsyncTeamResource:"); + expect(output).toContain("class AsyncMemberResource:"); + }); + + it("generates typed sync resource classes without a Sync prefix", () => { + expect(output).toContain("def __init__(self, http: SyncHttpClient):"); + expect(output).toContain("self.members = MemberResource(http)"); + expect(output).not.toContain("class SyncTeamResource:"); + expect(output).not.toContain("class SyncMemberResource:"); }); it("nests child resources in __init__", () => { expect(output).toContain("self.members = MemberResource(http)"); + expect(output).toContain("self.members = AsyncMemberResource(http)"); }); it("generates async methods", () => { @@ -255,6 +265,13 @@ describe("Python resource emitter", () => { expect(output).toContain("async def create(self,"); }); + it("generates sync methods with the same public signatures", () => { + expect(output).toContain("def list(self,"); + expect(output).toContain("def get(self,"); + expect(output).toContain("def create(self,"); + expect(output).toContain("return self._http.request("); + }); + it("uses snake_case params", () => { expect(output).toContain("team_id: str"); }); @@ -306,11 +323,24 @@ describe("Python contract tests include raw response operations", () => { it("emits happy-path assertions for raw content and mime type", () => { expect(content).toContain( - 'result = await client.v1.configs.content("test-value")' + 'result = client.v1.configs.content("test-value")' ); expect(content).toContain('assert result["content"] is not None'); expect(content).toContain('assert result["mime_type"]'); }); + + it("uses the sync PlatformClient in generated Python REST contract tests", () => { + expect(content).toContain("from archastro.platform import PlatformClient"); + expect(content).toContain("def _client() -> PlatformClient:"); + expect(content).toContain("return PlatformClient("); + expect(content).toContain("def test_configs_content_success():"); + expect(content).toContain("result = client.v1.configs.content("); + expect(content).toContain("finally:"); + expect(content).toContain("client.close()"); + expect(content).not.toContain("from archastro.platform import AsyncPlatformClient"); + expect(content).not.toContain("async def test_configs_content_success"); + expect(content).not.toContain("await client.v1.configs.content"); + }); }); describe("Python resource emitter uses summary and description in method docstrings", () => { @@ -463,8 +493,13 @@ describe("Python auth emitter", () => { it("returns AuthTokens only for operations with token-role response fields", () => { expect(output).toContain("async def login(self, email: str) -> AuthTokens:"); + expect(output).toContain("class AsyncAuthClient:"); + expect(output).toContain("class AuthClient:"); + expect(output).toContain("def login(self, email: str) -> AuthTokens:"); + expect(output).toContain("data = self._http.request("); expect(output).toContain("access_token=data.get(\"token\")"); expect(output).toContain("async def allowed_auth_methods(self) -> dict:"); + expect(output).toContain("def allowed_auth_methods(self) -> dict:"); expect(output).toMatch( /async def allowed_auth_methods\(self\) -> dict:\s+data = await self\._http\.request\([\s\S]+?return data/ ); @@ -518,8 +553,19 @@ describe("Python auth emitter", () => { describe("Python client emitter", () => { const output = emitPythonClientFile(ast); - it("generates PlatformClient class", () => { + it("generates distinct sync and async client classes", () => { expect(output).toContain("class PlatformClient:"); + expect(output).toContain("class AsyncPlatformClient:"); + expect(output).toContain("self._http = SyncHttpClient("); + expect(output).toContain("self.v1 = V1(self._http)"); + expect(output).toContain("self.v1 = AsyncV1(self._http)"); + expect(output).not.toContain("PlatformClient = AsyncPlatformClient"); + }); + + it("generates a sync HTTP-backed PlatformClient", () => { + expect(output).toContain("from .runtime.http_client import HttpClient, SyncHttpClient"); + expect(output).not.toContain("class _SyncRunner:"); + expect(output).not.toContain("class _SyncResourceProxy:"); }); it("has __init__ with keyword-only params", () => { @@ -529,8 +575,8 @@ describe("Python client emitter", () => { }); it("has version namespace and resource aliases", () => { - // Version namespace expect(output).toContain("self.v1 = V1(self._http)"); + expect(output).toContain("self.v1 = AsyncV1(self._http)"); // Backward-compat aliases to default version expect(output).toContain("self.teams = self.v1.teams"); expect(output).toContain("self.agents = self.v1.agents"); @@ -541,6 +587,23 @@ describe("Python client emitter", () => { expect(output).toContain("self._http.set_access_token(token)"); }); + it("generates close methods for async and sync clients", () => { + expect(output).toContain("self._extra_http_clients: list[HttpClient] = []"); + expect(output).toContain("self._extra_http_clients: list[SyncHttpClient] = []"); + expect(output).toContain("async def close(self):"); + expect(output).toContain("await self._http.close()"); + expect(output).toContain("for http in self._extra_http_clients:"); + expect(output).toContain("def close(self):"); + expect(output).toContain("self._http.close()"); + }); + + it("generates context managers for async and sync clients", () => { + expect(output).toContain("async def __aenter__(self):"); + expect(output).toContain("async def __aexit__(self, exc_type, exc, tb):"); + expect(output).toContain("def __enter__(self):"); + expect(output).toContain("def __exit__(self, exc_type, exc, tb):"); + }); + it("uniquifies with_credentials params using the auth method mapping", () => { const out = emitPythonClientFile({ baseUrl: "https://api.example.test", @@ -597,10 +660,103 @@ describe("Python client emitter", () => { } as any); expect(out).toContain( - 'async def with_credentials(cls, api_key: str, async_: str, async_2: str, base_url: str | None = None) -> "PlatformClient":' + 'async def with_credentials(cls, api_key: str, async_: str, async_2: str, base_url: str | None = None) -> "AsyncPlatformClient":' + ); + expect(out).toContain( + 'def with_credentials(cls, api_key: str, async_: str, async_2: str, base_url: str | None = None) -> "PlatformClient":' ); expect(out).toContain("tokens = await client.auth.login(async_, async_2)"); expect(out).not.toContain("client.auth.login(async_, async_)"); + expect(out).not.toContain("tokens.refresh_token"); + expect(out).not.toContain("set_refresh_handler(_refresh)"); + }); + + it("keeps refresh handling when auth tokens are returned through a schema ref", () => { + const tokenSchema = { + name: "AuthTokens", + fields: [ + { + name: "token", + type: { kind: "primitive", type: "string" }, + required: true, + sdkRole: "access_token", + }, + { + name: "refresh_token", + type: { kind: "primitive", type: "string" }, + required: false, + sdkRole: "refresh_token", + }, + ], + }; + const out = emitPythonClientFile({ + baseUrl: "https://api.example.test", + versions: [], + defaultVersion: "v1", + schemas: [tokenSchema], + resources: [], + channels: [], + auth: { + schemes: { + publishable_key: { type: "apiKey", name: "x-api-key" }, + }, + tokenFlows: { + login: { operation_name: "login" }, + }, + }, + authOperations: [ + { + name: "login", + operationId: "post_auth_login", + method: "POST", + path: "/api/v1/auth/login", + deprecated: false, + pathParams: [], + queryParams: [], + body: { + fields: [ + { + name: "email", + type: { kind: "primitive", type: "string" }, + required: true, + }, + { + name: "password", + type: { kind: "primitive", type: "string" }, + required: true, + }, + ], + }, + returnType: { kind: "ref", schema: "AuthTokens" }, + errors: [], + }, + { + name: "refresh", + operationId: "post_auth_refresh", + method: "POST", + path: "/api/v1/auth/refresh", + deprecated: false, + pathParams: [], + queryParams: [], + body: { + fields: [ + { + name: "refresh_token", + type: { kind: "primitive", type: "string" }, + required: true, + }, + ], + }, + returnType: { kind: "ref", schema: "AuthTokens" }, + errors: [], + }, + ], + } as any); + + expect(out).toContain("if tokens.refresh_token:"); + expect(out).toContain("client.set_refresh_token(tokens.refresh_token)"); + expect(out).toContain("client._http.set_refresh_handler(_refresh)"); + expect(out).toContain("refresh_auth = AuthClient(refresh_http)"); }); }); @@ -2029,7 +2185,9 @@ describe("Full Python generation", () => { const client = files["/tmp/test-python-sdk/src/archastro/platform/client.py"]!; expect(client).toBeDefined(); - expect(client).toContain("PlatformClient"); + expect(client).toContain("class PlatformClient:"); + expect(client).toContain("AsyncPlatformClient"); + expect(client).not.toContain("PlatformClient = AsyncPlatformClient"); }); it("generates channel files", () => { @@ -2038,10 +2196,10 @@ describe("Full Python generation", () => { ).toBeDefined(); }); - it("generates package __init__.py with PlatformClient and version from metadata", () => { + it("generates package __init__.py with PlatformClient, AsyncPlatformClient, and version from metadata", () => { const init = files["/tmp/test-python-sdk/src/archastro/platform/__init__.py"]!; - expect(init).toContain("from .client import PlatformClient"); + expect(init).toContain("from .client import AsyncPlatformClient, PlatformClient"); expect(init).toContain("from .v1 import V1"); expect(init).toContain("_pkg_version"); // The distro-name passed to importlib.metadata must match the @@ -2086,9 +2244,11 @@ describe("Multi-version Python generation", () => { it("generates namespace files for both versions", () => { expect(files[`${pkg}/v1/__init__.py`]).toBeDefined(); expect(files[`${pkg}/v1/__init__.py`]).toContain("class V1:"); + expect(files[`${pkg}/v1/__init__.py`]).toContain("class AsyncV1:"); expect(files[`${pkg}/v2/__init__.py`]).toBeDefined(); expect(files[`${pkg}/v2/__init__.py`]).toContain("class V2:"); + expect(files[`${pkg}/v2/__init__.py`]).toContain("class AsyncV2:"); }); it("v2 namespace has workflows but v1 does not", () => { @@ -2096,12 +2256,30 @@ describe("Multi-version Python generation", () => { const v2Ns = files[`${pkg}/v2/__init__.py`]!; expect(v1Ns).not.toContain("workflows"); expect(v2Ns).toContain("self.workflows = WorkflowResource(http)"); + expect(v2Ns).toContain("self.workflows = AsyncWorkflowResource(http)"); + }); + + it("version namespaces include sync and async resource namespaces", () => { + const v1Ns = files[`${pkg}/v1/__init__.py`]!; + expect(v1Ns).toContain("class V1:"); + expect(v1Ns).toContain("class AsyncV1:"); + expect(v1Ns).toContain("from .resources.teams import AsyncTeamResource, TeamResource"); + expect(v1Ns).toContain("def __init__(self, http: SyncHttpClient):"); + expect(v1Ns).toContain("def __init__(self, http: HttpClient):"); + expect(v1Ns).toContain("self.teams = TeamResource(http)"); + expect(v1Ns).toContain("self.teams = AsyncTeamResource(http)"); + expect(v1Ns).not.toContain("SyncTeamResource"); + expect(v1Ns).not.toContain("class SyncV1:"); }); it("client has both v1 and v2 namespaces", () => { const client = files[`${pkg}/client.py`]!; + expect(client).toContain("self.v1 = AsyncV1(self._http)"); expect(client).toContain("self.v1 = V1(self._http)"); + expect(client).toContain("self.v2 = AsyncV2(self._http)"); expect(client).toContain("self.v2 = V2(self._http)"); + expect(client).not.toContain("SyncV1"); + expect(client).not.toContain("SyncV2"); }); it("client has backward-compat aliases to default version (v1)", () => { @@ -2125,7 +2303,9 @@ describe("Multi-version Python generation", () => { it("package __init__.py exports both version namespaces", () => { const init = files[`${pkg}/__init__.py`]!; expect(init).toContain("from .v1 import V1"); + expect(init).toContain("from .v1 import AsyncV1"); expect(init).toContain("from .v2 import V2"); + expect(init).toContain("from .v2 import AsyncV2"); }); it("types are shared, not duplicated per version", () => { diff --git a/packages/sdk-generator/package.json b/packages/sdk-generator/package.json index cf8af57..d4b0d28 100644 --- a/packages/sdk-generator/package.json +++ b/packages/sdk-generator/package.json @@ -1,6 +1,6 @@ { "name": "@archastro/sdk-generator", - "version": "0.3.2", + "version": "0.4.0", "description": "Generate typed TypeScript and Python SDKs (plus contract tests) from an OpenAPI spec.", "keywords": [ "openapi", diff --git a/packages/sdk-generator/src/backends/contract-tests/python-emitter.ts b/packages/sdk-generator/src/backends/contract-tests/python-emitter.ts index 6f7a793..50e2441 100644 --- a/packages/sdk-generator/src/backends/contract-tests/python-emitter.ts +++ b/packages/sdk-generator/src/backends/contract-tests/python-emitter.ts @@ -170,24 +170,29 @@ function emitHappyPathTest(cb: CodeBuilder, call: MethodCallInfo): void { const returnsNoContent = call.operation.returnType.kind === "void"; cb.line(); - cb.line(`async def ${testName}():`); + cb.line(`def ${testName}():`); cb.indent(); cb.line("client = _client()"); + cb.pyBlock("try", () => { if (returnsNoContent) { - cb.line(`result = await ${methodCall}`); + cb.line(`result = ${methodCall}`); cb.line("assert result is None"); } else if (call.operation.rawResponse) { - cb.line(`result = await ${methodCall}`); + cb.line(`result = ${methodCall}`); cb.line('assert result["content"] is not None'); cb.line('assert result["mime_type"]') } else { - cb.line(`result = await ${methodCall}`); + cb.line(`result = ${methodCall}`); cb.line("assert result is not None"); if (hasDataArray) { cb.line('assert "data" in result'); cb.line('assert isinstance(result["data"], list)'); } } + }); + cb.pyBlock("finally", () => { + cb.line("client.close()"); + }); cb.dedent(); } @@ -209,14 +214,19 @@ function emitErrorTests(cb: CodeBuilder, call: MethodCallInfo): void { const methodCall = `ec.${chainPy}.${pythonParameterName(call.methodName)}(${argStr})`; cb.line(); - cb.line(`async def ${testName}():`); + cb.line(`def ${testName}():`); cb.indent(); cb.line(`ec = _error_client(${code})`); - cb.line("with pytest.raises(ApiError) as exc_info:"); - cb.indent(); - cb.line(`await ${methodCall}`); - cb.dedent(); - cb.line(`assert exc_info.value.status == ${code}`); + cb.pyBlock("try", () => { + cb.line("with pytest.raises(ApiError) as exc_info:"); + cb.indent(); + cb.line(methodCall); + cb.dedent(); + cb.line(`assert exc_info.value.status == ${code}`); + }); + cb.pyBlock("finally", () => { + cb.line("ec.close()"); + }); cb.dedent(); } } diff --git a/packages/sdk-generator/src/backends/python/auth-emitter.ts b/packages/sdk-generator/src/backends/python/auth-emitter.ts index 53a6042..9e67691 100644 --- a/packages/sdk-generator/src/backends/python/auth-emitter.ts +++ b/packages/sdk-generator/src/backends/python/auth-emitter.ts @@ -25,7 +25,7 @@ export function emitPythonAuthFile(spec: SdkSpec): string { cb.line(); cb.line("from dataclasses import dataclass"); cb.line(); - cb.line("from .runtime.http_client import HttpClient"); + cb.line("from .runtime.http_client import HttpClient, SyncHttpClient"); cb.line(); cb.line(); @@ -43,8 +43,7 @@ export function emitPythonAuthFile(spec: SdkSpec): string { cb.line(); } - // AuthClient - cb.pyBlock("class AuthClient", () => { + cb.pyBlock("class AsyncAuthClient", () => { cb.pyBlock("def __init__(self, http: HttpClient)", () => { cb.line("self._http = http"); }); @@ -54,6 +53,19 @@ export function emitPythonAuthFile(spec: SdkSpec): string { emitAuthMethod(cb, op, tokenFields, spec.schemas); } }); + cb.line(); + cb.line(); + + cb.pyBlock("class AuthClient", () => { + cb.pyBlock("def __init__(self, http: SyncHttpClient)", () => { + cb.line("self._http = http"); + }); + + for (const op of authOps) { + cb.line(); + emitSyncAuthMethod(cb, op, tokenFields, spec.schemas); + } + }); return cb.toString(); } @@ -193,6 +205,72 @@ function emitAuthMethod( }); } +function emitSyncAuthMethod( + cb: CodeBuilder, + op: OperationDef, + tokenFields: TokenFieldInfo[], + schemas: SchemaDef[] +): void { + const methodName = pyAuthMethodName(op); + const params = extractPythonAuthInputParams(op); + const responseFields = getResponseFields(op.returnType, schemas); + const hasTokenReturn = responseFields.some((field) => Boolean(field.sdkRole)); + const returnType = hasTokenReturn ? "AuthTokens" : "dict"; + const requiredParams = params.filter((p) => p.required); + const optionalParams = params.filter((p) => !p.required); + const sortedParams = [...requiredParams, ...optionalParams]; + const sig = sortedParams + .map((p) => { + if (p.required) return `${p.name}: str`; + return `${p.name}: str | None = None`; + }) + .join(", "); + const methodParams = sig ? `self, ${sig}` : "self"; + + cb.pyBlock(`def ${methodName}(${methodParams}) -> ${returnType}`, () => { + if (sortedParams.length > 0) { + cb.line("body: dict[str, object] = {}"); + for (const p of sortedParams) { + if (p.required) { + cb.line(`body["${p.originalName}"] = ${p.name}`); + } else { + cb.pyBlock(`if ${p.name} is not None`, () => { + cb.line(`body["${p.originalName}"] = ${p.name}`); + }); + } + } + cb.line(); + } + + cb.line("data = self._http.request("); + cb.indent(); + cb.line(`"${op.path}",`); + cb.line(`method="${op.method}",`); + if (sortedParams.length > 0) { + cb.line("body=body,"); + } + cb.dedent(); + cb.line(")"); + + if (hasTokenReturn) { + cb.line("return AuthTokens("); + cb.indent(); + for (const tf of tokenFields) { + const field = responseFields.find((f) => f.sdkRole === tf.role); + if (field) { + cb.line(`${pythonParameterName(tf.role)}=data.get("${field.name}"),`); + } else { + cb.line(`${pythonParameterName(tf.role)}=None,`); + } + } + cb.dedent(); + cb.line(")"); + } else { + cb.line("return data"); + } + }); +} + export function extractPythonAuthInputParams(op: OperationDef): AuthParam[] { const entries: Array> = []; diff --git a/packages/sdk-generator/src/backends/python/client-emitter.ts b/packages/sdk-generator/src/backends/python/client-emitter.ts index 8d6c51e..d220221 100644 --- a/packages/sdk-generator/src/backends/python/client-emitter.ts +++ b/packages/sdk-generator/src/backends/python/client-emitter.ts @@ -1,8 +1,8 @@ -import type { SdkSpec, OperationDef } from "../../ast/types.js"; +import type { FieldDef, OperationDef, SchemaDef, SdkSpec, TypeRef } from "../../ast/types.js"; import { CodeBuilder, generatedHeaderPython } from "../../utils/codegen.js"; import { pythonParameterName } from "./identifiers.js"; import { extractPythonAuthInputParams, pyAuthMethodName } from "./auth-emitter.js"; -import { pyVersionClassName } from "./namespace-emitter.js"; +import { pyAsyncVersionClassName, pyVersionClassName } from "./namespace-emitter.js"; export function emitPythonClientFile(spec: SdkSpec): string { const cb = new CodeBuilder(" "); @@ -14,14 +14,15 @@ export function emitPythonClientFile(spec: SdkSpec): string { cb.line(); if (hasAuth) { - cb.line("from .auth import AuthClient"); + cb.line("from .auth import AsyncAuthClient, AuthClient"); } - cb.line("from .runtime.http_client import HttpClient"); + cb.line("from .runtime.http_client import HttpClient, SyncHttpClient"); // Import version namespace classes for (const versionSet of spec.versions) { const cls = pyVersionClassName(versionSet.version); - cb.line(`from .${versionSet.version} import ${cls}`); + const asyncCls = pyAsyncVersionClassName(versionSet.version); + cb.line(`from .${versionSet.version} import ${asyncCls}, ${cls}`); } cb.line(); @@ -36,7 +37,7 @@ export function emitPythonClientFile(spec: SdkSpec): string { return true; }); - cb.pyBlock("class PlatformClient", () => { + cb.pyBlock("class AsyncPlatformClient", () => { const initParams = [ "self", "*", `base_url: str = "${spec.baseUrl}"`, @@ -59,12 +60,12 @@ export function emitPythonClientFile(spec: SdkSpec): string { cb.dedent(); cb.line(")"); if (hasAuth) { - cb.line("self.auth = AuthClient(self._http)"); + cb.line("self.auth = AsyncAuthClient(self._http)"); } // Instantiate version namespaces for (const versionSet of spec.versions) { - const cls = pyVersionClassName(versionSet.version); + const cls = pyAsyncVersionClassName(versionSet.version); cb.line(`self.${versionSet.version} = ${cls}(self._http)`); } @@ -78,6 +79,7 @@ export function emitPythonClientFile(spec: SdkSpec): string { } cb.line("self._refresh_token: str | None = None"); + cb.line("self._extra_http_clients: list[HttpClient] = []"); }); cb.line(); @@ -99,6 +101,29 @@ export function emitPythonClientFile(spec: SdkSpec): string { cb.line("self._refresh_token = token"); }); + cb.line(); + cb.pyBlock("async def __aenter__(self)", () => { + cb.line("return self"); + }); + + cb.line(); + cb.pyBlock("async def __aexit__(self, exc_type, exc, tb)", () => { + cb.line("await self.close()"); + }); + + cb.line(); + cb.pyBlock("async def close(self)", () => { + cb.pyBlock("try", () => { + cb.line("await self._http.close()"); + }); + cb.pyBlock("finally", () => { + cb.pyBlock("for http in self._extra_http_clients", () => { + cb.line("await http.close()"); + }); + cb.line("self._extra_http_clients.clear()"); + }); + }); + const schemes = spec.auth?.schemes ?? {}; const flows = spec.auth?.tokenFlows ?? {}; @@ -111,7 +136,7 @@ export function emitPythonClientFile(spec: SdkSpec): string { cb.line(); cb.line("@classmethod"); cb.pyBlock( - `def with_secret_key(cls, key: str, base_url: str | None = None) -> "PlatformClient"`, + `def with_secret_key(cls, key: str, base_url: str | None = None) -> "AsyncPlatformClient"`, () => { cb.line(`"""${schemes.secret_key.description ?? "Create a client with a secret API key"}"""`); cb.line("kwargs = {}"); @@ -126,7 +151,7 @@ export function emitPythonClientFile(spec: SdkSpec): string { cb.line(); cb.line("@classmethod"); cb.pyBlock( - `def with_token(cls, api_key: str, access_token: str, base_url: str | None = None) -> "PlatformClient"`, + `def with_token(cls, api_key: str, access_token: str, base_url: str | None = None) -> "AsyncPlatformClient"`, () => { cb.line('"""Create a client with a publishable key and pre-existing access token."""'); cb.line("kwargs = {}"); @@ -145,8 +170,8 @@ export function emitPythonClientFile(spec: SdkSpec): string { const sig = requiredParams.map((p) => `${p.name}: str`).join(", "); // Discover token field accessors from the response schema - const accessTokenField = findSdkField(loginOp, "access_token"); - const refreshTokenField = findSdkField(loginOp, "refresh_token"); + const accessTokenField = findSdkField(loginOp, "access_token", spec.schemas); + const refreshTokenField = findSdkField(loginOp, "refresh_token", spec.schemas); const tokenAccessor = accessTokenField ? pythonParameterName(accessTokenField.sdkRole!) : "access_token"; @@ -161,7 +186,7 @@ export function emitPythonClientFile(spec: SdkSpec): string { cb.line(); cb.line("@classmethod"); cb.pyBlock( - `async def with_credentials(cls, api_key: str, ${sig}, base_url: str | None = None) -> "PlatformClient"`, + `async def with_credentials(cls, api_key: str, ${sig}, base_url: str | None = None) -> "AsyncPlatformClient"`, () => { cb.line(`"""${desc.replace(/[^\x20-\x7E]/g, " ")}"""`); cb.line("kwargs = {}"); @@ -174,37 +199,232 @@ export function emitPythonClientFile(spec: SdkSpec): string { cb.line(`raise ValueError("Login did not return an access token")`); }); cb.line(`client.set_access_token(tokens.${tokenAccessor})`); - cb.pyBlock(`if tokens.${refreshAccessor}`, () => { - cb.line(`client.set_refresh_token(tokens.${refreshAccessor})`); - }); - // Separate refresh-only HttpClient: cannot re-enter the main - // client's 401 retry, so concurrent dedup is deadlock-free. - cb.line("refresh_http = HttpClient("); - cb.indent(); - cb.line(`base_url=base_url or "${spec.baseUrl}",`); - cb.line(`default_headers={"${header}": api_key},`); - cb.line("refresh_only=True,"); - cb.dedent(); - cb.line(")"); - cb.line("refresh_auth = AuthClient(refresh_http)"); - cb.line(); - cb.pyBlock("async def _refresh() -> str", () => { - cb.line("rt = client.refresh_token"); - cb.pyBlock("if not rt", () => { - cb.line('raise ValueError("No refresh token available")'); + if (refreshTokenField) { + cb.pyBlock(`if tokens.${refreshAccessor}`, () => { + cb.line(`client.set_refresh_token(tokens.${refreshAccessor})`); }); - cb.line("refreshed = await refresh_auth.refresh(rt)"); - cb.pyBlock(`if not refreshed.${tokenAccessor}`, () => { - cb.line('raise ValueError("Refresh did not return an access token")'); + // Separate refresh-only HttpClient: cannot re-enter the main + // client's 401 retry, so concurrent dedup is deadlock-free. + cb.line("refresh_http = HttpClient("); + cb.indent(); + cb.line(`base_url=base_url or "${spec.baseUrl}",`); + cb.line(`default_headers={"${header}": api_key},`); + cb.line("refresh_only=True,"); + cb.dedent(); + cb.line(")"); + cb.line("client._extra_http_clients.append(refresh_http)"); + cb.line("refresh_auth = AsyncAuthClient(refresh_http)"); + cb.line(); + cb.pyBlock("async def _refresh() -> str", () => { + cb.line("rt = client.refresh_token"); + cb.pyBlock("if not rt", () => { + cb.line('raise ValueError("No refresh token available")'); + }); + cb.line("refreshed = await refresh_auth.refresh(rt)"); + cb.pyBlock(`if not refreshed.${tokenAccessor}`, () => { + cb.line('raise ValueError("Refresh did not return an access token")'); + }); + cb.line(`client.set_access_token(refreshed.${tokenAccessor})`); + cb.pyBlock(`if refreshed.${refreshAccessor}`, () => { + cb.line(`client.set_refresh_token(refreshed.${refreshAccessor})`); + }); + cb.line(`return refreshed.${tokenAccessor}`); }); - cb.line(`client.set_access_token(refreshed.${tokenAccessor})`); - cb.pyBlock(`if refreshed.${refreshAccessor}`, () => { - cb.line(`client.set_refresh_token(refreshed.${refreshAccessor})`); - }); - cb.line(`return refreshed.${tokenAccessor}`); + cb.line(); + cb.line("client._http.set_refresh_handler(_refresh)"); + } + cb.line("return client"); + } + ); + } + } + } + }); + cb.line(); + cb.pyBlock("class PlatformClient", () => { + const initParams = [ + "self", "*", + `base_url: str = "${spec.baseUrl}"`, + "access_token: str | None = None", + "get_access_token=None", + "on_refresh_token=None", + "path_prefix: str | None = None", + "default_headers: dict[str, str] | None = None", + ]; + + cb.pyBlock(`def __init__(${initParams.join(", ")})`, () => { + cb.line("self._closed = False"); + cb.line("self._http = SyncHttpClient("); + cb.indent(); + cb.line("base_url=base_url,"); + cb.line("access_token=access_token,"); + cb.line("get_access_token=get_access_token,"); + cb.line("on_refresh_token=on_refresh_token,"); + cb.line("path_prefix=path_prefix,"); + cb.line("default_headers=default_headers,"); + cb.dedent(); + cb.line(")"); + if (hasAuth) { + cb.line("self.auth = AuthClient(self._http)"); + } + for (const versionSet of spec.versions) { + const cls = pyVersionClassName(versionSet.version); + cb.line(`self.${versionSet.version} = ${cls}(self._http)`); + } + if (defaultVersionSet) { + for (const resource of aliasResources) { + cb.line( + `self.${resource.name} = self.${spec.defaultVersion}.${resource.name}` + ); + } + } + cb.line("self._refresh_token: str | None = None"); + cb.line("self._extra_http_clients: list[SyncHttpClient] = []"); + }); + + cb.line(); + cb.line("@property"); + cb.pyBlock("def refresh_token(self) -> str | None", () => { + cb.line("return self._refresh_token"); + }); + + cb.line(); + cb.pyBlock("def set_access_token(self, token: str)", () => { + cb.line("self._http.set_access_token(token)"); + }); + + cb.line(); + cb.pyBlock("def set_refresh_token(self, token: str)", () => { + cb.line("self._refresh_token = token"); + }); + + cb.line(); + cb.pyBlock("def __enter__(self)", () => { + cb.line("return self"); + }); + + cb.line(); + cb.pyBlock("def __exit__(self, exc_type, exc, tb)", () => { + cb.line("self.close()"); + }); + + cb.line(); + cb.pyBlock("def close(self)", () => { + cb.pyBlock("if self._closed", () => { + cb.line("return"); + }); + cb.line("self._closed = True"); + cb.pyBlock("try", () => { + cb.line("self._http.close()"); + }); + cb.pyBlock("finally", () => { + cb.pyBlock("for http in self._extra_http_clients", () => { + cb.line("http.close()"); + }); + cb.line("self._extra_http_clients.clear()"); + }); + }); + + const schemes = spec.auth?.schemes ?? {}; + const flows = spec.auth?.tokenFlows ?? {}; + + if (Object.keys(schemes).length > 0) { + cb.line(); + cb.line("# ─── Factory constructors (generated from auth schemes) ───"); + + if (schemes.secret_key) { + const header = schemes.secret_key.name ?? "x-archastro-api-key"; + cb.line(); + cb.line("@classmethod"); + cb.pyBlock( + `def with_secret_key(cls, key: str, base_url: str | None = None) -> "PlatformClient"`, + () => { + cb.line(`"""${schemes.secret_key.description ?? "Create a client with a secret API key"}"""`); + cb.line("kwargs = {}"); + cb.pyBlock("if base_url", () => { cb.line('kwargs["base_url"] = base_url'); }); + cb.line(`return cls(default_headers={"${header}": key}, **kwargs)`); + } + ); + } + + if (schemes.publishable_key) { + const header = schemes.publishable_key.name ?? "x-archastro-api-key"; + cb.line(); + cb.line("@classmethod"); + cb.pyBlock( + `def with_token(cls, api_key: str, access_token: str, base_url: str | None = None) -> "PlatformClient"`, + () => { + cb.line('"""Create a client with a publishable key and pre-existing access token."""'); + cb.line("kwargs = {}"); + cb.pyBlock("if base_url", () => { cb.line('kwargs["base_url"] = base_url'); }); + cb.line(`return cls(access_token=access_token, default_headers={"${header}": api_key}, **kwargs)`); + } + ); + } + + if (hasAuth && schemes.publishable_key) { + const loginOp = findLoginOperation(authOps, flows); + if (loginOp) { + const header = schemes.publishable_key.name ?? "x-archastro-api-key"; + const requiredParams = getOperationRequiredInputParams(loginOp); + const sig = requiredParams.map((p) => `${p.name}: str`).join(", "); + const accessTokenField = findSdkField(loginOp, "access_token", spec.schemas); + const refreshTokenField = findSdkField(loginOp, "refresh_token", spec.schemas); + const tokenAccessor = accessTokenField + ? pythonParameterName(accessTokenField.sdkRole!) + : "access_token"; + const refreshAccessor = refreshTokenField + ? pythonParameterName(refreshTokenField.sdkRole!) + : "refresh_token"; + const authMethod = pyAuthMethodName(loginOp); + + cb.line(); + cb.line("@classmethod"); + cb.pyBlock( + `def with_credentials(cls, api_key: str, ${sig}, base_url: str | None = None) -> "PlatformClient"`, + () => { + cb.line("kwargs = {}"); + cb.pyBlock("if base_url", () => { cb.line('kwargs["base_url"] = base_url'); }); + cb.line(`client = cls(default_headers={"${header}": api_key}, **kwargs)`); + cb.line( + `tokens = client.auth.${authMethod}(${requiredParams.map((p) => p.name).join(", ")})` + ); + cb.pyBlock(`if not tokens.${tokenAccessor}`, () => { + cb.line(`raise ValueError("Login did not return an access token")`); }); - cb.line(); - cb.line("client._http.set_refresh_handler(_refresh)"); + cb.line(`client.set_access_token(tokens.${tokenAccessor})`); + if (refreshTokenField) { + cb.pyBlock(`if tokens.${refreshAccessor}`, () => { + cb.line(`client.set_refresh_token(tokens.${refreshAccessor})`); + }); + cb.line("refresh_http = SyncHttpClient("); + cb.indent(); + cb.line(`base_url=base_url or "${spec.baseUrl}",`); + cb.line(`default_headers={"${header}": api_key},`); + cb.line("refresh_only=True,"); + cb.dedent(); + cb.line(")"); + cb.line("client._extra_http_clients.append(refresh_http)"); + cb.line("refresh_auth = AuthClient(refresh_http)"); + cb.line(); + cb.pyBlock("def _refresh() -> str", () => { + cb.line("rt = client.refresh_token"); + cb.pyBlock("if not rt", () => { + cb.line('raise ValueError("No refresh token available")'); + }); + cb.line("refreshed = refresh_auth.refresh(rt)"); + cb.pyBlock(`if not refreshed.${tokenAccessor}`, () => { + cb.line('raise ValueError("Refresh did not return an access token")'); + }); + cb.line(`client.set_access_token(refreshed.${tokenAccessor})`); + cb.pyBlock(`if refreshed.${refreshAccessor}`, () => { + cb.line(`client.set_refresh_token(refreshed.${refreshAccessor})`); + }); + cb.line(`return refreshed.${tokenAccessor}`); + }); + cb.line(); + cb.line("client._http.set_refresh_handler(_refresh)"); + } cb.line("return client"); } ); @@ -238,10 +458,30 @@ function getOperationRequiredInputParams( function findSdkField( op: OperationDef, - role: string -): import("../../ast/types.js").FieldDef | undefined { - if (op.returnType.kind === "object") { - return op.returnType.fields.find((f) => f.sdkRole === role); + role: string, + schemas: SchemaDef[] +): FieldDef | undefined { + return findSdkFieldInType(op.returnType, role, schemas, new Set()); +} + +function findSdkFieldInType( + typeRef: TypeRef, + role: string, + schemas: SchemaDef[], + seenSchemas: Set +): FieldDef | undefined { + if (typeRef.kind === "object") { + return typeRef.fields.find((f) => f.sdkRole === role); + } + if (typeRef.kind === "ref") { + if (seenSchemas.has(typeRef.schema)) return undefined; + seenSchemas.add(typeRef.schema); + const schema = schemas.find((candidate) => candidate.name === typeRef.schema); + if (!schema) return undefined; + return schema.fields.find((f) => f.sdkRole === role); + } + if (typeRef.kind === "optional") { + return findSdkFieldInType(typeRef.inner, role, schemas, seenSchemas); } return undefined; } diff --git a/packages/sdk-generator/src/backends/python/index.ts b/packages/sdk-generator/src/backends/python/index.ts index 5db759a..7044c62 100644 --- a/packages/sdk-generator/src/backends/python/index.ts +++ b/packages/sdk-generator/src/backends/python/index.ts @@ -6,7 +6,12 @@ import { emitPythonResourceFile } from "./resource-emitter.js"; import { emitPythonClientFile } from "./client-emitter.js"; import { emitPythonChannelFile } from "./channel-emitter.js"; import { emitPythonAuthFile } from "./auth-emitter.js"; -import { emitPythonNamespaceFile, pyVersionClassName } from "./namespace-emitter.js"; +import { + emitPythonNamespaceFile, + pyAsyncVersionClassName, + pyVersionClassName, +} from "./namespace-emitter.js"; +import { pyAsyncResourceClassName } from "./resource-emitter.js"; import { generatedHeaderPython, addContentHash, cleanStaleFiles } from "../../utils/codegen.js"; import { snakeCase } from "../../utils/naming.js"; @@ -15,14 +20,14 @@ export interface PythonBackendOptions { } /** - * Generate a complete async Python SDK from the SDK AST. + * Generate a complete Python SDK from the SDK AST. * * Creates: * - archastro_platform/types/*.py — Pydantic models (shared) * - archastro_platform/{version}/resources/*.py — Async resource classes per version * - archastro_platform/{version}.py — Version namespace class * - archastro_platform/channels/*.py — Async channel classes (shared) - * - archastro_platform/client.py — PlatformClient class + * - archastro_platform/client.py — PlatformClient + AsyncPlatformClient classes * - archastro_platform/__init__.py — Package exports */ export function generatePython( @@ -153,6 +158,9 @@ function generateVersionedResourcesInit( lines.push( `from .${resource.name} import ${resource.className} # noqa: F401` ); + lines.push( + `from .${resource.name} import ${pyAsyncResourceClassName(resource.className)} # noqa: F401` + ); } return lines.join("\n") + "\n"; } @@ -172,15 +180,19 @@ function generatePackageInit(spec: SdkSpec): string { const lines = [generatedHeaderPython().trim(), ""]; lines.push("from importlib.metadata import version as _pkg_version"); lines.push(""); - lines.push("from .client import PlatformClient # noqa: F401"); + lines.push("from .client import AsyncPlatformClient, PlatformClient # noqa: F401"); for (const versionSet of spec.versions) { const cls = pyVersionClassName(versionSet.version); lines.push( `from .${versionSet.version} import ${cls} # noqa: F401` ); + const asyncCls = pyAsyncVersionClassName(versionSet.version); + lines.push( + `from .${versionSet.version} import ${asyncCls} # noqa: F401` + ); } if ((spec.authOperations ?? []).length > 0) { - lines.push("from .auth import AuthClient, AuthTokens # noqa: F401"); + lines.push("from .auth import AsyncAuthClient, AuthClient, AuthTokens # noqa: F401"); } lines.push(""); // Use the configured package name so the importlib.metadata lookup diff --git a/packages/sdk-generator/src/backends/python/namespace-emitter.ts b/packages/sdk-generator/src/backends/python/namespace-emitter.ts index 160af22..3597bd7 100644 --- a/packages/sdk-generator/src/backends/python/namespace-emitter.ts +++ b/packages/sdk-generator/src/backends/python/namespace-emitter.ts @@ -1,5 +1,6 @@ import type { VersionedResourceSet } from "../../ast/types.js"; import { CodeBuilder, generatedHeaderPython } from "../../utils/codegen.js"; +import { pyAsyncResourceClassName } from "./resource-emitter.js"; /** * Generate a Python version namespace file (e.g., V1, V2). @@ -24,7 +25,7 @@ export function emitPythonNamespaceFile( } cb.line(); - cb.line(`from ${runtimeImport} import HttpClient`); + cb.line(`from ${runtimeImport} import HttpClient, SyncHttpClient`); const seen = new Set(); const uniqueResources = versionSet.resources.filter((r) => { @@ -35,7 +36,7 @@ export function emitPythonNamespaceFile( for (const resource of uniqueResources) { cb.line( - `from ${resPrefix}.${resource.name} import ${resource.className}` + `from ${resPrefix}.${resource.name} import ${pyAsyncResourceClassName(resource.className)}, ${resource.className}` ); } cb.line(); @@ -43,7 +44,7 @@ export function emitPythonNamespaceFile( const className = pyVersionClassName(versionSet.version); cb.pyBlock(`class ${className}`, () => { - cb.pyBlock("def __init__(self, http: HttpClient)", () => { + cb.pyBlock("def __init__(self, http: SyncHttpClient)", () => { if (uniqueResources.length === 0) { // Empty namespace (e.g. a channels-only spec). Python rejects a // function body of zero statements, so emit `pass` to keep the @@ -56,6 +57,23 @@ export function emitPythonNamespaceFile( } }); }); + cb.line(); + cb.line(); + + const asyncClassName = pyAsyncVersionClassName(versionSet.version); + cb.pyBlock(`class ${asyncClassName}`, () => { + cb.pyBlock("def __init__(self, http: HttpClient)", () => { + if (uniqueResources.length === 0) { + cb.line("pass"); + } else { + for (const resource of uniqueResources) { + cb.line( + `self.${resource.name} = ${pyAsyncResourceClassName(resource.className)}(http)` + ); + } + } + }); + }); return cb.toString(); } @@ -64,3 +82,8 @@ export function emitPythonNamespaceFile( export function pyVersionClassName(version: string): string { return version.toUpperCase(); } + +/** Get the Python async class name for a version namespace (e.g., "v1" -> "AsyncV1"). */ +export function pyAsyncVersionClassName(version: string): string { + return `Async${pyVersionClassName(version)}`; +} diff --git a/packages/sdk-generator/src/backends/python/resource-emitter.ts b/packages/sdk-generator/src/backends/python/resource-emitter.ts index bf2dce4..38991b2 100644 --- a/packages/sdk-generator/src/backends/python/resource-emitter.ts +++ b/packages/sdk-generator/src/backends/python/resource-emitter.ts @@ -110,7 +110,7 @@ export function emitPythonResourceFile( if (typingImports.size > 0) { cb.line(`from typing import ${[...typingImports].sort().join(", ")}`); } - cb.line(`from ${runtimeImport} import HttpClient`); + cb.line(`from ${runtimeImport} import HttpClient, SyncHttpClient`); // Add type imports for schema refs used in operations + as $ref bodies if (options?.schemaImports) { @@ -153,6 +153,12 @@ export function emitPythonResourceFile( cb.line(); } + for (let i = 0; i < allResources.length; i++) { + emitAsyncResourceClass(cb, allResources[i]!, inputNameByOpId, responseNameByOpId); + cb.line(); + cb.line(); + } + for (let i = 0; i < allResources.length; i++) { emitResourceClass(cb, allResources[i]!, inputNameByOpId, responseNameByOpId); if (i < allResources.length - 1) { cb.line(); cb.line(); } @@ -161,6 +167,10 @@ export function emitPythonResourceFile( return cb.toString(); } +export function pyAsyncResourceClassName(className: string): string { + return `Async${className}`; +} + function flattenResourcesBottomUp(resource: ResourceDef): ResourceDef[] { const result: ResourceDef[] = []; for (const child of resource.children) { @@ -287,18 +297,20 @@ function collectInlineInputs(resources: ResourceDef[]): InlineInputGroup[] { return groups; } -function emitResourceClass( +function emitAsyncResourceClass( cb: CodeBuilder, resource: ResourceDef, inputNameByOpId: Map, responseNameByOpId: Map ): void { - cb.pyBlock(`class ${resource.className}`, () => { + cb.pyBlock(`class ${pyAsyncResourceClassName(resource.className)}`, () => { // __init__ cb.pyBlock("def __init__(self, http: HttpClient)", () => { cb.line("self._http = http"); for (const child of resource.children) { - cb.line(`self.${pythonParameterName(child.name)} = ${child.className}(http)`); + cb.line( + `self.${pythonParameterName(child.name)} = ${pyAsyncResourceClassName(child.className)}(http)` + ); } }); @@ -310,6 +322,29 @@ function emitResourceClass( }); } +function emitResourceClass( + cb: CodeBuilder, + resource: ResourceDef, + inputNameByOpId: Map, + responseNameByOpId: Map +): void { + cb.pyBlock(`class ${resource.className}`, () => { + cb.pyBlock("def __init__(self, http: SyncHttpClient)", () => { + cb.line("self._http = http"); + for (const child of resource.children) { + cb.line( + `self.${pythonParameterName(child.name)} = ${child.className}(http)` + ); + } + }); + + for (const op of resource.operations) { + cb.line(); + emitSyncOperation(cb, op, resource, inputNameByOpId, responseNameByOpId); + } + }); +} + function emitOperation( cb: CodeBuilder, op: OperationDef, @@ -370,6 +405,62 @@ function emitOperation( ); } +function emitSyncOperation( + cb: CodeBuilder, + op: OperationDef, + resource: ResourceDef, + inputNameByOpId: Map, + responseNameByOpId: Map +): void { + const pythonNames = buildOperationPythonNames(op, resource); + const params = buildParamList(op, resource, inputNameByOpId, pythonNames); + const responseName = responseNameByOpId.get(op.operationId); + const returnType = op.rawResponse + ? "dict[str, str]" + : (responseName ?? typeRefToPython(op.returnType)); + const methodName = pythonParameterName(op.name); + const returnAnnotation = returnType; + + cb.pyBlock( + `def ${methodName}(self${params ? ", " + params : ""}) -> ${returnType}`, + () => { + emitOperationDocstring(cb, op.summary, op.description); + + if (op.queryParams.length > 0) { + cb.line("query: dict[str, object] = {}"); + for (let i = 0; i < op.queryParams.length; i++) { + const qp = op.queryParams[i]!; + const py = pythonNames.query[i]!; + const wireKey = JSON.stringify(qp.name); + if (qp.required) { + cb.line(`query[${wireKey}] = ${py}`); + } else { + cb.line(`if ${py} is not None:`); + cb.line(` query[${wireKey}] = ${py}`); + } + } + } + + const pathExpr = buildPathExpression(op, resource, pythonNames); + const optParts = buildRequestOptionParts(op, pythonNames); + const requestMethod = op.rawResponse ? "request_raw" : "request"; + const prefix = returnAnnotation === "None" ? "" : "return "; + const allArgs = [pathExpr, ...optParts]; + const oneLiner = `${prefix}self._http.${requestMethod}(${allArgs.join(", ")})`; + + if (oneLiner.length + 8 <= 100) { + cb.line(oneLiner); + } else { + cb.line(`${prefix}self._http.${requestMethod}(`); + for (const arg of allArgs) { + cb.line(` ${arg},`); + } + cb.line(")"); + } + } + ); +} + function emitOperationDocstring( cb: CodeBuilder, summary?: string, From 6c504c09047accdabde821481538b9a125087a8a Mon Sep 17 00:00:00 2001 From: Calvin Grunewald Date: Fri, 12 Jun 2026 12:41:39 -0700 Subject: [PATCH 2/2] Update Python generator sync async clients --- .../__tests__/backends/python.test.ts | 52 +++++++- .../backends/contract-tests/python-emitter.ts | 88 +++++++++++++- .../src/backends/python/client-emitter.ts | 113 ++++++++++++++++++ 3 files changed, 248 insertions(+), 5 deletions(-) diff --git a/packages/sdk-generator/__tests__/backends/python.test.ts b/packages/sdk-generator/__tests__/backends/python.test.ts index 4c931f1..11340d8 100644 --- a/packages/sdk-generator/__tests__/backends/python.test.ts +++ b/packages/sdk-generator/__tests__/backends/python.test.ts @@ -330,16 +330,32 @@ describe("Python contract tests include raw response operations", () => { }); it("uses the sync PlatformClient in generated Python REST contract tests", () => { - expect(content).toContain("from archastro.platform import PlatformClient"); + expect(content).toContain("from archastro.platform import AsyncPlatformClient, PlatformClient"); expect(content).toContain("def _client() -> PlatformClient:"); expect(content).toContain("return PlatformClient("); expect(content).toContain("def test_configs_content_success():"); expect(content).toContain("result = client.v1.configs.content("); expect(content).toContain("finally:"); expect(content).toContain("client.close()"); - expect(content).not.toContain("from archastro.platform import AsyncPlatformClient"); - expect(content).not.toContain("async def test_configs_content_success"); - expect(content).not.toContain("await client.v1.configs.content"); + }); + + it("also emits async REST contract tests for AsyncPlatformClient", () => { + expect(content).toContain("def _async_client() -> AsyncPlatformClient:"); + expect(content).toContain("return AsyncPlatformClient("); + expect(content).toContain("@pytest.mark.asyncio"); + expect(content).toContain("async def test_async_configs_content_success():"); + expect(content).toContain("result = await client.v1.configs.content("); + expect(content).toContain("await client.close()"); + }); + + it("emits async REST contract error cases when the operation declares 4xx responses", () => { + const files = emitPythonContractTests(ast, { outDir: "/tmp/test-python-sdk" }); + const teams = files["/tmp/test-python-sdk/tests/contract/v1/test_teams.py"]!; + + expect(teams).toContain("async def test_async_teams_get_error_404():"); + expect(teams).toContain("with pytest.raises(ApiError) as exc_info:"); + expect(teams).toContain("await ec.v1.teams.get("); + expect(teams).toContain("assert exc_info.value.status == 404"); }); }); @@ -604,6 +620,34 @@ describe("Python client emitter", () => { expect(output).toContain("def __exit__(self, exc_type, exc, tb):"); }); + it("generates an authenticated socket convenience on AsyncPlatformClient only", () => { + const asyncClass = output.slice( + output.indexOf("class AsyncPlatformClient:"), + output.indexOf("class PlatformClient:") + ); + const syncClass = output.slice(output.indexOf("class PlatformClient:")); + + expect(output).toContain("from urllib.parse import urlparse, urlunparse"); + expect(output).toContain("from archastro.phx_channel import Socket"); + expect(asyncClass).toContain("self._base_url = base_url"); + expect(asyncClass).toContain("self._default_headers = default_headers or {}"); + expect(asyncClass).toContain("self._sockets: list[Socket] = []"); + expect(asyncClass).toContain("async def open_socket("); + expect(asyncClass).toContain("url: str | None = None"); + expect(asyncClass).toContain("params: dict[str, str] | None = None"); + expect(asyncClass).toContain("connect: bool = True"); + expect(asyncClass).toContain('socket = Socket(url or self._default_websocket_url()'); + expect(asyncClass).toContain("await socket.connect()"); + expect(asyncClass).toContain("if not socket.is_connected:"); + expect(asyncClass).toContain('raise ConnectionError("WebSocket connection failed")'); + expect(asyncClass).toContain("return socket"); + expect(asyncClass).toContain('socket_params["api_key"] = api_key'); + expect(asyncClass).toContain('socket_params["token"] = token'); + expect(asyncClass).toContain("await socket.disconnect()"); + expect(syncClass).not.toContain("open_socket"); + expect(syncClass).not.toContain("self._sockets"); + }); + it("uniquifies with_credentials params using the auth method mapping", () => { const out = emitPythonClientFile({ baseUrl: "https://api.example.test", diff --git a/packages/sdk-generator/src/backends/contract-tests/python-emitter.ts b/packages/sdk-generator/src/backends/contract-tests/python-emitter.ts index 50e2441..3b5dccf 100644 --- a/packages/sdk-generator/src/backends/contract-tests/python-emitter.ts +++ b/packages/sdk-generator/src/backends/contract-tests/python-emitter.ts @@ -73,7 +73,7 @@ function emitResourceTestFile( } cb.line(); cb.line("import pytest"); - cb.line("from archastro.platform import PlatformClient"); + cb.line("from archastro.platform import AsyncPlatformClient, PlatformClient"); cb.line("from archastro.platform.runtime.http_client import ApiError"); cb.line(); cb.line(); @@ -95,10 +95,28 @@ function emitResourceTestFile( cb.line(' access_token="test-token",'); cb.line(" )"); cb.line(); + cb.line(); + cb.line("def _async_client() -> AsyncPlatformClient:"); + cb.line(" return AsyncPlatformClient("); + cb.line(" base_url=PRISM_URL,"); + cb.line(' default_headers={"x-archastro-api-key": "pk_test-key"},'); + cb.line(' access_token="test-token",'); + cb.line(" )"); + cb.line(); + cb.line(); + cb.line("def _async_error_client(code: int) -> AsyncPlatformClient:"); + cb.line(" return AsyncPlatformClient("); + cb.line(" base_url=PRISM_URL,"); + cb.line(' default_headers={"x-archastro-api-key": "pk_test-key", "Prefer": f"code={code}"},'); + cb.line(' access_token="test-token",'); + cb.line(" )"); + cb.line(); for (const call of calls) { emitHappyPathTest(cb, call); emitErrorTests(cb, call); + emitAsyncHappyPathTest(cb, call); + emitAsyncErrorTests(cb, call); } return cb.toString(); @@ -231,6 +249,70 @@ function emitErrorTests(cb: CodeBuilder, call: MethodCallInfo): void { } } +function emitAsyncHappyPathTest(cb: CodeBuilder, call: MethodCallInfo): void { + const testName = buildAsyncTestName(call, "success"); + const argStr = buildPythonArgs(call); + const chainPy = call.accessorChain.replace("client.", ""); + const methodCall = `client.${chainPy}.${pythonParameterName(call.methodName)}(${argStr})`; + const hasDataArray = returnTypeHasDataArray(call.operation.returnType); + const returnsNoContent = call.operation.returnType.kind === "void"; + + cb.line(); + cb.line("@pytest.mark.asyncio"); + cb.line(`async def ${testName}():`); + cb.indent(); + cb.line("client = _async_client()"); + cb.pyBlock("try", () => { + if (returnsNoContent) { + cb.line(`result = await ${methodCall}`); + cb.line("assert result is None"); + } else if (call.operation.rawResponse) { + cb.line(`result = await ${methodCall}`); + cb.line('assert result["content"] is not None'); + cb.line('assert result["mime_type"]'); + } else { + cb.line(`result = await ${methodCall}`); + cb.line("assert result is not None"); + if (hasDataArray) { + cb.line('assert "data" in result'); + cb.line('assert isinstance(result["data"], list)'); + } + } + }); + cb.pyBlock("finally", () => { + cb.line("await client.close()"); + }); + cb.dedent(); +} + +function emitAsyncErrorTests(cb: CodeBuilder, call: MethodCallInfo): void { + for (const code of call.errorCodes) { + if (code < 400) continue; + + const testName = buildAsyncTestName(call, `error_${code}`); + const argStr = buildPythonArgs(call); + const chainPy = call.accessorChain.replace("client.", ""); + const methodCall = `ec.${chainPy}.${pythonParameterName(call.methodName)}(${argStr})`; + + cb.line(); + cb.line("@pytest.mark.asyncio"); + cb.line(`async def ${testName}():`); + cb.indent(); + cb.line(`ec = _async_error_client(${code})`); + cb.pyBlock("try", () => { + cb.line("with pytest.raises(ApiError) as exc_info:"); + cb.indent(); + cb.line(`await ${methodCall}`); + cb.dedent(); + cb.line(`assert exc_info.value.status == ${code}`); + }); + cb.pyBlock("finally", () => { + cb.line("await ec.close()"); + }); + cb.dedent(); + } +} + function buildTestName(call: MethodCallInfo, suffix: string): string { // e.g., "test_agents_create_success" or "test_agents_schedules_list_error_404" const parts = call.groupLabel @@ -239,6 +321,10 @@ function buildTestName(call: MethodCallInfo, suffix: string): string { return `test_${parts.join("_")}_${snakeCase(call.methodName)}_${suffix}`; } +function buildAsyncTestName(call: MethodCallInfo, suffix: string): string { + return buildTestName(call, suffix).replace(/^test_/, "test_async_"); +} + function emitConftest(opts: { includePrism: boolean; includeHarness: boolean; diff --git a/packages/sdk-generator/src/backends/python/client-emitter.ts b/packages/sdk-generator/src/backends/python/client-emitter.ts index d220221..7f0bce4 100644 --- a/packages/sdk-generator/src/backends/python/client-emitter.ts +++ b/packages/sdk-generator/src/backends/python/client-emitter.ts @@ -9,10 +9,20 @@ export function emitPythonClientFile(spec: SdkSpec): string { const authOps = spec.authOperations ?? []; const hasAuth = authOps.length > 0; + const hasChannels = spec.channels.length > 0; + const socketApiKeyHeader = + spec.auth?.schemes?.publishable_key?.name ?? + spec.auth?.schemes?.secret_key?.name ?? + "x-archastro-api-key"; for (const line of generatedHeaderPython().trim().split("\n")) { cb.line(line); } cb.line(); + if (hasChannels) { + cb.line("from urllib.parse import urlparse, urlunparse"); + cb.line(); + cb.line("from archastro.phx_channel import Socket"); + } if (hasAuth) { cb.line("from .auth import AsyncAuthClient, AuthClient"); } @@ -49,6 +59,10 @@ export function emitPythonClientFile(spec: SdkSpec): string { ]; cb.pyBlock(`def __init__(${initParams.join(", ")})`, () => { + cb.line("self._base_url = base_url"); + cb.line("self._access_token = access_token"); + cb.line("self._get_access_token = get_access_token"); + cb.line("self._default_headers = default_headers or {}"); cb.line("self._http = HttpClient("); cb.indent(); cb.line("base_url=base_url,"); @@ -80,6 +94,9 @@ export function emitPythonClientFile(spec: SdkSpec): string { cb.line("self._refresh_token: str | None = None"); cb.line("self._extra_http_clients: list[HttpClient] = []"); + if (hasChannels) { + cb.line("self._sockets: list[Socket] = []"); + } }); cb.line(); @@ -92,6 +109,7 @@ export function emitPythonClientFile(spec: SdkSpec): string { cb.line(); cb.pyBlock("def set_access_token(self, token: str)", () => { + cb.line("self._access_token = token"); cb.line("self._http.set_access_token(token)"); }); @@ -114,6 +132,16 @@ export function emitPythonClientFile(spec: SdkSpec): string { cb.line(); cb.pyBlock("async def close(self)", () => { cb.pyBlock("try", () => { + if (hasChannels) { + cb.pyBlock("try", () => { + cb.pyBlock("for socket in self._sockets", () => { + cb.line("await socket.disconnect()"); + }); + }); + cb.pyBlock("finally", () => { + cb.line("self._sockets.clear()"); + }); + } cb.line("await self._http.close()"); }); cb.pyBlock("finally", () => { @@ -124,6 +152,91 @@ export function emitPythonClientFile(spec: SdkSpec): string { }); }); + if (hasChannels) { + cb.line(); + cb.pyBlock("def _current_access_token(self) -> str | None", () => { + cb.pyBlock("if self._get_access_token", () => { + cb.line("return self._get_access_token()"); + }); + cb.line("return self._access_token"); + }); + + cb.line(); + cb.pyBlock("def _api_key(self) -> str | None", () => { + cb.pyBlock("for key, value in self._default_headers.items()", () => { + cb.pyBlock(`if key.lower() == "${socketApiKeyHeader}".lower()`, () => { + cb.line("return value"); + }); + }); + cb.line("return None"); + }); + + cb.line(); + cb.pyBlock("def _socket_params(self, params: dict[str, str] | None = None) -> dict[str, str]", () => { + cb.line("socket_params: dict[str, str] = {}"); + cb.line("api_key = self._api_key()"); + cb.pyBlock("if api_key", () => { + cb.line('socket_params["api_key"] = api_key'); + }); + cb.line("token = self._current_access_token()"); + cb.pyBlock("if token", () => { + cb.line('socket_params["token"] = token'); + }); + cb.pyBlock("if params", () => { + cb.line("socket_params.update(params)"); + }); + cb.line("return socket_params"); + }); + + cb.line(); + cb.pyBlock("def _default_websocket_url(self) -> str", () => { + cb.line("parsed = urlparse(self._base_url)"); + cb.line('scheme = "wss" if parsed.scheme == "https" else "ws" if parsed.scheme == "http" else parsed.scheme'); + cb.line('path = parsed.path.rstrip("/") + "/socket/api/websocket"'); + cb.line('return urlunparse(parsed._replace(scheme=scheme, path=path, params="", query="", fragment=""))'); + }); + + cb.line(); + cb.pyBlock( + [ + "async def open_socket(", + "self,", + "*,", + "url: str | None = None,", + "params: dict[str, str] | None = None,", + "connect: bool = True,", + "heartbeat_interval: float = 30,", + "timeout: float = 10,", + "reconnect_backoff_ms: list[int] | None = None,", + "auto_reconnect: bool = True,", + ") -> Socket", + ].join(" "), + () => { + cb.line("socket_params = self._socket_params(params)"); + cb.pyBlock('if connect and not socket_params.get("token")', () => { + cb.line('raise ValueError("AsyncPlatformClient.open_socket requires an access token")'); + }); + cb.line("socket = Socket(url or self._default_websocket_url(),"); + cb.indent(); + cb.line("params=socket_params,"); + cb.line("heartbeat_interval=heartbeat_interval,"); + cb.line("timeout=timeout,"); + cb.line("reconnect_backoff_ms=reconnect_backoff_ms,"); + cb.line("auto_reconnect=auto_reconnect,"); + cb.dedent(); + cb.line(")"); + cb.pyBlock("if connect", () => { + cb.line("await socket.connect()"); + cb.pyBlock("if not socket.is_connected", () => { + cb.line('raise ConnectionError("WebSocket connection failed")'); + }); + }); + cb.line("self._sockets.append(socket)"); + cb.line("return socket"); + } + ); + } + const schemes = spec.auth?.schemes ?? {}; const flows = spec.auth?.tokenFlows ?? {};