• 欢迎光临~

Python相对路径导入问题

开发技术 开发技术 2022-10-12 次浏览

Python相对路径导入问题

如果某个项目的文件结构如上,想要在f1.py中导入pkg包的时候,可能会这样写:

from .... import pkg

但是很遗憾,这样会引发ImportError异常。

直接运行f1.py时,异常信息是ImportError: attempted relative import with no known parent package。如果在f2.py中导入f1.pyimport 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

这段字节码的含义如下:

  1. LOAD_CONST 0:从co_consts里取出下标为0的元素并放入栈顶。这里这个元素是4
  2. LOAD_CONST 1:同上,这里这个元素是元组('a', 'b'),代表后面要import进来两个子包a和b。(注意,就算只import进来一个子包,这个元素依然会是一个元组,只是元组的元素个数为1。)
  3. IMPORT_NAME 0:从co_names里取出下标为0的元素并放入栈顶。这里这个元素是空字符串,因为代码中from后面没有指定包名。在执行IMPORT_NAME字节码时,会弹出当前栈顶元素(即元组('a', 'b')),然后把搜索到的包放到栈顶。
  4. 6-14就是搜索子包并把它们和相应的名字绑定在一起,然后从栈顶弹出该包。
  5. 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();
        }

关键语句含义如下:

  1. PyObject *name = GETITEM(names, oparg);:得到要导入的包的名字,其中names就对应于Python层面的co_namesoparg是字节码IMPORT_NAME的操作数,也就是0。对照co_names可知这里是空字符串。
  2. PyObject *fromlist = POP();:从栈顶弹出需要导入的子包元组。
  3. PyObject *level = TOP();:得到相对路径需要向上寻找的层级。这个例子里是4,由compile函数根据....解析得到。
  4. 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;要么在pkgd1所在的目录的更上一层的模块中导入f1.py,使模块f1__package__中包含有pkg所在的目录的信息。

程序员灯塔
转载请注明原文链接:Python相对路径导入问题
喜欢 (0)
违法和不良信息举报电话:022-22558618 举报邮箱:dljd@tidljd.com