首先,我建议不要贩卖;一些主要的软件包之前确实使用了 vendoring,但为了避免不得不处理 vendoring 的痛苦,它们已经放弃了。一个这样的例子是requests library。如果您依赖使用pip install 的人来安装您的软件包,那么只需使用依赖项 并告诉人们有关虚拟环境的信息。不要假设您需要承担保持依赖关系解开的负担,或者需要阻止人们在全局 Python site-packages 位置安装依赖项。
同时,我很欣赏第三方工具的插件环境是不同的,如果向该工具使用的 Python 安装添加依赖项很麻烦或不可能,那么供应商化可能是一个可行的选择。我看到 Anki 将扩展作为 .zip 文件分发而没有 setuptools 支持,所以肯定是这样的环境。
因此,如果您选择供应商依赖项,请使用脚本来管理您的依赖项并更新它们的导入。这是您的选项#1,但自动。
这是pip 项目选择的路径,请参阅他们的tasks subdirectory 了解他们的自动化,它建立在invoke library 之上。请参阅 pip 项目vendoring README 了解他们的政策和基本原理(其中最主要的是pip 需要引导 本身,例如让他们的依赖项能够安装任何东西)。
您不应使用任何其他选项;您已经列举了 #2 和 #3 的问题。
选项 #4 的问题是,使用自定义导入器,您仍然需要重写导入。换句话说,setuptools 使用的自定义导入器钩子根本不能解决供应商命名空间问题,而是可以在供应商包丢失时动态导入顶级包(pip solves with a manual debundling process 的问题)。 setuptools 实际上使用选项#1,他们重写了供应商包的源代码。例如,参见 setuptools vendored 子包中的 these lines in the packaging project; setuptools.extern 命名空间由自定义导入钩子处理,然后如果从供应商包导入失败,则重定向到 setuptools._vendor 或顶级名称。
pip 自动更新供应商的软件包需要以下步骤:
- 删除
_vendor/ 子目录中的所有内容,文档、__init__.py 文件和需求文本文件除外。
- 使用
pip 将所有供应商的依赖项安装到该目录中,使用名为vendor.txt 的专用需求文件,避免编译.pyc 字节缓存文件并忽略临时依赖项(假设这些已在vendor.txt 中列出);使用的命令是pip install -t pip/_vendor -r pip/_vendor/vendor.txt --no-compile --no-deps。
- 删除由
pip 安装但在供应商环境中不需要的所有内容,即*.dist-info、*.egg-info、bin 目录,以及pip 永远不会使用的已安装依赖项中的一些内容。
- 收集所有安装的目录和添加的文件,没有
.py扩展名(所以任何不在白名单中的东西);这是vendored_libs 列表。
- 重写导入;这只是一系列正则表达式,其中
vendored_lists 中的每个名称都用于将出现的import <name> 替换为import pip._vendor.<name>,并将每个出现的from <name>(.*) import 替换为from pip._vendor.<name>(.*) import。
- 应用一些补丁来清除所需的剩余更改;从供应商的角度来看,这里只有
pip patch for requests 有趣,因为它更新了 requests 库已删除的供应商包的 requests 库向后兼容层;这个补丁非常元!
所以本质上,pip 方法中最重要的部分,vendored 包导入的重写非常简单;意译为简化逻辑并删除pip特定部分,它只是以下过程:
import shutil
import subprocess
import re
from functools import partial
from itertools import chain
from pathlib import Path
WHITELIST = {'README.txt', '__init__.py', 'vendor.txt'}
def delete_all(*paths, whitelist=frozenset()):
for item in paths:
if item.is_dir():
shutil.rmtree(item, ignore_errors=True)
elif item.is_file() and item.name not in whitelist:
item.unlink()
def iter_subtree(path):
"""Recursively yield all files in a subtree, depth-first"""
if not path.is_dir():
if path.is_file():
yield path
return
for item in path.iterdir():
if item.is_dir():
yield from iter_subtree(item)
elif item.is_file():
yield item
def patch_vendor_imports(file, replacements):
text = file.read_text('utf8')
for replacement in replacements:
text = replacement(text)
file.write_text(text, 'utf8')
def find_vendored_libs(vendor_dir, whitelist):
vendored_libs = []
paths = []
for item in vendor_dir.iterdir():
if item.is_dir():
vendored_libs.append(item.name)
elif item.is_file() and item.name not in whitelist:
vendored_libs.append(item.stem) # without extension
else: # not a dir or a file not in the whilelist
continue
paths.append(item)
return vendored_libs, paths
def vendor(vendor_dir):
# target package is <parent>.<vendor_dir>; foo/_vendor -> foo._vendor
pkgname = f'{vendor_dir.parent.name}.{vendor_dir.name}'
# remove everything
delete_all(*vendor_dir.iterdir(), whitelist=WHITELIST)
# install with pip
subprocess.run([
'pip', 'install', '-t', str(vendor_dir),
'-r', str(vendor_dir / 'vendor.txt'),
'--no-compile', '--no-deps'
])
# delete stuff that's not needed
delete_all(
*vendor_dir.glob('*.dist-info'),
*vendor_dir.glob('*.egg-info'),
vendor_dir / 'bin')
vendored_libs, paths = find_vendored_libs(vendor_dir, WHITELIST)
replacements = []
for lib in vendored_libs:
replacements += (
partial( # import bar -> import foo._vendor.bar
re.compile(r'(^\s*)import {}\n'.format(lib), flags=re.M).sub,
r'\1from {} import {}\n'.format(pkgname, lib)
),
partial( # from bar -> from foo._vendor.bar
re.compile(r'(^\s*)from {}(\.|\s+)'.format(lib), flags=re.M).sub,
r'\1from {}.{}\2'.format(pkgname, lib)
),
)
for file in chain.from_iterable(map(iter_subtree, paths)):
patch_vendor_imports(file, replacements)
if __name__ == '__main__':
# this assumes this is a script in foo next to foo/_vendor
here = Path('__file__').resolve().parent
vendor_dir = here / 'foo' / '_vendor'
assert (vendor_dir / 'vendor.txt').exists(), '_vendor/vendor.txt file not found'
assert (vendor_dir / '__init__.py').exists(), '_vendor/__init__.py file not found'
vendor(vendor_dir)