【避坑指北】Python3相对路径导入方法

前情

最近在优化原项目一部分Python代码,遇到了代码重复拷贝的问题,一个方法拷贝了n多份,这个“坏味道”当然忍不了,准备将方法写到utils.py里,由于Python3已经支持相对路径导入了,utils放到当前包的common目录,用到此方法的代码导入utils使用即可。so easy!

后来?后来我就掉进坑里。

我以为的相对路径导入并不是真实的相对路径导入。

Python导入包或方法

假设我们的工程项目是这样的:

1
2
3
4
5
6
7
8
.
├── a
│   └── callee.py
├── b
│   └── caller.py
├── c
│   └── hello.py
└── main.py

常规操作

hello.py中实现了一个打印“say hi~”方法hi()

1
2
3
# c/hello.py
def hi():
print("say hi~")

现在想要在main.py中调用,那我们只需要加入一行from c.hello import hi,然后直接调用hi()即可。

1
2
3
4
# main.py
from c.hello import hi

hi()

我们运行python3 main.py,正常输出“say hi~”。

python和Java一样都是用目录管理包的,运行时会从当前路径(main.py所在目录)开始查找匹配的包名对应的c/hello.py文件,然后找到其中名为hi的方法,并调用。

import默认搜索顺序

默认情况下,python的import关键字会选择优先查找python的内建模块,若没找到,则会去sys.path保存的路径列表中寻找。

sys.path保存的路径列表包括几个部分:

  1. 当前脚本所在目录
  2. 环境变量$PYTHONPATH设置的目录
  3. python标准库的目录
  4. 任何能够找到的.pth文件的内容
  5. 第三方扩展的site-package目录,也就是pip安装第三方包的路径

相对路径导入的那些坑

现在有一个需求就是b目录下的caller.py希望执行a目录callee.py中的方法caller_test()方法,这个方法可以对应出调用者的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# a/callee.py
import sys
import os


def caller_test():
"""打印调用者信息"""
back_frame = sys._getframe().f_back

if back_frame is None:
print("back_frame is None, no py caller!")
else:
back_filename = os.path.basename(back_frame.f_code.co_filename)
print("caller: {}".format(back_filename.split('.')[0]))

python3已经可以支持相对路径导入包了,简单写一下:

1
2
3
4
5
6
7
8
9
10
11
12
# b/caller.py
import sys

from ..a import callee

def call():
print('------ caller.py ------')
print("name: {}".format(__name__))
callee.caller_test()

if __name__ == '__main__':
call()

这里可以看到a包名前额外多了两个点..,按照python手册中关于相对导入的介绍:两个点..表示从当前目录的父目录开始查找a/callee.py文件,一个点.表示当前目录,那么如果我想找父目录的父目录中的包呢?那就用三个点...,通常用到三个点的情况并不多。

看上去毫无问题,正常极了,一运行就傻眼了。

错误1

执行./b/caller.py,提示错误:ImportError: attempted relative import with no known parent package

尝试在import前一行加入打印__name____package__sys.path,结果如下:

1
2
3
name: __main__
package: None
sys.path: ['/home/rfw/test/b', '/usr/lib/python38.zip', '/usr/lib/python3.8', '/usr/lib/python3.8/lib-dynload', '/home/rfw/.local/lib/python3.8/site-packages', '/usr/local/lib/python3.8/dist-packages', '/usr/lib/python3/dist-packages']

很奇怪,看到sys.path中当前路径是b目录所在路径,按照相对导入的逻辑,..a就应该进入了test/a目录才对!

错误2

StackOverflow上查了下,可以使用python -m b.caller以模块的方式运行,将包信息告诉python解释器。

尝试了下,这次错误提示变了,ValueError: attempted relative import beyond top-level package,提示是说相对导入找到的路径已经超过最顶级的了。

此时再次打印,错误日志如下:

1
2
3
name: __main__
package: b
sys.path: ['/home/rfw/test', '/usr/lib/python38.zip', '/usr/lib/python3.8', '/usr/lib/python3.8/lib-dynload', '/home/rfw/.local/lib/python3.8/site-packages', '/usr/local/lib/python3.8/dist-packages', '/usr/lib/python3/dist-packages']

这时,和上一次打印不一样的地方在与__package__的值为b,当前运行路径为test目录。

由于显示当前目录是test,因此,尝试把导入改成from a.callee import caller_test,运行正常!打印如下:

1
2
3
4
name: __main__
package: b
sys.path: ['/home/rfw/test', '/usr/lib/python38.zip', '/usr/lib/python3.8', '/usr/lib/python3.8/lib-dynload', '/home/rfw/.local/lib/python3.8/site-packages', '/usr/local/lib/python3.8/dist-packages', '/usr/lib/python3/dist-packages']
caller: caller

但是这就不是相对导入了啊。百思不得其解。

真相只有一个

查了下python官方文档关于相对导入的说明(https://www.python.org/dev/peps/pep-0328/ ),恍然大明白。

Relative imports use a module’s __name__ attribute to determine that module’s position in the package hierarchy. If the module’s name does not contain any package information (e.g. it is set to ‘__main__‘) then relative imports are resolved as if the module were a top level module, regardless of where the module is actually located on the file system.

翻译过来就是:

  1. 相对导入依赖于一个模块的__name__属性,根据这个属性去决定该模块在整个包中的层级结构。

  2. 当一个模块的__name__属性不包含任何包信息时,如直接运行py脚本时,__name__会被设置成__main__,这时,不管这个文件位于包目录的哪个位置,相对导入机制会把当前脚本视为顶级模块。

这就意味着,只要是我从终端运行python脚本,都会遇到__name____main__的问题,当前被运行的python脚本永远无法使用相对导入

现在在根目录下修改main.py,并在b/b1目录下创建caller_proxy.py

1
2
3
4
5
6
7
8
9
10
11
.
├── a
│ └── callee.py
├── b
│ ├── b1
│ │ └── caller_proxy.py
│ └── caller.py
├── c
│ └── hello.py
└── main.py

main.py的内容如下:

1
2
3
4
5
6
7
8
9
import sys

from c.hello import hi

print("__name__: {}, __package__: {}".format(__name__, __package__))

from b.b1 import caller_proxy

caller_proxy.proxy()

caller_proxy.py的内容如下:

1
2
3
4
5
6
7
8
9
import sys
from .. import caller # 相对导入

print(__package__)

def proxy():
print("------ caller_proxy.py ------")
print("name: {}".format(__name__))
caller.call()

该文件使用了相对导入,现在运行./main.py,结果如下。

1
2
3
4
5
6
7
__name__: __main__, __package__: None
say hi~
------ caller_proxy.py ------
name: b.b1.caller_proxy
------ caller.py ------
name: b.caller
caller: caller

这时,caller_proxy.py执行时的__name__值是正常的包名结构b.b1.caller_proxy,因此可以使用相对导入..找到b.caller

caller.py执行时的包名结构是b.caller,因此,相对导入只能找到b包下的文件,所以,只能使用from a.callee import caller_test

通常应该怎么做

为了避免一些奇奇怪怪的问题,还是比较推荐在sys.path数组追加要导入包绝对路径的方式。

1
2
3
4
5
import os
import inspect
sys.path.append(os.path.realpath(os.path.join(os.path.dirname(inspect.getfile(inspect.currentframe())), '../common')))

from utils import xxx_func

以之前的caller.py为例,想要调用a/callee.py,可以写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import sys
import os

import inspect
sys.path.append(os.path.realpath(os.path.join(os.path.dirname(inspect.getfile(inspect.currentframe())), '../a')))

from callee import caller_test

def call():
print('------ caller.py ------')
print("name: {}".format(__name__))

caller_test()


if __name__ == '__main__':
call()

这样就不用care是直接运行,还是用-m参数以模块去运行了,直接运行./b/caller.py,输出结果如下:

1
2
3
4
$ ./b/caller.py
------ caller.py ------
name: __main__
caller: caller

以上,就是之前处理Python import导入包时遇到的坑,简单记录。

小黑杂说


【避坑指北】Python3相对路径导入方法
https://wuruofan.com/2021/08/27/about-python3-relative-import-errors-i-met/
作者
rf.w
发布于
2021年8月27日
许可协议