如果某个项目的文件结构如上,想要在f1.py
中导入pkg
包的时候,可能会这样写:
from .... import pkg
但是很遗憾,这样会引发ImportError
异常。
直接运行f1.py
时,异常信息是ImportError: attempted relative import with no known parent package
。如果在f2.py
中导入f1.py
(import d1.d2.d3.f1
)再运行f2.py
,异常信息是ImportError: attempted relative import beyond top-level package
。
下面通过Python字节码和CPython源码(3.10版本)浅析一下Python的相对导入过程,找出这些异常出现的原因,并寻求解决方法。
字节码
在code.py
中写入以下代码:
from dis import dis
origin_code = 'from .... import a, b'
code_obj = compile(origin_code, 'non-file', 'exec')
print('consts:', code_obj.co_consts)
print('names:', code_obj.co_names)
dis(code_obj)
运行结果:
consts: (4, ('a', 'b'), None)
names: ('', 'a', 'b')
1 0 LOAD_CONST 0 (4)
2 LOAD_CONST 1 (('a', 'b'))
4 IMPORT_NAME 0
6 IMPORT_FROM 1 (a)
8 STORE_NAME 1 (a)
10 IMPORT_FROM 2 (b)
12 STORE_NAME 2 (b)
14 POP_TOP
16 LOAD_CONST 2 (None)
18 RETURN_VALUE
这段字节码的含义如下:
LOAD_CONST 0
:从co_consts
里取出下标为0
的元素并放入栈顶。这里这个元素是4
。LOAD_CONST 1
:同上,这里这个元素是元组('a', 'b')
,代表后面要import进来两个子包a和b。(注意,就算只import进来一个子包,这个元素依然会是一个元组,只是元组的元素个数为1。)IMPORT_NAME 0
:从co_names
里取出下标为0
的元素并放入栈顶。这里这个元素是空字符串,因为代码中from
后面没有指定包名。在执行IMPORT_NAME
字节码时,会弹出当前栈顶元素(即元组('a', 'b')
),然后把搜索到的包放到栈顶。- 6-14就是搜索子包并把它们和相应的名字绑定在一起,然后从栈顶弹出该包。
- 16-18是没有指定返回值的语句常规返回
None
。
关键是IMPORT_NAME
的执行过程。下面结合源码分析。
IMPORT_NAME
该字节码的源码如下:
case TARGET(IMPORT_NAME): {
PyObject *name = GETITEM(names, oparg);
PyObject *fromlist = POP();
PyObject *level = TOP();
PyObject *res;
res = import_name(tstate, f, name, fromlist, level);
Py_DECREF(level);
Py_DECREF(fromlist);
SET_TOP(res);
if (res == NULL)
goto error;
DISPATCH();
}
关键语句含义如下:
PyObject *name = GETITEM(names, oparg);
:得到要导入的包的名字,其中names
就对应于Python层面的co_names
,oparg
是字节码IMPORT_NAME
的操作数,也就是0
。对照co_names
可知这里是空字符串。PyObject *fromlist = POP();
:从栈顶弹出需要导入的子包元组。PyObject *level = TOP();
:得到相对路径需要向上寻找的层级。这个例子里是4
,由compile
函数根据....
解析得到。PyObject *res;res = import_name(tstate, f, name, fromlist, level);SET_TOP(res);
:得到要导入的包并放到栈顶。
这里又有一个关键问题:import_name()
函数的执行过程。
但是这个函数太过复杂,超出了本篇文章索要关注的路径问题,因此只把关注点放在执行过程中调用的resolve_name()
函数的执行过程。
直接运行
.py
文件不管是导入还是直接运行,都会先被构造成一个module
类型的对象,通常的说法就是变成一个模块。module
对象包含有__name__
、__package__
和__spec__
等属性。其中__name__
就是常见的if __name__ == '__main__'
中用到的;__package__
表示模块所在的包的名字(包含相对路径),__spec__
则记录跟详细的所在包的信息,两者都和相对路径解析有关。
在f1.py
中写入以下内容:
import sys
IDENTIFIER = 'RELATIVE_IMPORT_f1'
this = None
# sys.modules是一个字典,保存了所有已导入的模块,当前模块自然是已经导入了
for m in sys.modules.values():
if hasattr(m, 'IDENTIFIER') and getattr(m, 'IDENTIFIER') == IDENTIFIER:
this = m
print('in f1.py:')
print('\tname:', this.__name__)
print('\tpackage:', this.__package__)
print('\tspec:', this.__spec__)
直接运行的结果为:
in f1.py:
name: __main__
package: None
spec: None
可以看到,这里的__name__
为'__main__'
,__package__
和__spec__
为None
,因为默认__main__
模块不包含在任何包中。
因为__package__
是None
,所以再继续向上一层相对路径解析时,便会引发ImportError: attempted relative import with no known parent packag
异常。
导入
在f2.py
中写入以下代码:
from d1.d2.d3 import f1
执行f2.py
的结果为:
in f1.py:
name: d1.d2.d3.f1
package: d1.d2.d3
spec: ModuleSpec(name='d1.d2.d3.f1', loader=<_frozen_importlib_external.SourceFileLoader object at 0x000002A9C747AE50>, origin='D:\\Projects\\Space\\Test\\relative-import\\d1\\d2\\d3\\f1.py')
可以看到,这时候__package__
已经包含了相应的路径信息,所以可以向上层寻找。
但是此时f1.py
中如果有from .... import pkg
依然会报错。原因在于resolve_name()
函数中的这段代码:
for (level_up = 1; level_up < level; level_up += 1) {
last_dot = PyUnicode_FindChar(package, '.', 0, last_dot, -1);
if (last_dot == -2) {
goto error;
}
else if (last_dot == -1) {
_PyErr_SetString(tstate, PyExc_ImportError,
"attempted relative import beyond top-level "
"package");
goto error;
}
}
结合本文的例子,这里的level
值为4
,也就是循环时执行3次,从右开始寻找__package__
中的下一个.
号。因为__package__
中只有两个.
号,所以在第三次循环时便会引发ImportError: attempted relative import beyond top-level package
。
解决方法
按照从相对路径导入包的流程来看,f1.py
要想导入pkg
包,要么把pkg
所在的目录加入sys.path
;要么在pkg
和d1
所在的目录的更上一层的模块中导入f1.py
,使模块f1
的__package__
中包含有pkg
所在的目录的信息。