前言

  你是否也会为 reload Python 的模块干到烦恼。
  需要在不同的脚本加上 reload 导入的模块确保可以看到代码的更新。
  Python 是怎么缓存 import 的模块的。

TLDR;

  我后来了解了 Python 的加载机制之后弄了一个函数,只要将我们开发的包命名加上,就可以实现整个开发包 reload 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def module_cleanup(module_name):
"""Cleanup module_name in sys.modules cache.

Args:
module_name (str): Module Name
"""
if module_name in sys.builtin_module_names:
return
packages = [mod for mod in sys.modules if mod.startswith("%s." % module_name)]
for package in packages + [module_name]:
module = sys.modules.get(package)
if module is not None:
del sys.modules[package] # noqa:WPS420

# NOTES(timmyliang): 这个操作等同于对 test_module 下所有的 module 进行 reload
module_cleanup("test_module")

  如果我们的 test_module 下有众多脚本就不需要逐个去添加 reload 了。
  万一不小心把 reload 发布出去了也会稍微降低脚本运行的性能。

Python Import

https://docs.python.org/3/reference/import.html

  上面是 Python 的官方文档讲述 Python的 import 的时候背后的运行机理,也可以切换成中文进行阅读。
  这里我将上面的文章结合自己的实践总结一番。

  Python import 模块可以用关键字 import 或者 importlib.import_module()
备注: 关键字调用无法放到 lambda 函数里面,这也是为什么 Python2 下默认 print 无法放入 lambda 里面, python3 print 不再是关键字可以放入 lambda
  使用 import 关键字其实背后执行的是 __import__() 内置方法。
  import 触发之后会从 sys.modules 查找缓存,找不到就从 sys.path 里面匹配模块 (这个过程也会触发 meta_path 等触发自定义的 import 行为)
  找到匹配的模块就会创建模块 否则 raise ModuleNotFoundError
  生成的模块会放入到 sys.modules 进行缓存。

import 执行操作(不考虑自定义 import 情况)

  1. sys.modules 查找模块缓存
  2. sys.path 匹配脚本 生成模块 放入 sys.modules 缓存

sys.modules

  由于 sys.modules 的缓存机制,Python 下次导入就从已经加载的缓存中获取模块,导致模块用的还是旧的代码逻辑。
  相应的也可以修改 sys.modules 的字典实现骚操作

1
2
3
4
5
import sys
sys.modules['a'] = 1

import a
print(a) # 打印 1

  当然这种骚操作不推荐使用就是了。
  另外还有一些危险的操作,比如 del sys.modules["builtins"] 会让 Python 变得不正常(:з」∠)

1
2
3
4
5
del sys.modules["builtins"]  
map
# Traceback (most recent call last):
# File "<stdin>", line 1, in <module>
# RuntimeError: lost builtins module

  基于这个原理,如果将缓存清理了,下次 Python import 就会重新加载这个模块,实现 reload 的效果。
  我最初也是在 mGear 的代码里面学习它们的 reload 方法学习到的。

image

  它背后实现的代码就是 del sys.modules["mgear"] 等相关的模块

https://docs.python.org/3/reference/import.html#the-module-cache

  根据官方文档的说明,如果一个大模块下有很多子模块,都是单独键值缓存的。
  所以要 reload 所有的子模块需要编译键值将匹配的都删除掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def module_cleanup(module_name):
"""Cleanup module_name in sys.modules cache.

Args:
module_name (str): Module Name
"""
if module_name in sys.builtin_module_names:
return
packages = [mod for mod in sys.modules if mod.startswith("%s." % module_name)]
for package in packages + [module_name]:
module = sys.modules.get(package)
if module is not None:
del sys.modules[package] # noqa:WPS420

# NOTES(timmyliang): 这个操作等同于对 test_module 下所有的 module 进行 reload
module_cleanup("test_module")

  这个就是我整理的遍历所有匹配的模块进行缓存删除的函数,sys.builtin_module_names 通过规避对内置模块的清理。
  这样源代码不需要添加 reload ,我们只在开发用的调试脚本添加这个函数执行 reload 即可。
  另外有一个小小注意点,用这个删除缓存的方式 reload 会将之前的 module 删除生成新的 module 对象,但是如果用 reload 的话是沿用之前的 module 对象。
  目前我实践上还没遇到过因为这个导致出现问题的情况。

packages 命名空间包

https://packaging.python.org/en/latest/guides/packaging-namespace-packages/

  按照上面链接提供的目录结构

1
2
3
4
5
6
7
8
9
10
11
12
mynamespace-subpackage-a/
setup.py
mynamespace/
subpackage_a/
__init__.py

mynamespace-subpackage-b/
setup.py
mynamespace/
subpackage_b/
__init__.py
module_b.py

  然后就可以 from mynamespace import subpackage_b from mynamespace import subpackage_a
  用同一个 mynamespace 包导入两个不同路径的模块。

image

  但是上面的链接也提到 命名空间包并不适用所有的情况,反而是用前缀包会更好。

模块遍历查找

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import pkgutil
import xml
for finder,name,ispkg in pkgutil.walk_packages(xml.__path__,xml.__name__+'.'):
print(finder,name,ispkg)

# 输出如下
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml') xml.dom True
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\dom') xml.dom.NodeFilter False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\dom') xml.dom.domreg False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\dom') xml.dom.expatbuilder False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\dom') xml.dom.minicompat False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\dom') xml.dom.minidom False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\dom') xml.dom.pulldom False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\dom') xml.dom.xmlbuilder False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml') xml.etree True
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\etree') xml.etree.ElementInclude False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\etree') xml.etree.ElementPath False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\etree') xml.etree.ElementTree False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\etree') xml.etree.cElementTree False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml') xml.parsers True
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\parsers') xml.parsers.expat False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml') xml.sax True
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\sax') xml.sax._exceptions False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\sax') xml.sax.expatreader False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\sax') xml.sax.handler False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\sax') xml.sax.saxutils False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\sax') xml.sax.xmlreader False

  通过 pkgutil.walk_packages 可以遍历一个模块所有的子模块。
  from setuptools import find_packages 也可以实现类似的功能
  但是 find_packages 面对命名空间模块不好使,但是 walk_packages 好使。(原因是 find_packages 通过 os.walk 去查找路径的)
  也可以通过这个方式将对应模块的缓存进行删除~

判断模块是否存在

1
2
3
4
5
6
def importable(module_name)
try:
__import__(module_name)
return True
except ImportError:
return False

  过去判断一模块是否可以 import 通常使用异常进行处理。
  其实 pkgutil.find_loader 也可以返回模块是否可以 import

1
2
3
4
import pkgutil
loader = pkgutil.find_loader("maya")
has_maya = loader and loader.load_module("maya")
print(has_maya) # 如果存在返回 maya 库,不存在返回 None

  上面的方式就不需要用 exception 进行处理。(find_loader 的源码已经有 exception 的逻辑)

  如果模块可以导入会返回对应的 loader,使用 load_module 可以进行加载。
注: py2 的 load_module 必须要传参。

自定义 import 行为

  除了 sys.path 通过系统路径查找 python 包进行加载之外。
  Python 还有 sys.meta_path 存储一系列 Finder 类 (Py3还需要 Loader 类) 来自定义 import 逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import sys
import types

class CustomFinder(object):
def __init__(self):
self.submodule_search_locations = []
self.has_location = False
self.origin = None

def create_module(self, spec):
return self.load_module(spec.name)

def exec_module(self, module):
"""Execute the given module in its own namespace
This method is required to be present by importlib.abc.Loader,
but since we know our module object is already fully-formed,
this method merely no-ops.
"""

def find_spec(self, fullname,*args):
self.name = fullname
self.loader = self
return self.find_module()

# NOTES(timmyliang): compat with Python2
def find_module(self,*args):
return self

def load_module(self, fullname):
module = sys.modules.get(fullname)
if module:
return module

new_module = types.ModuleType(fullname)
sys.modules[fullname] = new_module
new_module.__name__ = fullname
new_module.__loader__ = self
return new_module

if __name__ == "__main__":
sys.meta_path.append(CustomFinder())

import myapp
print(myapp)
# Py3: <module 'myapp' (<__main__.CustomFinder object at 0x000002A2A2904808>)>
# Py2: <module 'myapp' (built-in)>

  上面的代码实现了 py2 py3 的 Finder 兼容。
  可以实现加载任意名称的模块都能成功返回而不会引发 ImportError
  当然这种操作如果用到项目里面,肯定会被人打死 😄

  在 Py2 环境下 Finder 需要实现 find_moduleload_module 方法
  Py3 环境可以参考下面的链接。

https://stackoverflow.com/a/58275573/13452951

  需要有 Finder 需要实现 find_spec 返回 ModuleSpec 类,这个类需要有 Loader 进行加载逻辑

  官方提供的 zipimport.zipimporter 在 Py2 下是 Finder ,在 Py3 下是 Loader。
  可以从下面官方文档的类方法中看出来。

https://docs.python.org/2.7/library/zipimport.html?highlight=zip#module-zipimport
https://docs.python.org/3.10/library/zipimport.html?highlight=zip#module-zipimport

  通过需改 import 机制,可以实现很多黑科技,但是推荐使用侵入性较小的使用方式。
  这个机制可以让某个模块虚空导入而不报错,这不符合正常使用 Python 的逻辑,可能会让团队其他人很懵逼的。
  如果某个 BUG 是因为这个机制导致的,其他人又不熟悉这块的话,那这问题查半天也不一定有结果 😢

  这种黑科技的方式无法支持 mypy 类型检测和回溯,倒是可以做一些代码桩来实现提示,但不是很推荐。

总结

  本次深入浅出地学习了 Python Import 的各种底层逻辑。
  以后有机会的话也想好好学习一下 CPython 的底层实现。