在不修改sys.path或第三方软件包的情况下,在Python软件包中导入供应商依赖性
概要
我正在为Anki(开源抽认卡程序)开发一系列附加组件。Anki附加组件以Python软件包的形式提供,其基本文件夹结构如下所示:
anki_addons/
addon_name_1/
__init__.py
addon_name_2/
__init__.py
anki_addons``sys.path
由基本应用程序附加到,然后由导入每个add_on import <addon_name>
。
我一直试图解决的问题是找到一种可靠的方式来 通过我的附件运送软件包及其依赖项,同时又不污染全局状态或退回对供应商软件包的手动编辑 。
细节
具体来说,给定这样的附加结构…
addon_name_1/
__init__.py
_vendor/
__init__.py
library1
library2
dependency_of_library2
...
…我希望能够导入_vendor
目录中包含的任何任意软件包,例如:
from ._vendor import library1
这样的相对导入的主要困难在于,它们不适用于还依赖于通过绝对引用导入的其他软件包的软件包(例如import
dependency_of_library2
在的源代码中library2
)
解决方案尝试
到目前为止,我已经探索了以下选项:
- 手动更新第三方软件包,以便它们的import语句指向我的python软件包(例如
import addon_name_1._vendor.dependency_of_library2
)中的标准模块路径。但这是繁琐的工作,无法扩展到较大的依赖树,也无法移植到其他程序包。 - 添加
_vendor
到sys.path
通过sys.path.insert(1, <path_to_vendor_dir>)
我的包中初始化文件。此方法有效,但它对模块查找路径进行了全局更改,这将影响其他加载项,甚至影响基本应用程序本身。似乎这是一种黑客行为,可能会在以后导致pandora出现一系列问题(例如,同一软件包的不同版本之间存在冲突,等等)。 - 为我的进口临时修改sys.path ; 但这不适用于方法级导入的第三方模块。
- 根据我在setuptools中找到的示例编写一个PEP302样式的自定义导入器,但是我无法做到这一点。
我已经在这个问题上停留了好几个小时,而且我开始认为我要么完全错过了执行此操作的简单方法,要么我的整个方法从根本上错了。
有没有办法在我的代码中附带第三方软件包的依赖树,而不必求助于sys.path
黑客或修改相关软件包?
编辑:
只是要澄清一下:我无法控制如何从anki_addons文件夹中导入加载项。anki_addons只是基本应用程序提供的目录,所有附加组件均安装在该目录中。它被添加到sys路径中,因此其中的附加软件包的行为几乎与位于Python模块查找路径中的任何其他python软件包一样。
-
首先,我建议不要出售。一些主要软件包以前曾使用过供应商,但是为了避免不得不处理供应商的痛苦,已经放弃了。
requests
图书馆就是一个这样的例子。如果您依靠pip install
用来安装软件包的人员,则 只需使用依赖项
并向人们介绍虚拟环境。不要假设您需要承担使依赖关系不复杂的负担,也不必阻止人们在全局Pythonsite-packages
位置中安装依赖项。同时,我知道第三方工具的插件环境有所不同,并且如果对该工具使用的Python安装添加依赖项很麻烦或无法进行商贩销售,则是可行的选择。我看到Anki在
.zip
不支持setuptools的情况下将扩展名作为文件分发,因此肯定是这种环境。因此,如果您选择供应商依赖性,则可以使用脚本来管理依赖性并更新其导入。这是您的选择#1,但 自动化 。
这是
pip
项目选择的路径,有关其自动化的信息,请参见其tasks
子目录,该子目录建立在invoke
库上。请参阅pip项目供应商README,以了解其政策和原理(其中之一是pip
需要
自举 ,例如,可以使用其依赖项来安装任何东西)。您不应使用任何其他选项;您已经列举了#2和#3的问题。
使用自定义导入程序的选项#4的问题在于, 您仍然需要重写import
。换句话说,所使用的自定义导入器钩子setuptools
根本无法解决供应商名称空间的问题,而是可以在缺少供应商软件包的情况下动态导入顶级软件包(pip
通过
手动
分包过程解决的问题)。setuptools
实际上使用选项#1,在那里他们重写供应商软件包的源代码。例如,在packaging
项目的setuptools
供应商子包中查看这些行;该setuptools.extern
命名空间是由自定义导入钩,然后重定向要么处理setuptools._vendor
或顶级名称(如果从供应商化的软件包导入失败)。该
pip
自动化更新vendored包采取以下步骤:- 删除 所有 的
_vendor/
子目录,除了文档的__init__.py
文件和要求的文本文件。 - 用于使用
pip
名为的专用需求文件将所有供应商的依赖项安装到该目录中,vendor.txt
避免编译.pyc
字节缓存文件并忽略瞬时依赖项(假定vendor.txt
已在其中列出);使用的命令是pip install -t pip/_vendor -r pip/_vendor/vendor.txt --no-compile --no-deps
。 - 这是由安装删除一切
pip
,但在vendored环境中不需要的,即*.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
补丁requests
是有趣的,因为它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)
- 删除 所有 的