Python 3.5+:如何在存在隐式同级导入的情况下,根据完整文件路径动态导入模块?

42

问题

标准库清楚地记录了如何直接导入源文件(给定源文件的绝对路径),但是如果该源文件使用如下所述的隐式兄弟导入,则此方法将无法正常工作。

如何才能使该示例适应隐式兄弟导入的情况?

我已经查看了此处此其他关于这个主题的Stackoverflow问题,但它们没有解决手动导入文件中存在的隐式兄弟导入

设置/示例

以下是说明性示例

目录结构:

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

但是运行 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'

解决方法

如果我在app.py中添加import sys; sys.path.insert(0, os.path.dirname(isi_path)),那么python app.py会像预期的一样输出world,但是如果可能的话,我希望避免修改sys.path

答案要求

我想让python app.py打印出ISI says: world,并且我希望通过修改path_import函数来实现这一点。

我不确定改变sys.path的影响。例如,如果有一个directory/requests.py,我将directory路径添加到sys.path中,我不希望import requests开始导入directory/requests.py而不是导入我使用pip install requests安装的requests库

该解决方案必须实现为一个Python函数,接受所需模块的绝对文件路径,并返回模块对象

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


PYTHONPATH

如果我有多个相关项目,我不想每次切换项目时都要记得设置PYTHONPATH。用户只需使用pip install安装我的项目,并在没有任何额外设置的情况下运行它。

-m

-m标志是推荐的/Pythonic方法,但标准库也明确记录了如何直接导入源文件。我想知道如何调整该方法以处理隐式相对导入。显然,Python的内部必须这样做,那么内部与“直接导入源文件”文档的区别在哪里?


就Python而言,这种“隐式兄弟导入”是普通的绝对导入,绝不是隐式相对导入。在Python 3中不再支持隐式相对导入。 - user2357112
1
修改 sys.path 可能是最好的选择。无论你做什么让导入机制查找该文件夹,它都必须在初始导入的持续时间之外存在,因为当你调用来自该文件的函数时,它们可能执行进一步的导入。 - user2357112
@user2357112,确实PEP 8指出在Python 3中禁用了相对隐式导入。但我想知道:如果上面的示例不是相对隐式导入,那么什么是相对隐式导入?你有例子吗? - Pedro Cattori
@user2357112,“import lib”这行代码没有指定任何包名称,也没有使用“.”。那么,唯一使它不成为隐式相对导入的是它不是在一个包内进行的这个事实吗? - Pedro Cattori
这个问题基本上是在问:“如果不使用我已经拥有的工具,我该如何完成某件事情。”像其他人一样,我会修改sys.path。 - Darrick Herwehe
1
@DarrickHerwehe 如问题所述,只要您能够证明其合理性,修改 sys.path 是可以接受的。在这种情况下,只需要“解释为什么篡改 sys.path 是最佳选择”即可。 - Pedro Cattori
5个回答

25

我能想到的最简单的解决方案是在执行导入操作的函数中暂时修改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 语句的第二个参数直接注入一个或多个全局变量来使 import 语句的行为不同。然而,“无论你做什么来让导入机制查找那个文件夹,它都必须持续超出初始导入的持续时间,因为从那个文件中获取函数时可能会执行进一步的导入”,如在问题评论中所述,@user2357112。

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

此外,这个问题似乎不是特定于 importlib 的,而是关于同级导入的一个普遍问题。

编辑2:如果您不想让模块最终出现在 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

我认为在运行此过程时,sys.modules也会受到影响...但不确定如何避免这种副作用。 - Har
这似乎是真的 - 但我不明白为什么这会是不可取的/有问题?- 模块毕竟已经被加载了。 - Jonathan von Schroeder
也许他们可以通过提供一个同名模块来替换当前命名空间中的模块。 - Har
1
除非你像这样做m = path_import(...),否则它不会修改当前命名空间,然后我相信它的行为类似于import m,这似乎是合理的。sys.modules的工作方式是未来导入的查找表https://docs.python.org/3/reference/import.html#the-module-cache-无论如何,您应该能够用副本替换`sys.modules`,然后恢复它(就像我对`sys.path`所做的那样)。 - Jonathan von Schroeder
是的,没错。谢谢你的建议,非常有帮助。 - Har
我可能错了,但是如果在导入'folder'中的一个lib.py之前已经存在'directory'中的一个lib.py(就像使用普通相对模块路径导入另一个目录中的函数时通常会做的那样),它不仍然会导入'directory'中的那个吗? 如果是这样,那将是一团糟。 - JeopardyTempest

6
将你的应用程序路径添加到PYTHONPATH环境变量中。

增加模块文件的默认搜索路径。格式与 shell 的 PATH 相同:一个或多个目录路径名,由 os.pathsep 分隔(在 Unix 上是冒号,在 Windows 上是分号)。不存在的目录将被默默忽略。

在 bash 中,可以这样实现:

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

或直接运行:

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

我已经更新了我的答案,提供了更精确的解决方案要求。不幸的是,由于我需要一个纯Python的解决方案,这个方法行不通。 - Pedro Cattori
请问您原始的需求是什么?特别是哪个方面让你只能用纯Python解决它? - lucid_dreamer

1
  1. 确保你的根目录在一个明确被搜索的文件夹中 PYTHONPATH

  2. 使用绝对导入:

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


正如问题所述,我正在寻找一种可重复使用(无需每次重新配置),纯Python解决方案,因此这种方法行不通。 - Pedro Cattori
@Pedro,重复配置不是比你提出的解决方案更好吗(显然看起来非常不符合Python风格)? - lucid_dreamer

1

楼主的想法很好,只需添加适当命名的兄弟模块到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()

1

尝试:

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

或者直接运行:
PYTHONPATH="./folder/:${PYTHONPATH}" python directory/app.py

请确保您的根目录在一个被明确搜索的文件夹中,PYTHONPATH 中。使用绝对导入:

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

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接