一、传统基石:requirements.txt与命令式时代

在深入探讨现代Python打包的复杂性之前,必须首先理解其历史基础。requirements.txt和setup.py文件共同构成了多年来Python依赖管理的支柱。这个时代以命令式、基于脚本的方法为特征,虽然在当时解决了关键问题,但也为后来的声明式转变埋下了伏笔。本部分将解构这一传统体系的起源、功能及其固有的局限性。

1.1 requirements.txt的起源与目的:确保可复现性

在Python开发的早期阶段,项目协作成为了一个日益严峻的挑战。当一个开发者将项目代码分享给另一位同事,或者部署到生产服务器时,如何确保目标环境与开发环境拥有一致的依赖库及其版本,成了一个核心问题。为了解决这个问题,requirements.txt文件应运而生 。

requirements.txt的核心功能是提供一个项目运行所需依赖包的明确列表,其首要目标是确保环境的可复现性(Reproducibility)。它好比一个项目环境的“蓝图”。通过这份蓝图,任何开发者或自动化系统都可以使用一个简单的命令pip install -r requirements.txt 来精确地重建一个经过测试、稳定可靠的Python运行环境。这种能力对于团队协作至关重要,因为它消除了因依赖版本不一致而导致的“在我机器上能跑”的典型问题,保证了所有成员,无论使用何种操作系统(Windows/macOS/Linux),都能在相同的依赖基础上工作。

从历史角度看,requirements.txt并非源于一个正式的PEP(Python Enhancement Proposal,Python增强提案),而是作为一个“意外的标准”(accidental standard)或社区惯例逐渐形成的。它本质上是一系列 pip install 命令的快捷方式,其简洁性和实用性使其迅速普及。这种非正式的起源也解释了它格式相对简单但功能有限的特点。它为Python生态系统提供了一个基础的、易于理解的依赖管理方案,解决了在没有更复杂工具的时代里最紧迫的环境一致性问题。

1.2 requirements.txt的结构与语法

requirements.txt文件的核心是一个简单的文本文件,其基本格式为每行列出一个软件包。最常见的做法是使用== 操作符来“钉住”(pin)一个确切的版本号,例如 requests==2.26.0。这种精确的版本锁定是确保环境稳定性和可靠性的关键,因为它能防止因依赖库更新(尤其是破坏性更新)而引入的非预期行为。

然而,requirements.txt的语法远不止于此。它支持多种灵活的依赖声明方式,这些方式与 pip install命令行的语法高度兼容。开发者可以使用比较操作符来指定版本范围,如>=(大于等于)、<(小于)、~=(兼容版本)等。此外,它还支持更高级的用法如:

  • 从URL安装:可以直接指定一个指向包归档文件(如 .zip 或 .whl)的URL

  • 从本地路径安装:可以指向本地文件系统中的一个包文件

  • 引用其他需求文件:通过 -r other-requirements.txt 语法,可以将一个需求文件嵌套在另一个文件中,这有助于分层管理不同环境(如开发、测试、生产)的依赖

  • 引用约束文件:通过 -c constraints.txt,可以应用一个约束文件,该文件只定义版本限制而不直接安装包

生成requirements.txt最常见的方法是使用pip freeze命令。在激活了项目的虚拟环境后,执行 pip freeze > requirements.txt 会将当前环境中所有已安装的包及其精确版本号输出到文件中。这个过程被称为“冻结”(freezing)环境。

尽管pip freeze非常便捷,但它也存在一个核心问题:它会不加区分地列出虚拟环境中的所有包,这不仅包括项目的直接依赖,还包括这些依赖的依赖(即传递性依赖),甚至还可能包含一些与当前项目无关、仅用于开发者本地实验的包。这导致生成的requirements.txt是一个扁平化的长列表,它掩盖了项目的真实依赖结构。开发者无法从中轻易分辨哪些是项目的顶层、直接需求,哪些是为了满足这些需求而附带安装的。这种信息的缺失使得依赖的维护变得异常困难:当需要升级或移除某个顶层依赖时,开发者很难确定哪些关联的传递性依赖也可以被安全地移除,从而导致requirements.txt文件日益膨胀且难以管理。

1.3 setup.py的角色:一个必要但有缺陷的前身

在pyproject.toml出现之前,setup.py是Python生态系统中用于构建、分发和安装软件包的核心脚本。它通过调用setuptools库中的setup()函数,来定义项目的元数据(如包名、版本、作者等)以及构建指令。

在依赖管理方面,setup.py扮演了一个与requirements.txt截然不同但又常常被混淆的角色。它通过install_requires参数来声明一个软件包的抽象依赖。这里的“抽象”意味着它通常指定一个较为宽松的版本范围(例如 requests>=2.20),而不是一个固定的版本。

install_requires与requirements.txt之间的区别是理解传统Python打包哲学的关键。这个区别源于它们服务的目标不同:

  • install_requires用于可分发的库(Library):当一个项目作为库被其他项目依赖时,它应该声明尽可能宽松的依赖版本。如果一个库锁死了其依赖的具体版本,那么当多个库依赖同一个包的不同版本时,就会引发几乎无解的依赖冲突。因此,库的作者通过install_requires来表达“我的库在这些版本范围内都能正常工作”,将最终版本的选择权留给最终的应用开发者。

  • requirements.txt用于可部署的应用(Application):当一个项目是最终的应用(如一个网站、一个数据分析脚本)时,其首要目标是确保在不同环境中(开发、测试、生产)的部署是完全一致和可复现的。这时,就需要一个包含所有依赖(包括传递性依赖)及其精确版本的“混凝土”列表。requirements.txt正是为了满足这一需求而存在的。

这种“库用setup.py,应用用requirements.txt”的模式虽然在理论上清晰,但在实践中却造成了长期的困惑和争论,许多开发者试图将二者合一,但这违背了它们各自的设计初衷。

1.4 可执行安装脚本的安全隐患:任意代码执行

setup.py模型最根本的缺陷在于,它是一个可执行的Python脚本。在现代打包标准出现之前,当pip需要从源码发行版(sdist,通常是.tar.gz文件)安装一个包时,它无法静态地获知这个包的元数据或构建时依赖。为了解决这个“先有鸡还是先有蛋”的问题,pip唯一的选择就是执行setup.py文件。

这个执行过程是获取包信息的必要步骤,但它也打开了一个巨大的安全漏洞。因为setup.py可以包含任意Python代码,恶意行为者可以将有害代码(如数据窃取、反向shell等)嵌入到setup.py中。当用户或CI/CD系统执行pip install时,这些恶意代码就会在安装过程中被执行。更令人担忧的是,由于pip需要执行setup.py来解析依赖关系,即使是pip downloadpip install --dry-run这样的看似无害的命令,也可能触发代码执行。

这种任意代码执行的风险是软件供应链安全中的一个严重问题。攻击者只需在依赖树的任何一个环节中插入一个使用恶意setup.py的源码包,就可能危及整个应用。这个根本性的安全风险,成为了推动Python社区从命令式的setup.py转向声明式的pyproject.toml配置文件的最主要动力之一。

旧的打包体系虽然在一定程度上满足了可复现性和包分发的需求,但其内在的矛盾和安全隐患也日益凸显。pip freeze提供了可复现性,但牺牲了依赖关系的可维护性。setup.py提供了包定义的能力,但其可执行的本质带来了严重的安全风险。整个生态系统缺乏一个统一、安全、声明式的方式来管理项目。正是这些无法调和的痛点,催生了以pyproject.toml为核心的现代打包转变。

二、现代标准:pyproject.toml与声明式转变

面对传统setup.py体系的碎片化、不安全和维护困难等诸多挑战,Python社区通过一系列具有里程碑意义的Python增强提案(PEPs),开启了一场深刻的打包转变。这场转变的核心是引入pyproject.toml文件,旨在用一个统一的、声明式的配置文件,取代过去混乱的、命令式的脚本,从而重塑整个Python打包生态。

2.1 变革的催化剂:setup.py生态系统的局限性

在pyproject.toml诞生之前,Python项目的配置状态可以用“混乱”来形容。一个典型的项目根目录可能散落着多个配置文件:setup.py用于打包,requirements.txt用于环境依赖,setup.cfg用于setuptools的静态配置,MANIFEST.in用于指定包含的文件,以及tox.ini, .coveragerc, .flake8等用于各种开发工具的配置文件。这种碎片化的配置方式不仅增加了项目的认知负担和维护成本,也导致了项目结构的不一致性。

然而,比混乱更严重的是setup.py体系的两个根本性技术缺陷:

  1. 安全风险:正如前文所述,setup.py的可执行性使其成为一个潜在的任意代码执行入口,对软件供应链安全构成严重威胁。

  2. 引导问题(Bootstrapping Problem):这是一个经典的“鸡生蛋还是蛋生鸡”的难题。一个项目可能需要特定版本的构建工具(如setuptools或Cython)来正确执行其setup.py脚本。但是,安装工具(如pip)在执行setup.py之前,无法知道这些构建时依赖是什么。pip只能预先假设所有项目都依赖于setuptools和wheel,这使得任何想要使用其他构建系统的项目都面临着无法向pip传达其需求的困境。

正是为了解决这个致命的引导问题,并为解决更广泛的配置混乱和安全问题铺平道路,pyproject.toml应运而生。

2.2 PEP驱动的演进:标准化的三部曲

pyproject.toml的崛起并非一蹴而就,而是通过一个精心设计、分阶段实施的标准化过程实现的。这个过程可以被视为一部由三个关键PEP主演的“三部曲”,每一部都解决了前一个时代的一个核心痛点。

2.2.1 第一幕:PEP 518 - 规范构建系统依赖

PEP 518于2016年被接受,它标志着pyproject.toml文件的正式诞生。这个PEP的目标非常专注:解决构建系统的引导问题。它规定,Python项目可以在其根目录中包含一个名为pyproject.toml的文件,该文件采用TOML(Tom's Obvious, Minimal Language)格式。

PEP 518的贡献是定义了[build-system]这个表(table)。在这个表中,项目可以明确地、声明式地列出其构建过程所需的依赖包及其版本要求。一个典型的[build-system]表示例:

[build-system]
requires = ["setuptools>=61.0", "wheel"]

这个简单的配置向构建前端(如pip)传递了一个清晰的信号:在尝试构建这个项目之前,请先确保在一个隔离的环境中安装了setuptools(版本至少为61.0)和wheel。这完美地解决了引导问题,使得pip等工具可以在不执行任何项目代码的情况下,安全地准备好构建环境。

2.2.2 第二幕:PEP 517 - 统一的构建后端/前端接口

在PEP 518解决了“如何准备构建环境”的问题后,PEP 517接着解决了“如何进行构建”的问题。它定义了一个标准的、独立于具体构建工具的接口(API),将构建过程清晰地划分为两个角色:构建前端(build frontend)和构建后端(build backend)。

  • 构建前端:用户直接交互的工具,如pip或build。它的职责是解析pyproject.toml,准备好[build-system].requires中指定的构建环境,然后调用构建后端的标准接口来执行构建

  • 构建后端:实际执行构建工作的库,如setuptools、hatchling或flit-core。它必须实现PEP 517定义的一组“钩子”(hooks),如build_wheel和build_sdist

为了实现这一点,PEP 517扩展了[build-system]表,增加了一个关键字段build-backend。这个字段指定了实现了构建API的Python对象的入口点。

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

PEP 517将构建过程与setuptools完全解耦,打破了后者在Python打包领域事实上的垄断地位。这催生了一个充满活力的、竞争性的构建后端生态系统,开发者可以根据项目需求自由选择最适合的构建工具,而无需担心与pip的兼容性问题。

2.2.3 第三幕:PEP 621 - 统一项目元数据

尽管PEP 517和518解决了构建过程的标准化问题,但项目的核心元数据(如名称、版本、依赖等)仍然依赖于各个构建后端自己的方式来定义,通常还是通过执行setup.py或解析setup.cfg。为了彻底实现声明式配置,PEP 621应运而生。

PEP 621规定了一个标准的[project]表,用于在pyproject.toml中静态地声明项目的核心元数据。这个表中的字段,如name、version、dependencies等,直接对应于Python包的核心元数据标准。

[project]
name = "my-awesome-package"
version = "1.0.0"
description = "A short description of my package."
dependencies = [
"requests>=2.28.0",
]

PEP 621的通过,意味着对于绝大多数项目而言,setup.py文件不再是必需品。所有关键的项目信息都可以集中在一个静态、易于人类阅读和机器解析的TOML文件中。这不仅极大地提高了安全性(因为不再需要执行代码来获取元数据),也为所有打包工具提供了一个统一的、可靠的元数据来源,实现了“单一事实来源”(single source of truth)的理想状态。

这一系列PEP的演进过程,展现了Python社区成熟的工程思维。它通过分阶段解决最关键的问题——首先是构建引导,然后是构建接口,最后是元数据声明——成功地对整个打包基础设施进行了现代化改造,而没有对生态系统造成剧烈冲击。pyproject.toml正是这一历时数年、深思熟虑的改革的结晶。

2.3 pyproject.toml的结构剖析:深度解读

一个现代的pyproject.toml文件主要由三个核心的顶级表组成:[build-system][project][tool]。每个表都承担着不同的职责,共同构成了一个完整的项目配置中心。

2.3.1 [build-system]表:指定你的构建工具

这是由PEP 518引入的、强烈推荐包含的表。它的存在告知了构建前端(如pip)如何处理你的项目。

  • requires: 一个字符串列表,定义了构建项目所需的依赖包。这里必须包含你选择的构建后端,例如setuptools或hatchling。如果构建过程还需要其他工具(比如用setuptools-scm从Git标签动态生成版本号),也需要在这里声明。

  • build-backend: 一个字符串,指定了构建后端的入口点。这是构建前端用来调用构建过程的钩子函数的路径。

2.3.2 [project]表:项目元数据的单一事实来源 (PEP 621)

这是由PEP 621标准化的核心部分,用于声明式地定义项目的所有元数据。

  • 核心字段:包括name(包名)、version(版本号)、description(简短描述)、readme(指向README文件的路径)、requires-python(要求的Python版本)、license(许可证信息)、authors/maintainers(作者/维护者信息)、keywords(关键词)和classifiers(PyPI分类器)等。这些字段是静态的,取代了过去在setup.py中通过setup()函数参数传递的信息。

  • 依赖声明:

    • dependencies: 一个数组,用于列出项目的运行时依赖(即抽象依赖)。
    • optional-dependencies: 一个表,用于定义“附加功能”(extras)。这允许用户根据需要安装额外的依赖组,常用于定义开发(dev)、测试(test)或文档(docs)等不同场景的依赖。例如,用户可以通过pip install "my-package[dev,test]"来安装开发和测试所需的依赖。
  • 脚本与入口点:

    • [project.scripts]:这个表用于创建命令行脚本。表中的键是脚本名,值是module.path:function_name格式的字符串。安装后,用户可以直接在命令行中运行该脚本。
    • [project.entry-points]: 用于更高级的插件系统注册。例如,一个pytest插件会在这里注册自己,以便pytest能够发现它。
  • 动态元数据:dynamic字段是一个字符串数组,允许将某些元数据的最终确定推迟到构建时。当一个字段被标记为dynamic时,构建后端负责在构建过程中计算并提供其值。一个常见的用例是版本号,可以使用setuptools-scm等工具从Git标签或版本控制历史中动态生成版本号,避免了手动更新pyproject.toml中的version字段。

2.3.3 [tool]表:集中化生态系统配置

[tool]表是pyproject.toml最具扩展性的部分。它为各种第三方开发工具提供了一个统一的、官方的配置命名空间。在此之前,项目根目录常常被各种“点文件”(dotfiles)所占据,如.isort.cfg, .flake8, mypy.ini等。pyproject.toml通过[tool]表极大地改善了这一状况,使得项目配置更加整洁和集中。

几乎所有现代Python开发工具现在都支持在pyproject.toml中进行配置。配置项通常位于以工具名命名的子表中,例如:

  • [tool.ruff]: 配置强大的Rust-based代码检查和格式化工具Ruff

  • [tool.black]: 配置代码格式化工具Black

  • [tool.isort]: 配置导入排序工具isort

  • [tool.pytest.ini_options]: 配置测试框架Pytest

  • [tool.mypy]: 配置静态类型检查工具Mypy

  • [tool.poetry], [tool.pdm], [tool.hatch]:集成式工作流管理器也使用[tool]表来存放它们特有的配置,这些配置超出了PEP 621定义的[project]表的范围。

这种集中化的配置方式不仅减少了项目根目录的文件数量,还使得所有工具的配置都置于版本控制之下,方便团队成员共享和保持一致。

下面的表格为希望从旧系统迁移的开发者提供了一个快速参考,清晰地展示了传统setup.py或setup.cfg中的配置项如何映射到现代pyproject.toml的[project]表中。

pyproject.toml 字段 ([project] 表)setup.py setup() 参数setup.cfg [metadata] 或 [options] 键描述
namenamename包的发布名称,在PyPI上必须唯一
versionversionversion包的版本号。可以标记为dynamic以实现动态版本控制
descriptiondescriptiondescription项目的单行简短描述
readmelong_description, long_description_content_typelong_description指向包含详细描述的文件的路径(如README.md)
requires-pythonpython_requirespython_requires指定项目兼容的Python版本范围,如">=3.8"
licenselicenselicense项目的许可证,推荐使用SPDX标识符
authors / maintainersauthor, author_email / maintainer, maintainer_emailauthor, author_email / maintainer, maintainer_email作者和维护者信息,以包含name和email的表数组形式提供
dependenciesinstall_requiresinstall_requires项目运行时抽象依赖列表
optional-dependenciesextras_requireoptions.extras_require可选依赖组,用于实现dev, test等附加功能
scriptsentry_points (用于 console_scripts)options.entry_points创建命令行可执行脚本
urlsurl, project_urlsproject_urls在PyPI上展示的额外链接,如项目主页、文档、源码仓库等
classifiersclassifiersclassifiersPyPI分类器列表,用于项目分类和搜索

三、对比分析:项目定义与环境规约

在现代Python打包生态中,最核心也最容易被误解的概念,是pyproject.toml和requirements.txt在哲学层面的根本区别。前者用于定义一个项目,而后者用于规约一个环境。这种区别并非简单的语法或功能差异,而是两种截然不同目的的体现。理解这一核心二元性,是掌握现代Python依赖管理、避免常见陷阱的关键。

3.1 核心二元性:抽象依赖与具体依赖

依赖管理的核心挑战在于平衡“兼容性”与“可复现性”。这两个目标在不同场景下具有不同的优先级,并分别由抽象依赖和具体依赖来满足。

  • 抽象依赖 (Abstract Dependencies) - pyproject.toml的领域抽象依赖定义了一个项目需要什么,但对其具体版本保留了一定的灵活性。它们通常使用版本范围来表达,例如 django>=4.2,<5.0requests~=2.28 。其核心目的是声明项目与某个依赖库在一个较宽版本范围内的兼容性。这种灵活性至关重要,因为它最大化了一个库被其他不同应用成功集成的可能性,避免了因版本限制过于严格而导致的“依赖地狱”(dependency hell)。因此,pyproject.toml中的[project].dependencies是声明抽象依赖的标准场所,其主要服务于库(Library)的开发和分发。

  • 具体依赖 (Concrete Dependencies) - requirements.txt或锁文件的领域具体依赖则精确地定义了一个已知可工作的环境中,所有软件包(包括直接依赖和所有层级的传递性依赖)的确切版本号,例如 django==4.2.4, asgiref==3.7.2。其唯一目的是“锁定”(lock)环境,确保每一次安装都能创建一个与测试或上一次部署时完全相同的环境,从而实现确定性的构建和部署requirements.txt(尤其是由pip freeze或pip-compile生成的)或现代工具的锁文件(如poetry.lock, pdm.lock)是承载具体依赖的载体,其主要服务于应用(Application)的部署。

3.2 用例分析:库 vs. 应用

根据上述定义,pyproject.toml和requirements.txt在库开发和应用开发中扮演着截然不同的角色。

  • 库(Libraries)的开发:一个库的作者必须在pyproject.toml中通过dependencies字段来指定抽象依赖。这是因为库本身是作为其他项目的一部分存在的。如果库作者在install_requires(旧方法)或pyproject.toml中钉死了具体版本,比如requests==2.25.0,那么任何一个需要requests==2.26.0的应用都将无法同时使用这个库,从而导致依赖冲突。库作者的责任是确保其代码的健壮性,使其能与依赖的多个版本兼容,并将最终选择具体哪个版本的决定权交给应用开发者。

  • 应用(Applications)的开发与部署:一个应用的开发者的最终目标是将一个可工作的软件实体部署到生产环境。对于应用而言,可复现性是最高优先级。因此,应用开发的工作流通常如下:

    1. 在pyproject.toml中声明应用的顶层抽象依赖
    2. 使用一个依赖解析工具(如pip-tools、Poetry或PDM)来计算出满足所有抽象依赖约束的一个具体的、完整的依赖集合
    3. 将这个具体的依赖集合固化(lock)到一个文件中,即requirements.txt或工具特定的锁文件
    4. 在部署时(无论是部署到服务器还是构建Docker镜像),使用这个锁文件来安装依赖,从而确保生产环境与开发和测试环境完全一致

在这个上下文中,所谓的“更高级别的打包”(higher-level packaging),如Docker,与requirements.txt的配合就显得非常自然。Docker打包的是整个运行环境,包括操作系统和Python解释器。requirements.txt作为一个精确的环境规约文件,是pip install在Docker镜像构建过程中最理想的输入,它能确保镜像内的Python环境是精确可控的。

3.3 弥合差距:现代工作流中如何并用二者

现代Python的最佳实践并非在pyproject.toml和requirements.txt之间二选一,而是将它们整合到一个结构化的工作流中,各司其职。这种工作流清晰地分离了“人类的意图”和“机器的执行”。

  1. pyproject.toml作为单一事实来源 (Single Source of Truth):在这个模型中,pyproject.toml是唯一需要人类手动维护的文件。开发者在这里声明项目的顶层、直接依赖,包括生产环境所需的(在[project].dependencies中)和仅开发所需的(在[project.optional-dependencies]中)。这个文件表达了项目的“意图”。

  2. requirements.txt作为生成的构建产物 (Generated Artifact):requirements.txt(或多个requirements-*.txt文件)不再是手动编辑的文件,而是由工具自动生成的。这个生成过程被称为“编译”或“锁定”。工具(如pip-compile)会读取pyproject.toml中的抽象依赖,运行复杂的依赖解析算法,找到一个满足所有约束的、无冲突的具体版本组合,然后将这个完整的、扁平化的依赖列表写入requirements.txt。这个生成的文件是一个“锁文件”,它应该被提交到版本控制系统中,但不应被手动修改。

这个工作流的转变,是现代Python依赖管理思想成熟的体现。它解决了传统方法的诸多痛点:

  • 可维护性:开发者只需关心顶层依赖,无需手动追踪和管理成百上千的传递性依赖

  • 清晰性:项目的直接需求清晰地记录在pyproject.toml中,新成员可以快速理解项目结构

  • 可复现性:生成的requirements.txt确保了跨环境的一致性

  • 自动化:依赖的升级和管理可以通过工具自动化完成,减少了人为错误

3.4 并列比较:一份权威性的清单

为了更直观地总结两者的差异,下表从多个维度对pyproject.toml和requirements.txt进行了详细的比较。

特性/维度pyproject.tomlrequirements.txt
主要目的定义一个可分发的项目(库或应用)定义一个可复现的环境
依赖类型抽象的 (Abstract),通常是灵活的版本范围,如 requests>=2.20具体的 (Concrete),通常是固定的版本,如 requests==2.26.0
目标受众库的作者;应用的开发者(作为依赖声明的起点)部署脚本、CI/CD流水线、应用开发者(用于环境锁定)
编写者主要由人类手动编写和维护主要由机器(工具如 pip freeze, pip-compile)生成
内容范围综合性:项目元数据、构建系统配置、依赖、工具配置等专注性:仅包含Python包的安装列表
标准化由PEP 517, 518, 621等正式规范定义事实标准,格式较为松散,但被广泛接受
安全性声明式,避免了setup.py的任意代码执行风险可通过--hash选项包含文件哈希,用于包完整性校验
在现代工作流中的角色单一事实来源 (Source of Truth),定义项目的意图生成的构建产物 (Generated Artifact),作为锁文件实现可复现性

这个从“抽象 vs. 具体”的核心区别出发的思考过程,揭示了Python打包工具演进的内在逻辑。早期的开发者常常混淆这两个概念,导致了脆弱的库和不可复现的应用。社区通过最佳实践的传播和工具的演进,逐渐确立了“抽象在setup.py,具体在requirements.txt”的模式。

pyproject.toml继承并优化了setup.py的“抽象”角色,使其更加安全和规范。然而,如何从抽象依赖平滑地过渡到具体依赖,仍然是一个挑战。这正是pip-tools以及后来的Poetry、PDM等集成化工具出现的原因。它们的核心价值之一就是作为“锁文件生成器”,自动化地将开发者在pyproject.toml中表达的“意图”转化为可执行的、具体的“环境规约”。因此,现代工作流的精髓并非pyproject.toml与requirements.txt的对立,而是pyproject.toml → [工具] → requirements.txt/锁文件的协作流程。

四、实践指南与现代工作流

理论的理解最终需要通过实践来巩固。本部分将提供针对常见开发场景的、可操作的、分步的工作流指南。这些指南将前几部分的概念——抽象与具体依赖、pyproject.toml的结构、以及现代工具——整合到实际操作中,展示如何高效、规范地管理现代Python项目。

4.1 工作流一:编写一个可分发的库

此工作流的目标是创建一个旨在发布到PyPI(Python Package Index)或其他包索引的库,供其他开发者使用。核心原则是最大化兼容性。

步骤:

  1. 项目初始化与pyproject.toml创建:在项目根目录创建一个pyproject.toml文件。这是项目的起点和配置中心。

  2. 选择并配置构建后端:在[build-system]表中声明构建工具。对于大多数纯Python库,hatchling、setuptools或flit-core都是优秀的选择。

    [build-system]
    requires = ["hatchling"]
    build-backend = "hatchling.build"
    
  3. 填充项目元数据:在[project]表中详细填写项目的元数据。这些信息将展示在PyPI上,是用户了解和发现你的库的关键。

    [project]
    name = "my-data-utils"
    version = "0.1.0"
    description = "A utility library for common data processing tasks."
    readme = "README.md"
    requires-python = ">=3.8"
    license = { text = "MIT" }
    authors = [
      { name = "Your Name", email = "[email protected]" }
    ]
    classifiers =
    
  4. 定义抽象的运行时依赖:在[project].dependencies数组中列出库正常运行所必需的依赖。关键在于使用抽象的版本说明符,以提供最大的灵活性。

    [project]
    #...
    dependencies = [
      "pandas>=1.5,<3.0",
      "numpy>=1.22"
    ]
    
  5. 定义可选的开发依赖:将仅用于开发、测试和文档构建的依赖放入[project.optional-dependencies]表中。这使得贡献者可以轻松设置开发环境,而普通用户则不会被强制安装这些额外的包。

    [project.optional-dependencies]
    dev = ["pytest", "ruff", "mypy"]
    docs = ["sphinx", "furo"]
    

    贡献者可以使用pip install -e ".[dev,docs]"来安装这些依赖。

  6. 构建分发包:使用一个构建前端(如build包)来生成源码分发包(sdist)和二进制分发包(wheel)。

    # First, install the build tool
    pip install build
    
    # Then, run the build
    python -m build
    

    此命令会读取pyproject.toml,调用指定的构建后端,并在dist/目录下生成.tar.gz和.whl文件,这些文件即可用于上传到PyPI。

4.2 工作流二:开发一个可部署的应用

此工作流的目标是开发一个最终产品(如Web应用、数据管道),并为其创建一个可锁定的、完全可复现的部署环境。

4.2.1 方法A:使用pip-tools进行依赖锁定

这种方法保持了pip生态系统的核心,同时引入了强大的依赖编译能力。

  1. 项目设置: 遵循工作流一的步骤1-3,创建pyproject.toml并配置好元数据。

  2. 声明应用依赖: 在pyproject.toml的[project].dependencies中声明应用的顶层依赖,在[project.optional-dependencies].dev中声明开发依赖。

  3. 生成锁文件: 使用pip-tools中的pip-compile命令来生成requirements.txt锁文件。这个过程会解析pyproject.toml中的依赖,并生成一个包含所有直接和传递性依赖的精确版本列表。

    # Install pip-tools
    pip install pip-tools
    # Compile production dependencies
    pip-compile -o requirements.txt pyproject.toml
    # Compile development dependencies, including production ones
    pip-compile --extra dev -o requirements-dev.txt pyproject.toml
    

    这将创建两个文件:requirements.txt用于生产环境,requirements-dev.txt用于开发环境。

  4. 安装依赖:在开发时,使用pip-sync(pip-tools的一部分)或pip install来同步环境。pip-sync会确保虚拟环境与锁文件完全一致,包括卸载多余的包。

    # Sync development environment
    pip-sync requirements-dev.txt
    

    在生产部署时,使用生产锁文件:

    pip install -r requirements.txt
    

4.2.2 方法B:使用集成式工作流管理器(如Poetry, PDM)

这类工具提供了一个更加无缝的、一体化的体验,将依赖声明、锁定和环境管理整合到一套命令中。

  1. 项目初始化:使用工具的init命令来创建pyproject.toml并设置项目。

    # Using Poetry
    poetry init
    # Using PDM
    pdm init
    

    这将引导你完成元数据的配置。

  2. 添加和管理依赖:使用工具的add命令来添加依赖。这个命令会自动更新pyproject.toml中的依赖声明,并立即解析和更新锁文件(poetry.lock或pdm.lock)。

    # Add a production dependency
    poetry add requests
    pdm add requests
    
    # Add a development dependency
    poetry add pytest --group dev
    pdm add pytest --dev
    
  3. 安装依赖:使用install命令从锁文件安装所有依赖。工具会自动管理虚拟环境。

    poetry install
    pdm install
    

    这会创建一个与poetry.lock或pdm.lock文件精确匹配的环境。

  4. 导出requirements.txt(如果需要):如果你需要为外部系统(如Docker构建或云平台部署)提供一个标准的requirements.txt文件,这些工具通常提供export命令。

    poetry export -f requirements.txt --output requirements.txt --without-hashes
    pdm export -o requirements.txt
    

    这会从其内部锁文件中生成一个requirements.txt文件。

4.3 工作流三:配置开发环境

pyproject.toml的[tool]表是统一开发工具配置的利器,可以显著减少项目根目录下的配置文件数量。步骤:

  1. 打开pyproject.toml文件

  2. 配置代码格式化工具 (Black):在[tool.black]表中设置行长、目标Python版本等。

    [tool.black]
    line-length = 119
    target-version = ['py311']
    
  3. 配置Linter和格式化器Ruff:Ruff功能强大,其配置项也较多,可以精细控制规则、排除文件等。

    [tool.ruff]
    line-length = 119
    select = # E/F/W=pycodestyle/pyflakes, I=isort, B=flake8-bugbear
    ignore = ["E501"] # Ignore line-too-long
    [tool.ruff.lint.isort]
    known-first-party = ["my_project"]
    
  4. 配置测试框架(Pytest):在[tool.pytest.ini_options]表中定义测试路径、默认命令行参数、标记等。

    [tool.pytest.ini_options]
    minversion = "7.0"
    testpaths = ["tests"]
    addopts = "-ra -q --cov=my_project --cov-report=html"
    

4.4 工作流四:从setup.py迁移到pyproject.toml

对于希望现代化的遗留项目,迁移过程可以平滑地进行。

步骤:

  1. 创建pyproject.toml并添加[build-system]:首先,在项目根目录创建pyproject.toml文件,并添加[build-system]表,明确指定setuptools为构建后端。这会立即让pip等现代工具识别并采用PEP 517构建流程。

    [build-system]
    requires = ["setuptools>=61.0"]
    build-backend = "setuptools.build_meta"
    
  2. 迁移静态元数据:将setup.py中setup()函数调用的所有静态参数(如name, version, author, description, install_requires, extras_require, python_requires等)以及setup.cfg中的相应配置,逐一迁移到pyproject.toml的[project]表中。可以参考本报告第二部分的映射表(Table 1)。

  3. 处理动态元数据:如果setup.py中包含动态逻辑(例如,从文件中读取版本号,或动态生成long_description),在pyproject.toml的[project]表中将相应的字段标记为dynamic。

    [project]
    name = "my-legacy-project"
    dynamic = ["version"]
    #...
    

    同时,保留setup.py中计算这些动态值所需的最小化代码。

  4. 简化或移除setup.py:

    • 如果所有元数据和配置都已迁移到pyproject.toml和setup.cfg,setup.py可以被简化为一个最小的“垫片”(shim):
      from setuptools import setup
      setup()
      
      这个文件的存在是为了兼容一些仍旧期望setup.py存在的旧工具或工作流。
    • 如果项目足够简单,并且所有配置都在pyproject.toml中,setup.py文件甚至可以被完全移除

通过这些实际的工作流程,可以看出,现代Python开发的核心思想是“关注点分离”。开发者负责管理项目的“意图”(在pyproject.toml中声明抽象依赖和配置),而工具则负责处理复杂的“实现”(解析依赖、生成锁文件、构建包)。这种自动化和标准化的结合,使得依赖管理从一门玄学变成了一项可靠的工程实践。

五、更广阔的生态系统与未来轨迹

Python的打包生态系统正处于一个前所未有的快速发展阶段。pyproject.toml的标准化不仅统一了项目配置,更催生了一系列创新的工具和标准。本部分将视野拓宽,审视构成这个现代生态系统的关键参与者——从构建后端到集成式工作流管理器,再到重塑性能预期的Rust工具链,并展望即将到来的标准化锁文件格式,共同描绘出Python打包的未来蓝图。

5.1 现代构建后端巡礼

PEP 517的出现打破了setuptools的垄断,催生了多个各具特色的构建后端。开发者可以根据项目需求自由选择。

  • setuptools:作为历史最悠久、使用最广泛的后端,setuptools依然是功能最全面的选择,尤其是在处理复杂的构建场景,如包含C/C++扩展模块时。它现在完全兼容pyproject.toml,能够与现代工作流无缝集成。

  • hatchling:作为Hatch工作流管理器的默认后端,hatchling是一个现代、设计精良且高度可扩展的构建后端。它严格遵循标准,并通过插件系统支持自定义构建逻辑。

  • pdm-backend:PDM的默认后端,其一个显著特点是灵活性。它可以在构建过程中委托其他后端(如setuptools)来处理特定任务,例如编译C扩展,这为混合项目提供了极大的便利。

  • maturin:这是一个专为Rust语言设计的构建后端。它与PyO3框架紧密集成,极大地简化了将高性能Rust代码打包成Python扩展模块的过程。对于希望利用Rust提升性能的Python项目来说,maturin是不可或缺的工具。

5.2 集成式工作流管理器的崛起:Poetry, PDM, Hatch, 与 uv

这些工具的共同目标是提供一个“一站式”的解决方案,覆盖从项目初始化、依赖管理、虚拟环境、测试、构建到发布的整个开发生命周期。

  • Poetry: 作为这个领域的先驱之一,Poetry以其强大的依赖解析器和一体化的体验赢得了大量用户。它使用自己的poetry.lock文件来确保可复现性。尽管在历史上对PEP 621的遵循稍有滞后(使用[tool.poetry]而非[project]表来定义元数据),但它仍在积极向标准靠拢。

  • PDM (Python Development Master): PDM在功能上与Poetry类似,但它有一个独特的区别:它支持PEP 582(尽管该PEP最终被拒绝)。这意味着PDM可以选择将依赖安装在项目本地的pypackages目录中,而不是传统的虚拟环境中,提供了类似Node.js node_modules的体验。

  • Hatch: Hatch的核心优势在于其强大的、可扩展的环境管理能力。它允许开发者轻松定义和管理用于测试、文档构建等不同目的的多个环境矩阵(类似于tox),非常适合需要跨多个Python版本进行测试的库项目。

  • uv: uv是这个领域最新的、最具颠覆性的参与者。它是一个用Rust编写的包安装器和解析器,旨在作为pip和pip-tools的高性能替代品。uv的速度极快,并且可以作为独立工具使用,也可以被PDM等其他工作流管理器集成为其底层引擎。

特性PoetryPDMHatchuv
主要焦点一体化项目管理一体化项目管理,支持PEP 582可扩展的环境/任务运行器高速安装器/解析器
PEP 621 兼容性部分(使用[tool.poetry]不适用(消费[project]
锁文件poetry.lockpdm.lock无原生锁文件(有插件)uv.lock(或生成requirements.txt)
环境管理管理自己的venv管理venv或使用PEP 582强大的环境矩阵venv创建与管理
实现语言PythonPythonPythonRust
独特功能成熟的生态系统支持PEP 582(无venv模式)类似tox的环境矩阵极致的速度

5.3 性能的迫切需求:Rust如何重塑Python工具链

近年来,Python生态系统的一个最重要趋势是用Rust等高性能编译语言重写核心开发工具。对于代码检查、格式化、依赖解析和安装这类CPU密集型任务,Python作为解释型语言的性能瓶颈日益明显。

  • 案例研究:ruff ruff是一个用Rust编写的linter和formatter,其性能比传统的Python工具(如Flake8, Black, isort)快10到100倍。这种速度上的飞跃使得实时linting(在每次保存文件时检查)和在CI/CD流水线中快速完成代码质量检查成为可能,极大地改善了开发体验。

  • 案例研究:uv uv同样是用Rust编写的包管理器,其在依赖解析和安装方面的速度比pip、Poetry或PDM快几个数量级。对于拥有大量依赖的大型项目,uv可以将原本需要数分钟的安装过程缩短到几秒钟。

这一趋势正在从根本上改变开发者对工具性能的期望。它催生了一种新的“最佳组合”工具链模式:开发者使用Python编写应用逻辑,同时使用Rust编写的高性能工具来支持开发过程。

5.4 新的前沿:使用pylock.toml实现标准化锁文件 (PEP 751)

尽管Poetry和PDM等工具都有锁文件机制,但它们各自的格式互不兼容,这造成了新的“碎片化”问题。Python生态系统长期以来缺少一个官方的、所有工具都能理解和生成的锁文件标准。

  • 失败的尝试PEP 665:一个早期的锁文件标准提案PEP 665最终被拒绝。主要原因是它不支持源码发行版sdist,这对于许多项目,特别是需要从源码编译的科学计算社区来说,是不可接受的。

  • 成功的解决方案PEP 751:吸取了PEP 665的教训,定义了一个名为pylock.toml的标准锁文件格式。它明确支持sdist,并设计为默认安全(强制要求包含文件哈希值),旨在成为一个由机器生成的、可靠的环境规约。

  • 未来影响:虽然工具的全面采纳尚需时日,但pylock.toml的标准化承诺将为Python的依赖锁定带来前所未有的互操作性。未来,开发者将能够使用一个工具(如PDM)生成pylock.toml文件,然后使用另一个工具(如pip或uv)来安装它,这与npm的package-lock.json或Rust的Cargo.lock所扮演的角色类似。

5.5 治理与稳定:Python打包委员会的角色 (PEP 772)

为了应对日益复杂的打包生态系统,并为其提供更稳定、更专注的治理,Python社区正在组建一个正式的Python打包委员会(Python Packaging Council)。这个委员会将拥有对打包相关标准的决策权,旨在提供比过去松散的PyPA组织或职责宽泛的Python指导委员会更有效的治理结构,以确保Python打包的未来发展方向是清晰和可持续的。

整个Python打包生态正经历着一场深刻的变革。一方面,通过pyproject.toml和新接受的pylock.toml,配置标准正在走向“大一统”,形成一个从意图声明到具体实现的全链条声明式体系。另一方面,核心工具正受益于Rust等高性能语言,经历着一场“大加速”。pyproject.toml的标准化为工具创新提供了稳定的基石,而这些工具的竞争和发展又反过来推动了新标准的诞生(如对标准化锁文件的需求)。未来的Python开发,将由这种“标准趋同”和“工具加速”的双重力量共同定义,为开发者带来更安全、更高效、更一致的体验。

六、战略建议与企业级考量

将现代Python打包的理论和工具应用于实际项目中,需要根据具体场景做出战略性选择。本节旨在提供明确的指导建议,并探讨在企业环境中至关重要的安全性和私有化基础设施等议题。

6.1 新项目启动指南:选择你的技术栈

为新项目选择合适的打包和依赖管理工具,是确保项目长期健康发展的关键第一步。

  • 对于库(Libraries)项目: 目标是创建可供他人使用的、兼容性强的软件包。 建议:采用一个最小化的pyproject.toml配置,并选择一个严格遵循标准的构建后端,如hatchling或setuptools。避免使用那些会强加自身生态系统行为的集成式工作流管理器。核心是保持包的“纯净”,不给下游用户带来不必要的约束。

  • 对于应用(Applications)项目: 目标是快速开发并实现可复现的部署。 建议:直接采用一个功能齐全的集成式工作流管理器。

    • Poetry/PDM:对于追求稳定、一体化体验的团队,这两个工具都是成熟且可靠的选择
    • Hatch:如果项目的核心需求是跨多个Python版本进行严格的测试(类似于tox的工作流),Hatch是最佳选择
    • uv:对于性能要求极高或偏爱更精简工具链(如uv + venv)的开发者,它的速度优势能显著提升开发和CI/CD的效率

总体推荐:用pyproject.toml作为配置中心,并结合uv进行依赖安装和解析,可以获得速度、标准兼容性和简洁性的最佳平衡。

6.2 企业级依赖管理最佳实践

在企业环境中,依赖管理不仅是技术问题,更是核心的安全议题。

6.2.1 软件供应链安全与哈希校验

  • 面临的风险:软件供应链攻击,如拼写错误劫持(typo-squatting)和依赖混淆(dependency confusion),是真实且日益增长的威胁。攻击者可能发布与常用包或内部包名称相似的恶意包,诱导开发者或自动化系统安装。

  • 解决方案:强制哈希校验,为了确保所安装的包未被篡改,必须对其进行完整性校验。这通过使用加密哈希(如SHA-256)来实现。

    1. 生成带哈希的锁文件: pip-tools等工具可以通过pip-compile --generate-hashes命令,在生成requirements.txt时为每个包添加其文件哈希值。
    2. 强制校验安装: 在安装时,使用pip install --require-hashes -r requirements.txt命令。该标志会开启pip的哈希校验模式:pip会下载包,计算其哈希值,并与requirements.txt中记录的哈希值进行比对。如果不匹配,安装将失败。这能有效抵御中间人攻击或仓库被篡改的风险。
  • 未来的强化:pylock.toml,新接受的pylock.toml标准(PEP 751)将哈希校验从一个“可选的最佳实践”提升为“默认的强制要求”。在该标准中,哈希值是每个包条目的必需字段,这将进一步加固Python生态系统的安全性。

6.2.2 与私有包索引的集成(如JFrog Artifactory, AWS CodeArtifact)

  • 企业需求:企业通常会部署私有包索引(或称二进制仓库管理器)。其主要目的有两个:

    1. 缓存公共包:作为PyPI的代理,缓存所有外部依赖。这可以提高下载速度,增强构建的稳定性(不受PyPI服务中断的影响),并对依赖进行安全扫描。
    2. 托管内部包:存放企业内部开发的、不希望公开的专有Python包。
  • 安全配置:在配置pip或其他工具以使用私有索引时,一个至关重要的安全实践是区分--index-url--extra-index-url

    • --extra-index-url:此选项会添加一个额外的包索引源。pip会同时在PyPI和这个额外源中查找包,并通常会选择版本号更高的那个。这正是“依赖混淆”攻击的利用点:攻击者可以在公共PyPI上发布一个与你内部包同名但版本号极高的恶意包,pip可能会优先选择它。
    • --index-url:此选项会替换默认的PyPI索引。pip将只从你指定的私有索引中查找包。这是防止依赖混淆攻击的正确做法。所有公共包都应通过私有索引的代理来获取。
  • 认证与工具集成:访问私有索引通常需要认证。应使用有时效性的令牌(token)而非永久密码,并通过安全的方式(如CI/CD系统的秘密管理、环境变量)提供给构建工具。像Artifactory这样的专业工具还提供丰富的API,允许企业以编程方式管理包、元数据和访问策略,从而实现复杂的自动化DevOps工作流。

现代Python打包的演进,不仅仅是为了提升开发者体验,更是一次深刻的企业级安全升级。从setup.py到pyproject.toml的转变,从根本上关闭了通过安装脚本执行任意代码的安全漏洞。而标准化锁文件和私有索引的最佳实践,则共同构成了抵御依赖混淆和包篡改等多重威胁的纵深防御体系。对于任何严肃的企业级应用开发而言,采纳这套现代化的、声明式的、默认安全的技术栈,已不再是可选项,而是保障软件供应链安全的基本要求。

总结:拥抱一个声明式、安全且高性能的未来

Python的打包和依赖管理生态系统已经走过了一条漫长而曲折的道路。从早期setup.py和requirements.txt并存的、充满命令式脚本和安全隐患的混乱时代,到一个以pyproject.toml为核心的、标准化的、声明式的全新范式,这场变革的广度和深度都是空前的。回顾这段历程,可以清晰地看到一条主线:通过标准化和工具创新,系统性地解决历史遗留问题。

  • 声明式取代命令式:pyproject.toml的出现,用静态、可解析的配置文件取代了可执行的setup.py脚本,从根本上解决了任意代码执行的安全风险,并为工具生态的繁荣奠定了基础;

  • 关注点分离:现代工作流明确区分了“项目定义”(pyproject.toml中的抽象依赖)和“环境规约”(锁文件中的具体依赖)。开发者只需关注前者,后者则交由工具自动化生成和管理,这极大地降低了心智负担,提升了可维护性;

  • 性能转变:以Rust编写的uv和ruff等新一代工具的崛起,正以前所未有的速度重塑开发体验,证明了在保持Python动态性的同时,可以利用高性能语言来构建极致高效的开发工具链;

  • 标准持续演进:新接受的pylock.toml标准(PEP 751)和正在组建的Python打包委员会(PEP 772)表明,Python社区正致力于构建一个更加完整、健壮和可持续发展的打包生态系统。

总结目前的包管理最佳实践:

  1. 拥抱pyproject.toml:让它成为所有新项目的起点和唯一的配置中心;

  2. 采纳现代工具:无论是选择pip-tools这样的轻量级组合,还是Poetry、PDM、Hatch这样的一体化管理器,亦或是拥抱uv带来的极致速度,都应果断告别手动管理requirements.txt和直接调用setup.py的旧模式;

  3. 将安全置于首位:利用锁文件和哈希校验来确保依赖的完整性和可复现性,特别是在企业和生产环境中,这是不可或缺的安全措施。

Python打包的未来是声明式的、默认安全的,并且将越来越快。通过理解其演进的内在逻辑并采纳与之配套的现代工具和工作流,开发者可以从繁琐的依赖管理中解放出来,更专注于创造价值的核心业务逻辑。

参考资料