Skip to content
Open
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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ Edit `openapi.config.json` to use your local or HTTPS OpenAPI specification and
"apiKey": { "environment": "EXAMPLE_API_KEY" },
"docs": { "basePath": "/reference" },
"header": "Example API - https://example.com/license",
"license": { "id": "AGPL-3.0-only", "file": "LICENSE", "url": "https://spdx.org/licenses/AGPL-3.0-only.html" },
"license": {
"id": "AGPL-3.0-only",
"file": "LICENSE",
"url": "https://spdx.org/licenses/AGPL-3.0-only.html"
},
"python": {
"authors": [{ "name": "Example", "email": "hello@example.com" }],
"classifiers": ["Programming Language :: Python :: 3 :: Only"],
Expand All @@ -51,6 +55,9 @@ 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`.

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.

Set `OPENAPI_CONFIG` to use a configuration outside this repository, such as a product-specific consumer:

```bash
Expand Down
13 changes: 13 additions & 0 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface OpenApiConfig {
authProvider?: string;
authors?: Array<{ email?: string; name: string }>;
client: string;
cli?: { command: string; source: string };
classifiers?: string[];
description?: string;
install: string;
Expand Down Expand Up @@ -60,5 +61,17 @@ export function getConfig(): OpenApiConfig {
if (config.python.authProvider && !isAbsolute(config.python.authProvider)) {
config.python.authProvider = resolve(directory, config.python.authProvider);
}
if (config.python.cli) {
if (
typeof config.python.cli.command !== "string" ||
!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(config.python.cli.command)
) {
throw new Error(`${configPath} requires a valid python.cli.command`);
}
if (typeof config.python.cli.source !== "string" || !config.python.cli.source) {
throw new Error(`${configPath} requires python.cli.source`);
}
config.python.cli.source = resolve(directory, config.python.cli.source);
}
return config;
}
40 changes: 40 additions & 0 deletions lib/generators/python.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,46 @@ describe("Python generator", () => {
expect(() => getOperations(contentParameter)).toThrow("Unsupported content parameter: query filter");
});

test("includes an opt-in consumer CLI in the Python package", async () => {
const directory = await mkdtemp(join(tmpdir(), "openapi-cli-"));
try {
const source = join(directory, "cli.py");
const target = join(directory, "generated");
const runtime =
'"""Consumer-owned launcher."""\nfrom __future__ import annotations\n\nfrom ._cli_metadata import MULTIPART_FILES\n\ndef main():\n print(MULTIPART_FILES)\n return 7\n\nif __name__ == "__main__":\n raise SystemExit(main())\n';
await Bun.write(source, runtime);
const cliConfig = { ...config, python: { ...config.python, cli: { command: "example", source } } };
const fixture = structuredClone(document);
const upload = getOperations(fixture).find((operation) => requestMedia(operation)?.[0] === "multipart/form-data");
const schema = upload && requestMedia(upload)?.[1].schema;
if (!schema) throw new Error("Missing multipart fixture");
schema.minProperties = 1;
await generatePython(fixture, cliConfig, target);
const root = join(target, "src", config.python.package);
expect(await Bun.file(join(root, "cli.py")).text()).toBe(runtime);
for (const command of [["python", "-m", `${config.python.package}.cli`], [cliConfig.python.cli.command]]) {
const result = Bun.spawnSync([
"uv",
"run",
"--no-project",
"--python",
"python3",
"--with",
target,
...command,
]);
expect(result.exitCode).toBe(7);
expect(result.stdout.toString().trim()).toBe("{'uploads.create': ['file']}");
}
await generatePython(document, config, target);
expect(await Bun.file(join(root, "cli.py")).exists()).toBe(false);
expect(await Bun.file(join(root, "_cli_metadata.py")).exists()).toBe(false);
expect(await Bun.file(join(target, "pyproject.toml")).text()).not.toContain("[project.scripts]");
} finally {
await rm(directory, { recursive: true, force: true });
}
});

test("uses configured consumer README and credential provider sources", async () => {
const directory = await mkdtemp(join(tmpdir(), "openapi-readme-"));
const readme = join(directory, "README.md");
Expand Down
21 changes: 20 additions & 1 deletion lib/generators/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,19 @@ 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 cli = config.python.cli ? await Bun.file(config.python.cli.source).text() : undefined;
const multipartFiles = Object.fromEntries(
[...resources].flatMap(([resource, operations]) =>
operations.flatMap((operation) => {
const body = operation.arguments.find((argument) => argument.wholeBody);
if (!body || operation.contentType !== "multipart/form-data") return [];
const files = Object.entries(objectSchema(document, body.schema)?.properties ?? {})
.filter(([, schema]) => resolveSchema(document, schema)?.format === "binary")
.map(([name]) => name);
return files.length ? [[`${resource}.${operation.name}`, files]] : [];
}),
),
);
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 All @@ -859,11 +872,17 @@ export async function generatePython(
await Promise.all([
Bun.write(
`${output}/pyproject.toml`,
`[build-system]\nrequires = ["uv_build>=0.12.3,<0.13"]\nbuild-backend = "uv_build"\n\n[project]\nname = "${config.python.project}"\nversion = "${version}"\n${projectMetadata}\nreadme = "README.md"\nlicense = "${license.id}"\nlicense-files = ["LICENSE"]\ndependencies = ["httpx>=0.28,<1"]${projectUrls}\n\n[tool.ruff]\nline-length = 120\n\n[tool.uv.build-backend]\nmodule-name = "${config.python.package}"\n`,
`[build-system]\nrequires = ["uv_build>=0.12.3,<0.13"]\nbuild-backend = "uv_build"\n\n[project]\nname = "${config.python.project}"\nversion = "${version}"\n${projectMetadata}\nreadme = "README.md"\nlicense = "${license.id}"\nlicense-files = ["LICENSE"]\ndependencies = ["httpx>=0.28,<1"]${projectUrls}${config.python.cli ? `\n\n[project.scripts]\n${JSON.stringify(config.python.cli.command)} = "${config.python.package}.cli:main"` : ""}\n\n[tool.ruff]\nline-length = 120\n\n[tool.uv.build-backend]\nmodule-name = "${config.python.package}"\n`,
),
Bun.write(`${output}/README.md`, readme),
Bun.write(`${output}/LICENSE`, licenseText),
...(authProvider === undefined ? [] : [Bun.write(`${root}/_auth.py`, authProvider)]),
...(cli === undefined
? []
: [
Bun.write(`${root}/cli.py`, cli),
Bun.write(`${root}/_cli_metadata.py`, `MULTIPART_FILES = ${JSON.stringify(multipartFiles)}\n`),
]),
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
2 changes: 1 addition & 1 deletion scripts/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ async function addHeader(path: string): Promise<void> {
if (!prefix) return;

const content = await Bun.file(child).text();
if (!content.startsWith(prefix)) await Bun.write(child, `${prefix}${content}`);
if (!content.startsWith(`${prefix.trimEnd()}\n`)) await Bun.write(child, `${prefix}${content}`);
}),
);
}
Expand Down