摘要
我正在为Anki开发一系列插件,这是一个开源的闪卡程序。Anki附加组件以Python包的形式提供,基本文件夹结构如下:
anki_addons/
addon_name_1/
__init__.py
addon_name_2/
__init__.py
基础应用程序将anki_addons
附加到sys.path
,然后使用import <addon_name>
导入每个add_on。
我一直在努力解决的问题是找到一种可靠的方法来用我的附加组件运送包及其依赖项,同时不污染全局状态或回到对供应商包的手动编辑。
规格
具体来说,给定这样的附加结构。。。
addon_name_1/
__init__.py
_vendor/
__init__.py
library1
library2
dependency_of_library2
...
我希望能够导入_vendor
目录中包含的任何任意包,例如:
from ._vendor import library1
像这样的相对导入的主要困难是,它们不适用于同样依赖于通过绝对引用导入的其他包的包(例如,library2
源代码中的import dependency_of_library2
)
解决方案尝试
到目前为止,我已经探索了以下选项:
- 手动更新第三方包,以便它们的导入语句指向我的python包中的完全限定模块路径(例如
import addon_name_1._vendor.dependency_of_library2
)。但这是一项乏味的工作,无法扩展到更大的依赖树,也无法移植到其他包 - 在我的包初始化文件中通过
sys.path.insert(1, <path_to_vendor_dir>)
将_vendor
添加到sys.path
。这是有效的,但它对模块查找路径进行了全局更改,这将影响其他加载项,甚至影响基本应用程序本身。这看起来就像是一次黑客攻击,可能会在以后导致潘多拉的一系列问题(例如,同一软件包的不同版本之间的冲突等) - 临时修改导入的sys.path;但对于具有方法级导入的第三方模块,这是不起作用的
- 根据我在setuptools中找到的一个例子编写了一个PEP302风格的自定义导入程序,但我对此一无所知
我已经在这个问题上坚持了好几个小时,我开始认为我要么完全错过了一个简单的方法,要么我的整个方法有根本性的问题。
我是否无法在不使用sys.path
黑客攻击或修改有问题的包的情况下,随代码一起发布第三方包的依赖树
编辑:
澄清一下:我无法控制如何从anki_addons文件夹导入加载项。anki_addons只是基本应用程序提供的目录,所有附加组件都安装在该目录中。它被添加到sys路径中,因此其中的附加包的行为与位于python模块查找路径中的任何其他python包几乎相同。
首先,我建议不要贩卖;一些主要的包裹以前确实使用过vendoring,但为了避免不得不处理vendoring的痛苦,它们已经放弃了。一个这样的例子是requests
库。如果您依赖于使用pip install
的人员来安装您的软件包,那么只需使用依赖项并告诉人们有关虚拟环境的信息。不要认为你需要承担保持依赖关系的重担,或者需要阻止人们在全局Pythonsite-packages
位置安装依赖关系。
同时,我理解第三方工具的插件环境是不同的,如果在该工具使用的Python安装中添加依赖项很麻烦或不可能,那么出售可能是一个可行的选择。我看到Anki在没有setuptools支持的情况下将扩展名分发为.zip
文件,所以这肯定是一个这样的环境。
因此,如果您选择供应商依赖关系,则使用脚本来管理依赖关系并更新其导入。这是您的选项#1,但自动。
这是pip
项目选择的路径,请参阅他们的tasks
子目录以了解其自动化,该子目录构建在invoke
库上。请参阅pip项目供应商README的策略和基本原理(其中最主要的是pip
需要引导本身,例如,让它们的依赖项可以安装任何东西)。
您不应该使用任何其他选项;您已经列举了#2和#3的问题。
使用自定义导入程序的选项#4的问题是您仍然需要重写导入。换句话说,setuptools
使用的自定义导入器挂钩根本不能解决vendorized命名空间问题,相反,它可以在vendoried包丢失的情况下动态导入顶级包(pip
通过手动拆散过程解决了这个问题)。setuptools
实际上使用了选项#1,在该选项中,他们重写了vendorized包的源代码。例如,参见setuptools
vendored子包中packaging
项目中的这些行;setuptools.extern
名称空间由自定义导入挂钩处理,如果从vendorized包导入失败,则该挂钩重定向到setuptools._vendor
或顶级名称。
pip
自动化更新供应商包需要以下步骤:
- 删除
_vendor/
子目录中的所有内容,文档、__init__.py
文件和需求文本文件除外 - 使用
pip
将所有vendored依赖项安装到该目录中,使用名为vendor.txt
的专用需求文件,避免编译.pyc
字节缓存文件并忽略瞬态依赖项(假设这些依赖项已在vendor.txt
中列出);所使用的命令是CCD_ 34 - 删除由
pip
安装但在供应商环境中不需要的所有内容,即*.dist-info
、*.egg-info
、bin
目录,以及pip
永远不会使用的已安装依赖项中的一些内容 - 收集所有安装的目录和添加的文件,无扩展名
.py
(所以白名单中没有任何内容);这是CCD_ 41列表 - 重写导入;这只是一系列正则表达式,其中
vendored_lists
中的每个名称都用于用import pip._vendor.<name>
替换import <name>
,用from pip._vendor.<name>(.*) import
替换from <name>(.*) import
- 应用一些补丁来清除所需的剩余更改;从供应商的角度来看,这里只有
requests
的pip
补丁很有趣,因为它为requests
库删除的供应商包更新了requests
库的向后兼容性层;这个补丁相当元
因此,从本质上讲,作为pip
方法最重要的部分,供应商包导入的重写非常简单;为了简化逻辑并删除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)
让anki_addons
文件夹成为一个包,并将所需的库导入主包文件夹中的__init__.py
。
所以它会有点像
anki/
__init__.py
在anki.__init__.py
:中
from anki_addons import library1
在anki.anki_addons.__init__.py
:中
from addon_name_1 import *
我是新来的,所以请耐心等待。
为了扩展Martijn Pieters的出色回复,pip自pip 20.0以来一直使用专用的CLI工具来处理依赖关系。该工具名为vendoring,似乎主要关注pip的需求,但我希望它能成为任何有类似需求的项目的一个很好的框架。
在我写这篇评论的时候,他们还没有面向用户的文档:https://github.com/pradyunsg/vendoring/issues/3
它可以通过pyproject.toml文件进行配置:
[tool.vendoring]
destination = "src/pip/_vendor/"
requirements = "src/pip/_vendor/vendor.txt"
namespace = "pip._vendor"
protected-files = ["__init__.py", "README.rst", "vendor.txt"]
patches-dir = "tools/vendoring/patches"
它可以安装在虚拟环境中,如下所示:
$ pip install vendoring
它的工作原理如下:
$ vendoring sync /path/to/location # Install dependencies in destination folder
$ vendoring update /path/to/location # Update vendoring dependencies
编辑:
我一直在合成器软件的python插件上使用这个工具。点击此处了解更多信息:https://nomenclator-nuke.readthedocs.io/en/stable/installing.html#managing-外部依赖
捆绑依赖关系的最佳方法是使用virtualenv
。Anki
项目应该至少能够安装在一个内部。
我想你要的是namespace packages
。
https://packaging.python.org/guides/packaging-namespace-packages/
我想主要的Anki项目有一个setup.py
,每个插件都有自己的setup.py
,可以从自己的源发行版安装。然后附加组件可以在它们自己的setup.py
中列出它们的依赖项,pip将在site-packages
中安装它们。
命名空间包只解决了部分问题,正如您所说,您无法控制如何从anki_addons文件夹导入加载项。我认为设计如何导入附加组件和包装它们是齐头并进的。
pkgutil
模块为主项目提供了一种查找已安装的附加组件的方法。https://packaging.python.org/guides/creating-and-discovering-plugins/
Zope是一个广泛使用此功能的项目。http://www.zope.org
看看这里:https://github.com/zopefoundation/zope.interface/blob/master/setup.py