Python 基础体系 · 第 62/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python pyproject.toml:构建系统、项目元数据、入口和工具配置

pyproject.toml 是 Python 项目根目录中的 TOML 配置文件。它最初用于声明构建系统依赖,后来扩展为统一承载项目元数据、构建后端配置、命令行入口、插件入口以及格式化、测试、类型检查等工具配置。

当前规范明确约定了三个主要命名空间:

  • [build-system]:项目如何被构建;
  • [project]:项目是什么,以及它依赖什么;
  • [tool]:具体工具如何处理这个项目。

这三个命名空间解决的是不同层次的问题,不能互相替代。[build-system] 不是依赖清单,[project] 不是测试配置,[tool] 也不是任意字段的公共扩展区。(packaging.python.org)


一、先区分几个容易混淆的对象

在讨论 pyproject.toml 之前,需要区分以下概念。

1. 项目、发行包和导入包

假设项目名是:

wr-report

它可能包含一个可导入的 Python 包:

wr_report

两者不是同一个名字:

  • 项目(project):源代码、配置、文档、测试等组成的整体;
  • 发行包(distribution package):可以被 pip install 安装的构建产物;
  • 导入包(import package):可以通过 import wr_report 导入的 Python 模块或包。

因此:

python -m pip install wr-report

中的 wr-report 是发行包名称,而:

import wr_report

中的 wr_report 是导入包名称。

PyPI 上展示的是发行包,Python 导入系统处理的是导入包。一个项目可以生成多个发行文件,但这些文件通常代表同一个项目版本。(packaging.python.org)

2. 构建前端与构建后端

Python 打包通常分成两类角色:

  • 构建前端(build frontend):负责创建隔离环境、安装构建依赖、调用构建后端;
  • 构建后端(build backend):真正理解项目布局,并生成 wheel 或 sdist。

例如:

python -m build
        │
        │ 构建前端
        ▼
读取 pyproject.toml
        │
        ▼
安装 setuptools
        │
        ▼
调用 setuptools.build_meta
        │
        ├── build_wheel
        └── build_sdist

常见构建后端包括:

  • setuptools.build_meta
  • hatchling.build
  • flit_core.buildapi
  • poetry.core.masonry.api

pyproject.toml 中的 build-backend 指定的是后端,而不是执行构建命令的前端。

3. 虚拟环境与构建隔离环境不是一回事

开发者通常先创建一个虚拟环境:

python3.14 -m venv .venv

这个 .venv 用于运行项目、执行测试和安装开发工具。Python 的 venv 默认将环境与基础 Python 的第三方包隔离,并把解释器和 site-packages 放在独立目录中。(docs.python.org)

构建前端还可能为每次构建创建另一个临时的构建隔离环境。它的用途是安装:

[build-system]
requires = ["setuptools>=75"]

中的构建依赖,而不是安装项目运行时依赖。

这两个环境的区别是:

环境 目的 典型内容
项目虚拟环境 开发、测试、运行 pytest、ruff、项目依赖
构建隔离环境 执行构建后端 setuptools、hatchling、Cython 等构建依赖

如果项目在本地虚拟环境中“恰好能构建”,但 pyproject.toml 没有声明构建依赖,那么在干净机器、CI 或发布服务上仍可能失败。


二、pyproject.toml 的整体结构

一个典型项目可以从以下配置开始:

[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"

[project]
name = "wr-report"
version = "1.0.0"
description = "Generate reports from structured data"
readme = "README.md"
requires-python = ">=3.14"
license = "MIT"
authors = [
    { name = "WR Team", email = "team@example.com" },
]
dependencies = [
    "click>=8.1",
]

[project.optional-dependencies]
dev = [
    "pytest>=8",
    "ruff>=0.8",
    "mypy>=1.13",
]

[project.scripts]
wr-report = "wr_report.cli:main"

[project.entry-points."wr_report.plugins"]
json = "wr_report.plugins.json_plugin:plugin"

[tool.setuptools.packages.find]
where = ["src"]

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.ruff]
line-length = 100
target-version = "py314"

[tool.ruff.lint]
select = ["E", "F", "I"]

这个文件中:

  • [build-system] 决定“怎么构建”;
  • [project] 决定“构建出来的发行包叫什么、版本是多少、依赖什么”;
  • [project.scripts] 决定安装后产生什么命令;
  • [project.entry-points] 决定其他程序如何发现插件;
  • [tool.*] 决定工具如何工作。

规范要求工具专属配置放在 [tool] 下的子表中,例如 [tool.setuptools][tool.pytest]。工具命名空间不是任意字符串空间,通常应与工具项目在 PyPI 上拥有的名称对应,以避免配置冲突。(packaging.python.org)


三、[build-system]:声明构建系统

1. 最小结构

[build-system] 描述运行构建后端所需的 Python 依赖:

[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"

这里有两个关键字段。

requires

requires = ["setuptools>=75"]

它是字符串数组,每一项是一个依赖说明。构建前端需要先在隔离环境中安装这些依赖,之后才能导入并调用构建后端。

注意:

[build-system]
requires = ["setuptools>=75"]

不是说最终用户运行项目时必须安装 setuptools。它只说明构建阶段需要 setuptools

如果项目使用 Cython 生成扩展模块,则可能写成:

[build-system]
requires = [
    "setuptools>=75",
    "wheel",
    "cython>=3.0",
]
build-backend = "setuptools.build_meta"

如果 Cython 只是构建依赖,就不应把它放进 [project].dependencies

build-backend

build-backend = "setuptools.build_meta"

它是一个 Python 对象引用,通常表示:

模块名:对象名

例如:

setuptools.build_meta

表示导入模块:

import setuptools.build_meta

构建前端会根据后端定义的接口调用构建操作。常见操作包括:

  • 生成 wheel;
  • 生成 sdist;
  • 生成元数据;
  • 检查构建依赖。

build-backend 没有指定正确时,常见错误是:

BackendUnavailable
ModuleNotFoundError: No module named 'setuptools'

这类错误首先应检查:

  1. requires 是否包含构建后端;
  2. build-backend 的模块名是否拼写正确;
  3. 当前构建前端是否使用了隔离环境;
  4. 构建依赖是否能从当前索引下载。

2. backend-path:本地构建后端

规范还允许构建后端代码位于项目自身目录中:

[build-system]
requires = []
build-backend = "backend"
backend-path = ["build_backend"]

对应目录:

project/
├── pyproject.toml
└── build_backend/
    └── backend.py

这里的含义是:构建前端将 build_backend 加入导入路径,再导入 backend 模块。

这种方式适合需要自定义构建逻辑的项目,但风险也更高:

  • 后端代码本身必须能在构建阶段运行;
  • 构建逻辑不再完全由外部成熟工具维护;
  • CI 与本地构建结果可能因后端代码差异产生分歧;
  • 构建后端内部的依赖与项目运行时依赖容易混淆。

普通库通常不需要自定义 backend-path

3. 没有 [build-system] 会怎样

规范允许 pyproject.toml 只保存工具配置而不包含 [build-system]。如果文件存在但没有 [build-system],构建工具可以采用默认语义;如果 [build-system] 存在但缺少必需字段,则应视为错误。(packaging.python.org)

工程上仍建议明确写出:

[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"

原因不是“文件必须存在”,而是显式声明可以消除以下不确定性:

  • 使用哪个构建后端;
  • 构建环境要安装什么;
  • 构建结果是否依赖本机预装的工具;
  • CI 与开发机是否采用同一种构建路径。

四、构建数据流:从源代码到安装结果

一次标准构建可以抽象为以下流程:

flowchart TD
    A[源代码目录] --> B[读取 pyproject.toml]
    B --> C[解析 build-system]
    C --> D[创建构建隔离环境]
    D --> E[安装构建依赖]
    E --> F[加载 build backend]
    F --> G[读取 project 元数据]
    G --> H[生成 METADATA]
    H --> I[生成 wheel]
    G --> J[生成 PKG-INFO]
    J --> K[生成 sdist]
    I --> L[pip 安装 wheel]
    K --> M[pip 从源码构建 wheel]
    M --> L
    L --> N[写入 site-packages 与脚本目录]

关键路径如下:

  1. 构建前端读取 [build-system]
  2. 构建前端创建隔离环境;
  3. 构建前端安装 requires
  4. 构建前端导入 build-backend
  5. 后端读取项目源代码与配置;
  6. 后端生成发行文件;
  7. 安装器选择合适的 wheel,或者从 sdist 构建 wheel;
  8. 安装器把代码、元数据和命令行包装器写入目标环境。

因此,pyproject.toml 本身不是发行包。它是构建过程的输入之一。最终用户安装的是:

wr_report-1.0.0-py3-none-any.whl

或:

wr_report-1.0.0.tar.gz

五、[project]:项目元数据

[project] 遵循 PEP 621 定义的项目元数据格式。它描述的是最终发行包的公共信息,而不是某个具体工具的内部配置。

1. name:发行包名称

[project]
name = "wr-report"

name 是发行包的身份标识,必须存在并符合项目名称规范。

它与 Python 导入名称可以不同:

name = "wr-report"

但代码可能是:

import wr_report

名称中常见的连字符、下划线和大小写在包索引与元数据比较时会进行规范化,因此不要依赖仅大小写或分隔符差异来区分两个发行包。

2. version:发行版本

version = "1.0.0"

版本会被写入构建产物的元数据,例如 wheel 中的:

wr_report-1.0.0.dist-info/METADATA

版本必须符合 Python 包版本规范。以下是常见形式:

1.0.0
1.0.0a1
1.0.0b2
1.0.0rc1
1.0.0.post1
1.0.0.dev1

版本不是普通显示字符串,它会影响:

  • 包索引上的版本排序;
  • 依赖约束的匹配;
  • 安装器选择哪个版本;
  • 发布流程是否认为这是新版本。

例如:

dependencies = [
    "httpx>=0.27,<1",
]

表示依赖版本满足:

0.27v<10.27 \leq v < 1

其中 vv 是被解析出的 httpx 版本。版本比较不是简单的字符串比较,不能把 "10.0""2.0" 按字典序比较。

3. descriptionreadme

description = "Generate reports from structured data"
readme = "README.md"

description 是简短描述,readme 通常作为完整项目说明。

也可以内嵌文本:

readme = { text = "A report generator.", content-type = "text/plain" }

文件形式更适合真实项目:

readme = { file = "README.md", content-type = "text/markdown" }

如果使用表形式,content-type 描述 README 的 MIME 类型。Markdown 常用:

content-type = "text/markdown"

如果项目发布到包索引,README 的格式会影响索引页面如何渲染项目介绍。

4. requires-python:支持的 Python 版本

requires-python = ">=3.14"

它会成为发行包的 Requires-Python 元数据。安装工具可以根据当前解释器版本判断项目是否兼容。(packaging.python.org)

例如:

requires-python = ">=3.14,<3.15"

表示只接受 Python 3.14 系列。

需要注意,这个字段表达的是发行包声明的兼容范围,不会自动把当前运行环境升级到 Python 3.14。若当前解释器是 Python 3.13,安装器可能拒绝安装,但它不会替你创建新解释器。

5. authorsmaintainers

authors = [
    { name = "WR Team", email = "team@example.com" },
]

maintainers = [
    { name = "Release Team", email = "release@example.com" },
]

authors 表示作者,maintainers 表示维护者。它们都是元数据,不会自动创建操作系统用户,也不会自动赋予发布权限。

6. license 与许可证文件

当前规范中的 license 使用 SPDX 许可证表达式:

license = "MIT"

多许可证组合可以使用 SPDX 表达式,例如:

license = "MIT AND BSD-3-Clause"

许可证正文文件可以通过:

license-files = [
    "LICENSE",
    "NOTICE",
]

声明。

不要把以下内容误写成许可证表达式:

license = "See LICENSE file"

这是一段说明文字,不是规范化的 SPDX 表达式。若许可证比较复杂,应选择合法的 SPDX 表达式,并单独携带许可证文件。

7. keywordsclassifiersurls

keywords = ["report", "cli", "json"]

classifiers = [
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.14",
    "License :: OSI Approved :: MIT License",
]

[project.urls]
Homepage = "https://example.com/wr-report"
Repository = "https://example.com/wr-report.git"
Issues = "https://example.com/wr-report/issues"

这些字段主要用于检索、展示和项目分类:

  • keywords 是自由关键词;
  • classifiers 使用规范化分类值;
  • [project.urls] 是项目相关链接映射。

它们不会影响 Python 的导入路径,也不会替代依赖声明。


六、依赖声明:运行依赖、可选依赖和开发依赖

1. 运行时依赖

[project]
dependencies = [
    "click>=8.1",
    "orjson>=3.10; platform_python_implementation == 'CPython'",
]

dependencies 描述项目运行所需的发行包。每个字符串遵循依赖说明符语法,可以包含:

  • 项目名;
  • 版本约束;
  • 环境标记;
  • extras。

例如:

dependencies = [
    "click>=8.1,<9",
    "colorama>=0.4; sys_platform == 'win32'",
]

第二项只在 Windows 平台需要。

依赖声明与 requirements.txt 的用途不同:

文件或字段 主要用途
[project].dependencies 描述发行包运行所需的公共依赖
[project.optional-dependencies] 描述可选功能或额外安装集合
requirements.txt 常用于某个环境的具体安装输入
锁文件 记录解析后的具体版本集合,取决于工具和规范

库的公共运行依赖应进入 [project].dependencies,而不应只写进开发者本地的 requirements.txt

2. 可选依赖与 extras

[project.optional-dependencies]
pdf = [
    "reportlab>=4",
]
dev = [
    "pytest>=8",
    "ruff>=0.8",
]

用户可以请求:

python -m pip install "wr-report[pdf]"

此时安装器会安装 wr-report 的基础依赖和 pdf 组中的依赖。

可选依赖的逻辑可以表示为:

Dinstall=DbaseDextraD_{\text{install}} = D_{\text{base}} \cup D_{\text{extra}}

其中:

  • DbaseD_{\text{base}}dependencies
  • DextraD_{\text{extra}} 是指定 extra 对应的依赖;
  • 如果不指定 extra,则不应自动安装 DextraD_{\text{extra}}

extra 的名称应使用小写字母、数字和单个连字符组成的无歧义名称,例如:

pdf
dev
postgresql

不要把测试依赖误写成基础运行依赖:

dependencies = [
    "pytest>=8",  # 通常不正确
]

除非你的库在运行时确实需要 pytest。

3. Dependency Groups 不等于 extras

当前 PyPA 还定义了 Dependency Groups:

[dependency-groups]
test = [
    "pytest>=8",
]
lint = [
    "ruff>=0.8",
]

Dependency Groups 用于项目内部的开发依赖组织。构建后端不能把它们作为发行包的公共依赖元数据写入 wheel 或 sdist 的 METADATA。它们与 extras 的关键区别是:

  • extras 是发行包对用户公开的可选功能接口;
  • Dependency Groups 是项目内部工具使用的依赖分组;
  • extras 通常可被用户写成 package[feature]
  • Dependency Groups 没有规范统一的安装命令,具体由工具提供。

因此:

[project.optional-dependencies]
pdf = ["reportlab>=4"]

适合公开的 PDF 功能。

而:

[dependency-groups]
test = ["pytest>=8"]

适合项目开发测试。

(packaging.python.org)


七、静态元数据与 dynamic

1. 静态元数据

以下字段的值直接写在 pyproject.toml 中:

[project]
name = "wr-report"
version = "1.0.0"
description = "Generate reports"

这叫静态元数据。构建后端必须尊重这些值,不能在构建时悄悄覆盖。

2. 动态元数据

有些项目希望从 Git 标签、源码文件或其他工具中读取版本:

[project]
name = "wr-report"
dynamic = ["version"]

此时 version 不再直接写入,而由构建后端或插件负责提供。

例如,某些后端可能从 Git 标签中读取:

v1.0.0

然后生成:

Version: 1.0.0

dynamic 的作用是明确表示:

这个字段故意没有静态值,构建工具必须在之后提供它。

如果字段既有静态值,又出现在 dynamic 中:

[project]
version = "1.0.0"
dynamic = ["version"]

这是冲突配置,构建后端应报错。

如果字段没有出现在 dynamic 中,构建后端也不能随意替用户补充元数据。这一约束避免了“配置文件写的是 A,构建工具生成的是 B”的隐式行为。(packaging.python.org)

3. 什么时候不该使用 dynamic

如果版本、依赖和描述可以稳定地写入文件,优先使用静态值:

[project]
version = "1.2.0"

而不是:

[project]
dynamic = ["version"]

动态版本的代价包括:

  • 本地源码目录中的版本可能与构建产物不同;
  • 没有 Git 标签时可能无法构建;
  • 从 sdist 重建 wheel 时必须保证版本来源仍然可用;
  • 构建结果依赖额外插件或版本控制状态;
  • 审计构建输入时需要追踪更多来源。

动态元数据不是“更先进的写法”,而是对构建流程的额外承诺。


八、入口:命令行脚本、GUI 脚本和插件

“入口”不是 Python 文件中的 if __name__ == "__main__",而是发行包安装后暴露给其他程序的声明式接口。

入口点模型包含三个核心属性:

  1. group:入口属于哪一类;
  2. name:入口在该组中的名称;
  3. object reference:要加载的 Python 对象。

对象引用通常写成:

module.submodule:object

例如:

wr_report.cli:main

它等价于以下加载逻辑:

from wr_report.cli import main

如果有属性链:

wr_report.app:application.cli

则先导入 wr_report.app,再访问 application.cli

入口点规范定义了 console_scriptsgui_scripts 等特殊组,也支持应用程序自定义插件组。(packaging.python.org)

1. [project.scripts]:命令行入口

[project.scripts]
wr-report = "wr_report.cli:main"

安装项目后,安装器会在目标环境的脚本目录中创建名为 wr-report 的命令包装器。

等价的逻辑近似于:

import sys
from wr_report.cli import main

sys.exit(main())

因此入口函数应该设计成:

# src/wr_report/cli.py
from __future__ import annotations

import click


@click.command()
@click.option("--name", default="world")
def main(name: str) -> int:
    """Generate a report."""
    click.echo(f"hello, {name}")
    return 0

执行:

wr-report --name Hangzhou

预期输出:

hello, Hangzhou

入口函数通常不接收命令行参数列表,因为脚本包装器会调用它时不传参数。命令行参数应由 Click、argparse 或 Typer 等框架从 sys.argv 读取。

如果使用标准库 argparse,可以写成:

# src/wr_report/cli.py
from __future__ import annotations

import argparse


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--name", default="world")
    args = parser.parse_args()

    print(f"hello, {args.name}")
    return 0

2. 返回值与错误处理

入口函数返回整数时,包装器会把它作为进程退出码:

def main() -> int:
    try:
        run_report()
    except FileNotFoundError as exc:
        print(f"input file not found: {exc}", file=sys.stderr)
        return 2
    return 0

约定上:

  • 0 表示成功;
  • 非零值表示失败;
  • 错误信息应该写到标准错误流;
  • 不要把异常堆栈当作正常用户界面,除非这是开发模式或调试命令。

3. [project.gui-scripts]

[project.gui-scripts]
wr-report-gui = "wr_report.gui:main"

它与 [project.scripts] 的对象引用格式相同,但 Windows 下生成的包装器不会附着控制台,因此不能依赖标准输入、标准输出和标准错误流进行交互。

在 Linux 和 macOS 上,两者的区别通常不明显;在 Windows 上,GUI 脚本适合桌面程序,控制台脚本适合命令行程序。(packaging.python.org)

4. 自定义插件入口

假设主程序定义插件组:

wr_report.plugins

插件项目可以声明:

[project.entry-points."wr_report.plugins"]
json = "wr_report_json:plugin"
csv = "wr_report_csv:plugin"

运行时发现插件:

from importlib.metadata import entry_points


def load_plugins():
    plugins = entry_points(group="wr_report.plugins")

    loaded = {}
    for entry_point in plugins:
        loaded[entry_point.name] = entry_point.load()

    return loaded

entry_point.load() 会根据对象引用导入模块并取得对象。

一个插件可以是:

# wr_report_json/__init__.py
def plugin(record: dict) -> str:
    import json
    return json.dumps(record, ensure_ascii=False)

主程序不需要硬编码:

import wr_report_json

而是通过元数据发现已经安装的插件。

自定义 group 名称应避免与其他项目冲突,通常使用自己拥有的 PyPI 项目前缀,例如:

wr_report.plugins

不要随意使用过于通用的:

plugins

因为不同生态可能定义相同名字但不兼容的接口。入口点只负责发现对象,不负责验证对象是否满足接口;加载后仍应进行版本、属性或协议检查。(packaging.python.org)

5. 不要混用旧式特殊组

以下配置是错误或有歧义的:

[project.entry-points.console_scripts]
wr-report = "wr_report.cli:main"

应使用:

[project.scripts]
wr-report = "wr_report.cli:main"

同理,GUI 命令应使用:

[project.gui-scripts]
wr-report-gui = "wr_report.gui:main"

规范要求构建后端拒绝把 console_scriptsgui_scripts 放在 [project.entry-points] 下,因为它们分别与 [project.scripts][project.gui-scripts] 重复。(packaging.python.org)


九、完整项目示例

目录结构:

wr-report/
├── pyproject.toml
├── README.md
├── LICENSE
├── src/
│   └── wr_report/
│       ├── __init__.py
│       ├── cli.py
│       └── report.py
└── tests/
    └── test_report.py

1. pyproject.toml

[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"

[project]
name = "wr-report"
version = "1.0.0"
description = "Generate reports from structured data"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.14"
license = "MIT"
license-files = ["LICENSE"]

authors = [
    { name = "WR Team", email = "team@example.com" },
]

keywords = ["report", "cli"]
classifiers = [
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.14",
    "License :: OSI Approved :: MIT License",
]

dependencies = [
    "click>=8.1,<9",
]

[project.optional-dependencies]
dev = [
    "pytest>=8",
    "ruff>=0.8",
    "mypy>=1.13",
]

[project.scripts]
wr-report = "wr_report.cli:main"

[tool.setuptools.packages.find]
where = ["src"]

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.ruff]
line-length = 100
target-version = "py314"

[tool.ruff.lint]
select = ["E", "F", "I"]

2. 包代码

# src/wr_report/report.py
from __future__ import annotations


def render(name: str) -> str:
    return f"hello, {name}"
# src/wr_report/cli.py
from __future__ import annotations

import click

from .report import render


@click.command()
@click.option("--name", default="world", show_default=True)
def main(name: str) -> int:
    click.echo(render(name))
    return 0

3. 创建 Python 3.14 虚拟环境

Unix/macOS:

python3.14 -m venv .venv
. .venv/bin/activate

Windows PowerShell:

py -3.14 -m venv .venv
.venv\Scripts\Activate.ps1

验证解释器:

python --version
python -c "import sys; print(sys.executable)"

预期结果类似:

Python 3.14.x
/path/to/wr-report/.venv/bin/python

使用:

python -m pip

而不是直接使用:

pip

可以减少 pip 与当前 python 不属于同一个解释器的风险。

4. 安装构建前端和开发依赖

python -m pip install --upgrade pip build
python -m pip install -e ".[dev]"

-e 是可编辑安装。它通常创建指向源代码的安装关系,适合开发阶段;它不能代替验证最终 wheel,因为可编辑安装可能掩盖包文件遗漏、数据文件遗漏或构建配置错误。

5. 构建 wheel 和 sdist

rm -rf dist build src/*.egg-info
python -m build

Windows PowerShell 可以使用:

Remove-Item -Recurse -Force dist, build -ErrorAction SilentlyContinue
python -m build

默认会生成:

dist/
├── wr_report-1.0.0-py3-none-any.whl
└── wr_report-1.0.0.tar.gz

纯 Python 项目的 wheel 常见标签是:

py3-none-any

其中:

  • py3 表示 Python 3;
  • none 表示不依赖特定 ABI;
  • any 表示不限定平台。

wheel 是 ZIP 格式的二进制发行格式,文件名包含项目名、版本和兼容性标签。sdist 通常是 .tar.gz 源码发行包,需要在安装时再进行构建。(packaging.python.org)

6. 检查构建产物

查看 wheel 内容:

python -m zipfile -l dist/wr_report-1.0.0-py3-none-any.whl

应能看到类似:

wr_report/__init__.py
wr_report/cli.py
wr_report/report.py
wr_report-1.0.0.dist-info/METADATA
wr_report-1.0.0.dist-info/WHEEL
wr_report-1.0.0.dist-info/RECORD
wr_report-1.0.0.dist-info/entry_points.txt

其中:

  • METADATA 保存名称、版本、依赖、Python 版本等信息;
  • WHEEL 描述 wheel 自身;
  • RECORD 记录安装文件及其哈希;
  • entry_points.txt 保存入口点。

已安装项目的 .dist-info 目录中,METADATA 是必需文件;RECORD 记录文件路径、哈希和大小。(packaging.python.org)

读取元数据:

python -c "from importlib.metadata import metadata; m=metadata('wr-report'); print(m['Name']); print(m['Version'])"

预期输出:

wr-report
1.0.0

读取入口:

python -c "from importlib.metadata import entry_points; print(entry_points(group='console_scripts').select(name='wr-report'))"

输出会包含一个指向:

wr_report.cli:main

的入口点记录。

7. 在干净环境测试 wheel

为了避免当前源码目录影响测试,创建第二个环境:

python3.14 -m venv /tmp/wr-report-test
. /tmp/wr-report-test/bin/activate
python -m pip install dist/wr_report-1.0.0-py3-none-any.whl
wr-report --name Hangzhou

预期输出:

hello, Hangzhou

这个测试验证了几个关键事实:

  1. wheel 中确实包含了 wr_report 包;
  2. project.scripts 被转换成了可执行命令;
  3. 运行时依赖 click 被正确安装;
  4. 当前工作目录中的源码不是唯一可用代码来源。

如果只在源码目录中执行:

python -m wr_report.cli

并不能证明 wheel 正确,因为源码目录可能被自动加入 sys.path,即使发行包没有包含某些文件,源码运行也可能正常。


十、wheel、sdist 与安装选择

1. wheel 是安装格式

wheel 文件类似:

wr_report-1.0.0-py3-none-any.whl

它已经完成了构建阶段,安装器通常可以直接解压到目标环境的 site-packages,不需要重新运行项目构建逻辑。

对于包含 C 扩展的项目,wheel 可能是:

package-1.0.0-cp314-cp314-manylinux_2_28_x86_64.whl

其中兼容性标签描述:

  • Python 实现与版本;
  • ABI;
  • 操作系统和架构。

一个项目可能需要为不同的 Python、操作系统和 CPU 架构构建多个 wheel。(packaging.python.org)

2. sdist 是源码输入

sdist 文件类似:

wr_report-1.0.0.tar.gz

它包含源代码、pyproject.tomlPKG-INFO 以及构建项目所需的其他文件。安装器拿到 sdist 后,通常需要:

  1. 解压 sdist;
  2. 创建构建环境;
  3. 安装 [build-system].requires
  4. 调用构建后端生成 wheel;
  5. 安装生成的 wheel。

因此 sdist 的安装路径比 wheel 更长,可能要求目标机器具备:

  • 编译器;
  • 系统头文件;
  • 外部库;
  • 正确的构建工具链。

sdist 的价值在于:

  • 为尚未提供特定平台 wheel 的环境提供安装来源;
  • 允许下游重新构建;
  • 保留源码和构建输入;
  • 支持包含本地编译步骤的项目。

3. pip 如何选择

可以把选择过程简化为:

请求 wr-report
    │
    ▼
索引中是否存在兼容当前环境的 wheel?
    │
    ├── 是:下载并安装 wheel
    │
    └── 否:下载 sdist
              │
              ▼
          构建 wheel
              │
              ▼
          安装 wheel

如果项目只发布 sdist,用户安装时更容易遇到构建失败。如果项目只发布 wheel,则不兼容该 wheel 标签的平台可能无法安装。因此公共项目通常同时发布 sdist 和一个或多个 wheel。(packaging.python.org)


十一、包发现与源码布局

pyproject.toml 可以声明构建后端配置,但包发现规则通常是后端专属的。

对于 setuptools,以下配置表示从 src 目录发现包:

[tool.setuptools.packages.find]
where = ["src"]

对应:

src/
└── wr_report/
    ├── __init__.py
    └── cli.py

如果没有正确配置包发现,可能出现:

Successfully built wr-report

但安装后:

ModuleNotFoundError: No module named 'wr_report'

这说明“构建成功”只代表后端生成了一个合法构建产物,不代表产物一定包含预期代码。

可以检查 wheel:

python -m zipfile -l dist/*.whl

如果列表中没有:

wr_report/

就应检查:

  • src 路径是否配置正确;
  • 包目录是否存在;
  • 是否缺少 __init__.py
  • 构建后端是否使用了 namespace package;
  • 是否被 include/exclude 规则排除。

src 布局的一个重要效果是:项目根目录中的源码不会因为当前工作目录而自动遮蔽已安装包,因此更容易发现打包遗漏。代价是,未安装时不能直接从项目根目录导入 src 下的包,需要先执行可编辑安装或普通安装。


十二、工具配置:[tool] 不是统一工具 API

以下配置属于不同工具:

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.ruff]
line-length = 100

[tool.mypy]
strict = true

pyproject.toml 只提供了一个共享文件位置,并没有定义 pytest、Ruff、Mypy 的配置语义。每个工具决定:

  • 支持哪些字段;
  • 字段是什么类型;
  • 默认值是什么;
  • 版本之间是否兼容;
  • 不认识的字段是否报错。

因此:

[tool.some-tool]
unknown-option = true

是否有效,不能由 TOML 语法判断。TOML 只负责解析数据结构,工具还要对字段进行自己的校验。

1. TOML 语法错误与工具配置错误不同

TOML 语法错误:

[tool.ruff
line-length = 100

可能导致所有读取 pyproject.toml 的工具都失败。

工具配置错误:

[tool.ruff]
line_lenght = 100

TOML 语法仍然正确,但 Ruff 可能忽略或拒绝这个拼写错误。

诊断时应分两步:

python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb')))"

如果这一步失败,是 TOML 文件问题。

然后分别运行工具:

ruff check .
pytest
mypy src

如果 TOML 能解析而某个工具失败,则检查该工具对应版本的配置文档和诊断信息。

2. 不要把工具配置误当成构建配置

例如:

[tool.pytest.ini_options]
addopts = "-q"

不会改变 wheel 内容。

而:

[tool.setuptools.packages.find]
where = ["src"]

会影响构建后端如何发现包。

这说明 [tool] 下的配置是否影响发行产物,取决于具体工具是否参与构建。测试工具通常不改变发行包;构建后端工具会改变发行包。


十三、常见失败路径与诊断

1. pip install .pip install -e . 结果不同

可编辑安装:

python -m pip install -e .

适合开发,但它可能让源码目录直接参与导入。

普通安装:

python -m pip install .

通常会先构建 wheel,再安装构建结果。

如果可编辑安装成功、普通安装失败,应优先检查:

  • wheel 是否包含源码;
  • package discovery 是否正确;
  • 包数据是否声明;
  • 构建后端是否读取了正确目录。

2. 本地能构建,CI 失败

常见原因是本地环境污染:

python -m pip list

发现本地已经安装了:

setuptools
wheel
cython

[build-system].requires 没有声明它们。

正确诊断方法是使用干净环境:

python3.14 -m venv /tmp/wr-build-check
. /tmp/wr-build-check/bin/activate
python -m pip install --upgrade pip build
python -m build

如果干净环境失败,本地成功只是因为本机预装依赖提供了隐式支持。

3. 入口命令不存在

执行:

wr-report

得到:

command not found

可能有三类原因:

  1. 项目没有安装;
  2. 脚本目录不在 PATH
  3. 入口配置或安装失败。

先检查入口是否已生成:

python -c "import sysconfig; print(sysconfig.get_path('scripts'))"

再检查元数据:

python -c "from importlib.metadata import entry_points; print(list(entry_points(group='console_scripts').select(name='wr-report')))"

如果能看到入口点,但命令找不到,通常是脚本目录没有加入 PATH。安装器负责把脚本写入安装方案中的脚本目录,但不负责替用户修改 PATH。(packaging.python.org)

如果入口点存在但执行时报:

ModuleNotFoundError: No module named 'wr_report.cli'

则说明入口声明指向了不存在的模块,或者 wheel 没有包含该模块:

[project.scripts]
wr-report = "wr_report.cli:main"

要求最终安装环境中确实存在:

wr_report/cli.py

并且其中存在:

def main():
    ...

4. ModuleNotFoundError 与依赖未安装

如果报错是:

ModuleNotFoundError: No module named 'click'

应检查 click 是否进入:

[project]
dependencies = [
    "click>=8.1,<9",
]

如果只写在:

[project.optional-dependencies]
dev = ["click>=8.1"]

那么普通用户安装:

python -m pip install wr-report

不会获得 click。只有:

python -m pip install "wr-report[dev]"

才会安装它。

5. 修改 pyproject.toml 后重新构建仍得到旧结果

构建工具可能复用缓存或旧的构建目录。排查时可以删除:

rm -rf build dist src/*.egg-info
python -m build

但删除目录只解决缓存问题,不能解决配置逻辑错误。重新构建后仍应检查:

python -m zipfile -l dist/*.whl

以及:

python -c "from importlib.metadata import metadata; print(dict(metadata('wr-report')))"

十四、发布前验证:不要只验证源码目录

一个可靠的验证顺序如下:

python3.14 -m venv .venv
. .venv/bin/activate

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

python -m pip install --force-reinstall dist/*.whl

python -c "import wr_report; print(wr_report.__file__)"
wr-report --name Hangzhou

然后在另一个干净目录执行:

mkdir /tmp/wr-report-install-test
cd /tmp/wr-report-install-test

python3.14 -m venv .venv
. .venv/bin/activate

python -m pip install /path/to/wr-report/dist/wr_report-1.0.0-py3-none-any.whl
python -c "import wr_report; print(wr_report.__file__)"
wr-report --name Test

最后测试 sdist:

python -m pip uninstall -y wr-report
python -m pip install /path/to/wr-report/dist/wr_report-1.0.0.tar.gz
wr-report --name FromSdist

这三个安装路径分别验证:

  • 直接安装 wheel;
  • 在项目外安装 wheel;
  • 从 sdist 重新构建并安装。

如果只验证第一种路径,可能遗漏:

  • sdist 缺少 pyproject.toml
  • sdist 缺少 README 或许可证文件;
  • sdist 缺少构建所需源码;
  • wheel 与 sdist 的元数据不一致;
  • 当前源码目录掩盖了安装包缺失。

十五、pyproject.toml 的边界

pyproject.toml 很重要,但它不是所有工程问题的统一解决方案。

它可以声明:

  • 构建后端;
  • 构建依赖;
  • 项目元数据;
  • 运行依赖;
  • 可选依赖;
  • 命令行和插件入口;
  • 工具配置。

它不会自动解决:

  • 具体依赖版本的完整锁定;
  • 操作系统级动态库;
  • 编译器和系统头文件;
  • 私有索引的认证;
  • CI 流水线;
  • 发布签名;
  • 运行时配置和密钥;
  • 数据库迁移;
  • 容器镜像构建。

例如:

requires-python = ">=3.14"

只能声明 Python 版本要求,不能安装 Python 3.14。

又如:

dependencies = ["numpy>=2"]

只能声明 Python 发行包依赖,不能保证系统中存在某个 C 编译器或 GPU 驱动。

因此,理解 pyproject.toml 的核心,不是记住字段名称,而是明确它处于哪一层:

源代码与项目文件
        │
        ▼
pyproject.toml
        │
        ├── build-system:构建输入
        ├── project:发行元数据
        └── tool:工具配置
        │
        ▼
构建后端
        │
        ├── wheel:面向安装
        └── sdist:面向源码分发
        │
        ▼
安装器
        │
        ├── site-packages
        ├── .dist-info
        └── scripts 目录

当项目出现“无法构建、安装后无法导入、入口命令不存在、CI 与本地结果不同”等问题时,应沿着这条数据流逐层检查,而不是只反复修改某一个配置字段。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。