Python 3.5+:如何在给定完整文件路径的情况下动态导入模块(存在隐式兄弟导入)?

Python 3.5+: How to dynamically import a module given the full file path (in the presence of implicit sibling imports)?

问题

标准库清楚地记录了 how to import source files directly(给定源文件的绝对文件路径),但如果该源文件使用隐式兄弟导入,则此方法不起作用,如下例所述。

该示例如何适用于存在隐式同级导入的情况?

我已经检查了 this and this other 关于该主题的 Whosebug 问题,但它们没有解决 手动导入的文件中的隐式兄弟导入。

Setup/Example

这是一个说明性的例子

目录结构:

root/
  - directory/
    - app.py
  - folder/
    - implicit_sibling_import.py
    - lib.py

app.py:

import os
import importlib.util

# construct absolute paths
root = os.path.abspath(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
isi_path = os.path.join(root, 'folder', 'implicit_sibling_import.py')

def path_import(absolute_path):
   '''implementation taken from https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly'''
   spec = importlib.util.spec_from_file_location(absolute_path, absolute_path)
   module = importlib.util.module_from_spec(spec)
   spec.loader.exec_module(module)
   return module

isi = path_import(isi_path)
print(isi.hello_wrapper())

lib.py:

def hello():
    return 'world'

implicit_sibling_import.py:

import lib # this is the implicit sibling import. grabs root/folder/lib.py

def hello_wrapper():
    return "ISI says: " + lib.hello()

#if __name__ == '__main__':
#    print(hello_wrapper())

运行 python folder/implicit_sibling_import.py 注释掉 if __name__ == '__main__': 块在 Python 3.6.

中产生 ISI says: world

但是 运行ning python directory/app.py 产量:

Traceback (most recent call last):
  File "directory/app.py", line 10, in <module>
    spec.loader.exec_module(module)
  File "<frozen importlib._bootstrap_external>", line 678, in exec_module
  File "<frozen importlib._bootstrap>", line 205, in _call_with_frames_removed
  File "/Users/pedro/test/folder/implicit_sibling_import.py", line 1, in <module>
    import lib
ModuleNotFoundError: No module named 'lib'

解决方法

如果我将 import sys; sys.path.insert(0, os.path.dirname(isi_path)) 添加到 app.pypython app.py 会按预期产生 world,但我想尽可能避免修改 sys.path

回答要求

我想 python app.py 打印 ISI says: world 并且我想通过修改 path_import 函数来实现。

我不确定 mangling 的含义 sys.path。例如。如果有 directory/requests.py 并且我将 directory 的路径添加到 sys.path,我不希望 import requests 开始导入 directory/requests.py 而不是导入 requests library 我用 pip install requests.

安装

解决方案必须实现为一个python函数,该函数接受所需模块的绝对文件路径和returns module object.

理想情况下,解决方案不应引入副作用(例如,如果它确实修改了 sys.path,它应该 return sys.path 到其原始状态)。如果解决方案确实引入了副作用,它应该解释为什么在不引入副作用的情况下无法实现解决方案。


PYTHONPATH

如果我有多个项目这样做,我不想每次在它们之间切换时都必须记住设置 PYTHONPATH。用户应该能够 pip install 我的项目和 运行 它而无需任何额外设置。

-m

-m flag is the recommended/pythonic approach, but the standard library also clearly documents How to import source files directly。我想知道如何调整这种方法来处理隐式相对导入。显然,Python 的内部结构必须这样做,那么内部结构与 "import source files directly" 文档有何不同?

将应用程序所在的路径添加到 PYTHONPATH 环境变量

Augment the default search path for module files. The format is the same as the shell’s PATH: one or more directory pathnames separated by os.pathsep (e.g. colons on Unix or semicolons on Windows). Non-existent directories are silently ignored.

在 bash 上是这样的:

export PYTHONPATH="./folder/:${PYTHONPATH}"

或直接运行:

PYTHONPATH="./folder/:${PYTHONPATH}" python directory/app.py
  1. 确保您的根位于在 PYTHONPATH

    中明确搜索的文件夹中
  2. 使用绝对导入:

    from root.folder import implicit_sibling_import # called from app.py

我能想到的最简单的解决方案是在执行导入的函数中临时修改 sys.path

from contextlib import contextmanager

@contextmanager
def add_to_path(p):
    import sys
    old_path = sys.path
    sys.path = sys.path[:]
    sys.path.insert(0, p)
    try:
        yield
    finally:
        sys.path = old_path

def path_import(absolute_path):
   '''implementation taken from https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly'''
   with add_to_path(os.path.dirname(absolute_path)):
       spec = importlib.util.spec_from_file_location(absolute_path, absolute_path)
       module = importlib.util.module_from_spec(spec)
       spec.loader.exec_module(module)
       return module

这应该不会引起任何问题,除非您同时在另一个线程中进行导入。否则,由于 sys.path 已恢复到之前的状态,因此应该不会有任何副作用。

编辑:

我意识到我的回答有些不尽如人意,但是,深入研究代码后发现,行 spec.loader.exec_module(module) 基本上导致 exec(spec.loader.get_code(module.__name__),module.__dict__) 被调用。这里 spec.loader.get_code(module.__name__) 只是 lib.py 中包含的代码。

因此,要更好地回答这个问题,必须找到一种方法,通过简单地通过 exec-statement 的第二个参数注入一个或多个全局变量,使 import 语句的行为有所不同。但是,"whatever you do to make the import machinery look in that file's folder, it'll have to linger beyond the duration of the initial import, since functions from that file might perform further imports when you call them",正如@user2357112 在问题评论中所述。

不幸的是,更改 import 语句行为的唯一方法似乎是更改 sys.path 或在包 __path__ 中更改。 module.__dict__ 已经包含 __path__ 所以这似乎不起作用 sys.path (或者试图弄清楚为什么 exec 不将代码视为一个包,即使它有 __path____package__ ... - 但我不知道从哪里开始 - 也许这与没有 __init__.py 文件有关)。

此外,这个问题似乎并不是 importlib 特有的,而是 sibling imports 的普遍问题。

Edit2: 如果您不希望模块以 sys.modules 结尾,则以下内容应该有效(请注意添加到 sys.modules 的任何模块在导入过程中被 删除):

from contextlib import contextmanager

@contextmanager
def add_to_path(p):
    import sys
    old_path = sys.path
    old_modules = sys.modules
    sys.modules = old_modules.copy()
    sys.path = sys.path[:]
    sys.path.insert(0, p)
    try:
        yield
    finally:
        sys.path = old_path
        sys.modules = old_modules

OP 的想法很棒,通过向 sys.modules 添加具有适当名称的兄弟模块,这仅适用于此示例,我想说这与添加 PYTHONPATH 是一样的。测试并使用版本 3.5.1.

import os
import sys
import importlib.util


class PathImport(object):

    def get_module_name(self, absolute_path):
        module_name = os.path.basename(absolute_path)
        module_name = module_name.replace('.py', '')
        return module_name

    def add_sibling_modules(self, sibling_dirname):
        for current, subdir, files in os.walk(sibling_dirname):
            for file_py in files:
                if not file_py.endswith('.py'):
                    continue
                if file_py == '__init__.py':
                    continue
                python_file = os.path.join(current, file_py)
                (module, spec) = self.path_import(python_file)
                sys.modules[spec.name] = module

    def path_import(self, absolute_path):
        module_name = self.get_module_name(absolute_path)
        spec = importlib.util.spec_from_file_location(module_name, absolute_path)
        module = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(module)
        return (module, spec)

def main():
    pathImport = PathImport()
    root = os.path.abspath(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
    isi_path = os.path.join(root, 'folder', 'implicit_sibling_import.py')
    sibling_dirname = os.path.dirname(isi_path)
    pathImport.add_sibling_modules(sibling_dirname)
    (lib, spec) = pathImport.path_import(isi_path)
    print (lib.hello())

if __name__ == '__main__':
    main()

尝试:

export PYTHONPATH="./folder/:${PYTHONPATH}"

或直接运行:

PYTHONPATH="./folder/:${PYTHONPATH}" python directory/app.py

确保您的根位于 PYTHONPATH 中明确搜索的文件夹中。使用绝对导入:

from root.folder import implicit_sibling_import #called from app.py