From e407582ac557cda3a0db2f85a8a6285fae335bfd Mon Sep 17 00:00:00 2001 From: Javier Chulvi Bernad Date: Tue, 8 Sep 2026 00:38:21 +0200 Subject: [PATCH 1/5] Assemble optional consumer-owned Python CLI modules --- README.md | 9 ++++++++- lib/config.ts | 13 +++++++++++++ lib/generators/python.test.ts | 30 ++++++++++++++++++++++++++++++ lib/generators/python.ts | 23 ++++++++++++++++++++++- 4 files changed, 73 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2c42432..2f92f48 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`, appends a `MULTIPART_FILES` dictionary and a `__main__` block, and registers `example` as `.cli:main`. The source must define a zero-argument `main()` entrypoint and may declare `MULTIPART_FILES: dict = {}` for use inside its functions. The dictionary maps `resource.method` names to binary field names inside whole multipart bodies, whose SDK annotation is a plain dictionary. Other argument information is available 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 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..c4ce009 100644 --- a/lib/generators/python.test.ts +++ b/lib/generators/python.test.ts @@ -97,6 +97,36 @@ 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 = "def main():\n print(MULTIPART_FILES)\n return 0\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()).startsWith(runtime)).toBe(true); + const result = Bun.spawnSync(["python3", join(root, "cli.py")]); + expect(result.exitCode).toBe(0); + expect(result.stdout.toString().trim()).toBe("{'uploads.create': ['file']}"); + expect(await Bun.file(join(target, "pyproject.toml")).text()).toContain( + `"example" = "${config.python.package}.cli:main"`, + ); + await generatePython(document, config, target); + expect(await Bun.file(join(root, "cli.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..8d8227d 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,19 @@ 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.trimEnd()}\n\n\nMULTIPART_FILES = ${JSON.stringify(multipartFiles)}\n\n\nif __name__ == "__main__":\n raise SystemExit(main())\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)), From b17d3a8eb7d9fe5902d0046957562ad46df2db39 Mon Sep 17 00:00:00 2001 From: Javier Chulvi Bernad Date: Tue, 8 Sep 2026 00:56:08 +0200 Subject: [PATCH 2/5] Recognize existing license headers before docstrings --- scripts/headers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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}`); }), ); } From a869c3409dbc47180a8c192e7b7452f579fe7fe5 Mon Sep 17 00:00:00 2001 From: Javier Chulvi Bernad Date: Tue, 8 Sep 2026 00:57:51 +0200 Subject: [PATCH 3/5] Reject consumer CLI launchers before package assembly --- README.md | 2 +- lib/generators/python.test.ts | 2 ++ lib/generators/python.ts | 3 +++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2f92f48..c0eab90 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,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 `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`, appends a `MULTIPART_FILES` dictionary and a `__main__` block, and registers `example` as `.cli:main`. The source must define a zero-argument `main()` entrypoint and may declare `MULTIPART_FILES: dict = {}` for use inside its functions. The dictionary maps `resource.method` names to binary field names inside whole multipart bodies, whose SDK annotation is a plain dictionary. Other argument information is available 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 or executable is generated. +Optional `python.cli` accepts `{ "command": "example", "source": "cli.py" }`. The Python generator copies this consumer-owned source into the package as `cli.py`, appends a `MULTIPART_FILES` dictionary and a `__main__` block, and registers `example` as `.cli:main`. The source must be a module defining a zero-argument `main()` entrypoint and must not reference `__main__`; the generator rejects that reserved name to prevent premature or duplicate execution. It may declare `MULTIPART_FILES: dict = {}` for use inside its functions. The dictionary maps `resource.method` names to binary field names inside whole multipart bodies, whose SDK annotation is a plain dictionary. Other argument information is available 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 or executable is generated. Set `OPENAPI_CONFIG` to use a configuration outside this repository, such as a product-specific consumer: diff --git a/lib/generators/python.test.ts b/lib/generators/python.test.ts index c4ce009..1b00e64 100644 --- a/lib/generators/python.test.ts +++ b/lib/generators/python.test.ts @@ -122,6 +122,8 @@ describe("Python generator", () => { await generatePython(document, config, target); expect(await Bun.file(join(root, "cli.py")).exists()).toBe(false); expect(await Bun.file(join(target, "pyproject.toml")).text()).not.toContain("[project.scripts]"); + await Bun.write(source, `${runtime}\nif __name__ == "__main__":\n main()\n`); + await expect(generatePython(fixture, cliConfig, target)).rejects.toThrow("must not reference __main__"); } finally { await rm(directory, { recursive: true, force: true }); } diff --git a/lib/generators/python.ts b/lib/generators/python.ts index 8d8227d..e3dd346 100644 --- a/lib/generators/python.ts +++ b/lib/generators/python.ts @@ -834,6 +834,9 @@ export async function generatePython( 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; + if (cli?.includes("__main__")) { + throw new Error("python.cli.source must not reference __main__; the generator owns the launcher"); + } const multipartFiles = Object.fromEntries( [...resources].flatMap(([resource, operations]) => operations.flatMap((operation) => { From 28f3f25936a7c75fa33717dc3955cd5bc39f4443 Mon Sep 17 00:00:00 2001 From: Javier Chulvi Bernad Date: Tue, 8 Sep 2026 01:46:43 +0200 Subject: [PATCH 4/5] Copy consumer CLI unchanged and separate generated multipart metadata --- lib/generators/python.test.ts | 10 +++++----- lib/generators/python.ts | 9 ++------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/lib/generators/python.test.ts b/lib/generators/python.test.ts index 1b00e64..e2ef088 100644 --- a/lib/generators/python.test.ts +++ b/lib/generators/python.test.ts @@ -102,7 +102,8 @@ describe("Python generator", () => { try { const source = join(directory, "cli.py"); const target = join(directory, "generated"); - const runtime = "def main():\n print(MULTIPART_FILES)\n return 0\n"; + 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); @@ -112,18 +113,17 @@ describe("Python generator", () => { 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()).startsWith(runtime)).toBe(true); + expect(await Bun.file(join(root, "cli.py")).text()).toBe(runtime); const result = Bun.spawnSync(["python3", join(root, "cli.py")]); - expect(result.exitCode).toBe(0); + expect(result.exitCode).toBe(7); expect(result.stdout.toString().trim()).toBe("{'uploads.create': ['file']}"); expect(await Bun.file(join(target, "pyproject.toml")).text()).toContain( `"example" = "${config.python.package}.cli:main"`, ); 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]"); - await Bun.write(source, `${runtime}\nif __name__ == "__main__":\n main()\n`); - await expect(generatePython(fixture, cliConfig, target)).rejects.toThrow("must not reference __main__"); } finally { await rm(directory, { recursive: true, force: true }); } diff --git a/lib/generators/python.ts b/lib/generators/python.ts index e3dd346..ca70a7c 100644 --- a/lib/generators/python.ts +++ b/lib/generators/python.ts @@ -834,9 +834,6 @@ export async function generatePython( 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; - if (cli?.includes("__main__")) { - throw new Error("python.cli.source must not reference __main__; the generator owns the launcher"); - } const multipartFiles = Object.fromEntries( [...resources].flatMap(([resource, operations]) => operations.flatMap((operation) => { @@ -883,10 +880,8 @@ export async function generatePython( ...(cli === undefined ? [] : [ - Bun.write( - `${root}/cli.py`, - `${cli.trimEnd()}\n\n\nMULTIPART_FILES = ${JSON.stringify(multipartFiles)}\n\n\nif __name__ == "__main__":\n raise SystemExit(main())\n`, - ), + 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), From 47b2026b11351f988a00dd54e618354fbcbe43f1 Mon Sep 17 00:00:00 2001 From: Javier Chulvi Bernad Date: Tue, 8 Sep 2026 02:00:34 +0200 Subject: [PATCH 5/5] Document and validate consumer-owned CLI entrypoints --- README.md | 2 +- lib/generators/python.test.ts | 22 +++++++++++++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c0eab90..f8960df 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,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 `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`, appends a `MULTIPART_FILES` dictionary and a `__main__` block, and registers `example` as `.cli:main`. The source must be a module defining a zero-argument `main()` entrypoint and must not reference `__main__`; the generator rejects that reserved name to prevent premature or duplicate execution. It may declare `MULTIPART_FILES: dict = {}` for use inside its functions. The dictionary maps `resource.method` names to binary field names inside whole multipart bodies, whose SDK annotation is a plain dictionary. Other argument information is available 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 or executable is generated. +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: diff --git a/lib/generators/python.test.ts b/lib/generators/python.test.ts index e2ef088..5b7a821 100644 --- a/lib/generators/python.test.ts +++ b/lib/generators/python.test.ts @@ -103,7 +103,7 @@ describe("Python generator", () => { 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'; + '"""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); @@ -114,12 +114,20 @@ describe("Python generator", () => { await generatePython(fixture, cliConfig, target); const root = join(target, "src", config.python.package); expect(await Bun.file(join(root, "cli.py")).text()).toBe(runtime); - const result = Bun.spawnSync(["python3", join(root, "cli.py")]); - expect(result.exitCode).toBe(7); - expect(result.stdout.toString().trim()).toBe("{'uploads.create': ['file']}"); - expect(await Bun.file(join(target, "pyproject.toml")).text()).toContain( - `"example" = "${config.python.package}.cli:main"`, - ); + 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);