From 3239fa8634ab199f307dd083511bfa40a4875cf3 Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Tue, 8 Sep 2026 00:37:02 +0800 Subject: [PATCH 1/2] Keep credential discovery in consumer-owned Python providers --- AGENTS.md | 10 ++++++++++ README.md | 2 +- lib/config.ts | 9 +++++---- lib/generators/python.test.ts | 15 ++++++++++++-- lib/generators/python.ts | 37 +++++++---------------------------- 5 files changed, 36 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 191452d..924d437 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,16 @@ bun run build # build the static documentation application Run checks through the package scripts. Generated Python additionally supports `python3 -m compileall -q generated/python/src` and `uvx ruff@0.16.2 check generated/python`. +## Product Boundary (CRITICAL) + +This repository is a standalone, general-purpose OpenAPI-to-SDK and API documentation product, intended to compete with products such as Stainless and Scalar. Third-party users must be able to generate SDKs and documentation for their own APIs without inheriting Ultralytics application behavior. + +- Never add Ultralytics ML-package or Platform-specific integrations, endpoint knowledge, credential stores, filesystem conventions, business rules, or dependencies to the converter or its generated defaults. Configurable product names do not make application-specific policy generic. +- Ultralytics-specific SDK behavior belongs in `ultralytics/sdk`, which owns the Python SDK and future language SDKs. Platform API behavior and contracts belong in the Platform repository. +- Extend the converter only with reusable, opt-in capabilities that make sense for independent API providers. Keep language-specific customization under that language's configuration; default generation must remain independent of any consumer. +- Keep consumer customizations reproducible through generation and synchronization. Never hand-edit generated output or make the converter depend on a consumer repository. +- Review every change against this boundary. Relocate application-specific work to its owner instead of teaching the converter about one application. + ## Architecture - Downstream API docs and SDK consumers must track this repository's `main` branch. Never introduce a commit SHA or tag pin for `ultralytics/openapi` in Portal, SDK, or related automation. diff --git a/README.md b/README.md index 8c28d7a..2c42432 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. 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`. +`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`. 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 15e89e5..32f8936 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -5,16 +5,14 @@ import { dirname, isAbsolute, resolve } from "node:path"; import { fileURLToPath } from "node:url"; export interface OpenApiConfig { - apiKey: { - environment: string; - settings?: { directory: string; environment: string; filename: string; key: string }; - }; + apiKey: { environment: string }; docs?: { basePath?: string }; header?: string; license: { file: string; id: string; url?: string }; name: string; repository?: string; python: { + authProvider?: string; authors?: Array<{ email?: string; name: string }>; client: string; classifiers?: string[]; @@ -59,5 +57,8 @@ export function getConfig(): OpenApiConfig { 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); + } return config; } diff --git a/lib/generators/python.test.ts b/lib/generators/python.test.ts index 43743b6..5faa547 100644 --- a/lib/generators/python.test.ts +++ b/lib/generators/python.test.ts @@ -97,13 +97,24 @@ describe("Python generator", () => { expect(() => getOperations(contentParameter)).toThrow("Unsupported content parameter: query filter"); }); - test("uses a configured package README", async () => { + test("uses configured consumer README and credential provider sources", async () => { const directory = await mkdtemp(join(tmpdir(), "openapi-readme-")); const readme = join(directory, "README.md"); + const authProvider = join(directory, "auth.py"); try { await Bun.write(readme, "# Consumer-owned README\n"); - await generatePython(document, { ...config, python: { ...config.python, readme } }, join(directory, "generated")); + const provider = "def get_api_key() -> str | None:\n return None\n"; + await Bun.write(authProvider, provider); + await generatePython( + document, + { ...config, python: { ...config.python, authProvider, readme } }, + join(directory, "generated"), + ); expect(await Bun.file(join(directory, "generated/README.md")).text()).toBe("# Consumer-owned README\n"); + expect(await Bun.file(join(directory, "generated/src/example_api/_auth.py")).text()).toBe(provider); + expect(await Bun.file(join(directory, "generated/src/example_api/_client.py")).text()).toContain( + "return get_api_key()", + ); } finally { await rm(directory, { force: true, recursive: true }); } diff --git a/lib/generators/python.ts b/lib/generators/python.ts index aaa3662..4bc1d8d 100644 --- a/lib/generators/python.ts +++ b/lib/generators/python.ts @@ -653,52 +653,28 @@ function apiClientSource(async: boolean): string { } function clientSource(config: OpenApiConfig): string { - const settings = config.apiKey.settings; return `from __future__ import annotations import asyncio import json import os -${settings ? "import sys\n" : ""}import time +import time from collections.abc import Sequence -${settings ? "from pathlib import Path\n" : ""}from typing import Any +from typing import Any from urllib.parse import quote import httpx -from ._exceptions import APIConnectionError, APIError +${config.python.authProvider ? "from ._auth import get_api_key\n" : ""}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.""" + """Resolve explicit credentials, the environment, then the optional provider.""" 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" -} + return ${config.python.authProvider ? "get_api_key()" : "None"} class NotGiven: @@ -836,7 +812,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 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`; + 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.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 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( @@ -886,6 +862,7 @@ export async function generatePython( ), Bun.write(`${output}/README.md`, readme), Bun.write(`${output}/LICENSE`, licenseText), + ...(config.python.authProvider ? [Bun.write(`${root}/_auth.py`, Bun.file(config.python.authProvider))] : []), Bun.write(`${root}/_client.py`, clientSource(config)), Bun.write(`${root}/_exceptions.py`, EXCEPTIONS_SOURCE), Bun.write(`${root}/client.py`, publicClientSource(config, resources, false, baseUrl)), From 1c9fbf25e4149e71adc02d86e3731b1a8629f88f Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Tue, 8 Sep 2026 00:40:19 +0800 Subject: [PATCH 2/2] Read consumer provider before replacing generated output --- lib/generators/python.test.ts | 7 +++++++ lib/generators/python.ts | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/generators/python.test.ts b/lib/generators/python.test.ts index 5faa547..52e5daf 100644 --- a/lib/generators/python.test.ts +++ b/lib/generators/python.test.ts @@ -110,6 +110,13 @@ describe("Python generator", () => { { ...config, python: { ...config.python, authProvider, readme } }, join(directory, "generated"), ); + await expect( + generatePython( + document, + { ...config, python: { ...config.python, authProvider: join(directory, "missing.py") } }, + join(directory, "generated"), + ), + ).rejects.toThrow(); expect(await Bun.file(join(directory, "generated/README.md")).text()).toBe("# Consumer-owned README\n"); expect(await Bun.file(join(directory, "generated/src/example_api/_auth.py")).text()).toBe(provider); expect(await Bun.file(join(directory, "generated/src/example_api/_client.py")).text()).toContain( diff --git a/lib/generators/python.ts b/lib/generators/python.ts index 4bc1d8d..645b82e 100644 --- a/lib/generators/python.ts +++ b/lib/generators/python.ts @@ -832,6 +832,7 @@ export async function generatePython( .find(({ operation }) => operation.method === "get" && operation.arguments.every((argument) => !argument.required)); 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 readme = config.python.readme ? await Bun.file(config.python.readme).text() : `
\n\n# 🔌 ${config.name} Python SDK\n\n[![PyPI - Version](https://img.shields.io/pypi/v/${config.python.project}?logo=pypi&logoColor=white)](https://pypi.org/project/${config.python.project}/)\n[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/${config.python.project}?logo=python&logoColor=gold)](https://pypi.org/project/${config.python.project}/)\n\n
\n\nTyped synchronous and asynchronous Python clients generated from the ${config.name} contract.\n\n## 🐍 Installation\n\n\`\`\`bash\n${config.python.install}\n\`\`\`\n\n## 🔑 Authentication\n\nPass your API key directly when creating a client:\n\n\`\`\`python\nfrom ${config.python.package} import ${config.python.client}\n\nclient = ${config.python.client}(api_key="YOUR_API_KEY")\n\`\`\`\n\nAlternatively, set \`${config.apiKey.environment}\` and omit the \`api_key\` argument.\n\n## 🚀 Usage\n\nResources are grouped under one client and support context-manager cleanup:\n\n\`\`\`python\nfrom ${config.python.package} import ${config.python.client}\n\nwith ${config.python.client}() as client:\n ${readmeExample ? `response = client.${readmeExample.resource}.${readmeExample.operation.name}()` : "..."}\n\`\`\`\n\nEvery resource is also available through the asynchronous client:\n\n\`\`\`python\nimport asyncio\n\nfrom ${config.python.package} import Async${config.python.client}\n\n\nasync def main():\n async with Async${config.python.client}() as client:\n ${readmeExample ? `response = await client.${readmeExample.resource}.${readmeExample.operation.name}()` : "..."}\n\n\nasyncio.run(main())\n\`\`\`\n\n## ✨ Features\n\n- Typed synchronous and asynchronous resource clients\n- Multipart uploads and custom HTTP clients\n- Automatic retries for temporary failures\n- Structured API and connection errors\n- Context-manager cleanup\n\n## 📄 License\n\nThis SDK is licensed under the [${license.id.replace("-only", "")} License](${license.url ?? "LICENSE"}).\n${config.repository ? `\n## 🤝 Support\n\nFor bug reports and feature requests, open an issue at [${config.repository}/issues](${config.repository}/issues).\n` : ""}`; @@ -862,7 +863,7 @@ export async function generatePython( ), Bun.write(`${output}/README.md`, readme), Bun.write(`${output}/LICENSE`, licenseText), - ...(config.python.authProvider ? [Bun.write(`${root}/_auth.py`, Bun.file(config.python.authProvider))] : []), + ...(authProvider === undefined ? [] : [Bun.write(`${root}/_auth.py`, authProvider)]), Bun.write(`${root}/_client.py`, clientSource(config)), Bun.write(`${root}/_exceptions.py`, EXCEPTIONS_SOURCE), Bun.write(`${root}/client.py`, publicClientSource(config, resources, false, baseUrl)),