开源应用程序架构(卷 1)
Python 包

Tarek Ziadé

14.1. 概述

在应用程序安装方面,存在两种主要思想。第一种,常见于 Windows 和 Mac OS X,认为应用程序应该是自包含的,其安装不应依赖于任何其他内容。这种理念简化了应用程序管理:每个应用程序都是独立的“设备”,安装和删除它们不应影响操作系统的其余部分。如果应用程序需要一个不常见的库,那么该库将包含在应用程序的发布文件中。

第二种思想,在基于 Linux 的系统中更为普遍,将软件视为一系列称为的独立单元集合。库被捆绑到包中,任何给定的库包可能依赖于其他包。安装应用程序可能需要查找和安装数十个其他库的特定版本。这些依赖项通常从包含数千个包的中央存储库中获取。这就是为什么 Linux 发行版使用像 dpkgRPM 这样的复杂包管理系统来跟踪依赖项并防止安装使用同一库的不同版本的两个应用程序。

每种方法都有其优点和缺点。拥有一个高度模块化的系统,其中每个部分都可以更新或替换,可以简化管理,因为每个库都存在于一个地方,并且所有使用它的应用程序在它更新时都会受益。例如,特定库中的安全修复程序会立即传播到所有使用它的应用程序,而如果应用程序附带自己的库,那么安全修复程序的部署将更加复杂,尤其是当不同的应用程序使用库的不同版本时。

但一些开发人员认为这种模块化是一个缺点,因为他们无法控制自己的应用程序和依赖项。为他们提供独立的软件设备更容易,以确保应用程序环境稳定,并且在系统升级期间不会出现“依赖地狱”问题。

自包含的应用程序也使开发人员在需要支持多个操作系统时更容易。一些项目甚至发布了可移植应用程序,通过在自包含目录中工作,甚至包括日志文件,来消除任何与宿主系统的交互。

Python 的打包系统旨在使第二种理念(每次安装都有多个依赖项)尽可能地对开发人员、管理员、打包人员和用户友好。不幸的是,它存在(并且仍然存在)各种缺陷,导致或允许出现各种问题:不直观的版本方案、错误处理的数据文件、打包困难等等。三年前,我和其他一些 Python 开发者决定重新设计它来解决这些问题。我们称自己为“打包联盟”,本章介绍了我们一直在努力解决的问题,以及我们的解决方案是什么样子。

术语

在 Python 中,是指包含 Python 文件的目录。Python 文件称为模块。这个定义使“包”这个词的用法有点模糊,因为它也被许多系统用来指代项目的发布

Python 开发者本身有时也会对这个概念含糊不清。消除这种歧义的一种方法是在谈论包含 Python 模块的目录时使用术语“Python 包”。术语“发布”用于定义项目的某个版本,术语“发行版”用于定义发布的源代码或二进制发行版,例如 tarball 或 zip 文件。

14.2. Python 开发者的负担

大多数 Python 程序员希望他们的程序可以在任何环境中使用。他们通常还想使用标准 Python 库和系统相关的库的混合。但除非你为每个现有的打包系统单独打包你的应用程序,否则你注定要提供 Python 特定的发布文件 - Python 特定的发布文件是指旨在安装在 Python 安装中的发布文件,无论底层操作系统是什么 - 并且希望

有时,这根本不可能。例如,Plone(一个功能齐全的基于 Python 的 CMS)使用数百个小型纯 Python 库,这些库并不总是作为包在每个打包系统中可用。这意味着 Plone 必须在其可移植应用程序中提供它需要的所有内容。为此,它使用 zc.buildout,它收集所有依赖项并创建一个可在单个目录中运行于任何系统的可移植应用程序。它实际上是一个二进制发布文件,因为任何 C 代码都将在现场编译。

这对开发人员来说是一个巨大的胜利:他们只需要使用下面描述的 Python 标准描述他们的依赖项,然后使用 zc.buildout 发布他们的应用程序。但正如前面所述,这种类型的发布会在系统中建立一个堡垒,大多数 Linux 系统管理员会讨厌它。Windows 管理员不会介意,但管理 CentOS 或 Debian 的管理员会介意,因为这些系统管理基于以下假设:系统中的每个文件都是注册的、分类的,并且对管理工具是已知的。

这些管理员会希望根据他们自己的标准重新打包你的应用程序。我们需要回答的问题是,“Python 能否拥有一个打包系统,该系统可以自动转换为其他打包系统?”如果是这样,一个应用程序或库就可以安装在任何系统上,而无需额外的打包工作。在这里,“自动”并不一定意味着工作应该完全由脚本完成:RPMdpkg 打包人员会告诉你这是不可能的 - 他们总是需要在他们重新打包的项目中添加一些细节。他们还会告诉你,他们经常难以重新打包一段代码,因为其开发者没有意识到一些基本的打包规则。

以下是一个例子,说明你可以做些什么来使用现有的 Python 打包系统来惹恼打包人员:发布一个名为“MathUtils”的库,其版本名称为“Fumanchu”。编写这个库的天才数学家发现使用他猫的名字来命名他的项目版本很有趣。但打包人员如何知道“Fumanchu”是他第二只猫的名字,第一只猫叫“Phil”,所以“Fumanchu”版本应该在“Phil”版本之后?

这听起来可能很极端,但它确实可能发生在今天的工具和标准中。最糟糕的是,像 easy_installpip 这样的工具使用它们自己的非标准注册表来跟踪已安装的文件,并且会按字母数字顺序对“Fumanchu”和“Phil”版本进行排序。

另一个问题是如何处理数据文件。例如,如果你的应用程序使用 SQLite 数据库?如果你把它放在你的包目录中,你的应用程序可能会失败,因为系统不允许你写入树的该部分。这样做也会破坏 Linux 系统对应用程序数据备份位置的假设(/var)。

在现实世界中,系统管理员需要能够将你的文件放在他们想要的地方,而不会破坏你的应用程序,并且你需要告诉他们这些文件是什么。因此,让我们重新表述这个问题:Python 中是否可以有一个打包系统,它可以提供所有必要的信息来重新打包任何第三方打包系统中的应用程序,而无需读取代码,并让每个人都满意?

14.3. 打包的当前架构

随 Python 标准库提供的 Distutils 包存在上面描述的各种问题。由于它是标准,人们要么忍受它及其缺陷,要么使用更先进的工具,如 Setuptools,它在 Distutils 的基础上添加了一些功能,或者 Distribute,它是 Setuptools 的一个分支。还有 Pip,这是一个更高级的安装程序,它依赖于 Setuptools

然而,这些新工具都基于 Distutils,并继承了它的问题。人们曾试图修复 Distutils 本身,但代码被其他工具广泛使用,对它的任何更改,即使是对其内部结构的更改,也可能导致整个 Python 打包生态系统出现回归。

因此,我们决定冻结 Distutils,并从相同的代码库开始开发 Distutils2,而无需过分担心向后兼容性。为了理解发生了什么变化以及原因,让我们仔细看看 Distutils

14.3.1. Distutils 基础知识和设计缺陷

Distutils 包含命令,每个命令都是一个类,带有一个 run 方法,该方法可以用一些选项调用。Distutils 还提供了一个 Distribution 类,它包含每个命令都可以查看的全局值。

要使用 Distutils,开发人员会将单个 Python 模块添加到项目中,通常称为 setup.py。这个模块包含对 Distutils 的主要入口点的调用:setup 函数。这个函数可以接收许多选项,这些选项由 Distribution 实例保存,并由命令使用。以下是一个示例,它定义了一些标准选项,如项目的名称和版本,以及它包含的模块列表

from distutils.core import setup

setup(name='MyProject', version='1.0', py_modules=['mycode.py'])

然后,可以使用这个模块来运行 Distutils 命令,如 sdist,它会在一个存档文件中创建一个源代码发行版,并将其放置在 dist 目录中

$ python setup.py sdist

使用相同的脚本,你可以使用 install 命令来安装项目

$ python setup.py install
Distutils 提供了其他命令,例如

它还允许你通过其他命令行选项获取有关项目的信息。

因此,安装项目或获取有关项目的信息始终是通过这个文件调用 Distutils 来完成的。例如,要找到项目的名称

$ python setup.py --name
MyProject

因此,setup.py 是每个人与项目交互的方式,无论是构建、打包、发布还是安装它。开发人员通过传递给函数的选项来描述项目的内容,并使用该文件来完成所有打包任务。该文件还被安装程序用于在目标系统上安装项目。

[Setup]

图 14.1:安装

使用单个 Python 模块来打包、发布安装项目是 Distutils 的主要缺陷之一。例如,如果你想从 lxml 项目中获取 namesetup.py 将执行许多其他操作,而不仅仅是返回一个简单的字符串,如预期的那样

$ python setup.py --name
Building lxml version 2.2.
NOTE: Trying to build without Cython, pre-generated 'src/lxml/lxml.etree.c'
needs to be available.
Using build configuration of libxslt 1.1.26
Building against libxml2/libxslt in the following directory: /usr/lib/lxml

它甚至可能无法在某些项目中正常工作,因为开发人员假设 setup.py 仅用于安装,而其他 Distutils 功能仅在开发过程中使用。setup.py 脚本的多重角色很容易造成混淆。

14.3.2. 元数据和 PyPI

Distutils 构建一个发行版时,它会创建一个 Metadata 文件,该文件遵循 PEP 3141 中描述的标准。它包含所有常见元数据的静态版本,例如项目的名称或发布的版本。主要的元数据字段包括

这些字段在很大程度上易于映射到其他打包系统中的等效项。

Python 包索引 (PyPI)2 是类似于 CPAN 的中央包存储库,它能够通过 Distutilsregisterupload 命令注册项目并发布版本。register 构建 元数据 文件并将其发送到 PyPI,允许人们和工具(例如安装程序)通过网页或网络服务浏览它们。

[The PyPI Repository]

图 14.2:PyPI 存储库

您可以按 分类器 浏览项目,并获取作者姓名和项目 URL。同时,需要 可用于定义对 Python 模块的依赖关系。requires 选项可用于向项目添加 需要 元数据元素。

from distutils.core import setup

setup(name='foo', version='1.0', requires=['ldap'])

定义对 ldap 模块的依赖关系纯粹是声明性的:没有工具或安装程序可以确保此类模块存在。如果 Python 通过类似于 Perl 的 require 关键字在模块级别定义了需求,这将令人满意。然后,这将仅仅是安装程序浏览 PyPI 上的依赖关系并安装它们的问题;这基本上是 CPAN 所做的。但这在 Python 中不可行,因为名为 ldap 的模块可以存在于任何 Python 项目中。由于 Distutils 允许人们发布包含多个包和模块的项目,因此此元数据字段毫无用处。

元数据 文件的另一个缺陷是它们是由 Python 脚本创建的,因此它们特定于执行它们的平台。例如,提供特定于 Windows 的功能的项目可以将其 setup.py 定义为

from distutils.core import setup

setup(name='foo', version='1.0', requires=['win32com'])

但这假设该项目仅在 Windows 下工作,即使它提供了可移植的功能。解决此问题的一种方法是使 requires 选项特定于 Windows

from distutils.core import setup
import sys
if sys.platform == 'win32':
    setup(name='foo', version='1.0', requires=['win32com'])
else:
    setup(name='foo', version='1.0')

这实际上使问题更糟。请记住,该脚本用于构建源代码存档,然后通过 PyPI 发布到世界各地。这意味着发送到 PyPI 的静态 元数据 文件取决于用于编译它的平台。换句话说,没有办法在元数据字段中静态地指示它是平台特定的。

14.3.3. PyPI 的架构

[PyPI Workflow]

图 14.3:PyPI 工作流程

如前所述,PyPI 是一个 Python 项目的中央索引,人们可以在其中按类别浏览现有项目或注册自己的工作。源代码或二进制发行版可以上传并添加到现有项目中,然后下载以供安装或研究。PyPI 还提供可供安装程序等工具使用的网络服务。

注册项目和上传发行版

使用 Distutilsregister 命令将项目注册到 PyPI。它会构建一个包含项目元数据的 POST 请求,无论其版本是什么。该请求需要一个授权标头,因为 PyPI 使用基本身份验证来确保每个注册的项目都与首先在 PyPI 上注册的用户关联。凭据保存在本地 Distutils 配置中,或者在每次调用 register 命令时在提示符中输入。其用法的示例如下

$ python setup.py register
running register
Registering MPTools to http://pypi.python.org/pypi
Server response (200): OK

每个注册的项目都会获得一个网页,其中包含元数据的 HTML 版本,打包人员可以使用 upload 将发行版上传到 PyPI

$ python setup.py sdist upload
running sdist
…
running upload
Submitting dist/mopytools-0.1.tar.gz to http://pypi.python.org/pypi
Server response (200): OK

也可以通过 Download-URL 元数据字段将用户指向另一个位置,而不是直接将文件上传到 PyPI。

查询 PyPI

除了 PyPI 为 Web 用户发布的 HTML 页面之外,它还提供了两种工具可用于浏览内容的服务:简单索引协议和 XML-RPC API。

简单索引协议从 http://pypi.python.org/simple/ 开始,这是一个包含指向每个已注册项目的相对链接的纯 HTML 页面

<html><head><title>Simple Index</title></head><body>
⋮    ⋮    ⋮
<a href='MontyLingua/'>MontyLingua</a><br/>
<a href='mootiro_web/'>mootiro_web</a><br/>
<a href='Mopidy/'>Mopidy</a><br/>
<a href='mopowg/'>mopowg</a><br/>
<a href='MOPPY/'>MOPPY</a><br/>
<a href='MPTools/'>MPTools</a><br/>
<a href='morbid/'>morbid</a><br/>
<a href='Morelia/'>Morelia</a><br/>
<a href='morse/'>morse</a><br/>
⋮    ⋮    ⋮
</body></html>

例如,MPTools 项目有一个 MPTools/ 链接,这意味着该项目存在于索引中。它指向的站点包含与该项目相关的所有链接列表

  • 存储在 PyPI 中的每个发行版的链接
  • 每个版本注册的项目中定义的每个主页 URL 的链接
  • 每个版本定义的每个下载 URL 的链接。

MPTools 页面包含

<html><head><title>Links for MPTools</title></head>
<body><h1>Links for MPTools</h1>
<a href="../../packages/source/M/MPTools/MPTools-0.1.tar.gz">MPTools-0.1.tar.gz</a><br/>
<a href="http://bitbucket.org/tarek/mopytools" rel="homepage">0.1 home_page</a><br/>
</body></html>

想要查找项目发行版的工具(例如安装程序)可以在索引页面中查找它,或者简单地检查 http://pypi.python.org/simple/PROJECT_NAME/ 是否存在。

此协议有两个主要限制。首先,PyPI 目前是一个单一服务器,虽然人们通常拥有其内容的本地副本,但我们在过去两年中经历了多次停机时间,这些停机时间使不断使用浏览 PyPI 以获取构建项目所需的所有依赖项的安装程序的开发人员瘫痪。例如,构建 Plone 应用程序将在 PyPI 上生成数百个查询以获取所有必需的位,因此 PyPI 可能会充当单点故障。

其次,当发行版未存储在 PyPI 上并在简单索引页面中提供下载 URL 链接时,安装程序必须遵循该链接并希望该位置可用并且确实包含该版本。这些间接链接削弱了任何基于简单索引的流程。

简单索引协议的目标是为安装程序提供一个链接列表,他们可以使用这些链接来安装项目。项目元数据不会在其中发布;相反,有 XML-RPC 方法可以获取有关已注册项目的更多信息

>>> import xmlrpclib
>>> import pprint
>>> client = xmlrpclib.ServerProxy('http://pypi.python.org/pypi')
>>> client.package_releases('MPTools')
['0.1']
>>> pprint.pprint(client.release_urls('MPTools', '0.1'))
[{'comment_text': &rquot;,
'downloads': 28,
'filename': 'MPTools-0.1.tar.gz',
'has_sig': False,
'md5_digest': '6b06752d62c4bffe1fb65cd5c9b7111a',
'packagetype': 'sdist',
'python_version': 'source',
'size': 3684,
'upload_time': <DateTime '20110204T09:37:12' at f4da28>,
'url': 'http://pypi.python.org/packages/source/M/MPTools/MPTools-0.1.tar.gz'}]
>>> pprint.pprint(client.release_data('MPTools', '0.1'))
{'author': 'Tarek Ziade',
'author_email': 'tarek@mozilla.com',
'classifiers': [],
'description': 'UNKNOWN',
'download_url': 'UNKNOWN',
'home_page': 'http://bitbucket.org/tarek/mopytools',
'keywords': None,
'license': 'UNKNOWN',
'maintainer': None,
'maintainer_email': None,
'name': 'MPTools',
'package_url': 'http://pypi.python.org/pypi/MPTools',
'platform': 'UNKNOWN',
'release_url': 'http://pypi.python.org/pypi/MPTools/0.1',
'requires_python': None,
'stable_version': None,
'summary': 'Set of tools to build Mozilla Services apps',
'version': '0.1'}

这种方法的问题在于,XML-RPC API 发布的某些数据本来可以存储为静态文件并在简单索引页面中发布,以简化客户端工具的工作。这也将避免 PyPI 处理这些查询的额外工作。拥有像每个发行版的下载次数这样的非静态数据在专门的网络服务中发布是可以的,但是没有必要使用两种不同的服务来获取有关项目的全部静态数据。

14.3.4. Python 安装的架构

如果您使用 python setup.py install 安装 Python 项目,则包含在标准库中的 Distutils 将将文件复制到您的系统上。

从 Python 2.5 开始,元数据文件与模块和包一起复制为 project-version.egg-info。例如,virtualenv 项目可能有一个 virtualenv-1.4.9.egg-info 文件。这些元数据文件可以被视为已安装项目的数据库,因为可以迭代它们并构建一个包含其版本的项目列表。但是,Distutils 安装程序不会记录它在系统上安装的文件列表。换句话说,没有办法删除系统中复制的所有文件。这很可惜,因为 install 命令有一个 --record 选项,可以用来将所有已安装的文件记录到文本文件中。但是,此选项默认情况下不会使用,并且 Distutils 的文档几乎没有提到它。

14.3.5. Setuptools、Pip 和类似工具

如引言中所述,一些项目试图修复 Distutils 中的一些问题,取得了不同程度的成功。

依赖关系问题

PyPI 允许开发人员发布可以包含多个模块的 Python 项目,这些模块组织成 Python 包。但同时,项目可以通过 Require 定义模块级依赖关系。这两个想法都是合理的,但它们的组合却不是。

正确的做法是拥有项目级依赖关系,这正是 SetuptoolsDistutils 之上添加的功能。它还提供了一个名为 easy_install 的脚本,用于通过在 PyPI 上查找它们来自动获取和安装依赖关系。在实践中,模块级依赖关系从未真正使用过,人们纷纷使用 Setuptools 的扩展。但是,由于这些功能是在 Setuptools 特定的选项中添加的,而 Distutils 或 PyPI 忽略了它们,因此 Setuptools 有效地创建了自己的标准,并成为了糟糕设计之上的一个 hack。

因此,easy_install 需要下载项目的存档并再次运行其 setup.py 脚本以获取所需的元数据,并且它必须对每个依赖关系都这样做。依赖关系图是在每次下载之后一点一点地构建的。

即使新的元数据被 PyPI 接受并可在线浏览,easy_install 仍然需要下载所有存档,因为如前所述,发布在 PyPI 上的元数据特定于用于上传它的平台,这可能与目标平台不同。但是,这种安装项目及其依赖关系的能力在 90% 的情况下已经足够好,并且是一个很棒的功能。因此,Setuptools 被广泛使用,尽管它仍然存在其他问题

  • 如果依赖项安装失败,则不会进行回滚,系统可能最终处于损坏状态。
  • 依赖关系图是在安装过程中动态构建的,因此如果遇到依赖关系冲突,系统也可能最终处于损坏状态。

卸载问题

Setuptools 没有提供卸载程序,即使其自定义元数据可能包含一个包含已安装文件列表的文件。另一方面,Pip 扩展了 Setuptools 的元数据以记录已安装的文件,因此能够进行卸载。但这又是另一组自定义元数据,这意味着单个 Python 安装可能包含多达四种不同的元数据,用于每个已安装的项目

  • Distutilsegg-info,它是一个单一元数据文件。
  • Setuptoolsegg-info,它是一个包含元数据和额外 Setuptools 特定选项的目录。
  • Pipegg-info,它是前者的扩展版本。
  • 托管打包系统创建的任何东西。

14.3.6. 数据文件怎么办?

Distutils 中,数据文件可以安装在系统的任何位置。如果您在 setup.py 脚本中定义了一些包数据文件,例如

setup(…,
  packages=['mypkg'],
  package_dir={'mypkg': 'src/mypkg'},
  package_data={'mypkg': ['data/*.dat']},
  )

那么 mypkg 项目中所有扩展名为 .dat 的文件都将包含在发行版中,并最终与 Python 安装中的 Python 模块一起安装。

对于需要安装在 Python 发行版之外的数据文件,还有一个选项可以将文件存储在存档中,但将其放在定义的位置

setup(…,
    data_files=[('bitmaps', ['bm/b1.gif', 'bm/b2.gif']),
                ('config', ['cfg/data.cfg']),
                ('/etc/init.d', ['init-script'])]
    )

这对 OS 打包人员来说是个糟糕的消息,原因有以下几个

如果打包人员需要重新打包包含此类文件的项目,她别无选择,只能修补setup.py文件,使其在她的平台上按预期工作。为此,她必须检查代码并更改使用这些文件的所有行,因为开发人员对它们的路径做出了假设。SetuptoolsPip并没有改进这一点。

14.4. 改进的标准

因此,我们最终得到一个混乱且令人困惑的打包环境,其中一切由单个 Python 模块驱动,元数据不完整,并且无法描述项目包含的所有内容。以下是我们正在做的一些改进措施。

14.4.1. 元数据

第一步是修复我们的Metadata标准。PEP 345 定义了一个新版本,其中包括

版本

元数据标准的目标之一是确保所有操作 Python 项目的工具能够以相同的方式对它们进行分类。对于版本,这意味着每个工具都应该能够知道 "1.1" 在 "1.0" 之后。但是,如果项目有自定义的版本控制方案,这就会变得更加困难。

确保一致版本控制的唯一方法是发布项目必须遵循的标准。我们选择的方案是一个经典的基于序列的方案。如 PEP 386 所定义,其格式为

N.N[.N]+[{a|b|c|rc}N[.N]+][.postN][.devN]

其中

  • N 是一个整数。您可以使用任意数量的 N,并用点分隔它们,只要至少有两个 (MAJOR.MINOR)。
  • abcrc 分别是alphabetarelease candidate 标记。它们后跟一个整数。发布候选版本有两个标记,因为我们希望该方案与 Python 兼容,Python 使用 rc。但我们发现 c 更简单。
  • dev 后跟一个数字是一个开发标记。
  • post 后跟一个数字是一个发布后标记。

根据项目的发布流程,dev 或 post 标记可用于两个最终版本之间的所有中间版本。大多数流程使用 dev 标记。

遵循此方案,PEP 386 定义了一个严格的排序

  • alpha < beta < rc < final
  • dev < non-dev < post,其中 non-dev 可以是 alpha、beta、rc 或 final

以下是一个完整的排序示例

1.0a1 < 1.0a2.dev456 < 1.0a2 < 1.0a2.1.dev456
  < 1.0a2.1 < 1.0b1.dev456 < 1.0b2 < 1.0b2.post345
    < 1.0c1.dev456 < 1.0c1 < 1.0.dev456 < 1.0
      < 1.0.post456.dev34 < 1.0.post456

该方案的目的是使其他打包系统能够轻松地将 Python 项目的版本转换为它们自己的方案。PyPI 现在拒绝上传包含不符合 PEP 386 版本号的 PEP 345 元数据的任何项目。

依赖项

PEP 345 定义了三个新字段,它们替换了 PEP 314 的RequiresProvidesObsoletes。这些字段是 Requires-DistProvides-DistObsoletes-Dist,可以在元数据中多次使用。

对于Requires-Dist,每个条目包含一个字符串,命名此发行版所需的另一个Distutils项目。需求字符串的格式与Distutils项目名称的格式相同(例如,在Name字段中找到的名称),可以选择在括号中后跟一个版本声明。这些Distutils项目名称应与 PyPI 中找到的名称一致,版本声明必须遵循 PEP 386 中描述的规则。一些示例是

Requires-Dist: pkginfo
Requires-Dist: PasteDeploy
Requires-Dist: zope.interface (>3.5.0)

Provides-Dist用于定义项目中包含的其他名称。当一个项目希望与另一个项目合并时,它很有用。例如,ZODB 项目可以包含transaction项目并声明

Provides-Dist: transaction

Obsoletes-Dist用于将另一个项目标记为过时版本

Obsoletes-Dist: OldName

环境标记

环境标记是可以在字段末尾使用分号添加的标记,用于添加关于执行环境的条件。一些示例是

Requires-Dist: pywin32 (>1.0); sys.platform == 'win32'
Obsoletes-Dist: pywin31; sys.platform == 'win32'
Requires-Dist: foo (1,!=1.3); platform.machine == 'i386'
Requires-Dist: bar; python_version == '2.4' or python_version == '2.5'
Requires-External: libxslt; 'linux' in sys.platform

环境标记的微语言故意保持简单,以便非 Python 程序员也能理解:它使用==in运算符(以及它们的相反运算符)比较字符串,并允许常见的布尔组合。PEP 345 中可以使用此标记的字段是

  • Requires-Python
  • Requires-External
  • Requires-Dist
  • Provides-Dist
  • Obsoletes-Dist
  • Classifier

14.4.2. 安装了什么?

在所有 Python 工具之间共享一个安装格式对于互操作性是必不可少的。如果我们希望安装程序 A 检测到安装程序 B 之前已安装项目 Foo,那么它们都需要共享和更新相同的已安装项目数据库。

当然,理想情况下,用户应该在他们的系统中使用一个安装程序,但他们可能希望切换到具有特定功能的更新的安装程序。例如,Mac OS X 附带Setuptools,因此用户会自动拥有easy_install脚本。如果他们想切换到更新的工具,则需要它与以前的工具向后兼容。

在使用具有 RPM 等打包系统的平台上的 Python 安装程序时,另一个问题是无法告知系统正在安装一个项目。更糟糕的是,即使 Python 安装程序能够以某种方式 ping 中央打包系统,我们也需要在 Python 元数据和系统元数据之间建立映射。例如,项目的名称对于每个项目可能不同。这可能是由于几个原因造成的。最常见的原因是名称冲突:Python 领域之外的另一个项目已经使用相同的名称作为 RPM。另一个原因是使用的名称包含python前缀,这违反了平台的约定。例如,如果您将项目命名为foo-python,那么 Fedora RPM 很可能被称为python-foo

避免此问题的办法之一是保留全局 Python 安装,由中央打包系统管理,并在隔离的环境中工作。像Virtualenv这样的工具允许这样做。

无论如何,我们确实需要在 Python 中拥有一个安装格式,因为当其他打包系统安装它们自己的 Python 项目时,互操作性也是一个问题。一旦第三方打包系统在其系统上的自身数据库中注册了一个新安装的项目,它就需要为 Python 本身生成正确的元数据,以便项目看起来像是安装到 Python 安装程序或查询 Python 安装的任何 API 中。

在这种情况下,可以解决元数据映射问题:由于 RPM 知道它包装了哪些 Python 项目,因此它可以生成适当的 Python 级别的元数据。例如,它知道python26-webob在 PyPI 生态系统中被称为WebOb

回到我们的标准:PEP 376 定义了一个已安装包的标准,其格式与SetuptoolsPip使用的格式非常相似。此结构是一个带有dist-info扩展名的目录,其中包含

一旦所有工具都理解了这种格式,我们就可以在 Python 中管理项目,而不依赖于特定安装程序及其功能。此外,由于 PEP 376 将元数据定义为目录,因此添加新文件以扩展它将很容易。事实上,一个名为RESOURCES的新元数据文件可能会在不久的将来添加到安装的元数据中,而无需修改 PEP 376。最终,如果这个新文件被证明对所有工具都有用,它将被添加到 PEP 中。

14.4.3. 数据文件架构

如前所述,我们需要让打包人员决定在安装过程中将数据文件放在哪里,而不会破坏开发人员的代码。同时,开发人员必须能够使用数据文件,而无需担心它们的路径。我们的解决方案是常用的方法:间接。

使用数据文件

假设您的MPTools应用程序需要使用配置文件。开发人员会将该文件放在 Python 包中,并使用__file__访问它

import os

here = os.path.dirname(__file__)
cfg = open(os.path.join(here, 'config', 'mopy.cfg'))

这意味着配置文件像代码一样安装,并且开发人员必须将其与她的代码放在一起:在本例中,放在名为config的子目录中。

我们设计的数据文件的新架构使用项目树作为所有文件的根目录,并允许访问树中的任何文件,无论它位于 Python 包还是简单目录中。这允许开发人员为数据文件创建专用的目录,并使用pkgutil.open访问它们

import os
import pkgutil

# Open the file located in config/mopy.cfg in the MPTools project
cfg = pkgutil.open('MPTools', 'config/mopy.cfg')

pkgutil.open查找项目元数据,并查看它是否包含RESOURCES文件。这是一个简单的文件到系统可能包含的路径的映射

config/mopy.cfg {confdir}/{distribution.name}

这里,{confdir}变量指向系统的配置文件目录,{distribution.name}包含元数据中找到的 Python 项目的名称。

[Finding a File]

图 14.4:查找文件

只要在安装时创建了这个RESOURCES元数据文件,API 就会为开发人员找到mopy.cfg的路径。由于config/mopy.cfg是相对于项目树的路径,这意味着我们还可以提供一个开发模式,在该模式下,项目的元数据将就地生成并添加到pkgutil的查找路径中。

声明数据文件

在实践中,项目可以通过在其setup.cfg文件中定义一个映射器来定义数据文件应该放在哪里。映射器是一个(glob-style pattern, target)元组列表。每个模式指向项目树中的几个文件之一,而目标是可能包含方括号中的变量的安装路径。例如,MPToolssetup.cfg可能看起来像这样

[files]
resources =
        config/mopy.cfg {confdir}/{application.name}/
        images/*.jpg    {datadir}/{application.name}/

sysconfig模块将提供和记录可以使用的特定变量列表,以及每个平台的默认值。例如,{confdir}在 Linux 上是/etc。因此,安装程序可以在安装时使用此映射器和sysconfig来了解文件应该放在哪里。最终,它们将在安装的元数据中生成前面提到的RESOURCES文件,以便pkgutil可以找到这些文件。

[Installer]

图 14.5:安装程序

14.4.4. PyPI 改进

我之前说过,PyPI 实际上是一个单点故障。PEP 380 通过定义一个镜像协议来解决这个问题,因此用户可以在 PyPI 宕机时回退到备用服务器。目标是允许社区成员在世界各地运行镜像。

[Mirroring]

图 14.6:镜像

镜像列表以X.pypi.python.org形式的主机名列表的形式提供,其中X位于序列a,b,c,…,aa,ab,…中。a.pypi.python.org是主服务器,镜像从 b 开始。一个 CNAME 记录last.pypi.python.org指向最后一个主机名,因此使用 PyPI 的客户端可以通过查看 CNAME 来获取镜像列表。

例如,此调用告诉我们最后一个镜像是h.pypi.python.org,这意味着 PyPI 目前有 6 个镜像(从 b 到 h)。

>>> import socket
>>> socket.gethostbyname_ex('last.pypi.python.org')[0]
'h.pypi.python.org'

从理论上讲,此协议允许客户端通过将镜像的 IP 地址本地化来将请求重定向到最近的镜像,并且如果镜像或主服务器宕机,还可以回退到下一个镜像。镜像协议本身比简单的 rsync 更复杂,因为我们希望保持下载统计信息的准确性并提供最小的安全性。

同步

镜像必须减少中央服务器和镜像之间传输的数据量。为此,它们必须使用changelog PyPI XML-RPC 调用,并且只重新获取自上次更新以来已更改的包。对于每个包 P,它们必须复制文档/simple/P//serversig/P

如果中央服务器上删除了某个包,则必须删除该包及其所有关联文件。为了检测包文件的修改,它们可以缓存文件的 ETag,并可以使用 If-None-Match 头部请求跳过它。同步结束后,镜像会将它的 /last-modified 更新为当前日期。

统计数据传播

当你从任何镜像下载一个发布版本时,该协议确保下载命中信息会被传送到主 PyPI 服务器,然后传送到其他镜像。这样做可以确保浏览 PyPI 以了解某个发布版本被下载了多少次的人员或工具能够获取到所有镜像的总和值。

统计数据被分组到中央 PyPI 的 stats 目录下的每日和每周 CSV 文件中。每个镜像都需要提供一个 local-stats 目录,其中包含其自身的统计数据。每个文件都提供了每个归档文件的下载次数,按使用代理分组。中央服务器每天访问镜像以收集这些统计数据,并将它们合并回全局 stats 目录,因此每个镜像必须至少每天更新一次 /local-stats

镜像真实性

对于任何分布式镜像系统,客户端可能希望验证镜像副本的真实性。一些可能的威胁包括

  • 中央索引可能被入侵
  • 镜像可能被篡改
  • 中央索引和最终用户之间,或镜像和最终用户之间的中间人攻击

为了检测第一次攻击,包作者需要使用 PGP 密钥对其包进行签名,以便用户能够验证该包来自他们信任的作者。镜像协议本身只解决第二个威胁,尽管有一些尝试是为了检测中间人攻击。

中央索引在 URL /serverkey 上提供一个 DSA 密钥,以 openssl dsa -pubout3 生成的 PEM 格式提供。此 URL 不能被镜像,客户端必须直接从 PyPI 获取官方的 serverkey,或使用随 PyPI 客户端软件附带的副本。镜像仍然应该下载密钥,以便他们可以检测密钥滚动。

对于每个包,镜像签名在 /serversig/package 提供。这是并行 URL /simple/package 的 DSA 签名,以 DER 格式提供,使用 SHA-1 与 DSA4

使用镜像的客户端需要执行以下步骤来验证包

  1. 下载 /simple 页面,并计算其 SHA-1 哈希值。
  2. 计算该哈希值的 DSA 签名。
  3. 下载相应的 /serversig,并将其逐字节与步骤 2 中计算的值进行比较。
  4. 计算并验证(针对 /simple 页面)他们从镜像下载的所有文件的 MD5 哈希值。

从中央索引下载时不需要验证,客户端也不应该这样做,以减少计算开销。

大约一年一次,密钥将被替换为一个新的密钥。镜像将不得不重新获取所有 /serversig 页面。使用镜像的客户端需要找到一个新的服务器密钥的受信任副本。获得一个副本的方法之一是从 https://pypi.python.org/serverkey 下载它。为了检测中间人攻击,客户端需要验证 SSL 服务器证书,该证书将由 CACert 颁发机构签名。

14.5. 实现细节

之前部分描述的大多数改进的实现正在 Distutils2 中进行。setup.py 文件不再使用,项目完全在 setup.cfg 中描述,这是一个静态的 .ini 类似文件。通过这样做,我们使打包者更容易更改项目的安装行为,而无需处理 Python 代码。下面是一个此类文件的示例

[metadata]
name = MPTools
version = 0.1
author = Tarek Ziade
author-email = tarek@mozilla.com
summary = Set of tools to build Mozilla Services apps
description-file = README
home-page = http://bitbucket.org/tarek/pypi2rpm
project-url: Repository, http://hg.mozilla.org/services/server-devtools
classifier = Development Status :: 3 - Alpha
    License :: OSI Approved :: Mozilla Public License 1.1 (MPL 1.1)
[files]
packages =
        mopytools
        mopytools.tests

extra_files =
        setup.py
        README
        build.py
        _build.py

resources =
    etc/mopytools.cfg {confdir}/mopytools

Distutils2 使用此配置文件来

Distutils2 还通过其 version 模块实现了 VERSION

INSTALL-DB 实现将在 Python 3.3 的标准库中找到,并将位于 pkgutil 模块中。在此期间,该模块的一个版本存在于 Distutils2 中,可供立即使用。提供的 API 允许我们浏览安装,并确切地知道安装了什么内容。

这些 API 是 Distutils2 一些很酷功能的基础

14.6. 经验教训

14.6.1. 一切都与 PEP 相关

更改像 Python 包这样广泛而复杂的架构需要通过 PEP 流程仔细地更改标准。根据我的经验,更改或添加一个新的 PEP 需要大约一年的时间。

社区在前进的道路上犯的一个错误是交付工具,这些工具通过扩展元数据和 Python 应用程序的安装方式来解决一些问题,而没有尝试更改受影响的 PEP。

换句话说,根据你使用的工具,标准库 DistutilsSetuptools,应用程序的安装方式不同。问题被针对使用这些新工具的一部分社区所解决,但为世界其他地区带来了更多问题。例如,OS 打包者必须面对几个 Python 标准:官方的文档标准和 Setuptools 强加的事实上的标准。

但在同时,Setuptols 有机会以非常快的速度在现实规模(整个社区)上尝试一些创新,反馈非常宝贵。我们能够更加自信地写下新的 PEP,了解哪些有效,哪些无效,也许没有别的办法可以做到这一点。所以这一切都是关于检测何时一些第三方工具正在贡献创新,这些创新正在解决问题,并且应该点燃 PEP 变化。

14.6.2. 进入标准库的包相当于一只脚踏进了坟墓

我在标题中引用了 Guido van Rossum 的话,但这确实是 Python 的“包含电池”理念的一个方面,它对我们的工作产生了很大影响。

Distutils 是标准库的一部分,Distutils2 也将很快成为标准库的一部分。标准库中的包很难让它进化。当然,有弃用流程,你可以杀死或更改一个 API,方法是在 Python 的 2 个次要版本之后。但是,一旦一个 API 发布,它将存在几年。

因此,你对标准库中的包所做的任何更改,如果这不是错误修复,都可能会扰乱生态系统。因此,当你进行重要的更改时,你必须创建一个新的包。

我已经从 Distutils 中的经历中吸取了教训,因为我最终不得不撤销在其中进行了一年多的所有更改,并创建 Distutils2。将来,如果我们的标准以剧烈的方式再次改变,我们很有可能首先开始一个独立的 Distutils3 项目,除非标准库在某个时候独立发布。

14.6.3. 向后兼容性

更改 Python 中打包的工作方式是一个非常漫长的过程:Python 生态系统包含许多基于旧打包工具的项目,因此存在并将会存在很大的阻力来改变。(就本章讨论的一些主题达成共识花费了几年,而不是我最初预期的那几个月的時間。) 就像 Python 3 一样,所有项目切换到新标准将需要几年时间。

这就是为什么我们所做的一切都必须与所有以前的工具、安装和标准向后兼容,这使得 Distutils2 的实现成为一个棘手的问题。

例如,如果使用新标准的项目依赖于另一个尚未使用新标准的项目,我们不能通过告诉最终用户依赖项处于未知格式来停止安装过程!

例如,INSTALL-DB 实现包含兼容性代码,用于浏览由原始 DistutilsPipDistributeSetuptools 安装的项目。Distutils2 也能够通过动态转换其元数据来安装由原始 Distutils 创建的项目。

14.7. 参考资料和贡献

本文中的一些部分直接来自我们为打包编写的各种 PEP 文档。你可以在 https://pythonlang.cn 找到原始文档

我要感谢所有参与打包工作的人员;你将在我在文中提到的每个 PEP 中找到他们的名字。我还想特别感谢“打包者协会”的所有成员。此外,感谢 Alexis Metaireau、Toshio Kuratomi、Holger Krekel 和 Stefane Fermigier 对本章的反馈。

本章中讨论的项目是

脚注

  1. 本章末尾总结了我们提到的 Python 增强提案(PEP)。
  2. 以前被称为 CheeseShop。
  3. 即 RFC 3280 SubjectPublicKeyInfo,算法为 1.3.14.3.2.12。
  4. 即 RFC 3279 Dsa-Sig-Value,由算法 1.2.840.10040.4.3 创建。