【避坑指北】Python3相对路径导入方法
前情
最近在优化原项目一部分Python代码,遇到了代码重复拷贝的问题,一个方法拷贝了n多份,这个“坏味道”当然忍不了,准备将方法写到utils.py里,由于Python3已经支持相对路径导入了,utils放到当前包的common目录,用到此方法的代码导入utils使用即可。so easy!
后来?后来我就掉进坑里。
我以为的相对路径导入并不是真实的相对路径导入。
Python导入包或方法
假设我们的工程项目是这样的:
1 |
|
常规操作
hello.py
中实现了一个打印“say hi~”方法hi()
:
1 |
|
现在想要在main.py
中调用,那我们只需要加入一行from c.hello import hi
,然后直接调用hi()
即可。
1 |
|
我们运行python3 main.py
,正常输出“say hi~”。
python和Java一样都是用目录管理包的,运行时会从当前路径(main.py
所在目录)开始查找匹配的包名对应的c/hello.py
文件,然后找到其中名为hi
的方法,并调用。
import默认搜索顺序
默认情况下,python的import
关键字会选择优先查找python的内建模块,若没找到,则会去sys.path
保存的路径列表中寻找。
sys.path
保存的路径列表包括几个部分:
- 当前脚本所在目录
- 环境变量
$PYTHONPATH
设置的目录 - python标准库的目录
- 任何能够找到的.pth文件的内容
- 第三方扩展的site-package目录,也就是pip安装第三方包的路径
相对路径导入的那些坑
现在有一个需求就是b
目录下的caller.py
希望执行a
目录callee.py
中的方法caller_test()
方法,这个方法可以对应出调用者的信息。
1 |
|
python3已经可以支持相对路径导入包了,简单写一下:
1 |
|
这里可以看到a
包名前额外多了两个点..
,按照python手册中关于相对导入的介绍:两个点..
表示从当前目录的父目录开始查找a/callee.py
文件,一个点.
表示当前目录,那么如果我想找父目录的父目录中的包呢?那就用三个点...
,通常用到三个点的情况并不多。
看上去毫无问题,正常极了,一运行就傻眼了。
错误1
执行./b/caller.py
,提示错误:ImportError: attempted relative import with no known parent package
。
尝试在import前一行加入打印__name__
、__package__
、sys.path
,结果如下:
1 |
|
很奇怪,看到sys.path
中当前路径是b目录所在路径,按照相对导入的逻辑,..a
就应该进入了test/a
目录才对!
错误2
StackOverflow上查了下,可以使用python -m b.caller
以模块的方式运行,将包信息告诉python解释器。
尝试了下,这次错误提示变了,ValueError: attempted relative import beyond top-level package
,提示是说相对导入找到的路径已经超过最顶级的了。
此时再次打印,错误日志如下:
1 |
|
这时,和上一次打印不一样的地方在与__package__
的值为b
,当前运行路径为test
目录。
由于显示当前目录是test
,因此,尝试把导入改成from a.callee import caller_test
,运行正常!打印如下:
1 |
|
但是这就不是相对导入了啊。百思不得其解。
真相只有一个
查了下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.
翻译过来就是:
相对导入依赖于一个模块的
__name__
属性,根据这个属性去决定该模块在整个包中的层级结构。当一个模块的
__name__
属性不包含任何包信息时,如直接运行py脚本时,__name__
会被设置成__main__
,这时,不管这个文件位于包目录的哪个位置,相对导入机制会把当前脚本视为顶级模块。
这就意味着,只要是我从终端运行python脚本,都会遇到__name__
为__main__
的问题,当前被运行的python脚本永远无法使用相对导入。
现在在根目录下修改main.py
,并在b/b1
目录下创建caller_proxy.py
。
1 |
|
main.py
的内容如下:
1 |
|
caller_proxy.py
的内容如下:
1 |
|
该文件使用了相对导入,现在运行./main.py
,结果如下。
1 |
|
这时,caller_proxy.py
执行时的__name__
值是正常的包名结构b.b1.caller_proxy
,因此可以使用相对导入..
找到b.caller
。
而caller.py
执行时的包名结构是b.caller
,因此,相对导入只能找到b
包下的文件,所以,只能使用from a.callee import caller_test
。
通常应该怎么做
为了避免一些奇奇怪怪的问题,还是比较推荐在sys.path
数组追加要导入包绝对路径的方式。
1 |
|
以之前的caller.py
为例,想要调用a/callee.py
,可以写成:
1 |
|
这样就不用care是直接运行,还是用-m
参数以模块去运行了,直接运行./b/caller.py
,输出结果如下:
1 |
|
以上,就是之前处理Python import导入包时遇到的坑,简单记录。