From 7053936b3f0586bd663e0104bfab4b3d002d9f38 Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Tue, 8 Sep 2026 00:27:32 +0800 Subject: [PATCH] Generate shared saved-credential resolution for Python clients --- README.md | 2 +- lib/config.ts | 5 +++- lib/generators/python.test.ts | 3 ++- lib/generators/python.ts | 44 +++++++++++++++++++++++++++++++---- 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 15566d6..8c28d7a 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,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. 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 `apiKey.settings` configures a saved JSON key using `directory`, directory-override `environment`, `filename`, and `key`. Both clients resolve explicit credentials first, then `apiKey.environment`, then the saved key; an explicit empty string disables authentication. Saved settings use the OS config directory, with `/tmp` and the working directory as fallbacks when the preferred directory cannot be created. Reads never create or modify the settings file. 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`. Set `OPENAPI_CONFIG` to use a configuration outside this repository, such as a product-specific consumer: ```bash diff --git a/lib/config.ts b/lib/config.ts index 044b07c..15e89e5 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -5,7 +5,10 @@ import { dirname, isAbsolute, resolve } from "node:path"; import { fileURLToPath } from "node:url"; export interface OpenApiConfig { - apiKey: { environment: string }; + apiKey: { + environment: string; + settings?: { directory: string; environment: string; filename: string; key: string }; + }; docs?: { basePath?: string }; header?: string; license: { file: string; id: string; url?: string }; diff --git a/lib/generators/python.test.ts b/lib/generators/python.test.ts index 9c344c6..43743b6 100644 --- a/lib/generators/python.test.ts +++ b/lib/generators/python.test.ts @@ -1154,7 +1154,8 @@ describe("Python generator", () => { const client = await Bun.file(join(output, "src/example_api/client.py")).text(); const runtime = await Bun.file(join(output, "src/example_api/_client.py")).text(); const uploads = await Bun.file(join(output, "src/example_api/resources/uploads.py")).text(); - expect(client).toContain('os.environ.get("EXAMPLE_API_KEY")'); + expect(client).toContain("_resolve_api_key(api_key)"); + expect(runtime).toContain('os.environ.get("EXAMPLE_API_KEY")'); expect(runtime).not.toContain('headers={"Authorization": f"Bearer {api_key}"} if api_key else {}'); expect(runtime).toContain('path.lstrip("/")'); expect(runtime).toContain('retryable = method.upper() in {"GET", "HEAD", "OPTIONS"}'); diff --git a/lib/generators/python.ts b/lib/generators/python.ts index d2c2caf..aaa3662 100644 --- a/lib/generators/python.ts +++ b/lib/generators/python.ts @@ -652,14 +652,16 @@ function apiClientSource(async: boolean): string { `; } -function clientSource(): string { +function clientSource(config: OpenApiConfig): string { + const settings = config.apiKey.settings; return `from __future__ import annotations import asyncio import json -import time +import os +${settings ? "import sys\n" : ""}import time from collections.abc import Sequence -from typing import Any +${settings ? "from pathlib import Path\n" : ""}from typing import Any from urllib.parse import quote import httpx @@ -667,6 +669,38 @@ import httpx from ._exceptions import APIConnectionError, APIError +def _resolve_api_key(api_key: str | None) -> str | None: + """Resolve explicit credentials, the environment, then the configured saved key.""" + if api_key is not None: + return api_key + if api_key := os.environ.get(${quote(config.apiKey.environment)}): + return api_key +${ + settings + ? ` if config_dir := os.environ.get(${quote(settings.environment)}): + directory = Path(config_dir).expanduser() / ${quote(settings.directory)} + elif sys.platform == "linux": + directory = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / ${quote(settings.directory)} + elif sys.platform == "win32": + directory = Path.home() / "AppData" / "Roaming" / ${quote(settings.directory)} + elif sys.platform == "darwin": + directory = Path.home() / "Library" / "Application Support" / ${quote(settings.directory)} + else: + return None + # Select the same directory as the settings writer; never revive a key from another location after logout. + for candidate in (directory, Path("/tmp") / ${quote(settings.directory)}, Path.cwd() / ${quote(settings.directory)}): + if candidate.exists() or os.access(candidate.parent, os.W_OK): + break + try: + settings = json.loads((candidate / ${quote(settings.filename)}).read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + api_key = settings.get(${quote(settings.key)}) if isinstance(settings, dict) else None + return api_key if isinstance(api_key, str) else None` + : " return None" +} + + class NotGiven: """Sentinel for omitted request values.""" @@ -802,7 +836,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\nimport os\n\nimport httpx\n\nfrom ._client import ${apiClient}\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 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, " ")}.\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 http_client (${httpClient}, optional): Custom HTTP client.\n """\n resolved_api_key = api_key or os.environ.get("${config.apiKey.environment}")\n self._client = ${apiClient}(\n api_key=resolved_api_key,\n base_url=base_url,\n timeout=timeout,\n max_retries=max_retries,\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\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 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.apiKey.settings ? " then saved settings" : ""}. 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 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 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( @@ -852,7 +886,7 @@ export async function generatePython( ), Bun.write(`${output}/README.md`, readme), Bun.write(`${output}/LICENSE`, licenseText), - Bun.write(`${root}/_client.py`, clientSource()), + Bun.write(`${root}/_client.py`, clientSource(config)), Bun.write(`${root}/_exceptions.py`, EXCEPTIONS_SOURCE), Bun.write(`${root}/client.py`, publicClientSource(config, resources, false, baseUrl)), Bun.write(`${root}/async_client.py`, publicClientSource(config, resources, true, baseUrl)),