最近遇到一个需求,需要动态加载一堆 Python 模块,但是这些 .py
是 pb 文件生成的,无法控制命名,导致导入的时候各种报错。
目录结构
1 2 3 4 5 6 7 8 9 10 11 12 | ├── main.py └── temp_dir ├── __init__.py ├── basic │ ├── __init__.py │ └── package1.py ├── calendar │ ├── __init__.py │ └── package2.py └── math ├── __init__.py └── package3.py |
package1.py 内容
1 2 3 4 | from calendar import package2 from math import package3 print("import package1") |
package2.py 内容
1 | print("import package2") |
package3.py 内容
1 | print("import package3") |
可以看见在 basic.package1 中导入了 package2、package3。我们在 main 文件中导入 basic 包。
1 2 3 4 | import importlib if __name__ == '__main__': print(importlib.import_module("basic.package1")) |
这样肯定不行,报错了:ModuleNotFoundError: No module named 'basic'
sys.path
Google 一下或者稍微了解 Python 的就会知道,Python 导入非系统库需要指定 PYTHONPATH
,Python 启动时会读取此环境变量并加入到 sys.path
中。我们可以打印下:
1 2 3 4 | ['/mnt/c/Users/PycharmProjects/python-import-test', '/usr/lib/python38.zip', '/usr/lib/python3.8', '/usr/local/lib/python3.8/dist-packages'] |
可以看到默认加载了当前项目目录、Python系统目录以及 dist-packages(通过 pip 安装的),我们把 temp_dir
目录加进去就行了。代码增加:
1 2 | temp_dir = "%s/temp_dir" % os.path.dirname(__file__) sys.path.append(temp_dir) |
但还是会报错:ImportError: cannot import name 'package2' from 'calendar' (/usr/lib/python3.8/calendar.py),不过至少说明,加载路径是设置成功了,basic 包本身导入成功了,只是 from calendar import package2 执行失败了。
这个是一个经典错误,搞 Python 的都会遇到,那就是自己定义的包名不能和系统的重复,一般是改个名字或者加上命名空间,但是我们这个场景中,Python 文件是 pb 生成的,无法修改源文件,修改生成的文件也比较麻烦。也能解决,就是提高我们路径的优先级,改成 sys.path.insert(0, temp_dir) 加载完了再改回来。
BuiltinImporter
不过又有新的报错:ImportError: cannot import name 'package3' from 'math' (unknown location),有输出 import package2,看着还是导入了系统的 math 库,没有导入自己的,还没有显示路径。这里匪夷所思,卡了很久,继续单步调试代码和查阅资料,发现 Python 其实是支持多种导入方式,甚至可以自定义。importlib._bootstrap._find_spec 中,是循环 sys.meta_path 中的 Importer 哪个找到算哪个。我们可以打印下 sys.meta_path:
1 | [<class '_frozen_importlib.BuiltinImporter'>, <class '_frozen_importlib.FrozenImporter'>, <class '_frozen_importlib_external.PathFinder'>] |
可以看见最前面有个 BuiltinImporter
(具体是在 importlib._bootstrap._install 时注入的),唉,灵光一闪,math
就是一个内建库呀,这里结合源码猜测,C 的库(详见 sys.builtin_module_names)都是通过这个导入的。去掉这个就能导入成功了(记得加载完了还原回来)。
1 2 3 4 5 6 7 8 | builtin = None get_class_name = lambda _class: _class.__name__ if hasattr(_class, '__name__') else type(_class).__name__ for importer in sys.meta_path: if get_class_name(importer) == 'BuiltinImporter': builtin = importer break if builtin: sys.meta_path.remove(builtin) |
终于成功了:
1 2 3 4 | import package2 import package3 import package1 <module 'basic.package1' from 'python-import-test/temp_dir/basic/package1.py'> |
sys.modules
不过,这里只是个 demo。实际情况比这个复杂,还是会报 ImportError: cannot import name 'package2' from 'calendar' (/usr/lib/python3.8/calendar.py),这里很奇怪,已经保证了加载的优先级,但为什么还不行呢?没办法,扒一下源码,终于找到了一点端倪。importlib._bootstrap._find_and_load 方法中,会先在 sys.modules
里面找,如果有则直接返回,官方文档也有相应说明。
1 2 3 4 5 6 | def _find_and_load(name, import_): """Find and load the module.""" with _ModuleLockManager(name): module = sys.modules.get(name, _NEEDS_LOADING) if module is _NEEDS_LOADING: return _find_and_load_unlocked(name, import_) |
所以还得排除已加载的情况
1 2 3 | for exclude in ["calendar", "math"]: if exclude in sys.modules: del sys.modules[exclude] |
最终到此才彻底解决,完整 demo 代码:https://github.com/iyaozhen/python-import-test
参考资料
https://docs.python.org/3/library/importlib.html
妙用Hook来研究Python的Import机制,https://www.51cto.com/article/527713.html
Python3.7源码剖析之import,https://zhuanlan.zhihu.com/p/361720373