把 tool.py 复制给同事,再让对方执行 python path/to/tool.py,只能算“脚本能运行”,不能算 CLI 已交付。路径、当前目录、解释器和 PYTHONPATH 都会渗入使用方式;依赖升级后,用户也不知道自己运行的是哪个版本。真正可交付的命令行工具应该能由安装器创建命令入口,拥有明确的元数据,并且用最终产物而不是源码目录完成验收。

本文以纯 Python 工具 acme-report 为例,建立一条从源码到 wheel 的最小发布链路。

先分清三个名字

一个项目经常同时存在三个名字:发布到索引的 distribution 名 acme-report、代码里的 import package acme_report,以及终端命令 acme-report。最好在 README 中把三者明确写出。

采用 src 布局可以避免测试意外导入仓库根目录中的源码:

acme-report/
├── pyproject.toml
├── README.md
├── src/
│   └── acme_report/
│       ├── __init__.py
│       └── cli.py
└── tests/

如果测试只能在安装项目后通过,它更接近真实用户环境。反过来,平铺布局中的一次“本机通过”,可能只是当前目录被 Python 放进了导入路径。

用 pyproject.toml 声明构建与入口

现代项目用 [build-system] 指定构建后端及构建依赖,用 [project] 保存安装器需要的元数据。以下配置使用 Hatchling;同样的项目也可以选择 Setuptools、Flit 或其他符合规范的后端。

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "acme-report"
version = "0.1.0"
description = "Generate a compact project report"
readme = "README.md"
requires-python = ">=3.11"
dependencies = []

[project.scripts]
acme-report = "acme_report.cli:main"

[project.scripts] 不是 shell 别名。安装器会根据 acme_report.cli:main 创建适合当前平台的命令包装器,因此用户不必知道包所在路径,也不必手工拼接 python -m。依赖应该只列运行时真正需要的库;测试器、格式化器和构建工具属于开发依赖,不能无条件安装给每位用户。

入口函数是一份进程契约

入口函数最好接收可选参数并返回整数。它只负责解析参数、调用业务函数、把结果送到正确的数据流,不应该在导入模块时执行工作:

from __future__ import annotations

import argparse
import sys
from collections.abc import Sequence


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="acme-report")
    parser.add_argument("project")
    parser.add_argument("--format", choices=("text", "json"), default="text")
    return parser


def main(argv: Sequence[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    try:
        report = generate_report(args.project, args.format)
    except ReportError as error:
        print(f"acme-report: {error}", file=sys.stderr)
        return 2

    print(report)
    return 0

PyPA 的 entry point 规范将 console_scripts 指向一个无参数可调用对象;生成的包装器会调用它,并把整数返回值当作进程退出码。这里的 argv 默认值仍让包装器可以无参数调用,同时允许单元测试直接传入参数序列。

输出也需要契约:正常结果写入 stdout,诊断信息写入 stderr,成功返回 0,预期的用户输入错误返回稳定的非零码。不要在深层业务函数中散落 sys.exit(),否则复用和测试都会变难。若工具包含模板或默认配置,应把它们声明为包数据并通过 importlib.resources 读取,不要假设安装后还存在仓库相对路径。

构建的是产物,不是当前目录

使用独立的构建前端生成 sdist 和 wheel:

python -m pip install --upgrade build
python -m build

默认会在 dist/ 中得到源代码归档和 wheel。纯 Python 项目通常可以生成跨平台 wheel;包含本地扩展的项目则需要按 Python 版本、操作系统和处理器架构构建兼容矩阵。不要再把 python setup.py sdist 或 bdist_wheel 当作发布入口。

每次构建前应清理旧产物,并确认版本号与 Git 状态。否则 dist/ 里可能混入上一个版本,上传命令又恰好使用通配符。wheel 本质上是 ZIP,可以检查其中是否含有包代码、元数据、许可证和必要资源;sdist 则应包含从干净源码重新构建 wheel 所需的全部文件。

在隔离环境验收最终产物

最有价值的冒烟测试不是在仓库中执行 python -m acme_report,而是在一个全新的虚拟环境中只安装待发布文件:

python -m venv .venv-smoke
. .venv-smoke/bin/activate
python -m pip install dist/acme_report-0.1.0-py3-none-any.whl

acme-report --help
acme-report sample-project --format json
python -m pip show acme-report

测试至少要确认命令入口存在、帮助文本可读、正常路径退出码为零、错误路径写入 stderr 并返回约定值。随后再创建一个干净环境安装 sdist;这能发现归档中漏掉 README、类型声明、模板或构建文件的问题。CI 中还可以保存 dist/ 的校验和,使“已测试的文件”和“最终上传的文件”保持同一份字节,而不是发布前临时重建。

自动化测试可以直接调用 main(["sample-project"]) 验证参数与退出码,也应至少保留一次真实子进程测试,验证安装器生成的包装器确实可用。跨平台项目不要把包装器路径、路径分隔符或 shell 行为写死成 macOS/Linux 的形式。

发布应该是受控晋级

稳定的发布流程是让同一组产物逐级晋级:代码审查与测试通过后构建一次,先在临时索引或 TestPyPI 验证安装,再发布到正式索引。CI 发布到 PyPI 时优先使用 Trusted Publishing,让工作流以短期身份换取上传权限,避免保存长期 API token。

已经上传的版本应视为不可变。发现错误时递增版本并重新走构建和验收,不要试图用同一版本覆盖。对于团队工具,即使不发布到公网,也可以把 wheel 存进内部索引或制品库;核心原则仍然是安装经过测试的版本化产物,而不是从某个分支直接运行源码。

一份可执行的发布清单

发布前可以逐项确认:distribution、import package 与命令名已记录;pyproject.toml 同时声明构建后端和项目元数据;入口函数没有导入副作用,并遵守 stdout、stderr 与退出码约定;wheel 和 sdist 都来自干净提交;两种产物都在隔离环境安装成功;帮助、成功和失败路径通过冒烟测试;CI 上传使用短期凭据;最终上传文件与测试文件具有相同校验和。

从脚本到产品的关键并不是多一个压缩包,而是建立边界:源码由构建后端解释,安装器依据标准元数据创建命令,测试针对最终制品,发布系统只晋级已经验收的字节。做到这些,CLI 才真正脱离开发者电脑,成为用户可以信任的工具。