本文介绍了在 Python 库中 vendor 第三方库的正确方法。我知道这篇文章的受众非常狭窄,大部分 Python 开发者都不会也不需要用到这个技术, 但是本着分享的精神还是把它总结一二,作为软件的作者更是应该尊重所有其他库的作者的劳动。
WHAT - vendor 是什么?
Vendor,直译供应商,在软件中(比如 C, Go 等语言中),是一种把第三方库的代码直接内嵌到软件中的方式。 它不同于通过依赖文件指定的方式,第三方库的代码是直接包含在软件中的,有可能原样保留也有可能经过修改,所以需要注意各种 License 的限制, 特别是如果上游库采用了 GPL 系列的协议,使用 vendor 的软件也是要受到传染的。
WHY - Python 中什么时候要用到 vendor?
正如我开头说的,适用范围非常狭窄,有三种场景:
软件特性限制其必须是自包含,零依赖的。
在 Python 的世界中,最重度使用 vendor 的库就是我们天天都要用的
pip
。pip._vendor
中包含了 25 个依赖。pip
是现行标准的 Python 安装器,所以它不能 有任何依赖,否则为了装pip
,要先装这些依赖,而这些依赖又只能通过pip
安装,这就递归了。除此之外,还包括像setuptools
这样的基础构建工具。软件依赖某上游库的特定版本。这还包含上游库频繁 breaking change,导致 API 不稳定的情况。如果简单地在依赖中指定
third-party-lib==1.0.0
, 会导致与之共存的同样依赖此库的软件无法解析版本,造成依赖冲突。而如果转成 vendor,就相当于把这个非常严格的依赖限制去掉了。软件需要对上游库作一些变更,而由于上游库的维护问题,这些变更无法通过 PR 等方式合入上游并发布。在符合开源协议约束的情况下,可以通过 vendor 把源代码嵌入到软件中 并自行修改。
其实,针对上述的第 2、3 种场景,也不是非 vendor 不可。除了 vendor,还可以 fork 到自己的 git 仓库,再使用 git 依赖 引入,或者发布为一个新的 PyPI 包。只是 vendor 是一个最轻松的方式。
还有一个限制条件:对 Python 来说,只有纯 Python 的库才能 vendor。
HOW - 应该如何 vendor?
vendor 并不是简单地复制粘贴这种传统艺能就解决了的,在我看来,它还要注意以下两点:
- vendor 必须要遵守开源协议,并把协议文件也放到 vendor 目录中。
- 对源代码有修改时,需要记录 patch 文件,以便时机成熟时,反馈回上游。
所以,vendor 并不是复制粘贴,只是在开源框架下对现状的一种妥协,我们最终的目标,是消灭 vendor。
在 Python 中,除了把 vendor 库都放到代码库下一个目录中(比如 mypackage/vendor
)以外,还需要修改所有的 import 语句,指向到这个目录中。 比如,把 import requests
改成 from mypackage.vendor import requests
。PDM 中也包含了这样一个目录,我是使用和 pip
相同的工具来管理 vendor 的。 这个工具是 vendoring,文档很少(因为就没人要用)。它包含以下几个功能:
- 读取一个
requirements.txt
下载依赖到指定目录 - 下载所有库的 LICENSE 文件到这个目录中
- 从一个指定路径读取 patch 文件并应用到源代码中
- 重写所有 import 语句,指向到 vendor 目录中
- 更新 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 目录。生成方法:
- 配置好以后跑一次
vendoring sync
,把文件提交到本地仓库(只 commit 不 push) - 修改源代码
- 运行
git diff --patch <file_path> > <patches_dir>/<file_name>.patch
,把 patch 文件保存到patches_dir
中 - 审核 patch 文件,把其中已经被修改的 import 语句恢复成原始的 import 语句,比如
from mypackage.vendor import requests
改成import requests
1 - 运行
git add . && git commit --amend
,提交修改 - 再次运行
vendoring sync
验证一下,如果一切正常,应该不会产生任何变更,说明这个 vendor 过程是 reproducible 的
Footnotes
至于为何要这么做,因为 apply patch 是先于 import 重写的,所以 patch 文件中,应该都是未重写的 import 语句。修改时要注意不要改动任何空白字符,patch 文件对空白是敏感的。 ↩