Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project>`, `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 <project>`, `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 `<package>.cli:main`. The source defines `main()` callable without arguments and owns any `if __name__ == "__main__":` launcher for `python -m <package>.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.

Expand Down
9 changes: 4 additions & 5 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand Down Expand Up @@ -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 (
Expand Down
24 changes: 24 additions & 0 deletions lib/generators/python.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(");
Expand Down
14 changes: 8 additions & 6 deletions lib/generators/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,7 +616,7 @@ function modelSource(document: OpenApiDocument, resources: Map<string, PythonOpe
return `from __future__ import annotations\n\n${typingImport}\n${body}\n`;
}

function apiClientSource(async: boolean): string {
function apiClientSource(async: boolean, hooks: boolean): string {
const a = async ? "async " : "";
const w = async ? "await " : "";
return `class ${async ? "Async" : "Sync"}APIClient:
Expand All @@ -630,7 +630,7 @@ function apiClientSource(async: boolean): string {
retry_methods: Sequence[str],
http_client: httpx.${async ? "Async" : ""}Client | None,
) -> 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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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]) =>
Expand Down Expand Up @@ -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
? []
: [
Expand Down
Loading