Python DevOps 教程(一)

原文:DevOps in Python

协议:CC BY-NC-SA 4.0

一、安装 Python

在我们开始使用 Python 之前,我们需要安装它。一些操作系统,如 Mac OS X 和一些 Linux 变种,已经预装了 Python。这些版本的 Python,俗称“系统 Python”,对于想用 Python 开发的人来说,通常都是很差的默认设置。

首先,安装的 Python 版本通常落后于最新的实践。另一方面,系统集成商通常会以可能导致意外的方式修补 Python。例如,基于 Debian 的 Python 经常缺少像venvensurepip这样的模块。Mac OS X Python 链接到其本机 SSL 库周围的 Mac shim。这些事情意味着,尤其是在开始使用 FAQ 和 web 资源时,最好从头开始安装 Python。

我们将介绍几种方法以及每种方法的优缺点。

1.1 操作系统包

对于一些更受欢迎的操作系统,志愿者已经建立了现成的安装包。

其中最著名的是“死蛇”PPA(个人包档案)。名称中的“死”指的是那些包已经被构建的事实——比喻源代码是“活的”那些包是为 Ubuntu 构建的,通常会支持上游仍然支持的所有版本的 Ubuntu。获取这些包很简单:

$ sudo add-apt-repository ppa:deadsnakes/ppa
$ sudo apt update

在 Mac OS 上,homebrew第三方包管理器将拥有最新的 Python 包。家酿啤酒的介绍超出了本书的范围。由于家酿是一个滚动版本,Python 的版本会不时升级。虽然这意味着这是一种获得最新 Python 的有用方法,但对于可靠地分发工具来说,这是一个糟糕的目标。

对于日常开发来说,这也是一个有一些缺点的选择。因为它在新的 Python 版本发布后会快速升级,这意味着开发环境会很快中断,并且没有任何警告。这也意味着有时代码可能会停止工作:即使您小心地观察即将到来的重大变化,也不是所有的包都会这样。当需要一个构建良好的、最新的 Python 解释器来完成一次性任务时,自制 Python 是一个很好的选择。编写一个快速的脚本来分析数据,或者自动化一些 API,是对 Homebrew Python 的一个很好的利用。

最后,对于 Windows,可以从 Python.org 下载任何版本 Python 的安装程序。

1.2 使用 Pyenv

Pyenv 往往是为本地开发安装 Python 的最高投资回报。初始设置确实有一些微妙之处。然而,它允许根据需要并排安装任意多的 Python 版本。它允许管理一个将被访问的方式:基于每个用户的默认或每个目录的默认。

安装 pyenv 本身依赖于操作系统。在 Mac OS X 上,最简单的方法是通过自制软件安装。注意,在这种情况下,pyenv 本身可能需要升级以安装新版本的 Python。

在基于 UNIX 的操作系统上,比如 Linux 或 FreeBSD,安装 pyenv 最简单的方法是使用curl|bash命令:

$ PROJECT=https://github.com/pyenv/pyenv-installer \
  PATH=raw/master/bin/pyenv-installer \
  curl -L $PROJECT/PATH | bash

当然,这也带来了自身的安全问题,可以用两个步骤来代替:

$ git clone https://github.com/pyenv/pyenv-installer
$ cd pyenv-installer
$ bash pyenv-installer

用户可以在运行之前检查 shell 脚本,甚至可以使用git checkout锁定特定的修订版本。

遗憾的是,pyenv 不能在 Windows 上运行。

安装 pyenv 后,将其与运行的 shell 集成在一起是很有用的。我们通过向 shell 初始化文件(例如,.bash_profile)添加以下内容来实现这一点:

export PATH="~/.pyenv/bin:$PATH"
eval "$(pyenv init -)"
eval "$(pyenv virtualenv-init -)"

这将允许 pyenv 正确拦截所有需要的命令。

Pyenv 将安装的解释器和可用的解释器的概念分开。为了安装一个版本,

$ pyenv install <version>

对于 CPython 来说,<version>只是版本号,比如3.6.6或者3.7.0rc1

已安装的版本不同于可用版本。通过使用,版本可以“全局地”(对于用户)可用

$ pyenv global 3.7.0

或者在本地使用

$ pyenv local 3.7.0

本地意味着它们将在给定的目录中可用。这是通过在这个目录中放置一个文件python-version.txt来完成的。这对版本控制的存储库很重要,但是有一些不同的策略来管理它们。一种是将该文件添加到“忽略”列表中。这对开源项目的异质团队很有用。另一种方法是签入这个文件,以便在这个存储库中使用相同版本的 Python。

注意, pyenv ,因为它被设计成并排安装 Python 的版本,所以没有“升级”Python 的概念。为了使用更新的 Python,需要安装 pyenv ,然后设置为默认。

默认情况下, pyenv 安装 Python 的非优化版本。如果需要优化版本,

env PYTHON_CONFIGURE_OPTS="--enable-shared
                           --enable-optimizations
                           --with-computed-gotos
                           --with-lto
                           --enable-ipv6" pyenv install

将构建一个与来自python.org的二进制版本非常相似的版本。

1.3 从源代码构建 Python

从源代码构建 Python 的主要挑战是,在某种意义上,它太宽容了。禁用一个内置模块来构建它太容易了,因为没有检测到它的依赖关系。这就是为什么知道哪些依赖关系是脆弱的,以及如何确保本地安装是好的非常重要。

第一个脆弱的依赖是ssl。默认禁用,必须在Modules/Setup.dist中启用。仔细遵循那里关于 OpenSSL 库位置的说明。如果你已经通过系统包安装了 OpenSSL,它通常会在/usr/中。如果您已经从源代码安装了它,它通常会在/usr/local中。

最重要的是知道如何测试它。当 Python 完成构建后,运行./python.exe -c 'import _ssl'。这个.exe不是一个错误——这是构建过程调用刚刚构建好的可执行文件的方式,该文件在安装过程中被重命名为python。如果成功了,那么ssl模块就被正确构建了。

另一个可能构建失败的扩展是sqlite。因为它是内置的,所以很多第三方的包都依赖于它,即使你自己没有使用它。这意味着没有sqlite模块的 Python 安装是相当糟糕的。通过运行./python.exe -c 'import sqlite3'进行测试。

在基于 Debian 的系统(比如 Ubuntu)中,需要使用libsqlite3-dev才能成功。在基于 Red Hat 的系统(比如 Fedora 或 CentOS)中,需要使用libsqlite3-dev才能成功。

接下来,用./python.exe -c 'import _ctypes'检查_ctypes。如果失败,很可能没有安装libffi割台。

最后,记得在从源代码构建之后运行内置的回归测试套件。这是为了确保在构建包时没有愚蠢的错误。

1.4 皮比

Python 的“通常”实现有时被称为“CPython”,以区别于语言本身。最流行的替代实现是 PyPy。PyPy 是 Python 在 Python 中基于 Python 的 JIT 实现。因为它有一个动态的 JIT(即时)编译到汇编,它有时可以获得比普通 Python 显著的速度提升(3 倍甚至 10 倍)。

使用 PyPy 有时会有挑战。许多工具和软件包只能用 CPython 进行测试。然而,如果性能很重要,有时花精力检查 PyPy 是否与环境兼容是值得的。

从源代码安装 Python 有一些微妙之处。虽然理论上使用 CPython 进行“翻译”是可能的,但实际上 PyPy 中的优化意味着使用 PyPy 进行翻译可以在更合理的机器上工作。即使从源代码安装,也最好先安装一个二进制版本的引导程序。

引导版本应该是 PyPy,而不是 PyPy3。PyPy 是用 Python 2 方言编写的。这是唯一一种不用担心贬值的情况,因为 PyPy 是 Python 2 方言解释器。PyPy3 是 Python 3 方言的实现,通常更适合在生产中使用,因为大多数包都在慢慢放弃对 Python 2 的支持。

最新的 PyPy3 支持 Python 的 3.5 特性,以及 f 字符串。然而,Python 3.6 中添加的最新异步特性并不工作。

1.5 蟒蛇

Anaconda Python 是最接近“系统 Python”的一种,仍然可以合理地用作开发平台。Anaconda 是一种所谓的“元分发”本质上,它是操作系统之上的一个操作系统。Anaconda 植根于科学计算社区,因此它的 Python 为许多科学应用提供了易于安装的模块。从 PyPI 安装许多这样的模块并不容易,需要复杂的构建环境。

可以在同一台机器上安装多个 Anaconda 环境。当需要不同的 Python 版本或不同版本的 PyPI 模块时,这很方便。

为了引导 Anaconda,我们可以使用bash安装程序,可以从 https://conda.io/miniconda.html 获得。安装程序还会修改~/.bash_profile来添加安装程序conda的路径。

Conda 环境使用conda create --name <name>创建,使用source conda activate <name>激活。没有简单的方法来使用未激活的环境。可以在安装软件包的同时创建一个 conda 环境:conda create --name some-name python。我们可以使用= – conda create --name some-name python=3.5来指定版本。在环境被激活后,也可以使用conda install package[=version]在 conda 环境中安装更多的包。Conda 有很多预构建的 Python 包,尤其是那些在本地构建的包。如果这些包对您的用例很重要,这是一个很好的选择。

1.6 摘要

运行 Python 程序需要在系统上安装解释器。根据操作系统和所需的版本,有几种不同的方法来安装 Python。使用系统 Python 是一个有问题的选择。在 Mac 和 UNIX 系统上,使用pyenv几乎总是首选。在 Windows 上,使用 python.org 的预打包安装程序通常是个好主意。

二、包

在现实世界中,处理 Python 的大部分工作都是处理第三方包。很长一段时间,情况都不好。然而,事情已经有了显著的改善。重要的是要理解哪些“最佳实践”是过时的惯例,哪些是基于错误的假设,但有一些优点,哪些实际上是好主意。

在处理包时,有两种交互方式。一种是成为“消费者”,希望使用软件包的功能。另一种是成为“制作人”,发布一个包。这些通常描述不同的开发任务,而不是不同的人。

在转向“生产”之前,对包的“消费者”方面有一个坚实的理解是很重要的如果包发布者的目标是对包用户有用,那么在开始写一行代码之前想象“最后一英里”是至关重要的。

2.1 点

Python 的基本打包工具是pip。默认情况下,Python 的安装不附带pip。这使得pip可以比核心 Python 运行得更快——并且可以与 PyPy 等替代 Python 实现一起工作。然而,它们确实带有有用的ensurepip模块。这允许通过python -m ensurepip获得 pip。这通常是引导pip的最简单方式。

一些 Python 安装,尤其是系统安装,会禁用ensurepip。当缺少ensurepip时,有一种手动获取的方式:get-pip.py。这是一个可下载的单个文件,当执行时,它将解包pip

幸运的是,pip是唯一需要这些奇怪的旋转来安装的包。所有其他的包都可以并且应该使用pip来安装。这包括升级pip本身,可以用pip install --upgrade pip完成。

根据 Python 的安装方式,它的“真实环境”可能会也可能不会被我们的用户修改。各种自述文件和博客中的许多说明可能会鼓励进行 sudo pip 安装。这几乎总是错误的做法:它将在全局环境中安装软件包。

在虚拟环境中安装几乎总是更好,这些将在后面介绍。作为一种临时措施,也许是为了安装创建虚拟环境所需的东西,我们可以安装到我们的用户区域。这是用pip install --user完成的。

pip install命令将下载并安装所有依赖项。但是,它可能无法降级不兼容的软件包。总是可以安装显式版本:pip install package-name==<version>将安装这个精确的版本。这也是一个获得明确的非通用包的好方法,比如发布候选包、测试包或类似的包,用于本地测试。

如果安装了wheel,那么pip将为包构建轮子,通常是缓存轮子。这在处理高虚拟环境变动时特别有用,因为安装缓存轮是一个快速操作。这在处理所谓的“本机”或“二进制”包时也特别有用——那些需要用 C 编译器编译的包。轮缓存将消除再次构建轮缓存的需要。

pip确实允许卸载,带pip uninstall。默认情况下,该命令需要手动确认。除特殊情况外,不使用该命令。如果一个意想不到的包溜了进来,通常的反应是破坏环境并重建它。出于类似的原因,pip install --ugprade并不经常需要:常见的反应是破坏和重建环境。有一种情况是个好主意:pip install --upgrade pip。这是获得新版本pip的最好方法,它有错误修正和新特性。

pip install支持一个“需求文件”,pip install --requirementspip install -r。一个需求文件每行只有一个包。这与在命令行上指定包没有什么不同。然而,需求文件经常指定“严格依赖”一个需求文件可以从pip freeze的环境中生成。获取需求文件的通常方法是,在虚拟环境中,执行以下操作:

$ pip install -e .
$ pip freeze > requirements.txt

这意味着需求文件将拥有当前的包,以及它所有的递归依赖项,并有严格的版本。

2.2 虚拟环境

虚拟环境经常被误解,因为“环境”的概念并不清楚。Python 环境是指 Python 安装的根目录。它之所以重要是因为子目录lib/site-packageslib/site-packages目录是安装第三方软件包的地方。在现代,它们经常被pip安装。虽然过去有其他工具可以做到这一点,但甚至自举pipvirtualenv都可以用pip来完成,更不用说日常的包管理了。

对于系统 Python 来说,唯一常见的选择是系统包。在 Anaconda 环境中,一些包可能作为 Anaconda 的一部分安装。事实上,这是 Anaconda 的一大好处:许多 Python 包都是定制的,尤其是那些构建起来不简单的包。

“真实的”环境是基于 Python 安装的环境。这意味着要获得一个新的真实环境,我们必须重新安装(通常是重建)Python。这有时是一个昂贵的提议。例如,如果任何参数不同,tox将从头开始重建环境。为此,虚拟环境存在。

虚拟环境从真实环境中复制最少的必要内容,误导 Python 认为它有了一个新的根。确切的细节并不重要,重要的是这是一个简单的命令,只是复制文件(有时使用符号链接)。

使用虚拟环境有两种方式:激活的和未激活的。为了使用在脚本和自动化过程中最常见的未激活的虚拟环境,我们从虚拟环境中显式调用 Python。

这意味着如果我们在/home/name/venvs/my-special-env,中创建了一个虚拟环境,我们可以调用/home/name/venvs/my-special-env/bin/python在这个环境中工作。例如,/home/name/venvs/my-special-env/bin/python -m pip将运行pip,但安装在虚拟环境中。注意,对于基于入口点的脚本,它们将与 Python 一起安装,所以我们可以运行/home/name/venvs/my-special-env/bin/pip在虚拟环境中安装包。

使用虚拟环境的另一种方法是“激活”它。在 bash-like shell 中激活虚拟环境意味着提供激活脚本:

$ source /home/name/venvs/my-special-env/bin/activate

sourcing 设置了几个环境变量,其中只有一个实际上是重要的。重要的变量是PATH,它以/home/name/venvs/my-special-env/bin为前缀。这意味着像pythonpip这样的命令会首先出现在那里。有两个修饰性的变量被设定:VIRTUAL_ENV将指向环境的根源。这在希望了解虚拟环境的管理脚本中非常有用。

PS1将以(my-special-env),为前缀,这对于在控制台中交互工作时虚拟环境的视觉指示非常有用。

一般来说,在虚拟环境中只安装第三方软件包是一种好的做法。结合虚拟环境“廉价”的事实,这意味着如果一个人进入糟糕的状态,很容易删除整个目录并从头开始。例如,想象一个错误的包安装导致 Python 启动失败。即使运行pip uninstall也是不可能的,因为pip在启动时会失败。然而,“便宜”意味着我们可以移除整个虚拟环境,并用一组好的包重新创建它。

事实上,现代实践越来越倾向于将虚拟环境视为半不可变的:在创建它们之后,有一个“安装所有必需的包”的单一阶段如果需要升级,我们不是修改它,而是破坏环境,重新创建和重新安装。

创建虚拟环境有两种方式。一种方法是在 Python 2 和 Python 3 之间移植。这需要以某种方式引导,因为 Python 没有预装virtualenv。有几种方法可以实现这一点。如果 Python 是使用打包系统安装的,比如系统打包程序、Anaconda 或 Homebrew,那么通常同一个系统会打包virtualenv。如果 Python 是在用户目录中使用pyenv安装的,有时直接在“原始环境”中使用pip install是一个不错的选择,尽管这是“只安装到虚拟环境”的一个例外最后,这是pip install --user可能是个好主意的情况之一:这将把包安装到特殊的“用户区域”注意,这意味着有时它不在$PATH中,运行它的最佳方式是使用python-m virtualenv.

如果不需要可移植性,venv是一种创建虚拟环境的 Python 3 专用方法。它作为python -m venv被访问,因为没有专用的入口点。这解决了如何安装virtualenv的“引导”问题,尤其是在使用非系统 Python 时。

无论使用哪个命令来创建虚拟环境,它都会为该环境创建目录。最好是在此之前该目录不存在。最佳实践是在创建环境之前将其删除。还有关于如何创建环境的选项:使用哪个解释器和安装什么初始包。例如,有时完全跳过pip安装是有益的。然后我们可以通过使用get-pip.py在虚拟环境中引导pip。这是一种避免安装在真实环境中的pip的坏版本的方法——因为如果它足够坏,它甚至不能用于升级pip

2.3 设置和车轮

术语“第三方”(如“第三方包”)是指除 Python 核心开发人员(“第一方”)或本地开发人员(“第二方”)之外的人。我们已经在安装部分介绍了如何安装“第一方”包。我们使用了pipvirtualenv来安装“第三方”包。是时候最终将我们的注意力转向缺失的环节了:本地开发和安装本地包,或者“第二方”包。

这是一个有很多新成员的领域,比如pyproject.tomlflit。然而,理解经典的做事方式是很重要的。首先,新的最佳实践需要一段时间来适应。另一方面,现有的实践是基于setup.py,因此这种方式在一段时间内仍将是主要方式——甚至可能是在可预见的未来。

文件用代码描述了我们的“发行版”请注意,“分发”不同于“打包”包是 Python 可以导入的(通常是)__init__.py的目录。一个发行版可以包含几个包,甚至不包含任何包!但是,保持 1-1-1 的关系是一个好主意:一个发行版,一个包,名称相同。

通常,setup.py会以导入setuptoolsdistutils开始。distutils是内置的,setuptools不是。然而,由于它非常受欢迎,它几乎总是首先安装在虚拟环境中。不推荐 Distutils:它已经很久没有更新了。请注意,setup.py不能有意义地、显式地声明它需要setuptools也不能显式地请求一个特定的版本:当它被读取时,它将已经试图导入setuptools。这种非声明性是包替代品的部分动机。

绝对有效的最低限度setup.py如下:

import setuptools

setuptools.setup(
    packages=setuptools.find_packages(),
)

出于某种原因,官方文档称许多其他字段为“必需的”,尽管即使这些字段缺失,包也会被构建。对于一些人来说,这个将会导致产生难看的缺省值,比如包名是UNKNOWN

当然,这些领域中有很多都是值得拥有的。但是这个框架setup.py足以创建一个包含目录中的本地 Python 包的发行版。

现在,当然,几乎总是会有其他领域需要添加。如果要将这个包上传到打包索引,即使它是一个私有索引,也必须添加其他字段。

添加至少一个“名称”字段是一个好主意。这将为发行版命名。如前所述,在发行版中以单个顶级包命名几乎总是一个好主意。

那么,典型的源层次结构将如下所示:

setup.py
    import setuptools

    setuptools.setup(
        name='my_special_package',
        packages=setuptools.find_packages(),
    )
my_special_package/
    __init__.py
    another_module.py
    tests/
        test_another_module.py

另一个几乎总是好主意的领域是版本。软件版本化总是很难。尽管如此,即使是一个连续的数字,也是一个很好的方法来回答这个永恒的问题:“这是运行一个新的还是旧的版本?”

有一些工具可以帮助管理版本号,特别是假设我们希望在运行时 Python 也可以使用它。尤其是在进行日历版本管理时,incremental是一个强大的软件包,可以自动完成一些繁琐的工作。bumpversion是一个有用的工具,尤其是在选择语义版本化的时候。最后,versioneer支持与 git 版本控制系统的简单集成,所以发布只需要一个标签。

setup.py中的另一个流行字段是install_requires,它在文档中没有被标记为“required ”,但在几乎每个包中都存在。这就是我们如何标记代码使用的其他发行版。将“松散的”依赖关系放在setup.py中是一个很好的做法。这与指定特定版本的精确依赖相反。松散的依赖看起来像Twisted>=17.5——指定了最低版本,但没有最高版本。精确的依赖,像Twisted==18.1,在setup.py中通常是个坏主意。它们应该只在极端情况下使用:例如,当使用一个包的私有 API 的重要部分时。

最后,给find_packages一个白名单,列出要包含的内容,以避免虚假文件,这是一个好主意。例如,

setuptools.find_packages(include=["my_package∗"])

一旦我们有了setup.py和一些 Python 代码,我们想把它做成一个发行版。一个发行版可以有几种格式,但是我们这里要介绍的是。如果my-directory是有setup.py的那个,运行pip wheel my-directory,将产生一个轮子,以及它的所有递归依赖的轮子。

默认情况下,将轮子放在当前目录中,这很少是我们想要的行为。使用--wheel-dir<output-directory>将把轮子放到目录中——以及它所依赖的任何发行版的轮子。

我们可以用滚轮做几件事,但重要的是要注意我们可以做的一件事是pip install <wheel file>。如果我们添加了pip install <wheel file> --wheel-dir <output directory>,那么pip将使用目录中的轮子,而不会使用 PyPI。这对于可重复安装或支持气隙模式非常有用。

2.4 毒性

Tox 是一种自动管理虚拟环境的工具,通常用于测试和构建。它用于确保那些在定义良好的环境中运行,并智能地缓存它们以减少流失。Tox 作为一个测试运行工具,是按照测试环境配置的。

它使用一种独特的基于 ini 的配置格式。这使得编写配置变得困难,因为记住文件格式的微妙之处可能很难。然而,反过来,虽然很难利用,但是有很多能力可以帮助配置测试和构建清晰简洁的运行。

Tox 缺少的一点是构建步骤之间的依赖关系。这意味着这些通常是从外部管理的,通过在其他测试运行之后运行特定的测试运行,并以某种特别的方式共享工件。

Tox 环境或多或少对应于配置文件中的一个部分。

[testenv:some-name]

.
.
.

注意,如果名称包含pyNM(例如py36),那么 Tox 将默认使用 CPython N.M(本例中为 3.6)作为该测试环境的 Python。如果名称包含pypyNM,,Tox 将默认使用 PyPy N.M作为该版本——其中这些代表“CPython 兼容性版本”,而不是 PyPy 自己的版本化方案。

如果名称不包含pyNMpypyNM,或者如果需要覆盖缺省值,可以使用该部分中的basepython字段来表示特定的 Python 版本。默认情况下,Tox 将在路径中寻找这些蟒蛇。然而,如果安装了插件tox-pyenv,Tox 将查询pyenv是否在路径上找不到正确的 Python。

例如,我们将分析一个简单的 Tox 文件和一个更复杂的文件。

[tox]
envlist = py36,pypy3.5,py36-flake8

tox部分是一个全局配置。在本例中,我们仅有的全局配置是环境列表。

[testenv:py36-flake8]

这个部分配置py36-flake8测试环境。

deps =
    flake8

deps小节详细说明了哪些包应该安装在测试环境的虚拟环境中。这里我们选择用一个松散的依赖关系来指定flake8。另一个选项是指定一个严格的依赖项(如flake8==1.0.0.))。这有助于可重复的测试运行。我们也可以指定-r <requirements file>并单独管理需求。如果我们有接受需求文件的其他工具,这是很有用的。

commands =
    flake8 useful

在这种情况下,唯一的命令是在目录useful上运行flake8。默认情况下,如果所有命令都返回成功的状态代码,Tox 测试运行将会成功。作为一个从命令行运行的程序,flake8尊重这个惯例,只有在代码没有问题的情况下,才会以一个成功的状态代码退出。

[testenv]

缺少特定配置的其他两个环境将退回到通用环境。注意,由于它们的名字,它们将使用两种不同的解释器:兼容 Python 3.5 的 CPython 3.6 和 PyPy。

deps =
    pytest

在这个环境中,我们安装了pytest转轮。注意,通过这种方式,我们的tox.ini记录了运行测试所需工具的假设。例如,如果我们的测试使用了Hypothesis或者PyHamcrest,这就是我们记录它的地方。

commands =
    pytest useful

同样,命令运行很简单。再次注意,pytest遵守约定,只有在没有测试失败的情况下才会成功退出。

作为一个更现实的例子,我们转向ncolony:tox.ini

[tox]
envlist = {py36,py27,pypy}-{unit,func},py27-lint,py27-wheel,docs
toxworkdir = {toxinidir}/build/.tox

我们有更多的环境。注意,我们可以使用{}语法来创建一个环境矩阵。这意味着{py36,py27,pypy}-{unit,func}创造了3*2=6环境。请注意,如果我们有一个进行了“大跳跃”的依赖项(例如,Django 1 和 2),并且我们想要对两者进行测试,我们可以对总共的3*2*2=12环境进行{py36,py27, pypy}-{unit,func}-{django1,django2}。请注意,像这样的矩阵测试的数字攀升很快——当使用自动化测试环境时,这意味着事情要么需要更长的时间,要么需要更高的并行性。

这是测试的全面性和资源使用之间的正常权衡。除了仔细考虑官方支持多少变种,没有什么神奇的解决方法。

[testenv]

不是每个变体都有一个testenv,我们选择使用一个测试环境,但是通过匹配来特例化变体。这是创建许多测试环境变体的相当有效的方法。

deps =
    {py36,py27,pypy}-unit: coverage
    {py27,pypy}-lint: pylint==1.8.1
    {py27,pypy}-lint: flake8
    {py27,pypy}-lint: incremental
    {py36,py27,pypy}-{func,unit}: Twisted

我们只在单元测试中需要coverage,而单元测试和功能测试都需要Twistedpylint严格依赖确保了随着pylint添加更多的规则,我们的代码不会获得新的测试失败。这确实意味着我们需要不时地手动更新pylint

commands =
    {py36,py27,pypy}-unit: python -Wall \
                                  -Wignore::DeprecationWarning \
                                  -m coverage \
                                  run -m twisted.trial \
                                  --temp-directory build/_trial_temp \
                                  {posargs:ncolony}
    {py36,py27,pypy}-unit: coverage report --include ncolony∗ \
                           --omit ∗/tests/,/interfaces∗,/_version∗ \
↪                     --show-missing --fail-under=100
    py27-lint: pylint --rcfile admin/pylintrc ncolony
    py27-lint: python -m ncolony tests.nitpicker
    py27-lint: flake8 ncolony
    {py36,py27,pypy}-func: python -Werror -W ignore::DeprecationWarning \
                                  -W ignore::ImportWarning \
                                  -m ncolony tests.functional_test

配置“一个大的测试环境”意味着我们需要将所有的命令混合在一个包中,并基于模式进行选择。这也是一个更现实的测试运行命令——我们希望在启用警告的情况下运行,但是禁用我们不担心的警告,并且还启用代码覆盖测试。虽然具体的复杂性会有所不同,但我们几乎总是需要足够多的东西,以便命令能够增长到合适的大小。

[testenv:py27-wheel]
skip_install = True

deps =
      coverage
      Twisted
      wheel
      gather
commands =
      mkdir -p {envtmpdir}/dist
      pip wheel . --no-deps --wheel-dir {envtmpdir}/dist
      sh -c "pip install --no-index {envtmpdir}/dist/∗.whl"
      coverage run {envbindir}/trial \
               --temp-directory build/_trial_temp {posargs:ncolony}
      coverage report --include ∗/site-packages/ncolony∗ \
                      --omit ∗/tests/,/interfaces∗,/_version∗ \
                      --show-missing --fail-under=100

测试运行确保我们可以制造和测试一个轮子。作为一个副作用,这意味着一个完整的测试运行将建立一个轮子。这允许我们在发布的时候上传一个测试过的轮到 PyPI。

[testenv:docs]
changedir = docs
deps =
    sphinx
    Twisted
commands =
    sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html
basepython = python2.7

文档构建是 Tox 大放异彩的原因之一。它只在虚拟环境中安装sphinx用于构建文档。这意味着对sphinx未声明的依赖会使单元测试失败,因为sphinx没有安装在那里。

2.5 管道与诗歌

Pipenv 和 poem 是产生 Python 项目的两种新方法。它们分别受到 JavaScript 和 Ruby 的工具yarnbundler的启发,这些工具旨在编码更完整的开发流程。就其本身而言,它们并不能替代 Tox——它们不能编码与多个 Python 解释器一起运行的能力,也不能完全覆盖依赖性。然而,可以将它们与 CI 系统配置文件(如Jenkinsfile.circleci/config.yml)一起使用,以构建多种环境。

然而,它们的主要优势在于允许更容易的交互开发。有时,这对于更具探索性的编程很有用。

2.5.1 诗歌

安装诗歌最简单的方法就是使用pip install --user poetry。然而,这会将它的所有依赖项安装到您的用户环境中,这有可能把事情搞得一团糟。一种干净的方法是创建一个专用的虚拟环境。

$ python3 -m venv ~/.venvs/poetry
$ ~/.venvs/poetry/bin/pip install poetry
$ alias poetry=~/.venvs/poetry/bin/poetry

这是一个使用未激活的虚拟环境的例子。

使用诗歌的最好方法是为项目创建一个专用的虚拟环境。我们将构建一个小型演示项目。我们称之为“有用”

$ mkdir useful
$ cd useful
$ python3 -m venv build/useful
$ source build/useful/bin/activate
(useful)$ poetry init
(useful)$ poetry add termcolor
(useful)$ mkdir useful
(useful)$ touch useful/__init__.py
(useful)$ cat > useful/__main__.py
import termcolor
print(termcolor.colored("Hello", "red"))

如果我们做到了这一切,在虚拟环境中运行 python -m 有用的就会打印出红色的Hello。在我们交互式地尝试了各种颜色,并可能决定将文本加粗后,我们准备发布:

(useful)$ poetry build
(useful)$ ls dist/
useful-0.1.0-py2.py3-none-any.whl useful-0.1.0.tar.gz

管道 nv

Pipenv 是一个创建符合规范的虚拟环境的工具,此外还有改进规范的方法。它依赖于两个文件:PipfilePipfile.lock。我们可以像安装poetry一样安装pipenv,在定制的虚拟环境中添加一个别名。

为了开始使用它,我们希望确保没有虚拟环境被激活。然后,

$ mkdir useful
$ cd useful
$ pipenv add termcolor
$ mkdir useful
$ touch useful/__init__.py
$ cat > useful/__main__.py
import termcolor
print(termcolor.colored("Hello", "red"))
$ pipenv shell
(useful-hwA3o_b5)$ python -m useful

这将在它后面留下一个看起来像这样的Pipfile:

[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[packages]
termcolor = "∗"

[dev-packages]

[requires]
python_version = "3.6"

注意,为了封装useful,我们还是要写一个setup.py。Pipenv 将自己局限于管理虚拟环境,它确实考虑构建和发布单独的任务。

2.6 DevPI

DevPI 是一个 PyPI 兼容的服务器,可以在本地运行。虽然它不能扩展到类似 PyPI 的级别,但在许多情况下,它是一个强大的工具。

DevPI 由三部分组成。最重要的是devpi-server。对于许多用例来说,这是唯一需要运行的部分。服务器首先充当 PyPI 的缓存代理。它利用了 PyPI 上的包是不可变的的事实:一旦我们有了一个包,它就永远不会改变。

还有一个 web 服务器允许我们在本地包目录中进行搜索。由于许多用例甚至不涉及在 PyPI 网站上搜索,这肯定是可选的。最后,有一个客户机命令行工具,它允许在运行的实例上配置各种参数。客户端在更深奥的用例中最有用。

安装和运行 DevPI 非常简单。在虚拟环境中,只需运行:

(devpi)$ pip install devpi-server
(devpi)$ devpi-server --start --init

默认情况下,pip工具转到pypi.org。对于 DevPI 的一些基本测试,我们可以创建一个新的虚拟环境playground,并运行:

(playground)$ pip install \
              -i http://localhost:3141/root/pypi/+simple/ \
              httpie glom
(playground)$ http --body https://httpbin.org/get | glom '{"url":"url"}'
{
  "url": "https://httpbin.org/get"
}

每次都必须为pip指定-i ...参数会很烦人。在检查了一切都正常工作后,我们可以将配置放在一个环境变量中:

$ export PIP_INDEX_URL=http://localhost:3141/root/pypi/+simple/

或者让事情变得更持久:

$ mkdir -p ~/.pip && cat > ~/.pip/pip.conf << EOF
[global]
index-url = http://localhost:3141/root/pypi/+simple/

[search]
index = http://localhost:3141/root/pypi/

上述文件位置适用于 UNIX 操作系统。在 Mac OS X 上,配置文件是$HOME/Library/Application Support/pip/pip.conf。在 Windows 上,配置文件是%APPDATA%\pip\pip.ini

DevPI 对于断开连接的操作很有用。如果我们需要在没有网络的情况下安装包,可以用 DevPI 来缓存。如前所述,虚拟环境是一次性的,通常被视为不可改变的。这意味着,如果没有网络,拥有正确软件包的虚拟环境就不是有用的东西。很有可能某种情况会要求或建议从头开始创建它。

然而,缓存服务器是另一回事。如果所有的包检索都是通过缓存代理完成的,那么破坏一个虚拟环境并重新构建它是没问题的,因为事实的来源是包缓存。这对于将笔记本电脑带进森林进行离线开发非常有用,对于维护适当的防火墙边界和拥有所有已安装软件的一致记录也非常有用。

为了“预热”DevPI 缓存,也就是确保它包含所有需要的包,我们需要使用pip来安装它们。一种方法是,在配置 DevPI 和pip之后,对正在开发的软件的源库运行tox。由于tox经历了所有的测试环境,它下载了所有需要的包。

在一次性虚拟环境中预装任何相关的requirements.txt,这绝对是一个好的做法。

然而,DevPI 的效用并不局限于断开连接的操作。在您的构建集群中配置一个,并将构建集群指向它,完全避免了“leftpad 事件”的风险,即您所依赖的包被作者从 PyPI 中删除。它也可能使构建更快,而且它肯定会减少大量的输出流量。

DevPI 的另一个用途是在上传到 PyPI 之前测试上传。假设devpi-server已经在默认端口上运行,我们可以:

(devpi)$ pip install devpi-client twine
(devpi)$ devpi use http://localhost:3141
(devpi)$ devpi user -c testuser password=123
(devpi)$ devpi login testuser --password=123
(devpi)$ devpi index -c dev bases=root/pypi
(devpi)$ devpi use testuser/dev
(devpi)$ twine upload --repository http://localhost:3141/testuser/dev \
               -u testuser -p 123 my-package-18.6.0.tar.gz
(devpi)$ pip install -i http://localhost:3141/testuser/dev my-package

注意,这允许我们上传到一个我们只显式使用的索引,所以我们不会对所有没有显式使用它的环境隐藏my-package

一个更高级的用例,我们可以这样做:

(devpi)$ devpi index root/pypi mirror_url=https://ourdevpi.local

这将使我们的 DevPI 服务器成为本地“上游”DevPI 服务器的镜像。这允许我们将私有包上传到“中央”DevPI 服务器,以便与我们的团队共享。在这些情况下,上游 DevPI 服务器通常需要在代理服务器后面运行——我们需要一些工具来正确管理用户访问。

在一个要求用户名和密码的简单代理后面运行一个“集中式”DevPI 允许一个有效的私有存储库。为此,我们首先要删除root/pypi索引:

$ devpi index --delete root/pypi

然后重新创建它

$ devpi index --create root/pypi

这意味着根索引将不再镜像pypi。我们现在可以直接向它上传软件包。这种类型的服务器通常与参数--extra-index-urlpip一起使用,以允许pip从私有存储库和外部存储库中进行检索。然而,有时拥有一个只服务于特定包的DevPI实例是有用的。这允许在使用任何包之前执行关于审计的规则。每当需要一个新的包时,它就会被下载、审计,然后添加到私有存储库中。

2.7 Pex 和 Shiv

虽然目前将一个 Python 程序编译成一个自包含的可执行文件并不容易,但是我们可以做一些几乎一样好的事情。我们可以将一个 Python 程序编译成一个文件,只需要安装一个解释器就可以运行。这利用了 Python 处理启动的特殊方式。

运行python /path/to/filename时,Python 做两件事:

  • 将目录/path/to添加到模块路径中。

  • 执行/path/to/filename中的代码。

当运行python/path/to/directory/时,Python 的行为就像我们输入python/path/to/directory/__main__.py一样。

换句话说,Python 会做以下两件事:

  • 将目录/path/to/directory/添加到模块路径中。

  • 执行/path/to/directory/__main__.py中的代码。

运行python /path/to/filename.zip时,Python 会把文件当做一个目录。

换句话说,Python 会做以下两件事:

  • 将【目录】添加到模块路径中。

  • 执行从/path/to/filename.zip中提取的__main__.py中的代码。

Zip 是一种面向端的格式:元数据和指向数据的指针都在末尾。这意味着向 zip 文件添加前缀不会改变其内容。

因此,如果我们获取一个 zip 文件,并给它加上前缀#!/usr/bin/python<newline>,并将其标记为可执行,那么当运行它时,Python 将会运行一个 zip 文件。如果我们在__main__.py中放入正确的引导代码,并在 zip 文件中放入正确的模块,我们可以在一个大文件中获得我们所有的第三方依赖项。

Pex 和 Shiv 是生成这种文件的工具,但是它们都依赖于 Python 和 zip 文件的相同底层行为。

2.7.1 Pex

Pex 既可以用作命令行工具,也可以用作库。当使用它作为命令行工具时,防止它试图对 PyPI 进行依赖解析是一个好主意。所有的依赖关系解析算法在某些方面都有缺陷。然而,由于pip的流行,软件包将明确地解决其算法中的缺陷。Pex 不太受欢迎,不能保证软件包会明确地尝试使用它。

最安全的做法是使用pip wheel在一个目录中构建所有的轮子,然后告诉 Pex 只使用这个目录。

例如,

$ pip wheel --wheel-dir my-wheels -r requirements.txt
$ pex -o my-file.pex --find-links my-wheels --no-index \
      -m some_package

Pex 有几种方法可以找到切入点。最受欢迎的两个是-m some_package,它会表现得像python -m some_package;或者是-c console-script,,它将找到作为console-script安装的脚本,并调用相关的入口点。

也可以使用 Pex 作为库。

from pex import pex_builder

构建 Pex 文件的大部分逻辑都在pex_builder模块中。

builder = pex_builder.PEXBuilder()

我们创建一个构建器对象。

builder.set_entry_point('some_package')

我们设置了入口。这相当于命令行上的-m some_package参数。

builder.set_shebang(sys.executable)

Pex 二进制有一个复杂的参数来确定正确的线。这有时是特定于预期的部署环境的,所以最好考虑一下正确的部署路线。一个选项是/usr/bin/env python,会找到当前 shell 调用的python。有时在这里指定一个版本是一个好主意,例如/usr/local/bin/python3.6

subprocess.check_call([sys.executable, '-m', 'pip', 'wheel',
                       '--wheel-dir', 'my-wheels',
                       '--requirements', 'requirements.txt'])

我们再次用pip创建轮子。尽管很诱人,pip不能作为库使用,所以 shelling out 是唯一支持的接口。

for dist in os.listdir('my-wheels'):
    dist = os.path.join('my-wheels', dist)
    builder.add_dist_location(dist)

我们添加了pip构建的所有包。

builder.build('my-file.pex')

最后,我们让构建器生成一个 Pex 文件。

2.7.2 刀

Shiv 是 Pex 背后相同理念的现代体现。但是,由于它直接使用了pip,它自己需要做的事情就少了很多。

$ shiv -o my-file.shiv -e some_package -r requirements.txt

因为 shiv 只是卸载到 pip 实际依赖解析,所以直接调用它是安全的。Shiv 是 Pex 的年轻替代产品。这意味着很多糟粕已经被去除,但它仍然不够成熟。

例如,关于命令行参数的文档很少。目前也没有办法将它用作库。

2.8 XAR

XAR(可执行文件归档)是一种用于发布自包含可执行文件的通用格式。虽然不是特定于 Python 的,但它首先被设计为 Python。例如,它可以通过 PyPI 进行本地安装。

XAR 的缺点是,它假定了对fuse(用户空间中的文件系统)的某种程度的系统支持,而这种支持还不是通用的。如果所有设计用于运行 XAR、Linux 或 Mac OS X 的机器都在您的控制之下,这不是问题。关于如何安装适当的 FUSE 支持的说明并不复杂,但是它们需要管理权限。请注意,XAR 也没有 Pex 成熟。

然而,假设有适当的 SquashFS 支持,许多其他问题都会消失:包括,最重要的,与pexshiv相比,本地 Python 版本。这使得 XAR 成为交付开发人员工具或本地系统管理脚本的有趣选择。

为了构建一个 XAR,如果安装了xar,我们可以用bdist_xar调用setup.py

python setup.py bdist_xar --console-scripts=my-script

在本例中,my-script是控制台脚本入口点的名称,在setup.py中用以下内容指定:

entry_points=dict(
   console_scripts=["my-script = package.module:function"],
)

在某些情况下,--console-scripts参数是不必要的。如上例所示,如果只有一个控制台脚本入口点,那么它就是隐式的。否则,如果有一个与包同名的控制台脚本,则使用该脚本。这占了相当多的情况,也就是说这个论证往往是多余的。

2.9 摘要

Python 的强大之处很大程度上来自其强大的第三方生态系统:无论是对于数据科学还是网络代码,都有许多好的选择。理解如何安装、使用和更新第三方包对于用好 Python 至关重要。

对于私有包存储库,对内部库使用 Python 包,并以与开源库兼容的方式分发它们通常是个好主意。它允许使用相同的机制进行内部分发、版本控制和依赖性管理。

三、交互式使用

Python 通常用于探索性编程。通常,最终的结果不是程序,而是一个问题的答案。对科学家来说,问题可能是“医学干预起作用的可能性有多大?”对于排除计算机故障的人来说,问题可能是“哪个日志文件有我需要的消息?”

然而,不管这个问题是什么,Python 通常是回答这个问题的强大工具。更重要的是,在探索性编程中,我们期望遇到更多的问题,基于答案。

Python 中的交互模型来自最初的 Lisp 环境的“读取-评估-打印循环”(简称 REPL)。该环境读取一个 Python 表达式,在内存中持久化的环境中对其求值,打印结果,然后循环返回。

Python 本地的 REPL 环境很受欢迎,因为它是内置的。然而,一些第三方 REPL 工具甚至更强大,可以做本地工具不能或不愿做的事情。这些工具提供了一种与操作系统交互的强大方式,探索和塑造,直到达到期望的状态。

3.1 本机控制台

不带任何参数启动python将打开“交互式控制台”。使用pyenv或虚拟环境来确保 Python 版本是最新的是一个好主意。

无需安装任何其他东西就能立即获得交互式控制台,这是 Python 适合于探索性编程的一个原因。我们可以立即提问。

这些问题可能很琐碎:

>>> 2 + 2
4

它们可用于计算海湾地区的销售税:

>>> rate = 9.25
>>> price = 5.99
>>> after_tax = price ∗ (1 + rate / 100.)
>>> after_tax
6.544075

或者他们可以回答关于操作环境的重要问题:

>>> import os

>>> os.path.isfile(os.path.expanduser("~/.bashrc"))
True

使用没有readline的 Python 原生控制台是不愉快的。用readline支持重新构建 Python 是个好主意,这样原生控制台将会很有用。如果这不是一个选项,建议使用备用控制台之一。例如,带有特定选项的本地构建的 Python 可能不包括readline,并且向整个团队重新分发新的 Python 可能会有问题。

如果安装了 readline 支持,Python 将使用它来支持行编辑和历史。也可以使用readline.write_history_file保存历史记录。这通常是在使用控制台一段时间后,为了参考已经完成的工作,或者将任何想法复制到更永久的形式中。

当使用控制台时,_变量将计算最后一个表达式语句的值。请注意,异常、非表达式语句和 stat 语句是计算结果为None的表达式,它们不会改变_的值。这在交互式会话中很有用,只有在看到值的表示后,我们才意识到我们需要它作为一个对象。

>>> import requests

>>> requests.get("http://en.wikipedia.org")
<Response [200]>
>>> a=_
>>> a.text[:50]
'<!DOCTYPE html>\n<html class="client-nojs" lang="en'

只有在使用了.get函数之后,我们才意识到我们真正想要的是文本。幸运的是,Response对象保存在变量_中。我们立即将变量的值放入a_ 被快速替换。我们一评估a.text[:50]_现在就是一个 50 个字符的字符串。如果我们没有将_保存在变量中,除了前 50 个字符之外的所有字符都将丢失。

注意,每个好的 Python REPL 都遵守这个_约定,因此“将返回值保存在单字母变量中”的技巧在进行探索时通常很有用。

3.2 代码模块

模块允许我们运行自己的交互循环。这种方法有用的一个例子是,当运行带有特殊标志的命令时,我们可以在特定的点进入提示符状态。这允许我们在以某种方式设置好事情之后,拥有一个 REPL 环境。这适用于解释器内部,用有用的东西建立名称空间;在外部环境中,可能初始化文件或设置外部服务。

code的最高级用法是interact函数。

>>> import code

>>> code.interact(banner="Welcome to the special interpreter",
...               local=dict(special=[1, 2, 3]))
Welcome to the special interpreter
>>> special
[1, 2, 3]
>>> ^D
now exiting InteractiveConsole...
>>> special
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'special' is not defined

这显示了一个使用变量special运行 REPL 循环的例子,该变量设置为一个短列表。

对于code的最底层使用,如果你想自己拥有 UI,code.compile_command(source, [filename="<input>"], symbol="single")会返回一个 code 对象(可以传递给exec),如果命令不完整则返回None,如果命令有问题则抛出SyntaxErrorOverflowErrorValueError

symbol参数应该总是为"single"例外情况是,如果提示用户输入将计算为表达式的代码(例如,如果值将由底层系统使用)。在这种情况下,symbol应该设置为"eval"

这允许我们自己管理与用户的交互。它可以与 UI 或远程网络接口集成,以允许在任何环境中进行交互。

3.3 ptpython

ptpython工具是“prompt toolkit Python”的缩写,是内置 REPL 的替代工具。它使用 prompt toolkit 进行控制台交互,而不是使用readline

它的主要优点是安装简单。一个简单的pip install ptpython在虚拟环境中,不考虑 readline 构建问题,一个高质量的 Python REPL 出现了。

ptpython支持完成建议、多行编辑和语法高亮显示。

启动时,它将读取~/.ptpython/config.py。这意味着可以以任意方式在本地定制 ptpython。配置的方法是实现一个函数configure,它接受一个对象(类型为PythonRepl)并对其进行变异。

有很多种可能性,遗憾的是唯一真正的文档是源代码。相关参考号__init__ptpython.python_input.PythonInput。注意,config.py实际上是一个任意的 Python 文件。因此,如果你想在内部发布修改,可以发布一个本地的 PyPI 包,让人们从中导入一个configure函数。

3.4 IPython

IPython 也是 Jupyter 的基础,这将在后面介绍,它是一个交互式环境,其根源在于科学计算社区。IPython 是一个交互式命令提示符,类似于ptpython工具或 Python 的原生 REPL。

然而,它旨在提供一个复杂的环境。它做的事情之一是对解释器的每个输入和输出进行编号。以后能够参考这些数字是很有用的。IPython 将所有输入放入In数组,输出放入Out数组。这允许很好的对称:例如,如果 IPython 说In[4],这是如何访问该值。

In [1]: print("hello")
hello

In [2]: In[1]
Out[2]: 'print("hello")'

In [3]: 5 + 4.0
Out[3]: 9.0

In [4]: Out[3] == 9.0
Out[4]: True

它还支持现成的制表符补全。IPython 使用自己的完成和jedi库进行静态完成。

它还支持内置帮助。键入var_name?将试图找到变量中对象的最佳上下文相关帮助,并显示它。这适用于函数、类、内置对象等等。

In [1]: list?
Init signature: list(self, /, ∗args, ∗∗kwargs)
Docstring:
list() -> new empty list
list(iterable) -> new list initialized from iterable's items
Type:           type

IPython 还支持一种叫做“magic”的东西,在一行前面加上%将执行一个神奇的功能。例如,%run将在当前名称空间内运行一个 Python 脚本。又如,%edit将推出一个编辑器。如果在使用过程中,语句需要更复杂的编辑,这将非常有用。

此外,在一行前面加上!将运行系统命令。利用这一点的一个有用方法是!pip install something。这就是为什么在用于交互式开发的虚拟环境中安装 IPython 是有用的。

IPython 可以通过多种方式进行定制。在交互式会话中,%config魔术命令可用于更改任何选项。例如,%config InteractiveShell.autocall = True将设置 autocall 选项,这意味着调用可调用的表达式,即使没有括号。对于任何只影响启动的选项来说,这都是没有意义的。我们可以使用命令行更改这些选项以及任何其他选项。例如,ipython --InteractiveShell.autocall=True,将启动一个自动调用解释器。

如果我们希望自定义逻辑决定配置,我们可以从专门的 Python 脚本运行 IPython。

from traitlets import config

import IPython

my_config = config.Config()
my_config.InteractiveShell.autocall = True

IPython.start_ipython(config=my_config)

如果我们将它打包在一个专用的 Python 包中,我们可以使用 PyPI 或私有的包存储库将它分发给一个团队。这允许开发团队拥有同质的定制 IPython 配置。

最后,配置也可以编码在概要文件中,默认情况下,概要文件是位于~/.ipython下的 Python 片段。概要文件目录可以通过一个显式的命令行参数--ipython-dir或一个环境变量IPYTHONDIR来修改。

3.5 Jupyter Lab

Jupyter 是一个使用基于 web 的交互来允许复杂的探索性编程的项目。它并不局限于 Python,尽管它确实起源于 Python。这个名字代表“Julia/Python/R”,这三种语言在探索性编程中最受欢迎,尤其是在数据科学中。

Jupyter Lab 是 Jupyter 的最新进化,最初基于 IPython。它现在拥有一个全功能的网络界面和一种远程编辑文件的方式。Jupyter 的主要用户往往是科学家。他们利用查看结果如何得出的能力来增加可重复性和同行评审。

再现性和同行评审对 DevOps 工作也很重要。例如,显示导致决定重启哪个主机列表的步骤的能力是非常有用的,以便在环境发生变化时可以重新生成该列表。将笔记本附加到事后分析有助于了解发生了什么,以及如何在将来避免问题或更有效地从问题中恢复,笔记本详细描述了停机期间采取的步骤以及这些步骤的输出。

这里需要注意的是,笔记本是而不是可审计性工具:它们可以不按顺序执行,并且可以修改和重新执行程序块。然而,如果使用得当,它们可以让我们记录下做过的事情。

Jupyter 允许真正的探索性编程。这对科学家来说很有用,他们可能事先不了解问题的真实范围。

这里需要注意的是,笔记本是而不是可审计性工具:它们可以不按顺序执行,并且可以修改和重新执行程序块。然而,如果使用得当,它们可以让我们记录下做过的事情。这对于面临复杂系统的系统集成商也很有用,因为在探索之前很难预测问题出在哪里。

在虚拟环境中安装 Jupyter Lab 是一件简单的事情。当启动jupyter lab时,默认情况下,它将在从8888开始的开放端口上启动一个 web 服务器,并尝试启动一个 web 浏览器来观看它。如果在一个“太有趣”的环境中工作(例如,默认的 web 浏览器没有正确配置),标准输出将包含一个预先授权的 URL 来访问服务器。如果所有其他方法都失败,可以在 web 浏览器中手动输入 URL 后,将打印到标准输出的令牌复制粘贴到浏览器中。还可以使用jupyter notebook列表访问令牌,该列表将列出所有当前正在运行的服务器。

一旦进入 Jupyter 实验室,我们可以发射五个东西:

  • 安慰

  • 末端的

  • 文字编辑器

  • 笔记本

  • 电子表格编辑器

Console是 IPython 的一个基于 web 的接口。之前关于 IPython 的所有内容(例如,InOut数组)。Terminal是浏览器中成熟的终端模拟器。这对于 VPN 内部的远程终端很有用:它所需要的只是一个开放的 web 端口,它也可以用 web 端口的常规保护方式来保护:TLS、客户端证书等等。文本编辑器对于编辑远程文件很有用。这是运行远程 shell 的一种替代方式,其中有一个编辑器,比如vi。它的优点是避免了 UI 延迟,同时仍然具有完整的文件编辑功能。

不过,最有趣的是笔记本:事实上,很多会议除了笔记本什么都不用。笔记本是记录会话的 JSON 文件。随着会话的展开,Jupyter 将保存笔记本的“快照”以及最新版本。笔记本是由一系列细胞组成的。两种最流行的单元格类型是“代码”和“降价”“代码”单元类型将包含一个 Python 代码片段。它将在会话的名称空间的上下文中执行它。名称空间从一个单元执行到另一个单元执行是持久的,对应于一个“内核”运行。内核使用自定义协议接受单元内容,将它们解释为 Python,执行它们,并返回代码片段返回的内容和输出。

在启动 Jupyter 服务器时,默认情况下,它将使用本地 IPython 内核作为唯一可能的内核。这意味着,举例来说,服务器将只能使用相同的 Python 版本和相同的包集。但是,可以将不同环境中的内核连接到该服务器。唯一的要求是环境已经安装了ipykernel包。从环境中,运行:

python -m ipykernel install \
       --name my-special-env \
       --display-name "My Env"
       --prefix=$DIRECTORY

然后,在 Jupyter 服务器环境中,运行:

jupyter kernelspec install $DIRECTORY/jupyter/kernels/my-special-env

这将导致该环境中的 Jupyter 服务器支持来自特殊环境的内核。这允许运行一个半永久性的 Jupyter 服务器,并连接来自任何“有趣”环境的内核:安装特定的模块,运行特定版本的 Python,或任何其他差异。替代内核的另一个用途是替代语言,这里不详细介绍。Julia 和 R 内核是上游支持的,但是许多语言都有第三方内核——甚至bash

Jupyter 支持来自 IPython 的所有魔法命令。特别有用的是在虚拟环境中安装新软件包的!pip install ...命令。特别是如果小心谨慎,并安装精确的依赖关系,这使得笔记本成为如何以可重复的方式实现结果的高质量文档。

由于 Jupyter 是内核的一个间接层,我们可以直接从 Jupyter 重启内核。这意味着整个 Python 进程重新启动,所有内存中的结果都消失了。我们可以以任何顺序重新执行单元格,但有一种单键方式可以按顺序执行所有单元格。重新启动内核,并按顺序执行所有单元,这是“测试”笔记本工作条件的一种很好的方式——当然,对外部世界的任何影响都不会被重置。

Jupyter 笔记本作为票据和事后分析的附件非常有用,既可以记录具体的补救措施,也可以通过运行查询 API 并在笔记本中收集结果来记录“事情的状态”。通常,当以这种方式附加笔记本时,将其导出为更易于阅读的格式(如 HTML 或 PDF)并附加也是有用的。然而,越来越多的工具集成了直接笔记本查看,使得这一步变得多余。比如 GitHub 项目和 Gists 已经直接渲染笔记本了。

除了笔记本电脑,Jupyter 实验室还拥有一个基本但实用的基于浏览器的远程开发环境。第一部分是远程文件管理器。其中,这允许上传和下载文件。它的一个用途是能够从本地计算机上传笔记本,然后再下载回来。有更好的方法来管理笔记本,但在紧要关头,能够检索笔记本是非常有用的。类似地,还可以下载 Jupyter 的任何持久输出,比如经过处理的数据文件、图像或图表。

接下来,笔记本旁边是一个远程 IPython 控制台。虽然在笔记本旁边的使用有限,但仍然有一些情况下使用控制台更容易。通过使用 IPython 控制台,需要大量短命令的会话可以更加以键盘为中心,从而更加高效。

还有一个文件编辑器。虽然它与一个成熟的开发人员编辑器相差甚远,缺乏彻底的代码理解和完成,但在紧要关头它经常是有用的。它允许在远程 Jupyter 主机上直接编辑文件。一个用例是直接修复笔记本正在使用的库代码,然后重启内核。虽然将它集成到开发流程中需要一些小心,但是作为修复和继续的紧急措施,这是无价的。

最后,还有一个基于浏览器的远程终端。在终端、文件编辑器和文件管理器之间,运行的 Jupyter 服务器允许完全基于浏览器的远程访问和管理,甚至在考虑笔记本之前。记住这一点对安全性的影响很重要,但它也是一个强大的工具,我们将在后面探讨它的各种用途。现在,可以说,使用 Jupyter 笔记本给远程系统管理任务带来的能力是难以估量的。

3.6 摘要

反馈周期越快,我们就能越快地部署新的、经过测试的解决方案。交互式地使用 Python 可以获得最快的反馈:即时。

这通常有助于澄清一个库的文档,一个关于运行系统的假设,或者仅仅是你对 Python 的理解。

交互控制台也是一个强大的控制面板,当最终结果不能很好理解时,可以通过它启动计算:例如,调试软件系统的状态。

四、操作系统自动化

Python 最初是为了自动化一个叫做“变形虫”的分布式操作系统而构建的。尽管阿米巴操作系统几乎被遗忘了,但 Python 在自动化类 UNIX 操作系统任务方面找到了一个家。

Python 轻松地包装了传统的 UNIX C API,在使它们使用起来稍微安全一点的同时,给予运行 UNIX 的系统调用完全的访问权:这种方法被称为“带有泡沫填充物的 C”这种包装低级操作系统 API 的意愿使得它成为 UNIX shell 擅长的程序和 C 编程语言擅长的程序之间的一个很好的选择。

俗话说,权力越大,责任越大。为了考虑到程序员的能力和灵活性,Python 并不阻止程序员肆虐。小心翼翼地使用 Python 来编写工作的程序,更重要的是,以可预测的、安全的方式编写程序,这是一项值得掌握的技能。

4.1 文件

“一切都是文件”在 UNIX 上已经不是一个准确的咒语了。然而,许多东西都是文件,甚至更多的东西就像文件一样,用基于文件的系统调用来操作它们就足够了。

当处理文件内容时,Python 程序可以走两条路线中的一条。他们可以以“文本”或“二进制”格式打开它们尽管文件本身既不是文本也不是二进制文件,只是一个字节块,但打开模式很重要。

当以二进制格式打开文件时,字节以字节字符串的形式被读写。这对于非文本文件(如图片文件)非常有用。

当以文本形式打开文件时,必须使用编码。可以显式指定,但在某些情况下,会应用默认值。从文件中读取的所有字节都被解码,代码接收一个字符字符串。写入文件的所有字符串都被编码为字节。这意味着与文件的接口是字符串——字符序列。

二进制文件的一个简单例子是 GIMP“XCF”内部格式。GIMP 是一个图像处理程序,它以内部 XCF 格式保存文件,比图像有更多的细节。例如,为了便于编辑,XCF 中的图层将是独立的。

>>> with open("Untitled.xcf", "rb") as fp:

...     header = fp.read(100)

这里我们打开一个文件。rb参数代表“读取,二进制”我们读取前一百个字节。我们将需要更少,但这通常是一个有用的策略。许多文件在开头都有一些元数据。

>>> header[:9].decode('ascii')
'gimp xcf '

前九个字符实际上可以解码成 ASCII 文本,并且恰好是格式的名称。

>>> header[9:9+4].decode('ascii')
'v011'

接下来的四个字符是版本。这个文件是 XCF 的第 11 个版本。

>>> header[9+4]
0

0 字节结束“这是什么文件”元数据。这有各种好处。

>>> struct.unpack('>I', header[9+4+1:9+4+1+4])
(1920,)

接下来的四个字节是宽度,以大端格式表示。struct模块知道如何解析这些。>表示它是大端字节序,I表示它是一个无符号的 4 字节整数。

>>> struct.unpack('>I', header[9+4+1+4:9+4+1+4+4])
(1080,)

接下来的四个字节是宽度。这个简单的代码给了我们高层次的数据:它确认了这是 XCF,它显示了格式的版本,我们可以看到图像的尺寸。

以文本形式打开文件时,默认编码是 UTF-8。UTF-8 的一个优点是,它被设计成在某些东西不是 UTF-8 的情况下很快失败:它被精心设计成在先于 Unicode 的 ISO-8859-[1-9]上失败,以及在大多数二进制文件上失败。它还向后兼容 ASCII,这意味着纯 ASCII 文件仍然是有效的 UTF-8。

解析文本文件最流行的方式是逐行,Python 支持这种方式,让一个打开的文本文件成为一个迭代器,按顺序产生各行。

>>> fp = open("things.txt", "w")

>>> fp.write("""\

... one line

... two lines

... red line

... blue line

... """)
39

>>> fp.close()

>>> fpin = open("things.txt")

>>> next(fpin)
'one line\n'

>>> next(fpin)
'two lines\n'

>>> next(fpin)
'red line\n'

>>> next(fpin)
'blue line\n'

>>> next(fpin)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

通常我们不会直接调用next,而是使用for。此外,通常我们使用文件作为上下文管理器,以确保它们在一个很好理解的点关闭。然而,特别是在 REPL 场景中,有一个权衡:在没有上下文管理器的情况下打开文件允许我们探索阅读零碎的内容。

unix 系统上的文件不仅仅是数据块。它们附有各种元数据,可以查询,有时还可以更改。

rename系统调用被包装在os.rename Python 函数中。由于rename是原子的,这可以帮助实现需要特定状态的操作。

一般来说,注意到os模块倾向于是操作系统调用的一个薄薄的包装器。这里的讨论与类 UNIX 系统相关:Linux、基于 BSD 的系统,以及大多数情况下的 Mac OS X。这一点值得记住,但不值得指出我们做出特定于 UNIX 的假设的每个地方。

例如,

with open("important.tmp", "w") as fout:
    fout.write("The horse raced past the barn")
    fout.write("fell.\n")
os.rename("important.tmp", "important")

这确保了在读取important文件时,我们不会意外地误解句子。如果代码中途崩溃,我们不会相信马跑过了谷仓,而是从important中一无所获。我们只在最后将important.tmp重命名为important,在最后一个字被写入文件之后。

在 UNIX 中,不是 blob 的文件的最重要的例子是目录。os.makedirs函数允许我们确保一个目录容易存在

os.makedirs(some_path, exists_ok=True)

这与来自os.path的路径操作有力地结合起来,允许安全地创建嵌套文件:

def open_for_write(fname, mode=""):
    os.makedirs(os.path.dirname(fname), exists_ok=True)
    return open(fname, "w" + mode)

with open_for_write("some/deep/nested/name/of/file.txt") as fp:
    fp.write("hello world")

例如,在镜像现有文件布局时,这可能会很有用。

os.path模块主要有字符串操作函数,这些函数假设字符串是文件名。dirname函数返回目录名,因此os.path.dirname("a/b/c")将返回a/b。类似地,函数basename返回“文件名”,因此os.path.basename("a/b/c")将返回c。两者的逆函数是os.path.join函数,它连接路径:os.path.join("some", "long/and/winding", "path")将返回some/long/and/finding/path

os.path模块中的另一组函数对获取文件元数据进行了更高级别的抽象。值得注意的是,这些函数通常是操作系统功能的轻量级包装,并且不会试图隐藏操作系统的怪癖。这意味着操作系统的怪癖可以通过抽象“泄漏”。

最大的元数据是os.path.exists:文件存在吗?这有时会派上用场,尽管通常以不可知文件存在的方式编写代码会更好:文件存在可能会有竞争。更微妙的是os.path.is...函数:isdirisfileislink,更多的可以决定一个文件名是否指向我们所期望的。

os.path.get...函数获取非布尔元数据:访问时间、修改时间、c-time(有时简称为“创建时间”,但在一些微妙的情况下,这可能会引起误解,而不是实际的创建时间,更准确的说法是“i-node 修改时间”),以及getsize获取文件的大小。

shutil模块(“shell 工具”)包含一些高级操作。shutil.copy将复制文件的内容和元数据。shutil.copyfile将只复制内容。shutil.rmtree相当于rm -r,而shutil.copytree相当于cp -r

最后,临时文件通常很有用。Python 的tempfile模块产生安全且抗泄漏的临时文件。最有用的功能是NamedTemporaryFile,可以作为上下文使用。

典型的用法如下:

with NamedTemporaryFile() as fp:
    fp.write("line 1\n")
    fp.write("line 2\n")
    fp.flush()
    function_taking_file_name(fp.name)

注意这里的fp.flush很重要。文件对象缓存写操作,直到关闭。但是,NamedTemporaryFile一关闭就会消失。在调用将重新打开文件进行读取的函数之前,显式刷新它是很重要的。

4.2 流程

Python 中处理运行子流程的主要模块是subprocess。它包含一个高级抽象,与大多数人想到“运行命令”时的直观模型相匹配,而不是在 UNIX 中使用execfork实现的低级模型。

它也是调用os.system函数的强大替代方法,后者在几个方面都有问题。例如,os.system产生了一个额外的进程,外壳。这意味着它依赖于 shell,在一些更奇怪的安装中,shell 可能与更“奇特”的系统 shell 不同,如ashfish。最后,这意味着 shell 将解析字符串,这意味着字符串必须被正确序列化。这是一项艰巨的任务,因为 shell 解析器的正式规范很长。不幸的是,要写出在大多数情况下都能正常工作的东西并不困难,所以大多数错误都很微妙,并在最糟糕的时候出现。这有时甚至表现为安全缺陷。

虽然subprocess没有完全灵活,但是对于大多数需求,这个模块已经足够了。

subprocess本身也分为高层次的功能和低层次的实现层次。大多数情况下应该使用的高级函数是check_callcheck_output。除了其他好处之外,它们的行为就像用-eset err运行一个 shell 如果一个命令返回一个非零值,它们会立即引发一个异常。

稍微低一级的是Popen,它创建流程并允许对其输入和输出进行细粒度配置。check_callcheck_output都是在Popen之上实现的。正因为如此,它们共享一些语义和论点。最重要的论点是shell=True,最重要的是,使用它几乎总是一个坏主意。当给定参数时,需要一个字符串,并将其传递给 shell 进行解析。

Shell 解析规则很微妙,充满了死角。如果它是一个常量命令,没有任何好处:我们可以将命令翻译成代码中的独立参数。如果它包含一些输入,那么几乎不可能以一种不引入注入问题的方式可靠地避开它。另一方面,如果没有这一点,即使面对潜在的恶意输入,即时创建命令也是可靠的。

例如,下面将把一个用户添加到 docker 组。

subprocess.check_call(["usermod", "-G", "docker", "some-user"])

使用check_call意味着如果命令由于某种原因失败,比如用户不存在,这将自动引发一个异常。这避免了常见的失败模式,在这种模式下,脚本不会报告准确的状态。

如果我们想让它成为一个接受用户名的函数,这很简单:

def add_to_docker(username):
    subprocess.check_call(["usermod", "-G", "docker", username])

请注意,即使参数包含空格、#或其他具有特殊含义的字符,调用它也是安全的。

为了判断当前用户当前在哪个组中,我们可以运行groups

groups = subprocess.check_output(["groups"]).split()

同样,如果命令失败,这将自动引发一个异常。如果成功,我们将得到字符串形式的输出:不需要手动读取和确定结束条件。

这两个函数有共同的参数。cwd允许在给定目录内运行命令。这对于在当前目录中查找的命令很重要。

sha = subprocess.check_output(
          ["git", "rev-parse", "HEAD"],
          cwd="src/some-project").decode("ascii").strip()

这将获得项目的当前git散列,假设项目是 git 目录。如果不是这样,git rev-parse HEAD将返回非零值并引发一个异常。

注意,我们必须decode输出,因为subprocess.check_outputsubprocess中的大多数函数一样,返回一个字节字符串,而不是一个 Unicode 字符串。在这种情况下,rev-parse HEAD总是返回一个十六进制字符串,所以我们使用了ascii编解码器。这对于任何非 ASCII 字符都将失败。

在某些情况下,使用高级抽象是不可能的。例如,用它们不可能发送标准输入或成块读取输出。

Popen运行子流程,允许对输入和输出进行精细控制。虽然所有的事情都有可能,但大多数事情都不容易做对。编写长管道的 shell 模式实现起来令人不快;更令人不快的是,要确保没有挥之不去的死锁条件;最重要的是,没有必要。

如果需要将短消息转换成标准输入,最好的方法是使用communicate方法。

proc = Popen(["docker", "login", "--password-stdin"], stdin=PIPE)
out, err = proc.communicate(my_password + "\n")

如果需要更长的输入,让communicate在内存中缓冲可能会有问题。虽然可以以块的形式写入进程,但是在没有潜在死锁的情况下这样做是很重要的。最好的选择通常是使用临时文件:

with tempfile.TemporaryFile() as fp:
    fp.write(contents)
    fp.write(of)
    fp.write(email)
    fp.flush()
    fp.seek(0)
    proc = Popen(["sendmail"], stdin=fp)
    result = proc.poll()

事实上,在这种情况下,我们甚至可以使用check_call函数:

with tempfile.TemporaryFile() as fp:
    fp.write(contents)
    fp.write(of)
    fp.write(email)
    fp.flush()
    fp.seek(0)
    check_call(["sendmail"], stdin=fp)

如果您习惯于在 shell 中运行进程,那么您可能习惯于长管道:

$ ls -l | sort | head -3 | awk '{print $3}'

如上所述,在 Python 中避免真正的命令并行性是一个最佳实践:在所有情况下,我们都试图在从下一个阶段读取之前完成一个阶段。在 Python 中,一般来说,使用subprocess只用于调用外部命令。对于输入的预处理和输出的后处理,我们通常使用 Python 的内置处理能力:在上面的例子中,我们将使用sorted切片和字符串操作来模拟逻辑。

用于文本和数字处理的命令在 Python 中很少有用,Python 有一个很好的内存模型来进行这种处理。在脚本中调用命令的一般情况是,操作数据的方式要么只记录为可由命令访问——例如,通过ps -ef查询过程,要么命令的替代方式是一个微妙的库,有时需要二进制绑定,例如在dockergit的情况下。

这是一个将 shell 脚本翻译成 Python 必须仔细考虑的地方。原始代码有一个依赖于通过awksed的特殊字符串操作的长管道,Python 代码可能不那么并行,而且更明显。需要注意的是,在这些情况下,翻译中会丢失一些东西:最初的低内存需求和透明并行。然而,作为回报,我们得到了更易维护和调试的代码。

4.3 联网

Python 有大量的网络支持。它从最底层开始:支持基于socket的系统调用到高层协议支持。解决问题的一些最佳方法是使用内置库。对于其他问题,最好的解决方案是第三方库。

底层网络 API 最直接的翻译在socket模块中。这个模块公开了socket对象。

HTTP 协议非常简单,因此我们可以直接从 Python 交互式命令提示符下实现一个简单的客户端。

>>> import socket, json, pprint

>>> s = socket.socket()

>>> s.connect(('httpbin.org', 80))

>>> s.send(b'GET /get HTTP/1.0\r\nHost: httpbin.org\r\n\r\n')
40

>>> res = s.recv(1024)

>>> pprint.pprint(json.loads(

...               res.decode('ascii').split('\r\n\r\n', 1)[1]))
{'args': {},
 'headers': {'Connection': 'close', 'Host': 'httpbin.org'},
 'origin': '73.162.254.113',
 'url': 'http://httpbin.org/get'}

s = socket.socket()行创建一个新的套接字对象。我们可以用套接字对象做很多事情。其中之一是将它们连接到一个端点:在本例中,连接到服务器 httpbin.org ,端口 80。默认的套接字类型是一个互联网类型:这是 UNIX 引用 TCP 套接字的方式。

套接字连接后,我们可以向它发送字节。注意——在套接字上,只能发送字节串。我们读回结果并做一些特别的 HTTP 响应解析——并将实际内容解析为 JSON。

虽然一般来说,使用真正的 HTTP 客户端更好,但本文展示了如何编写低级套接字代码。这可能是有用的,例如,如果我们想通过重放确切的消息来诊断问题。

socket API 很微妙,上面的例子中有一些不正确的假设。在大多数情况下,这段代码可以工作,但是在遇到极端情况时会以奇怪的方式失败。

如果内部内核级发送缓冲区容纳不下所有数据,那么send方法可以不发送所有数据。这意味着它可以进行“部分发送”它返回上面的40,这是字节串的整个长度。正确的代码会检查返回值并发送剩余的块,直到什么都没有了。幸运的是,Python 已经有了一个方法:sendall

然而,recv出现了一个更微妙的问题。它将返回与内核级缓冲区一样多的数据,因为它不知道另一端打算发送多少数据。同样,在大多数情况下,尤其是对于短消息,这将工作得很好。对于像 HTTP 1.0 这样的协议,正确的行为是一直读取,直到连接关闭。

下面是代码的一个固定版本:

>>> import socket, json, pprint

>>> s = socket.socket()

>>> s.connect(('httpbin.org', 80))

>>> s.sendall(b'GET /get HTTP/1.0\r\nHost: httpbin.org\r\n\r\n')

>>> resp = b''

>>> while True:

...    more = s.recv(1024)

...    if more == b'':

...            break

...    resp += more

...

>>> pprint.pprint(json.loads(resp.decode('ascii').split('\r\n\r\n')[1]))
{'args': {},
 'headers': {'Connection': 'close', 'Host': 'httpbin.org'},
 'origin': '73.162.254.113',
 'url': 'http://httpbin.org/get'}

这是网络代码中的一个常见问题,在使用更高级别的抽象时也会发生。在简单的情况下,这些东西看起来可以工作,但在更极端的情况下,如高负载或网络拥塞时,它们就不能工作了。

有很多方法可以测试这些东西。其中之一是使用表现出极端行为的代理。编写或定制这些内容将需要使用socket的底层网络编码。

Python 也有更高层次的网络抽象。虽然urlliburllib2模块是标准库的一部分,但是 web 上的最佳实践发展很快,一般来说,对于更高级别的抽象,第三方库通常更好。

其中最流行的是第三方库,requests。有了requests,获取一个简单的 HTTP 页面就简单多了。

>>> import requests, pprint

>>> res=requests.get('http://httpbin.org/get')

>>> pprint.pprint(res.json())
{'args': {},
 'headers': {'Accept': '∗/∗',
             'Accept-Encoding': 'gzip, deflate',
             'Connection': 'close',
             'Host': 'httpbin.org',
             'User-Agent': 'python-requests/2.19.1'},
 'origin': '73.162.254.113',
 'url': 'http://httpbin.org/get'}

我们需要做的不是用原始字节制作自己的 HTTP 请求,而是给出一个 URL,类似于我们在浏览器中输入的 URL。请求解析它以找到要连接的主机( httpbin.org )端口(80,默认为 HTTP)和路径(/get)。一旦收到响应,它会自动将其解析为头和内容,并允许我们以 JSON 的形式直接访问内容。

虽然requests很容易使用,但是,更好的方法是多花一点力气使用Session对象。否则,将使用默认会话。这导致代码具有非本地副作用:调用请求的一个子库改变了一些会话状态,这导致另一个子库的调用行为不同。例如,HTTP cookies 是在一个会话中共享的。

上面的代码最好写成:

>>> import requests, pprint

>>> session = requests.Session()

>>> res = session.get('http://httpbin.org/get')

>>> pprint.pprint(res.json())
{'args': {},
 'headers': {'Accept': '∗/∗',
             'Accept-Encoding': 'gzip, deflate',
             'Connection': 'close',
             'Host': 'httpbin.org',
             'User-Agent': 'python-requests/2.19.1'},
 'origin': '73.162.254.113',
 'url': 'http://httpbin.org/get'}

在本例中,请求很简单,会话状态无关紧要。然而,这是一个需要养成的好习惯:即使在交互式解释器中,也要避免直接使用getput和其他函数,而只使用会话接口。

使用交互式环境来构建代码原型是很自然的,这将在以后使其成为生产程序。通过保持这样的好习惯,我们可以轻松过渡。

4.4 总结

Python 是自动化操作系统操作的强大工具。这来自于拥有作为本机操作系统调用的瘦包装器的库和强大的第三方库的组合。

这允许我们接近操作系统,而没有任何介入的抽象,以及编写不关心这些无关紧要的细节的高级代码。

这种组合通常使 Python 成为编写脚本的更好选择,而不是使用 UNIX shell。这确实需要一种不同的思维方式:Python 并不适合文本转换器的长管道方法,但是在实践中,那些文本转换器的长管道结果是 shell 限制的产物。

使用现代的内存管理语言,将整个文本流读入内存通常更容易,然后操纵它,而不仅限于那些可以指定为管道的转换。

五、测试

用于自动化系统的代码通常不会像应用代码那样关注测试。DevOps 团队通常很小,而且时间紧迫。这样的代码也很难测试,因为它意味着自动化大型系统,并且适当的测试隔离是很重要的。

然而,测试是提高代码质量的最好方法之一。它在许多方面有助于使代码更易于维护。它还降低了缺陷率。对于缺陷经常意味着整个系统中断的代码,因为它经常触及系统的所有部分,这很重要。

5.1 单元测试

单元测试有几个不同的目的。记住这些目的是很重要的,因为单元测试所产生的压力有时是不一致的。

第一个目的是作为 API 使用示例。这有时被概括为有点不准确的术语“测试驱动开发”,有时被概括为另一个有点不准确的术语,“单元测试就是文档”

测试驱动开发意味着在逻辑之前编写单元测试,但是它通常对包含单元测试和逻辑的最终源代码提交没有什么影响,除非注意保存原始的分支提交历史。

然而,在提交中出现的是“作为运用 API 的方法的单元测试”理想情况下,而不是它是 API 的唯一文档。然而,它确实是一个有用的参考:至少,我们知道单元测试正确地调用了 API,并得到了它们期望的结果。

另一个原因是获得信心,相信代码中表达的逻辑做了正确的事情。同样,这经常被误称为“回归测试”,在最常见的这类测试之后:一种测试,以确保某人检测到的 bug 被真正修复。然而,由于代码开发人员知道潜在的边缘情况和更棘手的流程,他们通常能够在之前添加测试*,这样的 bug 会出现在外部观察到的代码更改中:然而,这样一个增加信心的测试看起来就像一个“回归测试”*

最后一个原因是为了避免不正确的未来变化。这与上面的“回归测试”不同,因为通常被测试的情况对于代码来说是直接的,并且所涉及的流程已经被其他测试所覆盖。然而,似乎一些潜在的优化或其他自然变化可能会打破这种情况,所以包括它有助于未来的维护程序员。

当编写一个测试时,重要的是要考虑它要完成这些目标中的哪一个。

一个好的测试将完成不止一个。所有测试都有两个潜在影响:

  • 通过帮助未来的维护工作使代码变得更好。

  • 让未来的维护工作变得更加困难,从而使代码变得更糟糕。

每项测试都会做到这两点。一个好的测试做更多的第一个,一个坏的测试做更多的第二个。减少不良影响的一个方法是考虑这样一个问题:“这个测试是在测试代码承诺要做的事情吗?”如果答案是“否”,这意味着以某种方式更改代码是有效的,这将破坏测试,但不会导致任何错误。这意味着测试必须被改变或丢弃。

在编写测试时,尽可能多地测试代码的实际契约是很重要的。

这里有一个例子:

def write_numbers(fout):
    fout.write("1\n")
    fout.write("2\n")
    fout.write("3\n")

这个函数将几个数字写入一个文件。

一个糟糕的测试可能是这样的:

class DummyFile:

    def __init__(self):
        self.written = []

    def write(self, thing):
        self.written.append(thing)

def test_write_numbers():
    fout = DummyFile()
    write_numbers(fout)
    assert_that(fout.written, is_(["1\n", "2\n", "3\n"]))

这是一个糟糕的测试的原因是因为它检查了一个write_numbers从未做过的承诺:每次写只写一行。

未来的重构可能是这样的:

def write_numbers(fout):
    fout.write("1\n2\n3\n")

这将保持代码正确write_numbers的所有用户仍然拥有正确的文件——但是会导致测试中的变化。

一种稍微复杂一点的方法是将编写的字符串连接起来。

class DummyFile:

    def __init__(self):
        self.written = []

    def write(self, thing):
        self.written.append(thing)

def test_write_numbers():
    fout = DummyFile()
    write_numbers(fout)
    assert_that("".join(fout.written), is_("1\n2\n3\n"))

请注意,这个测试在我们上面建议的假设“优化”之前和之后都有效。但是,这还是比write_numbers的默示合同更考验人。毕竟,该函数应该对文件进行操作:它可能使用另一种方法来编写。

如果我们将write_numbers修改为:

def write_numbers(fout):
    fout.writelines(["1\n",
                     "2\n",
                     "3\n"]

一个好的测试只有在代码中有错误的时候才会失败。然而,这段代码仍然适用于write_numbers的用户,这意味着现在的维护涉及到不中断测试,纯粹是开销。

因为契约是为了能够写入文件对象,所以最好提供一个文件对象。在这种情况下,Python 有一个现成的:

def test_write_numbers():
    fout = io.StringIO()
    write_numbers(fout)
    assert_that(fout.getvalue(), is_("1\n2\n3\n"))

在某些情况下,这将需要编写一个自定义的假。稍后,我们将讨论假货的概念,以及如何书写它们。

我们谈到了write_numbers隐性契约。因为它没有文档,我们无法知道最初的程序员的意图是什么。不幸的是,这很常见——尤其是在内部代码中,只被项目的其他部分使用。当然,最好清楚地记录程序员的意图。然而,在缺乏清晰文档的情况下,重要的是对隐含契约做出合理的假设。

上面,我们使用了函数assert_thatis_来验证这些值是我们所期望的。这些函数来自hamcrest库。这个库移植自 Java,允许指定结构的属性并检查它们是否满足要求。

当使用pytest测试运行器运行单元测试时,可以使用带有assert关键字的常规 Python 操作符并获得有用的测试失败。然而,这将测试绑定到一个特定的运行者,并且有一组特别针对有用的错误消息的特定断言。

Hamcrest 是一个开放的库:虽然它内置了一些常见的断言(相等、比较、序列操作等等),但它也允许定义特定的断言。当处理复杂的数据结构时,例如从 API 返回的数据结构,或者当契约只能保证特定的断言时(例如,前三个字符可以是任意的,但必须在字符串的其余部分中的某个地方重复),这些就很方便了。

这允许测试函数的确切的约定。特别是,这是避免测试“过多”的另一个工具:测试可以改变的实现细节,当没有真正的用户被破坏时,需要改变测试。这一点至关重要,原因有三。

一个是简单明了的:花在更新本可以避免的测试上的时间是浪费时间。DevOps 团队通常很小,浪费资源的空间很小。

第二,习惯于在测试失败时改变测试是一个坏习惯。这意味着当行为因为一个错误而改变时,人们会认为正确的做法是更新测试。

最后,也是最重要的,这两者的结合将降低单元测试的投资回报,甚至更糟的是,感知的投资回报。因此,将会有组织上的压力,要求花更少的时间编写测试。测试实现细节的糟糕测试是“不值得为 DevOps 代码编写单元测试”的最大原因

作为一个例子,让我们假设我们有一个函数,在这个函数中,我们可以肯定地断言,结果必须能被其中一个自变量整除。

class DivisibleBy(hamcrest.core.base_matcher.BaseMatcher):

    def __init__(self, factor):
        self.factor = factor

    def _matches(self, item):
        return (item % self.factor) == 0

    def describe_to(self, description):
        description.append_text('number divisible by')
        description.append_text(repr(self.factor))

def divisible_by(num):
    return DivisibleBy(num)

按照惯例,我们将构造函数包装在一个函数中。如果我们想将参数转换成匹配器,这通常是有用的,在这种情况下没有意义。

def test_scale():
    result = scale_one(3, 7)
    assert_that(result,
                any_of(divisible_by(3),
                       divisible_by(7)))

我们将得到如下所示的错误:

Expected: (number divisible by 3 or number divisible by 7)
     but: was <17>

它让我们测试scale_one的契约到底承诺了什么:在这种情况下,它将一个参数放大一个整数倍。

强调检验精确契约的重要性并不是偶然的。这种强调是一种可以学习的技能,并且具有可以教授的原则,使得单元测试成为加速编写代码过程的东西,而不是使它变慢。

人们厌恶单元测试的大部分原因是这种误解,因为单元测试“浪费了 DevOps 工程师的时间”,并导致大量测试不佳的代码,而这些代码是软件部署等业务流程的基础。正确地应用高质量单元测试的原则会为操作代码带来更可靠的基础。

5.2 仿制品、存根和赝品

典型的 DevOps 代码对操作环境有巨大的影响。事实上,这几乎是好的 DevOps 代码的定义:它取代了大量的手工工作。这意味着测试 DevOps 代码需要仔细完成:我们不能简单地为每次测试运行启动几百个虚拟机。

自动化操作意味着编写代码,随意运行会对生产系统产生重大影响。在测试代码时,尽可能减少这些副作用是值得的。即使我们有高质量的登台系统,每次在操作代码中出现错误时牺牲一个也会导致大量时间的浪费。重要的是要记住,单元测试是在产生的最差的代码上运行的:运行它们并修复 bug 的行为意味着,即使是提交到特性分支的代码也很可能处于更好的状态。

正因为如此,我们经常试图对一个“假”系统运行单元测试。对我们所说的“假”进行分类,以及它如何影响单元测试和代码设计是很重要的:在开始编写代码之前,考虑如何测试好代码是值得的。

替代未被测试系统的事物的中性术语是“测试加倍”Fakes、mocks 和 stubs 通常有更精确的含义,尽管在非正式谈话中它们会互换使用。

最“真实”的测试替身是“验证过的假货”。一个经过验证的伪造品完全实现了未被测试的系统的接口,尽管经常被简化:可能实现效率较低,经常不涉及任何外部操作系统。“已验证”指的是这样一个事实,即 fake 有它自己的测试,验证它确实实现了接口。

验证假的一个例子是在测试中使用仅存储的 SQLite 数据库,而不是基于文件的数据库。由于 SQLite 有自己的测试,这是一个经过验证的赝品:我们可以确信它的行为就像一个真正的 SQLite 数据库。

验证过的假货下面就是假货。fake 实现了一个接口,但通常是以一种简单的形式实现的,不值得花力气去测试。

例如,可以创建一个与subprocess.Popen具有相同接口的对象,但它实际上从不运行流程:相反,它模拟一个消耗所有标准输入的流程,并将一些预定的内容输出到标准输出中,并以预定的代码退出。

如果足够简单,这个对象可能是一个存根。存根是一个简单的对象,它用预定的数据来回答,总是相同的,几乎不包含任何逻辑。这使得编写起来更容易,但也使它在能做的测试方面受到限制。

一个检查员,或者一个间谍,是一个附着在测试替身上并监控通话的对象。通常,函数契约的一部分是它将调用一些具有特定值的方法。检查器记录调用,并可以在断言中使用,以确保正确的调用得到了正确的参数。

如果我们将 inspect or 与存根或赝品结合起来,我们会得到一个模拟。因为这意味着 stub/fake 将比原来的有更多的功能(至少是检查录音所需的功能),这可能会导致一些副作用。然而,创建模拟的简单性和直接性通常通过使测试代码更简单来弥补。

5.3 测试文件

在许多方面,文件系统是 UNIX 系统中最重要的东西。虽然“一切都是文件”的口号不足以描述现代系统,但文件系统仍然是大多数操作的核心。

在考虑测试文件操作代码时,文件系统有几个值得考虑的属性。

首先,文件系统往往是健壮的。虽然文件系统中的错误并不是未知的,但它们很少发生,而且通常只由极端条件或不太可能的条件组合触发。

其次,文件系统往往很快。考虑这样一个事实,解压缩一个源 tarball,一个常规操作,将会快速连续地创建许多小文件(大约几千字节)。这是读写文件时快速系统调用机制与复杂缓存语义的结合。

文件系统也有一个奇怪的分形属性:除了一些深奥的操作,子-子-子目录支持与根目录相同的语义。

最后,文件系统有一个非常厚的接口。其中一些将内置于 Python 中,甚至——考虑到模块系统直接读取文件。也有第三方 C 库将使用它们自己的内部包装器来访问文件系统,以及几种打开文件的方法,甚至在 Python 中也是如此:内置的file对象以及os.open低级操作。

这结合了以下结论:对于大多数文件操作代码来说,伪造或模仿文件系统是低投资回报的。为了确保我们只测试一个功能的契约,投资是相当大的;由于该函数可以切换到低级文件操作,我们需要重新实现 Unix 文件语义的重要部分。回报低;直接使用文件系统是快速、可靠的,而且只要代码仅仅允许我们传递一个替代的“根路径”,几乎没有副作用。

设计文件操作代码的最佳方式是允许传入这样一个“根路径”参数,即使默认为/。对于这样的设计,最好的测试方法是创建一个临时目录,适当地填充它,调用代码,然后对目录进行垃圾收集。

如果我们使用 Python 的内置tempfile模块创建临时目录,那么我们可以配置 Tox runner 将临时文件放在 Tox 的内置临时目录中,从而保持一般文件系统的整洁,并且通常与已经忽略 Tox 工件的版本控制ignore文件兼容。

setenv =
    TMPDIR = {envtmpdir}
commands =
    python -m 'import os;os.makedirs(sys.argv[1])' {envtmpdir}
    # rest of test commands

创建临时目录很重要,因为 Python 的tempfile只有在指向真实目录时才会使用环境变量。

作为一个例子,我们将为一个寻找.js文件并将它们重命名为.py的函数编写测试。

def javascript_to_python_1(dirname):
    for fname in os.listdir(dirname):
        if fname.endswith('.js'):
            os.rename(fname, fname[:3] + '.py')

这个函数使用os.listdir调用来查找文件名,然后用os.rename重命名它们。

def javascript_to_python_2(dirname):
    for fname in glob.glob(os.path.join(dirname, "∗.js")):
        os.rename(fname, fname[:3] + '.py')

这个函数使用glob.glob函数通过通配符过滤所有匹配*.js模式的文件。

def javascript_to_python_3(dirname):
    for path in pathlib.Path(dirname).iterdir():
        if path.suffix == '.js':
            path.rename(path.parent.joinpath(path.stem + '.py'))

该函数使用内置模块pathlib(Python 3 中的新功能)迭代目录并找到其子节点。

测试中的真实函数不确定使用哪个实现:

def javascript_to_python(dirname):
    return random.choice([javascript_to_python_1,
                          javascript_to_python_2,
                          javascript_to_python_3])(dirname)

由于我们不能确定函数将使用哪个实现,我们只剩下一个选择:测试实际的契约。

为了编写一个测试,我们将定义一些助手代码。在真实的项目中,这些代码将存在于一个专用的模块中,可能被命名为类似于helpers_for_tests的东西。这个模块将通过自己的单元测试进行测试

我们首先为临时目录创建一个上下文管理器。这将尽可能确保临时目录被清理。

@contextlib.contextmanager

def get_temp_dir():
    temp_dir = tempfile.mkdtemp()
    try:
        yield temp_dir
    finally:
        shutil.rmtree(temp_dir)

因为这个测试需要创建很多文件,并且我们不太关心它们的内容,所以我们为此定义了一个助手方法。

def touch(fname, content=''):
    with open(fname, 'a') as fpin:
        fpin.write(content)

现在在这些函数的帮助下,我们终于可以编写一个测试了:

def test_javascript_to_python_simple():
    with get_temp_dir() as temp_dir:
        touch(os.path.join(temp_dir, 'foo.js'))
        touch(os.path.join(temp_dir, 'bar.py'))
        touch(os.path.join(temp_dir, 'baz.txt'))
        javascript_to_python(temp_dir)
        assert_that(set(os.listdir(temp_dir)),
                    is_({'foo.py', 'bar.py', 'baz.txt'}))

对于一个真实的项目,我们将编写更多的测试,其中许多可能使用我们上面的get_temp_dirtouch助手。

如果我们有一个应该检查特定路径的函数,我们可以让它接受一个参数来“相对化”它的路径。

例如,假设我们想要一个函数来分析我们的 Debian 安装路径,并给出我们下载软件包的所有域的列表。

def _analyze_debian_paths_from_file(fpin):
    for line in fpin:
        line = line.strip()
        if not line:
            continue

        line = line.split('#', 1)[0]
        parts = line.split()
        if parts[0] != 'deb':
            continue

        if parts[1][0] == '[':
            del parts[1]
        parsed = hyperlink.URL.from_text(parts[1].decode('ascii'))
        yield parsed.host

一个简单的方法是测试_analyze_debian_paths_from_file。然而,它是一个内部功能,并且没有*?? 合同。实现可以改变,也许读取文件,然后扫描所有字符串,或者可能分解这个函数,让顶层处理行循环。*

相反,我们想测试公共 API:

def analyze_debian_paths():
    for fname in os.listdir('/etc/apt/sources.list.d'):
        with open(os.path.join('/etc/apt/sources.list.d', fname)) as fpin:
            yield from _analyze_debian_paths_from_file(fpin)

然而,如果没有 root 权限,我们无法控制目录/etc/apt/sources.list.d,即使有 root 权限,这也是一个风险:让每个测试运行控制这样一个敏感的目录。此外,许多持续集成系统并不是为使用 root 特权运行测试而设计的,这是有充分理由的,使得这种方法成为一个问题。

相反,我们可以稍微推广一下这个函数。这意味着有意扩展该函数的官方、公共 API 以允许测试。这绝对是一种取舍。

然而,扩展是最小的:我们需要的只是一个明确的工作目录。作为回报,我们可以简化我们的测试需求,同时避免任何类型的“修补”,这不可避免地会涉及到私有的实现细节。

def analyze_debian_paths(relative_to='/'):
    sources_dir = os.path.join(relative_to, 'etc/apt/sources.list.d')
    for fname in os.listdir(sources_dir):
        with open(os.path.join(sources_dir, fname)) as fpin:
            yield from _analyze_debian_paths_from_file(fpin)

现在,使用与之前相同的助手,我们可以为此编写一个简单的测试:

def test_analyze_debian_paths():
    with get_temp_dir() as root:
        touch(os.path.join(root, 'foo.list'),
              content='deb http://foo.example.com\n')
        ret = list(analyze_debian_paths(relative_to=root))
        assert(ret, equals_to(['foo.example.com']))

同样,在一个真实的项目中,我们会编写不止一个测试,并试图确保覆盖更多的案例。这些可以用同样的技术建造。

给任何访问特定路径的函数添加一个relative_to参数是一个好习惯。

5.4 测试流程

测试流程操作代码通常是一项微妙的工作,充满了权衡。理论上,进程运行代码与操作系统有一个很厚的接口;我们讨论了subprocess模块,但是也可以直接使用os.spawn*函数,甚至使用代码os.forkos.exec*函数。同样,标准的输出/输入通信机制可以用许多方式实现,包括使用Popen抽象或者用os.pipeos.dup.直接操作文件描述符

流程操作代码也可能是最脆弱的。作为起点,运行外部命令取决于这些命令的行为。进程间通信意味着流本质上是并发的。让测试依赖于并不总是正确的假设,这是很容易犯的错误。这些错误会导致“古怪”的测试:在大多数情况下通过,但在看似随机的情况下失败。

那些排序假设有时在开发机器或未加载的机器上可能更经常是正确的,这意味着 bug 将只在生产中暴露,或者可能只在极端情况下才在生产中暴露。

这就是为什么关于使用进程的章节集中在减少并发性和使事情更有序的方法上。出于这个原因,精心设计可靠的可测试过程代码也是值得的。这种设计本身通常会给代码带来压力,要求代码简单可靠。

如果代码仅仅使用了subprocess.check_callsubprocess.check_output,而没有利用外来参数,我们通常可以使用一种叫做“依赖注入”的模式的简化形式来使其可测试。在这种情况下,“依赖注入”只是“向函数传递参数”的一种花哨说法

考虑以下函数:

def error_lines(container_name):
    logs = subprocess.check_output(["docker", "logs", container_name])
    for line in logs:
        if 'error' in line:
            return line

这个函数不容易测试。我们可以使用高级补丁来替换subprocess.check_output,但是这很容易出错,并且依赖于实现细节。相反,我们可以明确地将实现细节提升为契约的一部分:

def error_lines(container_name, runner=subprocess.check_output):
    logs = runner(["docker", "logs", container_name])
    for line in logs:
        if 'error' in line:
            yield line.strip()

现在runner是官方接口的一部分,测试变得容易多了。这可能看起来是一个微不足道的变化,但它比看起来更深刻;在某种意义上,error_lines现在已经主动将其接口约束到流程运行中。

我们可能想用类似下面的东西来测试它:

def test_error_lines():
    container_name = 'foo'

    def runner(args):
        if args[0] != 'docker':
            raise ValueError("Can only run docker", args)
        if args[1] != 'logs':
            raise ValueError("Can only run docker logs", args)
        if args[2] != container_name:
            raise ValueError("No such container", args[2])
        return iter(["hello\n", "error: 5 is not 6\n", "goodbye\n"])
    ret = error_lines(container_name, runner=runner)
    assert_that(list(ret), is_(["error: 5 is not 6"))

请注意,在这种情况下,我们并没有将自己限制为只让检查契约:error_lines可能已经运行,例如,docker logs -- <container_name>。然而,我们方法的一个优点是,我们可以慢慢提高我们的保真度,只有可以提高测试。

例如,我们可以给跑步者添加:

def runner(args):
    if args[0] != 'docker':
        raise ValueError("Can only run docker", args)
    if args[1] != 'logs':
        raise ValueError("Can only run docker logs", args)
    if args[2] == '--':
        arg_container_name = args[3]
    else:
        arg_container_name = args[2]
    if args_container_name != container_name:
        raise ValueError("No such container", args[2])
    return iter(["hello\n", "error: 5 is not 6\n", "goodbye\n"])

这将仍然与旧版本的代码一起工作,也将与修改后的代码一起工作。完全模仿 docker 是不现实的,也是不值得的。然而,这种方法会慢慢地提高测试的准确性,而且没有缺点。

如果我们的大量代码接口,例如 docker,我们最终可以将这样一个迷你 docker 仿真器分解到它自己的测试助手库中。

对流程运行使用更高级别的抽象有助于这种方法。例如,seashore库将计算命令的部分与底层运行程序分开,只允许替换底层运行程序。

def error_lines(container_name, executor):
    logs, _ignored = executor.docker.logs(container_name).batch()
    for line in logs.splitlines():
        if 'error' in line:
            yield line.strip()

当在生产环境中运行时,在顶部的某个地方,将使用类似如下的代码创建一个executor对象:

executor = seashore.Executor(seashore.Shell())

该对象将被传递给调用error_lines的对象,并在那里使用。通常,当使用seashore时,我们将执行器的创建留给顶层功能。

在测试中,我们创建了自己的外壳:

@attr.s

class DummyShell:

    _container_name = attr.ib()

    def batch(self, ∗args, ∗∗kwargs):
        if (args == ['docker', 'logs', self._container_name] and

            kwargs == {}):
            return "hello\nerror: 5 is not 6\ngoodbye\n", ""
        raise ValueError("unknown command", self, args, kwargs)

def test_error_lines():
    container_name = 'foo'
    executor = seashore.Executor(DummyShell(container_name))
    ret = error_lines(container_name, executor)
    assert_that(list(ret), is_(["error: 5 is not 6"]))

使用attrs库,尤其是在写各种假货的时候,往往是个好主意。赝品往往是有意制造的简单物品。因为断言和异常中会涉及到它们,所以对它们进行高质量的表示是很有用的。这正是attrs帮助减少的那种样板文件。

同样,我们可能需要慢慢提升我们的保真度。

因为流程很难测试,所以只在必要时使用流程运行是好的。尤其是在将 shell 脚本移植到 Python 时——当它们变得越来越复杂时,这通常是个好主意——用内存中的数据处理代替长管道是个好主意。

特别是如果我们以正确的方式分解代码,将数据处理作为一个简单的纯函数,接受一个参数并返回一个值,那么测试大部分代码就成了一种乐趣。

想象一下,例如,管道,

ps aux | grep conky | grep -v grep | awk '{print $2}' | xargs kill

这将杀死所有名字中有conky的进程。

下面是一种重构代码以使其更易于测试的方法:

def get_pids(lines):
    for line in lines:
        if 'conky' not in line:
            continue

        parts = line.split()
        pid_part = parts[1]
        pid = int(pid_part)
        yield pid

def ps_aux(runner=subprocess.check_output):
    return runner(["ps", "aux"])

def kill(pids, killer=os.kill):
    for pid in pids:
        killer(pid, signal.SIGTERM)

def main():
    kill(get_pid(ps_aux()))

注意最复杂的代码现在是如何在一个纯函数中:get_pids。希望这意味着大多数 bug 都会存在,我们可以针对它们进行单元测试。

更难进行单元测试的代码get_pids,我们不得不进行特别的依赖注入,现在是在简单的函数中,有更少的失败模式。

主要的逻辑在进行数据处理的函数中。测试这些只需要提供简单的数据结构并观察返回值。潜在的 bug 从需要单元测试更多精力的系统相关代码,移到更容易单元测试的纯逻辑,意味着减少bug;单元测试会发现更多的错误。

5.5 测试网络

requests库文档中,使用Session对象属于“高级”部分。这是不幸的。除了一次性脚本或交互式 REPL 用法,使用Session对象是最好的选择。到目前为止,测试是最不重要的原因——但是一旦使用了Session,测试就变得容易多了。

使用requests的简单示例代码可能如下所示:

def get_files(gist_id):
    gist = requests.get(f"https://api.github.com/gists/{gist_id}").json()
    result = {}
    for name, details in gist["files"].items():
        result[name] = requests.get(details["raw_url"]).content
    return result

这很难单独测试。相反,我们重写它以获取一个显式会话对象:

def get_files(gist_id, session=None):
    if session is None:
        session = requests.Session()
    gist = session.get(f"https://api.github.com/gists/{gist_id}").json()
    result = {}
    for name, details in gist["files"].items():
        result[name] = session.get(details["raw_url"]).content
    return result

代码几乎相同。然而,现在测试变成了用get方法编写一个对象的简单事情。

@attr.s(frozen=True)

class Gist:
    files = attr.ib()

@attr.s(frozen=True):

class Response:

    content = attr.ib()

    def json(self):
        return json.loads(content)

@attr.s(frozen=True)

class FakeSession:

    _gists = attr.ib()

    def get(self, url):
        parsed = hyperlink.URL.from_text(url)
        if parsed.host == 'api.github.com':
            tail = path.rsplit('/', 1)[-1]
            gist = self._gists[tail]
            res = dict(files={name: f'http://example.com/{tail}/{name}'
                              for name in gist.files})
            return Repsonse(json.dumps(res))
        if parsed.host == 'example.com':
            _ignored, gist, name = path.split('/')
            return Response(self.gists[gist][name])

这个有点啰嗦。有时,如果这个功能是本地化的,并且不值得编写整个助手库,我们可以使用unittest.mock库。

def make_mock():
    gist_name = 'some_name'
    files = {'some_file': 'some_content'}
    session = mock.Mock()
    session.get.content.return_value = 'some_content'
    session.get.json.return_value = json.dumps({'files': 'some_file'})
    return session

这是一个“快速和肮脏”的黑客,依靠的是这样一个事实(即合同中的而不是)使用content检索文件内容,使用json检索要点的逻辑结构。然而,使用稍微依赖于实现细节的模拟来编写快速测试通常比根本不编写测试要好。

将这样的测试视为“技术债务”并在某个时候对其进行改进以更多地依赖于合同而不是实现细节是很重要的。一个好的方法是在代码中添加注释,并将其链接到问题跟踪器。这也让测试代码的读者明白这仍然是一项正在进行的工作。

另一件重要的事情是,如果一个新的实现破坏了测试,通常正确的解决方法是而不是针对新的实现编写另一个测试。解决这个问题的正确方法是将更多的测试转移到基于契约的测试中。这可以通过首先改进测试来完成,但是要确保它运行在旧代码之上。然后开始重构代码,看到测试仍然通过。

当编写处理较低级概念(如套接字)的网络代码时,类似的思想仍然适用。因为 socket 对象的创建与它的任何使用是分开的,所以编写接受 socket 对象的函数并在外部创建它们会有很多好处。

为了模拟极端条件,看看我们的代码是否能在极端条件下工作,我们可能需要使用类似下面这样的东西作为 socket fake:

@attr.s

class FakeSimpleSocket:

    _chunk_size = attr.ib()

    _received = attr.ib(init=False, factory=list)

    _to_send = attr.ib()

    def connect(self, addr):
        pass

    def send(self, blob):
        actually_sent = blob[:chunk_size]
        self._received.append(actually_sent)
        return len(actually_sent)

    def recv(self, max_size):
        chunk_size = min(max_size, self._chunk_size)
        received, self._to_send = (self._to_send[:chunk_size],
                                   self._to_send[chunk_size:])
        return received

这允许我们控制“块”的大小一个极端的测试是使用 1 的chunk_size。这意味着字节一次输出一个,一次接收一个。没有真正的网络会这么糟糕,但是单元测试允许我们模拟比任何合理的网络更极端的条件。

这个伪造品对测试网络代码很有用。例如,这段代码执行一些特定的 HTTP 来获得一个结果:

def get_get(sock):
    sock.connect(('httpbin.org', 80))
    sock.send(b'GET /get HTTP/1.0\r\nHost: httpbin.org\r\n\r\n')
    res = sock.recv(1024)
    return json.loads(res.decode('ascii').split('\r\n\r\n', 1)[1]))

它有一个微妙的缺陷。我们可以通过一个简单的单元测试来发现这个错误,使用 socket fake。

def test_get_get():
    result = dict(url='http://httpbin.org/get')
    headers = 'HTTP/1.0 200 OK\r\nContent-Type: application/json\r\n\r\n'
    output = headers + json.dumps(result)
    fake_sock = FakeSimpleSocket(to_send=output, chunk_size=1)
    value = get_get(fake_sock)
    assert_that(value, is_(result))

这个测试会失败:我们的get_get假设网络连接质量很好,而这模拟的是一个很差的网络连接。如果我们把chunk_size改成1024就会成功。

我们可以循环运行测试,测试从 1 到 1024 的块大小。在真正的测试中,我们还会检查发送的数据,也可能发送无效的结果来查看响应。然而,重要的是,这些事情都不需要设置客户端或服务器,或者试图真实地模拟糟糕的网络。

5.6 总结

团队依靠 DevOps 代码来保持系统的功能性和可观察性。DevOps 代码的正确性至关重要。编写适当的测试将有助于提高代码的正确性。在对代码进行正确的修改时,考虑适当的测试编写原则将有助于减少修改测试的负担。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值