Python 3中的相对导入

34 浏览
0 Comments

Python 3中的相对导入

我想从同一目录的另一个文件中导入一个函数。

通常,以下之一可以起作用:

from .mymodule import myfunction

from mymodule import myfunction

...但另一个会给我一个这样的错误:

ImportError: attempted relative import with no known parent package

ModuleNotFoundError: No module named 'mymodule'

SystemError: Parent module '' not loaded, cannot perform relative import

为什么会这样呢?

admin 更改状态以发布 2023年5月21日
0
0 Comments

说明

来自PEP 328

相对导入使用模块的 __name__ 属性来确定该模块在包层次结构中的位置。如果模块的名称不包含任何包信息(例如设置为 '__main__'),那么相对导入将解析为顶层模块,而不管该模块实际上位于文件系统的何处

某个时刻,PEP 338PEP 328相冲突:

...... 相对导入依赖于 __name__ 来确定当前模块在包层次结构中的位置。在主模块中,__name__ 的值总是 '__main__',所以显式相对导入将总是失败(因为它们只适用于包内的模块)

针对这个问题,PEP 366引入了顶层变量__package__

该 PEP 通过添加一个新的模块级别属性,允许在使用 -m 开关执行模块时自动工作。在模块本身中添加少量样板文件即可让相对导入在文件按名称执行时正常工作。[...] 当它 [属性] 存在时,相对导入将基于此属性而不是模块 __name__ 属性。[...]
当通过文件名指定主模块时,__package__ 属性将设置为 None。[...] 当导入系统在模块中遇到显式相对导入而 __package__ 未设置(或设置为 None)时,它将计算并存储正确的值对于正常模块而言,为 __name__.rpartition('.')[0],对于包初始化模块而言,则为 __name__)。

(这是我的强调)

如果__name__'__main__'__name__.rpartition('.')[0]返回空字符串。这就是为什么错误描述中有空字符串字面量的原因:

SystemError: Parent module '' not loaded, cannot perform relative import

CPython的PyImport_ImportModuleLevelObject函数的相关部分:

if (PyDict_GetItem(interp->modules, package) == NULL) {
    PyErr_Format(PyExc_SystemError,
            "Parent module %R not loaded, cannot perform relative "
            "import", package);
    goto error;
}

如果在interp->modules(可以通过sys.modules访问)中未能找到package(包的名称),CPython就会引发此异常。由于sys.modules是“将模块名称映射到已加载的模块的字典”,因此现在清楚了,在执行相对导入之前必须显式绝对导入父模块。

注意:来自问题18018的修补程序已经添加了另一个if,在上面的代码之前将执行:

if (PyUnicode_CompareWithASCIIString(package, "") == 0) {
    PyErr_SetString(PyExc_ImportError,
            "attempted relative import with no known parent package");
    goto error;
} /* else if (PyDict_GetItem(interp->modules, package) == NULL) {
    ...
*/

如果package(与上面相同)是空字符串,则错误消息将是

ImportError: attempted relative import with no known parent package

但是,这只会在Python 3.6或更新版本中看到。

解决方案#1:使用-m运行脚本

考虑一个目录(这是一个Python):

.
├── package
│   ├── __init__.py
│   ├── module.py
│   └── standalone.py

这个package中的所有文件都以相同的两行代码开头:

from pathlib import Path
print('Running' if __name__ == '__main__' else 'Importing', Path(__file__).resolve())

我只包含这两行代码是为了使操作顺序明显。我们完全可以忽略它们,因为它们不会影响执行。

__init__.py和module.py只包含这两行代码(即它们实际上是空的)。

standalone.py还尝试通过相对导入导入module.py:

from . import module  # explicit relative import

我们很清楚,/path/to/python/interpreter package/standalone.py会失败。然而,我们可以使用-m命令行选项运行模块,该选项会“搜索sys.path中的指定模块,将其内容作为__main__模块执行”:

vaultah@base:~$ python3 -i -m package.standalone
Importing /home/vaultah/package/__init__.py
Running /home/vaultah/package/standalone.py
Importing /home/vaultah/package/module.py
>>> __file__
'/home/vaultah/package/standalone.py'
>>> __package__
'package'
>>> # The __package__ has been correctly set and module.py has been imported.
... # What's inside sys.modules?
... import sys
>>> sys.modules['__main__']

>>> sys.modules['package.module']

>>> sys.modules['package']

-m会为您完成所有导入工作,并自动设置__package__,但您也可以在

解决方案#2:手动设置__package__

请把它视为一个概念验证,而不是一个实际的解决方案。它不适用于在实际代码中使用。

PEP 366有一个解决此问题的解决方案,但它并不完整,因为仅设置__package__是不够的。你需要导入至少N个前导程序目录中的包,在模块层次结构中,N是用于搜索导入的模块的父目录数量(相对于脚本所在目录)。

因此,

  1. 将当前模块的第N个前置目录添加到sys.path中的父目录中

  2. sys.path中删除当前文件的目录

  3. 使用其完全限定名称导入当前模块的父模块

  4. __package__设置为第2步中的完全限定名称

  5. 执行相对导入

我将从解决方案#1中借用文件并添加一些更多子包:

package
├── __init__.py
├── module.py
└── subpackage
    ├── __init__.py
    └── subsubpackage
        ├── __init__.py
        └── standalone.py

这次standalone.py将使用以下相对导入从package包导入module.py:

from ... import module  # N = 3

我们需要在该行之前加上样板代码,使其工作。

import sys
from pathlib import Path
if __name__ == '__main__' and __package__ is None:
    file = Path(__file__).resolve()
    parent, top = file.parent, file.parents[3]
    sys.path.append(str(top))
    try:
        sys.path.remove(str(parent))
    except ValueError: # Already removed
        pass
    import package.subpackage.subsubpackage
    __package__ = 'package.subpackage.subsubpackage'
from ... import module # N = 3

它允许我们通过文件名执行standalone.py:

vaultah@base:~$ python3 package/subpackage/subsubpackage/standalone.py
Running /home/vaultah/package/subpackage/subsubpackage/standalone.py
Importing /home/vaultah/package/__init__.py
Importing /home/vaultah/package/subpackage/__init__.py
Importing /home/vaultah/package/subpackage/subsubpackage/__init__.py
Importing /home/vaultah/package/module.py

可以在此处找到一个通用的函数包装的解决方案。使用示例:

if __name__ == '__main__' and __package__ is None:
    import_parents(level=3) # N = 3
from ... import module
from ...module.submodule import thing

解决方案#3:使用绝对导入和setuptools

步骤如下:

  1. 将显式的相对导入替换为等效的绝对导入

  2. 安装package使其可以导入

例如,目录结构可能如下所示:

.
├── project
│   ├── package
│   │   ├── __init__.py
│   │   ├── module.py
│   │   └── standalone.py
│   └── setup.py

其中setup.py是:

from setuptools import setup, find_packages
setup(
    name = 'your_package_name',
    packages = find_packages(),
)

其余的文件从解决方案#1借用。

安装将允许您导入包,无论您的工作目录如何(假设不会有命名问题)。

我们可以修改standalone.py以利用这个优势(步骤1):

from package import module  # absolute import

将你的工作目录更改为 project 并运行 /path/to/python/interpreter setup.py install --user--user 将包安装到 你的site-packages目录)(步骤2):

vaultah@base:~$ cd project
vaultah@base:~/project$ python3 setup.py install --user

让我们验证现在是否可以将 standalone.py 作为脚本运行:

vaultah@base:~/project$ python3 -i package/standalone.py
Running /home/vaultah/project/package/standalone.py
Importing /home/vaultah/.local/lib/python3.6/site-packages/your_package_name-0.0.0-py3.6.egg/package/__init__.py
Importing /home/vaultah/.local/lib/python3.6/site-packages/your_package_name-0.0.0-py3.6.egg/package/module.py
>>> module

>>> import sys
>>> sys.modules['package']

>>> sys.modules['package.module']

注意:如果你决定使用这个方法,最好使用虚拟环境以隔离地安装包。

解决方案#4:使用绝对导入和一些样板代码

老实说,此安装不是必要的-你可以向你的脚本添加一些样板代码,以使绝对导入起作用。

我将从解决方案#1中借用文件并更改 standalone.py:

  1. 在尝试使用绝对导入从包中导入任何东西之前,将父目录添加到sys.path中:

    import sys
    from pathlib import Path # if you haven't already done so
    file = Path(__file__).resolve()
    parent, root = file.parent, file.parents[1]
    sys.path.append(str(root))
    # Additionally remove the current file's directory from sys.path
    try:
        sys.path.remove(str(parent))
    except ValueError: # Already removed
        pass
    

  2. 用绝对导入替换相对导入:

    from package import module  # absolute import
    

standalone.py可以正常运行:

vaultah@base:~$ python3 -i package/standalone.py
Running /home/vaultah/package/standalone.py
Importing /home/vaultah/package/__init__.py
Importing /home/vaultah/package/module.py
>>> module

>>> import sys
>>> sys.modules['package']

>>> sys.modules['package.module']

我认为我应该警告你:尤其是如果你的项目结构复杂,尽量不要这样做。


顺便提一句,PEP 8推荐使用绝对导入,但指出某些情况下明确的相对导入是可以接受的:

绝对导入被推荐使用,因为它们通常更易读且更好行为(或至少给出更好的错误消息)。[...] 然而,显式相对导入是与绝对导入可以接受的替代方案,特别是在处理复杂的包布局时,使用绝对导入会过于冗长。

0
0 Comments

不幸的是,这个模块需要在包内部,有时它也需要作为一个脚本运行。你有任何办法可以实现吗?

有如下这种布局是相当常见的...

main.py
mypackage/
    __init__.py
    mymodule.py
    myothermodule.py

...有一个像这样的 mymodule.py...

#!/usr/bin/env python3
# Exported function
def as_int(a):
    return int(a)
# Test function for module  
def _test():
    assert as_int('1') == 1
if __name__ == '__main__':
    _test()

...一个像这样的 myothermodule.py...

#!/usr/bin/env python3
from .mymodule import as_int
# Exported function
def add(a, b):
    return as_int(a) + as_int(b)
# Test function for module  
def _test():
    assert add('1', '1') == 2
if __name__ == '__main__':
    _test()

...以及像这样的 main.py...

#!/usr/bin/env python3
from mypackage.myothermodule import add
def main():
    print(add('1', '1'))
if __name__ == '__main__':
    main()

...它可以在你运行 main.pymypackage/mymodule.py 时正常工作,但在 mypackage/myothermodule.py 中由于相对导入而失败...

from .mymodule import as_int

你应该运行它的方式是...

python3 -m mypackage.myothermodule

...但这有点冗长,并且与类似于 #!/usr/bin/env python3 的 shebang 行不太搭配。

对于这种情况最简单的解决方案是,假设名称 mymodule 是全局唯一的,就避免使用相对导入,而直接使用...

from mymodule import as_int

...虽然,如果它不唯一,或你的包结构更加复杂,你将需要将包含你的包目录的目录包含在 PYTHONPATH 中,并像这样使用它...

from mypackage.mymodule import as_int

...或者如果你想让它 “开箱即用”,你可以先在代码中调整一下 PYTHONPATH,如下...

import sys
import os
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.append(os.path.dirname(SCRIPT_DIR))
from mypackage.mymodule import as_int

这有点麻烦,但在 Guido van Rossum 写的一封邮件中有一些提示,为什么会这样...

我对这个提议和任何其他关于__main__机制的改动都持反对态度。唯一的用例似乎是运行恰好存储在模块目录中的脚本,而我一直认为这是一种反模式。要让我改变主意,你必须说服我它不是反模式。

无论在包内运行脚本是否是一种反模式都是主观的,但个人觉得它在我拥有的一个包中非常有用,该包包含一些自定义的wxPython部件,因此我可以运行任何源文件的脚本,以显示仅包含该部件的wx.Frame,以进行测试目的。

0