在不修改 sys.path 或第 3 方包的情况下导入 Python 包中的销售依赖项

Import vendored dependencies in Python package without modifying sys.path or 3rd party packages

总结

我正在为 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

像这样的相对导入的主要困难是它们不适用于还依赖于通过绝对引用导入的其他包的包(例如 library2 的源代码中的 import dependency_of_library2

解决方案尝试

到目前为止,我已经探索了以下选项:

  1. 手动更新第三方包,以便它们的导入语句指向我的 python 包中的完全限定模块路径(例如 import addon_name_1._vendor.dependency_of_library2)。但这是一项繁琐的工作,无法扩展到更大的依赖树,也无法移植到其他包。
  2. 在我的包初始化文件中通过 sys.path.insert(1, <path_to_vendor_dir>) 添加 _vendorsys.path。这行得通,但它引入了对模块查找路径的全局更改,这将影响其他附加组件甚至基本应用程序本身。这看起来像是一个黑客攻击,可能会在以后导致潘多拉魔盒问题(例如,同一包的不同版本之间发生冲突等)。
  3. Temporarily modifying sys.path for my imports;但这不适用于具有方法级导入的第三方模块。
  4. 正在写一个 PEP302-style custom importer based off an example I found in setuptools,但我就是想不通。

我已经坚持这个问题好几个小时了,我开始认为我要么完全错过了一个简单的方法来做到这一点,要么我的整个方法存在根本性的错误.

我有没有办法用我的代码发送第三方包的依赖关系树,而不必求助于 sys.path hack 或修改有问题的包?


编辑:

澄清一下:我无法控制如何从 anki_addons 文件夹导入附加组件。 anki_addons 只是基本应用程序提供的目录,其中安装了所有附加组件。它被添加到 sys 路径,因此其中的附加包几乎与位于 Python 的模块查找路径中的任何其他 python 包一样。

如何将您的 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 *

我是新手,所以请多多包涵。

捆绑依赖项的最佳方法是使用 virtualenvAnki项目至少应该能安装在一个里面。

我想你追求的是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

首先,我建议不要销售;一些主要的软件包以前确实使用过 vendoring,但为了避免不得不处理 vendoring 的痛苦而已经切换。 requests library 就是这样的一个例子。如果您依赖使用 pip install 的人来安装您的软件包,那么 只需使用依赖关系 并告诉人们有关虚拟环境的信息。不要假设您需要承担保持依赖项的负担,或者需要阻止人们在全局 Python site-packages 位置安装依赖项。

同时,我意识到第三方工具的插件环境有些不同,如果向该工具使用的 Python 安装添加依赖项很麻烦或不可能进行供应商化成为一个可行的选择。我看到 Anki 在没有 setuptools 支持的情况下将扩展分发为 .zip 文件,所以这肯定是这样的环境。

因此,如果您选择供应商依赖项,请使用脚本来管理您的依赖项并更新它们的导入。这是您的选项 #1,但是 自动

这是 pip 项目选择的路径,请参阅他们的 tasks subdirectory for their automation, which builds on the invoke library. See the pip project vendoring README 了解他们的政策和理由(其中最主要的是 pip 需要 bootstrap 本身,例如,有可用的依赖项以便能够安装任何东西)。

您不应该使用任何其他选项;您已经列举了#2 和#3 的问题。

选项 #4 的问题是,使用自定义导入器,您仍然需要重写导入。换句话说,setuptools 使用的自定义导入器挂钩根本没有解决供应商命名空间问题,而是在供应商包丢失的情况下动态导入顶级包成为可能(这个问题 pip solves with a manual debundling process). setuptools actually uses option #1, where they rewrite the source code for vendorized packages. See for example these lines in the packaging projectsetuptools 供应商子包中;setuptools.extern 命名空间由自定义导入挂钩处理,如果从供应商化包导入失败,它将重定向到 setuptools._vendor 或顶级名称.

pip 自动更新销售包需要以下步骤:

  • 删除 子目录中的 所有内容 ,除了文档、__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-infobin 目录以及来自安装了 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'from {} 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'from {}.{}'.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)

扩展, pip has been using a dedicated CLI tool for vendoring dependencies since pip 20.0. The tool is called 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-external-dependencies