From 13ed2308784229551d00b8e702b3bb3af8f124f0 Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Fri, 30 Oct 2026 11:41:02 +0100 Subject: [PATCH] Let Python SDKs wire hand-written response hooks --- README.md | 2 +- lib/config.ts | 9 ++++----- lib/generators/python.test.ts | 24 ++++++++++++++++++++++++ lib/generators/python.ts | 14 ++++++++------ 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a0f71d7..d77ab50 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Edit `openapi.config.json` to use your local or HTTPS OpenAPI specification and } ``` -`source`, `name`, `apiKey.environment`, and the `python` `client`, `package`, and `project` keys are required; everything else is optional. `python.version` defaults to the contract's `info.version` so one bump in the API releases the SDK, `python.install` defaults to `pip install `, `python.readme` replaces the generated package README, `repository` fills the package URLs, and `docs.basePath` mounts the documentation under a sub-path. Optional `python.authProvider` points to a consumer-owned Python module exporting `get_api_key() -> str | None`. The module is included as `_auth.py` in the generated package. Both clients resolve explicit credentials first, then `apiKey.environment`, then the provider, once at client initialization; an explicit empty string disables authentication. Credential storage and discovery policy belongs in the consumer module, not the converter. Relative paths resolve against the configuration file. The first OpenAPI server becomes the SDK's default base URL. HTTP bearer authentication and header-based API keys are derived from `components.securitySchemes`. +`source`, `name`, `apiKey.environment`, and the `python` `client`, `package`, and `project` keys are required; everything else is optional. `python.version` defaults to the contract's `info.version` so one bump in the API releases the SDK, `python.install` defaults to `pip install `, `python.readme` replaces the generated package README, `repository` fills the package URLs, and `docs.basePath` mounts the documentation under a sub-path. Optional `python.authProvider` points to a consumer-owned Python module exporting `get_api_key() -> str | None`. The module is included as `_auth.py` in the generated package. Both clients resolve explicit credentials first, then `apiKey.environment`, then the provider, once at client initialization; an explicit empty string disables authentication. Credential storage and discovery policy belongs in the consumer module, not the converter. Optional `python.hooksProvider` points to a consumer-owned Python module exporting `response_hook(response)` and `async def async_response_hook(response)`, included as `_hooks.py`. The sync and async clients register them as httpx response event hooks on the HTTP client they create; a custom `http_client` is used as is, so register any hooks on it directly. Relative paths resolve against the configuration file. The first OpenAPI server becomes the SDK's default base URL. HTTP bearer authentication and header-based API keys are derived from `components.securitySchemes`. Optional `python.cli` accepts `{ "command": "example", "source": "cli.py" }`. The Python generator copies this consumer-owned source into the package as `cli.py` and registers `example` as `.cli:main`. The source defines `main()` callable without arguments and owns any `if __name__ == "__main__":` launcher for `python -m .cli`. The generated sibling `_cli_metadata.py` exports `MULTIPART_FILES`; use `from ._cli_metadata import MULTIPART_FILES` to access it. This dictionary maps `resource.method` names to binary field names inside whole multipart bodies, whose SDK annotation is a plain dictionary. Other argument information comes from SDK signatures and docstrings. Parsing, command defaults, authentication commands, output, and local-tool delegation belong in the consumer source. Without `python.cli`, no CLI module, metadata module, or executable is generated. diff --git a/lib/config.ts b/lib/config.ts index 3e66143..c072900 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -18,6 +18,7 @@ export interface OpenApiConfig { cli?: { command: string; source: string }; classifiers?: string[]; description?: string; + hooksProvider?: string; install: string; keywords?: string[]; maintainers?: Array<{ email?: string; name: string }>; @@ -55,11 +56,9 @@ export function getConfig(): OpenApiConfig { config.source = resolve(directory, config.source); } if (!isAbsolute(config.license.file)) config.license.file = resolve(directory, config.license.file); - if (config.python.readme && !isAbsolute(config.python.readme)) { - config.python.readme = resolve(directory, config.python.readme); - } - if (config.python.authProvider && !isAbsolute(config.python.authProvider)) { - config.python.authProvider = resolve(directory, config.python.authProvider); + for (const key of ["authProvider", "hooksProvider", "readme"] as const) { + const path = config.python[key]; + if (path && !isAbsolute(path)) config.python[key] = resolve(directory, path); } if (config.python.cli) { if ( diff --git a/lib/generators/python.test.ts b/lib/generators/python.test.ts index 43d8d8e..fab054e 100644 --- a/lib/generators/python.test.ts +++ b/lib/generators/python.test.ts @@ -168,6 +168,30 @@ describe("Python generator", () => { } }); + test("runs consumer response hooks on the HTTP clients it creates", async () => { + const directory = await mkdtemp(join(tmpdir(), "openapi-hooks-")); + const server = Bun.serve({ fetch: () => Response.json([]), port: 0 }); + try { + const hooksProvider = join(directory, "hooks.py"); + const target = join(directory, "generated"); + await Bun.write( + hooksProvider, + 'def response_hook(response):\n print("sync", response.status_code)\n\n\nasync def async_response_hook(response):\n print("async", response.status_code)\n', + ); + await generatePython(document, { ...config, python: { ...config.python, hooksProvider } }, target); + const script = `import asyncio, httpx\nfrom example_api import AsyncExample, Example\nExample(api_key="", base_url="${server.url}").widgets.list(limit=1)\nExample(api_key="", base_url="${server.url}", http_client=httpx.Client()).widgets.list(limit=1)\nasyncio.run(AsyncExample(api_key="", base_url="${server.url}").widgets.list(limit=1))\n`; + const child = Bun.spawn( + ["uv", "run", "--no-project", "--python", "python3", "--with", target, "python", "-c", script], + { stdout: "pipe" }, + ); + expect(await child.exited).toBe(0); + expect((await new Response(child.stdout).text()).trim().split("\n")).toEqual(["sync 200", "async 200"]); + } finally { + server.stop(true); + await rm(directory, { force: true, recursive: true }); + } + }); + test("generates resource methods and Google-style docstrings", async () => { const widgets = await Bun.file(join(output, "src/example_api/resources/widgets.py")).text(); expect(widgets).toContain("def list("); diff --git a/lib/generators/python.ts b/lib/generators/python.ts index 4227e9a..8dac153 100644 --- a/lib/generators/python.ts +++ b/lib/generators/python.ts @@ -616,7 +616,7 @@ function modelSource(document: OpenApiDocument, resources: Map None: - self._client = http_client or httpx.${async ? "Async" : ""}Client(timeout=timeout) + self._client = http_client or httpx.${async ? "Async" : ""}Client(timeout=timeout${hooks ? `, event_hooks={"response": [${async ? "async_" : ""}response_hook]}` : ""}) self._base_url = httpx.URL(f"{base_url.rstrip('/')}/") self._api_key = api_key self._max_retries = max_retries @@ -700,7 +700,7 @@ from urllib.parse import quote import httpx ${config.python.authProvider ? "from ._auth import get_api_key\n" : ""}from ._exceptions import APIConnectionError, APIError - +${config.python.hooksProvider ? "from ._hooks import async_response_hook, response_hook\n" : ""} def _resolve_api_key(api_key: str | None) -> str | None: """Resolve explicit credentials, the environment, then the optional provider.""" @@ -797,9 +797,9 @@ def _retry_delay(response: httpx.Response | None, attempt: int) -> float: return min(0.5 * 2.0**attempt, 8.0) -${apiClientSource(false)} +${apiClientSource(false, !!config.python.hooksProvider)} -${apiClientSource(true)}`; +${apiClientSource(true, !!config.python.hooksProvider)}`; } const EXCEPTIONS_SOURCE = `from __future__ import annotations @@ -846,7 +846,7 @@ function publicClientSource( const enter = async ? ` async def __aenter__(self) -> ${className}: # noqa: PYI034\n return self\n\n async def __aexit__(\n self,\n exc_type: type[BaseException] | None,\n exc: BaseException | None,\n traceback: object,\n ) -> None:\n await self.close()` : ` def __enter__(self) -> ${className}: # noqa: PYI034\n return self\n\n def __exit__(\n self,\n exc_type: type[BaseException] | None,\n exc: BaseException | None,\n traceback: object,\n ) -> None:\n self.close()`; - return `from __future__ import annotations\n\nfrom collections.abc import Sequence\n\nimport httpx\n\nfrom ._client import ${apiClient}, _resolve_api_key\nfrom .resources import (\n${imports.map((name) => ` ${name},`).join("\n")}\n)\n\n\nclass ${className}:\n """Client for the ${pythonDocstringText(config.name, " ")}."""\n\n def __init__(\n self,\n *,\n api_key: str | None = None,\n base_url: str = "${baseUrl}",\n timeout: float | httpx.Timeout = 60.0,\n max_retries: int = 2,\n retry_methods: Sequence[str] = ("GET", "HEAD", "OPTIONS"),\n http_client: ${httpClient} | None = None,\n ) -> None:\n """Initialize the client.\n\n Args:\n api_key (str, optional): API key. Defaults to ${pythonDocstringText(config.apiKey.environment, " ")}${config.python.authProvider ? " then the configured credential provider" : ""}. Pass an empty string to disable authentication.\n base_url (str): API base URL.\n timeout (float | httpx.Timeout): Request timeout.\n max_retries (int): Retries for connection errors and retryable responses.\n retry_methods (Sequence[str]): HTTP methods safe to repeat after network errors or HTTP 408, 409, 429, and 5xx responses. Defaults to GET, HEAD, OPTIONS. Opt in only when the operation is safe to repeat and its request body can be resent.\n http_client (${httpClient}, optional): Custom HTTP client.\n """\n resolved_api_key = _resolve_api_key(api_key)\n self._client = ${apiClient}(\n api_key=resolved_api_key,\n base_url=base_url,\n timeout=timeout,\n max_retries=max_retries,\n retry_methods=retry_methods,\n http_client=http_client,\n )\n${properties}\n\n ${async ? "async " : ""}def close(self) -> None:\n """Close the underlying HTTP client."""\n ${async ? "await " : ""}self._client.close()\n\n${enter}\n`; + return `from __future__ import annotations\n\nfrom collections.abc import Sequence\n\nimport httpx\n\nfrom ._client import ${apiClient}, _resolve_api_key\nfrom .resources import (\n${imports.map((name) => ` ${name},`).join("\n")}\n)\n\n\nclass ${className}:\n """Client for the ${pythonDocstringText(config.name, " ")}."""\n\n def __init__(\n self,\n *,\n api_key: str | None = None,\n base_url: str = "${baseUrl}",\n timeout: float | httpx.Timeout = 60.0,\n max_retries: int = 2,\n retry_methods: Sequence[str] = ("GET", "HEAD", "OPTIONS"),\n http_client: ${httpClient} | None = None,\n ) -> None:\n """Initialize the client.\n\n Args:\n api_key (str, optional): API key. Defaults to ${pythonDocstringText(config.apiKey.environment, " ")}${config.python.authProvider ? " then the configured credential provider" : ""}. Pass an empty string to disable authentication.\n base_url (str): API base URL.\n timeout (float | httpx.Timeout): Request timeout.\n max_retries (int): Retries for connection errors and retryable responses.\n retry_methods (Sequence[str]): HTTP methods safe to repeat after network errors or HTTP 408, 409, 429, and 5xx responses. Defaults to GET, HEAD, OPTIONS. Opt in only when the operation is safe to repeat and its request body can be resent.\n http_client (${httpClient}, optional): Custom HTTP client${config.python.hooksProvider ? ", used as is without the configured response hooks" : ""}.\n """\n resolved_api_key = _resolve_api_key(api_key)\n self._client = ${apiClient}(\n api_key=resolved_api_key,\n base_url=base_url,\n timeout=timeout,\n max_retries=max_retries,\n retry_methods=retry_methods,\n http_client=http_client,\n )\n${properties}\n\n ${async ? "async " : ""}def close(self) -> None:\n """Close the underlying HTTP client."""\n ${async ? "await " : ""}self._client.close()\n\n${enter}\n`; } export async function generatePython( @@ -867,6 +867,7 @@ export async function generatePython( const license = config.license; const licenseText = await Bun.file(license.file).text(); const authProvider = config.python.authProvider ? await Bun.file(config.python.authProvider).text() : undefined; + const hooksProvider = config.python.hooksProvider ? await Bun.file(config.python.hooksProvider).text() : undefined; const cli = config.python.cli ? await Bun.file(config.python.cli.source).text() : undefined; const multipartFiles = Object.fromEntries( [...resources].flatMap(([resource, operations]) => @@ -911,6 +912,7 @@ export async function generatePython( Bun.write(`${output}/README.md`, readme), Bun.write(`${output}/LICENSE`, licenseText), ...(authProvider === undefined ? [] : [Bun.write(`${root}/_auth.py`, authProvider)]), + ...(hooksProvider === undefined ? [] : [Bun.write(`${root}/_hooks.py`, hooksProvider)]), ...(cli === undefined ? [] : [