diff --git a/README.md b/README.md index 2c42432..f8960df 100644 --- a/README.md +++ b/README.md @@ -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"], @@ -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 `, `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 `.cli:main`. The source defines `main()` callable without arguments and owns any `if __name__ == "__main__":` launcher for `python -m .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 diff --git a/lib/config.ts b/lib/config.ts index 32f8936..3e66143 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -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; @@ -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; } diff --git a/lib/generators/python.test.ts b/lib/generators/python.test.ts index 52e5daf..5b7a821 100644 --- a/lib/generators/python.test.ts +++ b/lib/generators/python.test.ts @@ -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"); diff --git a/lib/generators/python.ts b/lib/generators/python.ts index 645b82e..ca70a7c 100644 --- a/lib/generators/python.ts +++ b/lib/generators/python.ts @@ -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() : `
\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` : ""}`; @@ -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)), diff --git a/scripts/headers.ts b/scripts/headers.ts index 2ea4a8a..9222543 100644 --- a/scripts/headers.ts +++ b/scripts/headers.ts @@ -31,7 +31,7 @@ async function addHeader(path: string): Promise { 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}`); }), ); }