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 @@ -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 <project>`, `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 <project>`, `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
Expand Down
5 changes: 4 additions & 1 deletion lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
3 changes: 2 additions & 1 deletion lib/generators/python.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"}');
Expand Down
44 changes: 39 additions & 5 deletions lib/generators/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -652,21 +652,55 @@ 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

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."""

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)),
Expand Down