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
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
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. 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 <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`.
Set `OPENAPI_CONFIG` to use a configuration outside this repository, such as a product-specific consumer:

```bash
Expand Down
9 changes: 5 additions & 4 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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;
}
22 changes: 20 additions & 2 deletions lib/generators/python.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,31 @@ 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"),
);
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(
"return get_api_key()",
);
} finally {
await rm(directory, { force: true, recursive: true });
}
Expand Down
38 changes: 8 additions & 30 deletions lib/generators/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -856,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()
: `<div align="center">\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</div>\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` : ""}`;
Expand Down Expand Up @@ -886,6 +863,7 @@ export async function generatePython(
),
Bun.write(`${output}/README.md`, readme),
Bun.write(`${output}/LICENSE`, licenseText),
...(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)),
Expand Down