[译] Python 的打包现状(写于 2019 年)

11,027 阅读20分钟

Python 的打包现状(写于 2019 年)

在这篇文章中,我将会试着给你讲清楚 python 打包那些错综复杂的细节。我在过去的两个月中,使用每天晚上精力最好的黄金时段尽可能多的收集相关信息、如今的解决方案,并搞清楚哪些是遗留的问题。

含糊不清的 python 术语是导致混乱的第一个来源。在编程相关的语境中,“包”(package)这个词意味着一个可以安装的组件(比如可以是一个库)。但是在 python 中却不是这样,在这里,可安装组件的术语是“发行版”(distribution)。但是,除非必要(特别是在官方文档和 Python 增强提案中),否则根本没人真的去用“发行版”这个术语。顺便说一下,使用这个术语其实是个非常糟糕的选择,因为“distribution”一词一般用来描述 Linux 的一个 brand。

这是一个你应该牢记于心的警告,因为 python 打包其实并不真的是关于 python 的,而是关于它的发行版。但是我还是称之为打包。

我不想花那么多时间去阅读。能不能给我个简短的版本?在 2019 年,我应该如何管理 python 包呢?

我假设你是一名想要开始研发一个 python 包程序员,步骤如下:

  • 首先使用 Poetry 创建开发环境,并使用严格模式指定项目的直接依赖。这样就可以保证你的研发和测试环境总是可以被重复创建的。
  • 创建一个 pyproject.toml 文件,然后使用 poetry 作为后端创建源代码版和二进制发行版。
  • 下一步要指定抽象包依赖。注意应指定你能确定的该包可运行的最低版本。这样就可以保证不会创建出无用的、会和其他包冲突的版本。

如果你真的想使用需要 setuptools 的老方法:

  • 创建 setup.py 文件,在文件中指定所有的抽象依赖,并在 install_requires 中指定这些依赖使用可工作的最低版本。
  • 创建 requirements.txt 文件,在其中指定严格、具体(即指定某个版本)、直接的依赖。接下来你将会需要使用这个文件生成实际的工作环境。
  • 使用命令 python -m venv 创建一个虚拟环境,激活该环境然后在该环境下使用 pip install -rrequirements.txt 命令安装依赖。用这个环境来开发。
  • 如果你需要用于测试的依赖(当然这也是非常有可能的事情),那么你需要创建一个 dev-requirements.txt 文件,并同样为其安装依赖。
  • 如果你需要将所有环境配置冻结(这是推荐的做法),执行 pip freeze >requirements-freeze.txt 并且以后也要用这个命令创建环境。

我的时间很充裕。请帮我解释清楚吧。

首先我将阐述目前存在的问题,真的有很多问题。

假设我想要用 python 创建某“项目”:它也许是一个独立程序,也许是一个库。这个项目的开发和使用需要包含以下“角色”:

  • 开发者:负责写代码的人或者团队。
  • CI:测试这个项目的自动化过程。
  • 构建:从我们的 git 仓库到其他人可以安装使用这个项目的自动或半自动过程。
  • 最终用户:最终使用这个项目的人或者团队。如果这个项目是一个库,那么最终用户也许是其他开发者;或者如果是一个应用,最终用户可能就是普通民众。又或者这个项目是某一种网络服务,那么最终用户就是云计算微服务。当然还有很多可能,你明白我的意思,不一一列举了。

我们的目标就是让所有的用户或者设备对该项目满意,但是他们都有不同的工作流和需求,并且有时候这些需求会有重叠的部分。另外,当项目发生更改、发布新版本、废除旧版本,或者几乎所有代码都要依赖其他代码来完成其任务的时候会产生问题。项目中必定存在依赖,而随着时间推移,这些依赖会发生变化,它们也许是必要的也许也不是,它们可能在很底层运行,所以我们必须考虑在不同操作系统甚至在同样的操作系统中它们都可能是不可移植的。这已经非常复杂了。

更糟糕的是,你的直接依赖也有各自的依赖集合。如果你的包直接依赖于 A 和 B,而它们两个都依赖于 C 又会怎样呢?你应该安装哪个版本的 C?如果 A 希望安装 C 的严格版本 2 而 B 则希望安装 C 的严格版本 1,是否可能做到呢?

为了一定程度上整治这种混乱,人们设计出代码打包的方法,这样代码包就可以被复用、安装、版本化并给出一些描述性的元信息,例如:“已在 windows 64 位系统上打包”,或者“仅适用于 macos 系统”,或者“需要该版本或以上才可运行”。

好吧,现在我知道问题所在了。那么解决方案是什么呢?

第一步是定义一个集合了指定软件指定发布版本的可交付实体。这个可交付实体就是我们所谓的(或者专业的 python 说法是发行版)。你可以用两种方式交付:

  • 源代码:将源代码打包为 zip 或者 tar.gz 格式的文件,然后由用户自己编译。
  • 二进制文件:由你编译代码,然后发布编译好的内容,用户可以直接使用,无需附加步骤。

两种方式都可能有用,通常情况下,两种都提供是不错的选择。当然,我们需要能够正确完成打包的工具,尤其是为了完成如下的任务:

  • 创建可交付的包(也就是前文提到的构建
  • 将包发布在某处,这样其他人就可以获取到
  • 下载并安装包
  • 处理依赖。如果包 A 需要包 B 才能运行怎么办?如果包 A 需不需要包 B 取决于你如何使用 A?如果包 A 只在 windows 上被安装时才需要包 B?
  • 定义运行时间。如前文所述,通常情况下一个小小的软件也需要很多依赖才能运行,并且这些依赖最好和其他软件的依赖需求隔离开。不管是当你进行开发的时候还是运行的时候,都应该这样。

可以说得更详细一些吗?我写代码之前,必须要做什么呢?

当然。在你写代码之前,通常你要完成如下步骤:

  1. 创建一个独立于系统 python 的 python 环境。这样你可以同步研发多个项目。而且如果不这样操作,A 项目的内容和 B 项目的内容可能会混在一起。
  2. 如果你想要规定项目的依赖,那么请牢记有两种方式可以完成:抽象方式,此时你只需要笼统地指出需要那些依赖(例如 numpy),以及具体方式,这时候你必须要规定版本号(例如 numpy 1.1.0)。至于为什么会有这样的区分,后文会详细说明。如果你想要创建一个可运行的开发环境,需要具体地规定依赖。
  3. 现在你已经做完了需要做的,可以开始研发了。

我需要使用什么工具来完成这些吗?

这个不好说,因为工具非常多并且在不断变化。一个选择是你可以使用 python 内建的 venv 创建独立的 python “虚拟环境”。然后使用 pip(也是 python 内建工具)来安装依赖的包。逐个输入并安装太麻烦了,所以人们通常会将具体依赖(硬编码的版本号)写入一个文件内然后通知 pip:“读取这个文件并安装文件中写明的所有包”。pip 就会照做了。这个文件就是人尽皆知的 requirements.txt,你可能已经在其他项目里见过了。

好吧,可是 pip 到底是什么呢?

pip 是一个用来下载和安装包的程序。如果这些包也有依赖,那么 pip 也会安装这些子依赖的。

pip 是怎么做到的?

它会在远程服务 pypi 上,通过名称和版本号找到对应的包并下载、安装。如果这个包已经是二进制文件,那么只需要安装它。如果是源代码,pip 就会进行编译然后再安装。但是 pip 做的还不止这些,因为这个包本身可能会有其他的依赖,所以它也会获取这些依赖,并且安装它们。

为什么你说使用 requirements.txt 的方法只是一个“选择”?

因为这种方式会随着项目扩展而变得冗长而且复杂。对于不同的平台,你需要手动管理直接依赖版本。例如,在 windows 系统你需要安装某个包,而在 linux 或其他系统你则需要另外的包,那结果是你就需要同时维护 win-requirements.txt、linux-requirements.txt 等等多个文件。

你还必须考虑到,一些依赖是你的软件运行所必需的;而其他只是用来运行测试,这些依赖只是开发者或者 CI 设备必需的,但是对于其他使用你的软件的人,其实并不需要,所以它们此时就不能作为项目的依赖了。因此,你就需要一个新的文件 dev-requirements.txt。

问题在于,requirements.txt 或许只会指定直接依赖,但是在实际应用的时候,你想要定制好创建环境所需要的所有依赖。为什么要这样?比方说,如果你安装了直接依赖 A,而 A 又依赖于版本 1.1 的 C。但是有一天 C 发布了新版本 1.2,那么从此之后,当你创建环境的时候,pip 就会下载可能带有漏洞的 1.2 版本的 C。也就是忽然间你的测试无法通过了,但你又不知道为什么。

所以你就想在 requirements.txt 中同时指定依赖和这些依赖的子依赖。但是这样的话,你在文件中却无法区分出这两种依赖了,那么当某个依赖出现问题你想要调试它的时候,你就要找出文件中哪个才是它的子依赖,以及…

现在你懂了。真的一团糟,你并不想去处理这样的乱局吧。

接下来你会面临的一个问题就是,pip 可以决定使用更加原始的方式来安装哪个版本,这可能会让它自己运行到一个死胡同里,呈现给你的就是某个无法工作的环境或者是错误。记住这个例子:包 A 和 B 都依赖于 C。因此你需要一个更加复杂的过程,在这个过程里,基本上使用 pip 仅仅是为了下载已经定义好版本的包,而需要决定安装什么版本的权限则交给其他程序,这个程序要有全局的考量,并能作出更明智的版本判定。

比如说?请给我举个例子吧。

pipenv 就是一个例子。它将 venv、pip 和其他一些黑科技集合在一起,你只需给出直接依赖列表,它则会尽最大努力为你解决上文提到的混乱并给你交付一个可运行的环境。Poetry 是另外一个例子。人们经常会讨论两者,并且由于人为和政策的原因还会引起一些争执。但是大多数人更偏向于 Poetry。

一些公司如 Continuum 和 Enthought 都有他们自己的版本管理(即 conda 和 edm),它们通常都可以避免由于平台不同而附加的依赖版本的复杂性。在这里我们就不展开讲了。我只想说,如果你想要用那些很多已经被编译好的依赖关系或者(这些依赖关系)依赖于编译好的库,比如说在科学计算的场景下这种需求就很常见,那么你最好用它们的系统来管理你的环境,这会为你免去不少麻烦。因为这本来就是它们拿手的。

那么 pipenv 和 Poetry 究竟哪个更好用呢?

正如我刚才说的,人们更偏向于 Poetry。这两个我都尝试过,于我而言 Poetry 也要更好一些,它提供了更具兼容性、更优质的解决方案。

嗯好,所以至少我们要去用 Poetry,它可以为我们创建好环境,这样我就可以安装依赖并开始编程了。

没错。但我还没有谈论到构建。也就是,一旦你有了代码,你该如何创建发布版呢?

嗯是的,所以这就是 setup.py、setuptools 和 distutils 的用武之地了?

可以这么说,但也并不确切。最初情况下,当你想要创建一个源代码或者二进制发行版的时候,你需要使用一个名为 distutils 的标准库模块。方法是使用一个名为 setup.py 的 python 脚本,它可以魔法般的创建出你可以交付给他人的项目。这个脚本可以任意命名,但 setup.py 是标准的命名方式,其他的工具(比如广泛使用的 pip)就会只寻找以此命名的文件。而如果 pip 没有找到需要依赖的可构建版本,它将会下载源代码并构建它,简单来说,只需运行 setup.py,然后我们只能祈祷结果是好的了。

但是,distutils 并不好用,所以有些人找到了替代的方案,它可以做比 distutils 多得多的事。尽管挑战很大,混乱很多,发展之路漫长,但是 setuptools 要更好,每个人都可以使用。如今 setuptools 还是使用 setup.py 文件,给人一种其实它们并没有变化、创建环境的过程也保持不变的假象。

为什么说我们只能祈祷结果是好的?

因为 pip 并不能保证它运行 setup.py 构建的包是真的可以运行的。它只是一个 python 脚本,也许会有自己的依赖,而你又无法在出现问题的时候修改它的依赖或者进行追踪。这是先有鸡还是先有蛋的问题了。

但是在 setuptools.setup() 中有 setup_requires 选项啊

这个方法就是个坑,你基本不能使用它解决什么问题。这还是个先有鸡还是先有蛋的问题。PEP 518 对此进行了详细的讨论,最后结论就是它就是渣渣。别用了。

所以 setuptools 和 setup.py 到底是不是构建发布的可选方法呢??

过去是的。但现在不一定是了,只是或许有时候还可以用。这要看你要发布的内容是什么了。现在的情况是,没人希望 setuptools 是唯一一种能决定包如何发布的方法。问题的根源要更深入一些,会涉及到一些技术型问题,但是如果你好奇,可以看一看 PEP 518。最重要的部分我在上文已经提到了:如果 pip 想要构建它下载的依赖,它该怎么确定下载哪个版本同时用来执行 setup 脚本呢?没错,它可以假设需要依靠 setuptools,但也只是假设。而你的环境中可能并不需要 setuptools,那么 pip 又该怎么做决策?在更多情况下,为什么必须使用 setuptools 而不是其他的工具呢?

很多时候这决定了,任何想要写自己的包管理工具的人应该都可以这么做,因此你只需要另一个配置工具来定义使用哪个包系统以及你需要哪些依赖来构建项目。

使用 pyproject.toml?

正确。更确切的来说,是一个可以在其中定义用来构建包的“后端”的子节。如果你想要使用一种不同的构建后端,pip 就可以完成。而如果你不想这样,那么 pip 会假设你在使用工具 distutils 或者 setuptools,因此它就会退而寻找 setup.py 文件并执行,我们祈祷它能构建成功吧。

setup.py 最终到底会不会消失?setuptools(在它之前是 distutils)用 setup.py 来描述如何生成构建。而其他工具或许会使用其他方法。或许,它们会依赖于为 pyproject.toml 添加一些内容而完成。

同时,你终于可以在 pyproject.toml 中规定用来执行构建的依赖了,这就解除了前文说得那种先有鸡还是先有蛋的难题。

为什么选择 toml 格式的文件?我都还从来没有听说过它。为什么不用 JSON、INI 或者 YAML?

标准的 JSON 不允许写注释。但是人们真的很需要依赖注释传递关于项目的信息。你可以不按照规则来,但那也就不是 JSON 了。另外,JSON 其实有些反人类,写起来并让人觉得不赏心悦目。

INI 则其实根本不是一种标准的写法,而且它在功能上有很多限制。

YAML 则可能会成为你项目潜在的安全威胁,它简直就像是病毒。

这样的话选择 toml 就可以理解了。但是,他们不能将 setuptools 包含在标准库中吗?

或许可以,但问题是标准库的发布周期真的超级长。distutils 的更新非常缓慢,这正激发了 setuptools 的应用和崛起。但是 setuptools 也不能保证满足所有需求。一些包或许会有一些特殊的需求。

好吧,那么我这么理解是否正确:我需要使用 Poetry 创建工作环境。使用 setup.py 和 setuptools,或者 pyproject.toml 构建包。

如果你想要使用 setuptools,你就需要 setup.py,但是你可能会遇到的问题是,其他用户也需要安装 setuptools 来构建你的包。

那么除了 setuptools 我还能使用什么其他的工具呢?

可以用 flit,或者 Poetry。

Poetry 不需要安装依赖吗?

需要,但它也可以用来构建。pipenv 就不行。

顺便说一下,如果我使用 setup.py 的话,为什么我就必须写明依赖呢?我下载的 setup.py 与 pipenv、Poetry 和 requirements.txt 有什么关系呢?

这些都是运行包需要的抽象依赖,也是 pip 在决定下载和安装哪些版本的时候需要的依赖。这里你应当放宽对依赖版本的限制,因为如果你不这样…还记得我之前说过的 A 和 B 都依赖于 C 的例子吗?如果 A 要求:“我要 1.2.1 版本的 C”,但是 B 要求:“我要 1.2.2 版本的 C”,那该怎么办呢?

当要构建下载资源的源代码发行版的时候,pip 没有其他的选择。pip 并不能获取到你写在 requirements.txt 文件中的需求。它只会去运行 setup.py,而这会导致 pip 去使用 setuptools,然后再次调用 pip 来将抽象依赖解析为具体的可安装依赖。

那么 eggs、easy install、.egg-info directories、distribute、virtualenv(这个不等于 venv)、zc.buildout、bento 这些工具又怎么样呢?

忽略它们吧。它们要么是一些遗留工具或者其他工具的分支,要么是一些毫无结果的尝试。

那 Wheels 呢?

还记得我之前说的吗?pip 需要知道从 pypi 下载什么资源,从而才能下载正确的版本和操作系统。Wheel 就是一个包含了要下载资源的文件,并且有一些特殊的、规定好的字段,pip 安装依赖和子依赖的时候会使用它们来决策。

Wheels 的文件名包含了作为元数据的标签(例如 pep-0425),所以当某些资源(例如 CPython)被编译了,Wheels 能知道编译的版本、ABI 等等。文件名中的标签有一个标准层,元数据中特定的词都有特定的含义。

记住,要为二进制发行版构建 wheels。

那么 .pyz 怎么样呢?

忽略它就好,严格来讲它和打包无关。但在其他某些方面它可能有用,如果你想知道更详细的信息,可以看 PEP-441。

那么 pyinstaller 怎么样呢?

Pyinstaller 是关于完全不同的另一个话题了。你看,“打包”这个单词的问题是,它没有清楚的表述出它真正的含义。到目前位置,我们讨论了关于:

  1. 创建一个可以开发库的环境
  2. 把你创建的项目构建为其他人也可以使用的格式

但是这些通常是应用于库的。而关于发行应用,情况就不同了。当你打包库的时候,你知道它将会是一个更大的项目体的一部分。而当你打包一个应用,那么这个应用就是那个更大的项目体

另外,如果你想为人们提供应用,那就应指定应用的平台。例如,你想要提供一个带图标的可执行文件,但是在 Windows、macOS 和 Linux 平台上,它们应当是有所不同的。

当你想要创建一个独立可执行应用的时候,PyInstaller 是可以使用的工具。它能够为你在用户桌面上创建出最终完成的应用。打包是关于管理你需要用来创建应用的依赖、库和工具的网络,而创建这个应用你可能会、也可能不会使用 pyinstaller。

注意不管怎样,使用这个方法的前提是,假设你的应用是比较简单并且是自包含的。如果应用在安装的时候需要做更复杂的事情,比如创建 Windows 登录密码,那你就需要一个更合适的、更成熟的安装器,比如 NSIS。我不知道在 Python 世界中是否有像 NSIS 这样的东西。但无论如何,NSIS 都不知道你部署了什么。你当然可以使用 pyinstaller 创建可执行应用,然后使用 NSIS 来部署它,并且还可以完成例如注册表修改或者文件系统修改这样的附加需求,让应用可以运作。

好的,但是我如何安装那些我已经有资源包的项目呢?使用 python setup.py?

不对。用 pip install .,因为这个命令能保证你之后还可以卸载应用,而且它总体上更好一些。pip 这时候会检查 pyproject.toml 并在后台运行构建。而如果 pip 没有找到 pyproject.toml 文件,它就只好退回到老方法,运行 setup.py 来尝试构建。

我很喜欢这篇文章,但是我还是有些问题没有搞清楚

你可以自己开一个 issue。如果我知道答案,我将会马上为你解答。如果我不知道,我会做一下研究并尽快给你回复。我的目标是这篇文章能让人们最终理解 python 打包。

有没有参考链接能让我更深入的学习呢?

当然,请见:

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏