在不修改sys.path或第三方软件包的情况下,在Python软件包中导入供应商的依赖项

时间:2018-09-27 13:31:48

标签: python import python-import

摘要

我正在为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的{​​{1}})

解决方案尝试

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

  1. 手动更新第三方程序包,以便它们的导入语句指向我的python程序包中的标准模块路径(例如library2)。但这是一件繁琐的工作,无法扩展到较大的依赖树,也无法移植到其他程序包中。
  2. 在我的程序包初始化文件中通过import addon_name_1._vendor.dependency_of_library2_vendor添加到sys.path。这可行,但是它对模块查找路径进行了全局更改,这将影响其他加载项,甚至影响基本应用程序本身。似乎是一种黑客行为,可能会在以后导致pandora出现一系列问题(例如,同一软件包的不同版本之间发生冲突等)。
  3. Temporarily modifying sys.path for my imports;但这对带有方法级导入的第三方模块不起作用。
  4. 根据我在PEP302中找到的示例编写setuptools样式的自定义导入程序,但我只是做不到这点。

我已经在这个问题上停留了好几个小时,而且我开始认为我要么完全错过了执行此操作的简单方法,要么我的整个方法存在根本上的错误。 / p>

我是否有办法在不借助sys.path.insert(1, <path_to_vendor_dir>)黑客或修改有问题的软件包的情况下,随代码附带第三方软件包的依赖树?


编辑:

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

4 个答案:

答案 0 :(得分:6)

首先,我建议不要出售;一些主要软件包以前曾使用过供应商,但是为了避免不得不处理供应商的痛苦,已经放弃了。 requests library就是这样一个例子。如果您依靠使用pip install的人来安装软件包,那么只需使用依赖项并向人们介绍虚拟环境。不必假设您需要承担使依赖关系混乱的负担,也不必阻止人们在全局Python site-packages位置安装依赖关系。

同时,我了解到第三方工具的插件环境有所不同,并且如果对该工具使用的Python安装添加依赖项很麻烦或无法进行商业化可能是一个可行的选择。我看到Anki在没有setuptools支持的情况下将扩展名分发为.zip文件,因此肯定是这种环境。

因此,如果您选择提供依赖关系,请使用脚本来管理依赖关系并更新其导入。这是您的选择#1,但自动

这是pip项目选择的路径,有关其自动化的信息,请参见其tasks subdirectory,该路径建立在invoke library上。有关其政策和原理,请参见pip项目vendoring README(其中pip需要自行进行引导,例如,可以使用其依赖项来安装任何内容)。

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

使用自定义导入程序的选项#4的问题在于,您仍然需要重写导入。换句话说,setuptools使用的自定义导入程序钩子根本无法解决供应商名称空间的问题,相反,如果缺少供应商软件包,则可以动态导入顶级软件包({{3 }})。 setuptools实际上使用选项#1,在那里他们重写供应商软件包的源代码。参见setuptools供应子包中的pip solves with a manual debundling processsetuptools.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-infobin目录以及已安装依赖项中的一些内容pip永远不会使用。
  • 收集所有已安装目录,并添加扩展名为.py的文件(因此白名单中没有任何内容);这是vendored_libs列表。
  • 重写进口;这只是一系列正则表达式,其中vendored_lists中的每个名称都用import <name>替换import pip._vendor.<name>个出现,而from <name>(.*) import中的每个from pip._vendor.<name>(.*) import个出现。 li>
  • 应用一些补丁以清除所需的其余更改;从供应商的角度来看,只有pip these lines in the packaging project在这里很有趣,因为它为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)

答案 1 :(得分:1)

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

我是新来的,所以请在这里忍受。

答案 2 :(得分:0)

捆绑依赖项的最佳方法是使用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

答案 3 :(得分:0)

为了延续 Martijn Pieters 的出色回复,自 pip 20.0 起,pip 一直使用专用的 CLI 工具来供应依赖关系。该工具名为 vendoring,似乎主要关注 pip 的需求,但我希望它可以成为任何具有类似需求的项目的绝佳框架。

在我写这篇评论时,他们还没有面向用户的文档: https://github.com/pradyunsg/vendoring/issues/3

可通过 pyproject.toml 文件进行配置:

tidyverse

它可以安装在虚拟环境中,如下所示:

[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