Published on

在 Python 中使用 vendor 的方法

Reading Time

7 min read

Photo by Cam Morin on Unsplash
Photo by Cam Morin on Unsplash

本文介绍了在 Python 库中 vendor 第三方库的正确方法。我知道这篇文章的受众非常狭窄,大部分 Python 开发者都不会也不需要用到这个技术, 但是本着分享的精神还是把它总结一二,作为软件的作者更是应该尊重所有其他库的作者的劳动。

WHAT - vendor 是什么?

Vendor,直译供应商,在软件中(比如 C, Go 等语言中),是一种把第三方库的代码直接内嵌到软件中的方式。 它不同于通过依赖文件指定的方式,第三方库的代码是直接包含在软件中的,有可能原样保留也有可能经过修改,所以需要注意各种 License 的限制, 特别是如果上游库采用了 GPL 系列的协议,使用 vendor 的软件也是要受到传染的。

WHY - Python 中什么时候要用到 vendor?

正如我开头说的,适用范围非常狭窄,有三种场景:

  1. 软件特性限制其必须是自包含,零依赖的。

    在 Python 的世界中,最重度使用 vendor 的库就是我们天天都要用的 pippip._vendor 中包含了 25 个依赖。pip 是现行标准的 Python 安装器,所以它不能 有任何依赖,否则为了装 pip,要先装这些依赖,而这些依赖又只能通过 pip 安装,这就递归了。除此之外,还包括像 setuptools 这样的基础构建工具。

  2. 软件依赖某上游库的特定版本。这还包含上游库频繁 breaking change,导致 API 不稳定的情况。如果简单地在依赖中指定 third-party-lib==1.0.0, 会导致与之共存的同样依赖此库的软件无法解析版本,造成依赖冲突。而如果转成 vendor,就相当于把这个非常严格的依赖限制去掉了。

  3. 软件需要对上游库作一些变更,而由于上游库的维护问题,这些变更无法通过 PR 等方式合入上游并发布。在符合开源协议约束的情况下,可以通过 vendor 把源代码嵌入到软件中 并自行修改。

其实,针对上述的第 2、3 种场景,也不是非 vendor 不可。除了 vendor,还可以 fork 到自己的 git 仓库,再使用 git 依赖 引入,或者发布为一个新的 PyPI 包。只是 vendor 是一个最轻松的方式。

还有一个限制条件:对 Python 来说,只有纯 Python 的库才能 vendor。

HOW - 应该如何 vendor?

vendor 并不是简单地复制粘贴这种传统艺能就解决了的,在我看来,它还要注意以下两点:

  1. vendor 必须要遵守开源协议,并把协议文件也放到 vendor 目录中。
  2. 对源代码有修改时,需要记录 patch 文件,以便时机成熟时,反馈回上游。

所以,vendor 并不是复制粘贴,只是在开源框架下对现状的一种妥协,我们最终的目标,是消灭 vendor。

在 Python 中,除了把 vendor 库都放到代码库下一个目录中(比如 mypackage/vendor)以外,还需要修改所有的 import 语句,指向到这个目录中。 比如,把 import requests 改成 from mypackage.vendor import requests。PDM 中也包含了这样一个目录,我是使用和 pip 相同的工具来管理 vendor 的。 这个工具是 vendoring,文档很少(因为就没人要用)。它包含以下几个功能:

  1. 读取一个 requirements.txt 下载依赖到指定目录
  2. 下载所有库的 LICENSE 文件到这个目录中
  3. 从一个指定路径读取 patch 文件并应用到源代码中
  4. 重写所有 import 语句,指向到 vendor 目录中
  5. 更新 vendor 的版本

使用过程,也大致按上面的步骤。首先建立一个 mypackage/vendor 目录,在其中创建一个 vendors.txt,填写依赖(requirements.txt 格式):

requests==2.24.1
click==8.0.1

然后在项目根路径下的 pyproject.toml 中,添加以下内容:

[tool.vendoring]
destination = "mypackage/vendor/"   # vendor目录路径
requirements = "mypackage/vendor/vendors.txt"  # requirements路径
namespace = "mypackage.vendor"  # import 重命名前缀

protected-files = ["__init__.py", "README.md", "vendors.txt"]  # 每次重新 vendor 时需要保留的文件
patches-dir = "tasks/patches"  # patch 文件目录

[tool.vendoring.transformations]
substitute = [  # 重命名没有覆盖到的 import,文件替换规则
  {match = '__import__("requests")', replace = '__import__("mypackage.vendor.requests")'}
]
drop = [   # 需要从 vendor 库中去除的文件
    "bin/",
    "*.so",
    "typing.*",
    "*/tests/"
]

最后运行 vendoring sync,就会自动把 vendor 全部准备好了。

对于 patch 文件,其实就是 git diff 的输出,有了这个文件,git 就可以从源代码重新建立 vendor 目录。生成方法:

  1. 配置好以后跑一次 vendoring sync,把文件提交到本地仓库(只 commit 不 push)
  2. 修改源代码
  3. 运行 git diff --patch <file_path> > <patches_dir>/<file_name>.patch,把 patch 文件保存到 patches_dir
  4. 审核 patch 文件,把其中已经被修改的 import 语句恢复成原始的 import 语句,比如 from mypackage.vendor import requests 改成 import requests1
  5. 运行 git add . && git commit --amend,提交修改
  6. 再次运行 vendoring sync 验证一下,如果一切正常,应该不会产生任何变更,说明这个 vendor 过程是 reproducible 的

Footnotes

  1. 至于为何要这么做,因为 apply patch 是先于 import 重写的,所以 patch 文件中,应该都是未重写的 import 语句。修改时要注意不要改动任何空白字符,patch 文件对空白是敏感的。

Share: