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

Glu*_*ate 15 python import python-import

摘要

我正在为一个开源闪存卡程序Anki开发一系列附加组件.Anki附加组件作为Python包提供,基本文件夹结构如下所示:

anki_addons/
    addon_name_1/
        __init__.py
    addon_name_2/
        __init__.py
Run Code Online (Sandbox Code Playgroud)

anki_addonssys.path由基础应用程序附加,然后基本应用程序导入每个add_on import <addon_name>.

我一直试图解决的问题是找到一种可靠的方法来发送包及其依赖项与我的附加组件,同时不污染全局状态或回退到销售包的手动编辑.

细节

具体来说,给定这样的附加结构......

addon_name_1/
    __init__.py
    _vendor/
        __init__.py
        library1
        library2
        dependency_of_library2
        ...
Run Code Online (Sandbox Code Playgroud)

...我希望能够导入_vendor目录中包含的任意包,例如:

from ._vendor import library1
Run Code Online (Sandbox Code Playgroud)

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

解决方案尝试

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

  1. 手动更新第三方软件包,使其import语句指向我的python包中的完全限定模块路径(例如import addon_name_1._vendor.dependency_of_library2).但这是繁琐的工作,不能扩展到更大的依赖树,也不能移植到其他包.
  2. 在我的包init文件中添加_vendor到sys.pathvia sys.path.insert(1, <path_to_vendor_dir>).这有效,但它引入了对模块查找路径的全局更改,这将影响其他加载项甚至基本应用程序本身.它看起来像是一个黑客,可能会导致后来的潘多拉盒子问题(例如同一个包的不同版本之间的冲突等).
  3. 暂时修改我的导入的sys.path ; 但这不适用于具有方法级导入的第三方模块.
  4. 根据我在setuptools中找到的一个例子编写一个PEP302风格的自定义导入器,但我无法做出头部或尾部.

我已经被困在这几个小时了,我开始认为我要么完全错过了一个简单的方法,要么我的整个方法存在根本性的错误.

我是否无法使用我的代码发送第三方软件包的依赖关系树,而无需诉诸sys.path黑客或修改有问题的软件包?


编辑:

只是为了澄清:我无法控制从anki_addons文件夹导入加载项的方式.anki_addons只是基本应用程序提供的目录,其中安装了所有加载项.它被添加到sys路径中,因此其中的附加软件包几乎就像Python的模块查找路径中的任何其他python包一样.

Mar*_*ers 6

首先,我建议反对出售; 一些主要的包装之前确实使用了售卖,但已经转移以避免必须处理售卖的痛苦.一个这样的例子是requests图书馆.如果您依赖于使用pip install安装程序包的人员,那么只需使用依赖项并告诉人们有关虚拟环境的信息.不要假设您需要承担保持依赖关系不变或者需要阻止人们在全局Python site-packages位置安装依赖项的负担.

同时,我理解第三方工具的插件环境是不同的,如果向该工具使用的Python安装添加依赖项是繁琐或不可能的,那么供应商可能是一个可行的选择.我看到Anki将扩展名分发为.zip没有setuptools支持的文件,所以这肯定是这样的环境.

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

这是pip项目选择的路径,查看其自动化的tasks子目录,该子目录构建在invoke库上.请参阅pip项目vendoring README了解它们的策略和基本原理(其中主要是pip需要自助引导,例如,它们的依赖项可以安装任何东西).

你不应该使用任何其他选择; 你已经列举了#2和#3的问题.

使用自定义导入程序的选项#4的问题是您仍然需要重写导入.换句话说,使用自定义进口商钩setuptools完全不解决vendorized命名空间的问题,而是它能够动态导入的顶级包,如果vendorized包丢失了(的问题pip与解决人工拆散过程).setuptools实际上使用选项#1,他们重写供应包的源代码.例如,在vendored子包中的packaging项目中查看这些行setuptools ; 该setuptools.extern命名空间由自定义进口钩,其然后重定向要么处理setuptools._vendor或顶级名称如果从vendorized包导入失败.

pip更新销售包的自动化采取以下步骤:

  • 删除所有的_vendor/子目录中的文件,除了在__init__.py文件和要求的文本文件.
  • 使用pip所有vendored依赖安装到该目录中,使用名为专用的需求文件vendor.txt,避免编译.pycbytecache文件和忽略短暂的依赖关系(这些假设在上市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)
Run Code Online (Sandbox Code Playgroud)