[Python导入系统] 访问子模块报错`AttributeError`的诡异问题解决

由Jeza Chen 发表于 September 27, 2024

前言

最近在写一个Python的导入钩子工具,在编写单元测试代码的时候,发现一个诡异的问题,百思不得其解:有一个标准库在访问一个前面已经import的子模块importlib.machinery时,却报错AttributeError: module 'importlib' has no attribute 'machinery'。通过多次注释代码,最终发现了问题的根源:

不要随意删除sys.modules中的模块,否则可能导致子模块无法访问!

不要随意删除sys.modules中的模块,否则可能导致子模块无法访问!

不要随意删除sys.modules中的模块,否则可能导致子模块无法访问!

重要的问题强调三遍。如果我们直接通过操纵sys.modules删除了一个模块a,然后重新导入,那么之前已经import的子模块a.b就不会自动绑定到父模块的命名空间中(也就是说,此时的a中并不包含属性b),即便我们也重新通过import语句导入子模块。这是一个不常见,但一旦出错了就会非常难以发现的问题,值得记录一下。

栗子

下面通过一个简单的例子来说明这个问题:

import sys


def _unimport(module_name):
    sys.modules.pop(module_name, None)


import importlib  # noqa

# 当我们调用`import importlib.machinery`时
# 会自动将machinery加入到importlib命名空间中
import importlib.machinery

# 我们尝试去掉importlib...
_unimport('importlib')
# 重新import
import importlib  # noqa
# 注意这里!!!
# 此时即便import一次importlib.machinery, 也不会将machinery加入到importlib命名空间中
# 因为解释器认为`importlib.machinery`已经在sys.modules中了, 不会再次触发导入流程
import importlib.machinery

# 注意, 此时machinery已经不在importlib命名空间中了!!!
# 因为importlib已经重新导入了, 前面的machinery属性并不会自动加入到importlib命名空间中
print(importlib.machinery)  # Boom!

import os

# 我们随意给os添加一个属性
os.something_we_add = 3
importlib.reload(os)
print(os.something_we_add)  # 此时不会炸! importlib.reload会保留我们添加的属性

通过上述代码可以看到,当我们通过直接操纵sys.modules.pop的方式(在unimport方法内)删除importlib模块后,再通过import语句重新导入importlib模块时,importlib.machinery并不会自动加入到importlib命名空间中(即便我们通过import语句再次声明导入importlib.machinery,但由于其已经缓存在sys.modules里,不会继续后面的导入流程,继而不会发生属性绑定的操作)。这就导致了importlib模块中并不包含machinery属性,导致访问importlib.machinery时报错。

importlib.reload方法自身并不会移除子模块绑定在该模块命名空间上的属性。通过源码发现,其复用原来的模块对象(types.ModuleType实例),只不过是重新调用exec_module方法重新运行一次模块代码,因此原来的属性大概率不会丢失(除非执行代码的时候主动删除了属性),但是这并不意味着importlib.reload是一个安全的、能保证命名空间内属性不会丢失的操作,因为重新执行模块代码可能会导致一些不可预测的行为,比如模块中的全局变量被重新初始化等。不能依赖文档外标准库的实现细节来保证代码的正确性!

备注:实际上,当我们通过import a.b导入子模块时,实际上包a__init__.py会被调用的。此外,如果我们使用import os.path,由于os模块并非是一个包,实际上os.py会被调用,并将os.path添加到sys.modules中。具体可以参考这篇文章

可能的改进方法

卸载模块的一个合理的改进方法是,当卸载一个模块modA时,顺便将已缓存在sys.modules上的子模块modA.xxx也一并卸载。但需要注意的是,如果采用递归卸载的方法,由于会存在三级或者更深层次的子模块的情况,递归卸载可能会导致上层的迭代操作失效。总之,需要依项目实际情况来制定合适的卸载策略。上述代码中,_unimport的一个可行的改进方法为:

def _unimport(module_name):
    # 单层迭代卸载
    for key in list(sys.modules.keys()):
        if key.startswith(f'{module_name}.'):
            del sys.modules[key]
    sys.modules.pop(module_name, None)