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_metahatchling.buildflit_core.buildapipoetry.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'
这类错误首先应检查:
requires是否包含构建后端;build-backend的模块名是否拼写正确;- 当前构建前端是否使用了隔离环境;
- 构建依赖是否能从当前索引下载。
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 与脚本目录]
关键路径如下:
- 构建前端读取
[build-system]; - 构建前端创建隔离环境;
- 构建前端安装
requires; - 构建前端导入
build-backend; - 后端读取项目源代码与配置;
- 后端生成发行文件;
- 安装器选择合适的 wheel,或者从 sdist 构建 wheel;
- 安装器把代码、元数据和命令行包装器写入目标环境。
因此,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",
]
表示依赖版本满足:
其中 是被解析出的 httpx 版本。版本比较不是简单的字符串比较,不能把 "10.0" 与 "2.0" 按字典序比较。
3. description 与 readme
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. authors 与 maintainers
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. keywords、classifiers 与 urls
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 组中的依赖。
可选依赖的逻辑可以表示为:
其中:
- 是
dependencies; - 是指定 extra 对应的依赖;
- 如果不指定 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"]
适合项目开发测试。
七、静态元数据与 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__",而是发行包安装后暴露给其他程序的声明式接口。
入口点模型包含三个核心属性:
- group:入口属于哪一类;
- name:入口在该组中的名称;
- 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_scripts、gui_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_scripts 或 gui_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
这个测试验证了几个关键事实:
- wheel 中确实包含了
wr_report包; project.scripts被转换成了可执行命令;- 运行时依赖
click被正确安装; - 当前工作目录中的源码不是唯一可用代码来源。
如果只在源码目录中执行:
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.toml、PKG-INFO 以及构建项目所需的其他文件。安装器拿到 sdist 后,通常需要:
- 解压 sdist;
- 创建构建环境;
- 安装
[build-system].requires; - 调用构建后端生成 wheel;
- 安装生成的 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
可能有三类原因:
- 项目没有安装;
- 脚本目录不在
PATH; - 入口配置或安装失败。
先检查入口是否已生成:
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 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 哈希、HMAC 与 TLS:完整性、密码存储、证书和误区
- 下一篇:Python 虚拟环境:venv、解释器隔离、路径和常见污染
- 延伸:Python 包构建与发布:wheel、sdist、索引、签名和版本
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论