打包并发布 Python 包到 PyPI

1. 概述

你写了一个有用的 Python 模块,想让其他人通过 pip install 安装它。为此,你需要将代码打包成一个 distribution,然后上传到 PyPI(Python Package Index)。

本文将介绍整个流程。你将构建标准的项目结构,在 pyproject.toml 中声明元数据,构建 wheel 和 sdist,在 TestPyPI 上测试,然后进行真正的发布。文末还介绍如何使用 GitHub Actions 和受信发布者来自动化发布过程。

2. 项目结构

本文的例子是一个小型库 nonla-order-tools,用来计算订单总额。我们使用 src layout:

nonla-order-tools/
├── pyproject.toml
├── README.md
├── LICENSE
├── src/
│   └── nonla_order_tools/
│       ├── __init__.py
│       ├── pricing.py
│       └── cli.py
└── tests/
    └── test_pricing.py

代码放在 src/ 中,而不是在根目录中。主要好处是运行测试时,Python 无法意外导入工作目录中的代码。它必须导入已安装的版本,这样你能尽早发现忘记打包文件的错误。

有两个名字需要区分:

  • PyPI 上的 distribution 名称:nonla-order-tools。用户使用 pip install 时输入这个名称。
  • import 包的名称:nonla_order_tools。用于 import 时,不能包含连字符。

2.1. 库的代码

# src/nonla_order_tools/pricing.py
from dataclasses import dataclass, field
from decimal import Decimal


@dataclass(frozen=True)
class OrderLine:
    product: str
    unit_price: Decimal
    quantity: int


@dataclass
class Order:
    customer: str
    lines: list[OrderLine] = field(default_factory=list)

    def add(self, product: str, unit_price: str, quantity: int = 1) -> None:
        if quantity <= 0:
            raise ValueError("数量必须为正数")
        self.lines.append(OrderLine(product, Decimal(unit_price), quantity))


def calculate_total(order: Order, vat_rate: Decimal = Decimal("0.10")) -> Decimal:
    subtotal = sum((l.unit_price * l.quantity for l in order.lines), Decimal("0"))
    return (subtotal * (1 + vat_rate)).quantize(Decimal("1"))

Order 包含 OrderLine 列表。calculate_total 计算每一行的金额之和,乘以 VAT(默认 10%),然后四舍五入到整数。代码使用 Decimal 而不是 float 来避免计算货币时的舍入误差。

# src/nonla_order_tools/__init__.py
from importlib.metadata import version

from .pricing import Order, OrderLine, calculate_total

__all__ = ["Order", "OrderLine", "calculate_total"]
__version__ = version("nonla-order-tools")

__version__ 从已安装包的元数据中读取。这样版本只需在 pyproject.toml 中声明一次。

添加一个小命令行工具:

# src/nonla_order_tools/cli.py
import sys
from decimal import Decimal

from .pricing import Order, calculate_total


def main() -> None:
    # 用法:order-total <价格> <数量> [<价格> <数量> ...]
    args = sys.argv[1:]
    order = Order(customer="cli")
    for price, qty in zip(args[::2], args[1::2]):
        order.add("item", price, int(qty))
    print(calculate_total(order, Decimal("0.10")))

3. 在 pyproject.toml 中声明元数据

pyproject.toml 是描述如何构建和包信息的唯一位置。你无需为新项目编写 setup.py。

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

[project]
name = "nonla-order-tools"
version = "0.1.0"
description = "计算包含 VAT 的订单总额"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Nón Lá Dev", email = "dev@nonla.example" }]
keywords = ["order", "pricing", "vat"]
classifiers = [
    "Programming Language :: Python :: 3",
    "Operating System :: OS Independent",
]
dependencies = []

[project.optional-dependencies]
test = ["pytest>=8"]

[project.scripts]
order-total = "nonla_order_tools.cli:main"

每个部分的说明:

  • [build-system] 告诉 pip 和 build 使用哪个 build backend。这里是 hatchling。你也可以使用 setuptools、flit-core 或 pdm-backend。[project] 中的元数据保持不变。
  • name 和 version 是必需的。版本应遵循 MAJOR.MINOR.PATCH 格式。
  • readme 将成为 PyPI 上的项目描述页面。
  • requires-python 阻止在不受支持的 Python 版本上安装。
  • license = "MIT" 是 SPDX 表达式(新标准)。此标准需要 hatchling>=1.27,因此上面指定了最小版本。
  • dependencies 列出运行时所需的库,例如 ["httpx>=0.27"]。
  • optional-dependencies 创建“extras”。用户可以通过 pip install "nonla-order-tools[test]" 安装。
  • [project.scripts] 在 PATH 中创建 order-total 命令。该命令调用 cli 模块中的 main 函数。

Hatchling 会自动找到 src/nonla_order_tools,因为目录名与规范化后的项目名相同(连字符变为下划线)。如果名称不同,你需要手动声明:

[tool.hatch.build.targets.wheel]
packages = ["src/nonla_order_tools"]

4. 编写测试并在可编辑模式下安装

在发布之前,确保核心功能正确:

# tests/test_pricing.py
from decimal import Decimal

import pytest

from nonla_order_tools import Order, calculate_total


def test_total_includes_vat():
    order = Order(customer="Lan")
    order.add("咖啡", "25000", 2)
    order.add("面包", "20000")

    assert calculate_total(order) == Decimal("77000")


def test_rejects_non_positive_quantity():
    order = Order(customer="Minh")
    with pytest.raises(ValueError, match="为正数"):
        order.add("茶", "15000", 0)

第一个测试计算 (25000 × 2 + 20000) × 1.1 = 77000。第二个测试检查拒绝零数量。

在 可编辑 模式下安装项目,然后运行测试:

python -m venv .venv
source .venv/bin/activate
pip install -e ".[test]"
pytest -q
..                                                    [100%]
2 passed in 0.02s

-e 标志将已安装的包链接到 src/ 目录。你修改代码时,更改会立即生效,无需重新安装。

5. 构建 wheel 和 sdist

安装 build 工具,然后在根目录运行它:

pip install --upgrade build twine
python -m build
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
  - hatchling>=1.27
* Building sdist...
* Building wheel from sdist
Successfully built nonla_order_tools-0.1.0.tar.gz and nonla_order_tools-0.1.0-py3-none-any.whl

dist/ 目录现在包含两个文件:

  • sdist(.tar.gz):包含元数据的源代码。当没有合适的 wheel 时使用。
  • wheel(.whl):预构建的二进制文件。后缀 py3-none-any 表示可在任何操作系统上的 Python 3 上运行,因为这是纯 Python 包。

检查 wheel 是否包含所有文件:

python -m zipfile -l dist/nonla_order_tools-0.1.0-py3-none-any.whl
File Name                                             Modified             Size
nonla_order_tools/__init__.py                  2020-02-02 00:00:00          209
nonla_order_tools/cli.py                       2020-02-02 00:00:00          363
nonla_order_tools/pricing.py                   2020-02-02 00:00:00          786
nonla_order_tools-0.1.0.dist-info/METADATA     2020-02-02 00:00:00         1012
nonla_order_tools-0.1.0.dist-info/WHEEL        2020-02-02 00:00:00           87
nonla_order_tools-0.1.0.dist-info/entry_points.txt 2020-02-02 00:00:00     58
...

固定的日期和时间是有意的:Hatchling 创建 可重现的构建。你机器上的文件大小可能会略有不同。

接下来,使用 twine 检查元数据,特别是 README 是否能正确渲染:

twine check dist/*
Checking dist/nonla_order_tools-0.1.0-py3-none-any.whl: PASSED
Checking dist/nonla_order_tools-0.1.0.tar.gz: PASSED

上传前的最后一步是在 干净的 环境中安装 wheel 并尝试导入:

python -m venv /tmp/verify && /tmp/verify/bin/pip install dist/*.whl
/tmp/verify/bin/python -c "import nonla_order_tools as n; print(n.__version__)"
/tmp/verify/bin/order-total 25000 2 20000 1
0.1.0
77000

如果这一步失败并显示 ModuleNotFoundError,很可能是你忘记打包某个文件或声明了错误的包路径。

6. 先在 TestPyPI 上测试

TestPyPI 是 PyPI 的独立副本,专用于测试。两者拥有独立的账户和令牌。

  1. 在 TestPyPI 上创建账户并启用两步验证(2FA)。
  2. 进入 Account settings → API tokens 并创建令牌。令牌以 pypi- 开头。
  3. 上传:
twine upload --repository testpypi dist/*

出现提示时,输入用户名 __token__ 和密码(令牌字符串)。为了避免重复输入,你可以保存到 ~/.pypirc:

[testpypi]
username = __token__
password = pypi-AgENdGVzdC5weXBpLm9yZwIk...

[pypi]
username = __token__
password = pypi-AgEIcHlwaS5vcmcCJ...

设置权限 chmod 600 ~/.pypirc,以防其他用户读取此文件。然后从 TestPyPI 尝试安装:

pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  nonla-order-tools

--extra-index-url 标志允许 pip 从真正的 PyPI 获取依赖项,因为 TestPyPI 通常缺少许多依赖包。

7. 发布到 PyPI

当一切正常时,真正 PyPI 的过程完全相同,只需省略 --repository:

twine upload dist/*
Uploading distributions to https://upload.pypi.org/legacy/
Uploading nonla_order_tools-0.1.0-py3-none-any.whl
Uploading nonla_order_tools-0.1.0.tar.gz
View at:
https://pypi.org/project/nonla-order-tools/0.1.0/

从现在开始,任何人都可以运行 pip install nonla-order-tools。

对于后续版本,你需要按顺序执行:

  1. 在 pyproject.toml 中增加 version,例如升至 0.1.1。
  2. 删除旧的 dist/ 目录:rm -rf dist。
  3. 重新运行 python -m build、twine check,然后 twine upload。

注意: PyPI 不允许重新上传 已存在的相同版本号的文件,即使你已删除该发布版本。如果不小心发布了有错误的版本,你只能将其 yank(标记为已弃用),然后发布新版本。这就是为什么应该始终先在 TestPyPI 上测试。

8. 使用受信发布者自动发布

在 CI 中存储长期令牌存在泄露风险。受信发布者 完全消除了令牌。PyPI 通过 OIDC 信任特定的 GitHub Actions workflow,并为每次运行颁发短期令牌。

在 PyPI 上设置:进入项目的 Publishing,或如果项目还不存在,使用“pending publisher”。在这里声明 owner、repository 名称、workflow 文件 publish.yml 和 environment pypi。

# .github/workflows/publish.yml
name: Publish to PyPI

on:
  release:
    types: [published]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: python -m pip install build
      - run: python -m build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  publish:
    needs: build
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      id-token: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/
      - uses: pypa/gh-action-pypi-publish@release/v1

Workflow 的工作方式:

  • 每当你创建 GitHub Release 时,workflow 运行。
  • build job 在 dist/ 中创建文件并将其保存为 artifact。
  • publish job 是独立的,仅具有 id-token: write 权限。此权限允许请求 OIDC 令牌,pypa/gh-action-pypi-publish action 使用该令牌上传。你无需任何 secret。
  • 分离两个 job 确保 build 步骤(执行你的代码和构建依赖项)无法访问发布权限。

9. 常见错误

  • 名称已被占用:PyPI 返回 403 或 “The name is too similar to an existing project”。在设置名称之前检查,并考虑添加自己的前缀,如 nonla-。
  • File already exists:你忘记增加版本号,或 dist/ 目录仍包含旧文件。始终在构建前删除 dist/。
  • 缺少数据文件(JSON、template 等):如果文件不在包目录中,它不会被打包。将它们放在 src/nonla_order_tools/ 中,并使用 importlib.resources 读取。
  • 测试在本地通过但安装后导入失败:这正是 src layout 帮助你避免的错误。始终尝试在干净的 venv 中安装 wheel,如第 5 部分所示。
  • 依赖关系声明过于严格:库不应固定版本,如 requests==2.32.3,因为这会与用户项目冲突。只指定下限,如 requests>=2.31。固定版本仅适用于 应用程序,应通过 lock 文件完成。
  • 令牌泄露:永远不要将 ~/.pypirc 或令牌 commit 到 repository。应使用按项目限制的令牌,或最好使用受信发布者。

注意: 不是所有代码都应该发布到 PyPI。公司内部库应使用专用索引(如 Artifactory、GitLab Package Registry)或直接从 Git 安装。PyPI 是公共空间,已占用的名称会很难收回。

10. 结论

现代 Python 打包流程非常简洁。所有操作都围绕一个 pyproject.toml 文件、build backend(如 hatchling)、python -m build 命令用于创建 wheel 和 sdist,以及 twine 用于检查和上传。

你应该养成按顺序操作的习惯:运行测试、构建、在干净的 venv 中安装、在 TestPyPI 上测试,然后发布到 PyPI。当项目稳定后,使用 GitHub Actions 和受信发布者。此后,每次发布只需创建一个 Release,你无需管理令牌。

pythonpypipackagingpyprojectgithub-actions