【问题标题】:Import vendored dependencies in Python package without modifying sys.path or 3rd party packages在不修改 sys.path 或 3rd 方包的情况下导入 Python 包中的供应商依赖项
【发布时间】:2019-03-03 10:38:13
【问题描述】:

总结

我正在为Anki(一个开源抽认卡程序)开发一系列插件。 Anki 附加组件以 Python 包的形式提供,基本文件夹结构如下:

anki_addons/
    addon_name_1/
        __init__.py
    addon_name_2/
        __init__.py

anki_addons 被基础应用附加到sys.path,然后使用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>)_vendor 添加到sys.path。这可行,但它引入了对模块查找路径的全局更改,这将影响其他附加组件甚至基础应用程序本身。这似乎是一种 hack,可能会导致稍后出现潘多拉魔盒的问题(例如,同一软件包的不同版本之间的冲突等)。
  3. Temporarily modifying sys.path for my imports;但这不适用于具有方法级导入的第三方模块。
  4. 根据我在 setuptools 中找到的示例编写 PEP302 风格的自定义导入器,但我就是无法理解。

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

我有没有办法在我的代码中提供第三方包的依赖树,而不必求助于sys.path hacks 或修改有问题的包?


编辑:

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

【问题讨论】:

    标签: python import python-import


    【解决方案1】:

    为了扩展来自Martijn Pieters 的出色回复,自pip 20.0 以来,pip 一直使用专用的 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-external-dependencies

    【讨论】:

      【解决方案2】:

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

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

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

      这是pip 项目选择的路径,请参阅他们的tasks subdirectory 了解他们的自动化,它建立在invoke library 之上。请参阅 pip 项目vendoring README 了解他们的政策和基本原理(其中最主要的是pip 需要引导 本身,例如让他们的依赖项能够安装任何东西)。

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

      选项 #4 的问题是,使用自定义导入器,您仍然需要重写导入。换句话说,setuptools 使用的自定义导入器钩子根本不能解决供应商命名空间问题,而是可以在供应商包丢失时动态导入顶级包(pip solves with a manual debundling process 的问题)。 setuptools 实际上使用选项#1,他们重写了供应商包的源代码。例如,参见 setuptools vendored 子包中的 these lines in the packaging projectsetuptools.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
      • 应用一些补丁来清除所需的剩余更改;从供应商的角度来看,这里只有 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'\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)
      

      【讨论】:

      • iter_subtreeos.walk(topdown=False) 不同吗?
      • @Ben: os.walk() 不给你pathlib 路径,只有字符串; iter_subtree() 仅适用于 pathlib.Path 实例。
      • 我明白了。所以你写它是为了避免从每个返回的字符串创建一个新的路径?谢谢解释
      • @Glutanimate:我担心您将不得不使用多个导入:from . import foofrom .foo import bar as _。第一个将foo 添加到命名空间,后者确保foo.bar 作为属性存在但将嵌套模块分配给名称_,普遍认为是此名称被忽略
      • @Glutanimate 第二个from .top import nested as _ 可能不需要如果顶级名称本身导入嵌套名称。 os.path 始终存在,因为 os 使用它,许多其他此类嵌套包也是如此。因此,进行试验并确定假设未来版本不会改变这种关系的风险与维持更复杂的替换规则的风险有多大。
      【解决方案3】:

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

      【讨论】:

      • 不,这不是 OP 要求的。他们不是在寻找 toplevel.packagename 设置,而是在询问如何最好地捆绑一系列依赖包,并且将它们安装在顶层的 site-packages 中。
      • @MartijnPieters 捆绑依赖的最佳方式是使用virtualenv
      • @EddyPronk:是的,这当然是我在回答中提倡的。但这与命名空间包有什么关系?
      【解决方案4】:

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

      我是新手,所以请耐心等待。

      【讨论】:

      • 感谢您的回复!真的很感激。澄清一下:我无法控制如何从anki_addons 文件夹导入附加组件。 anki_addons 只是基础应用程序提供的目录,所有附加组件都安装在该目录中。它被添加到 sys 路径中,因此其中的附加包几乎就像位于 Python 模块查找路径中的任何其他 python 包一样。
      • 但是顺着你的思路走:失败点总是出现在Python尝试通过无法解析的绝对导入来导入包的时候。例如:导入 library1 将失败,因为在模块查找路径中找不到它的依赖项 library2。由于 Python 3 不再支持隐式相对导入,即使将 library2 移动到 library1 的包文件夹中,如果不调整 library1 的源代码以进行显式相对导入,也将无法正常工作。
      • 更糟糕的是,很多包会使用自己的包名进行包内导入(例如import library1.module1 内的library1/__init__.py)。所以即使没有任何依赖,打包第三方包也可能会失败。
      • 感谢您的澄清。真的很感激。如果this 能有所帮助。
      • 根据我的经验,我们使用过 conda,有时甚至使用过 pipenv,但这些都是较小的项目。我不确定他们是否对你的情况有帮助,但只是把它放在那里。
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2022-10-15
      • 2019-08-28
      • 2011-10-08
      • 2020-02-24
      • 1970-01-01
      • 2021-09-21
      • 1970-01-01
      相关资源
      最近更新 更多