前言
最近在写一个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)