Python 导入一个模块要走几步:.py、.pyc、.pye 各走哪条路
写下 import a.b 之后,Python 做的事情并不多:先看这个模块导过没有,没导过就先把父包导好,然后找文件、建模块对象、执行代码,最后绑定名字。.py、.pyc 和 pyconcrete 加密出来的 .pye,只在"找哪个文件"和"怎么拿到代码"这两步上有区别,其余步骤用的是同一套。
文中的结论都在 Windows 上的 CPython 3.12 跑过。pyconcrete 部分用的是 1.3.0 版的 __init__.py,解密模块换成了一个纯 Python 写的替身,导入路径和真实安装一致。
1. 整体流程
以 import a.b 为例:
拆开说是六步:
- 查
sys.modules。 导过就直接拿来用,模块里的代码不会再跑一遍。 - 导父包。
import a.b.c会先确保a、a.b都已经导好,每一层都是完整的一次导入。 - 找。 顶层模块在
sys.path里找,子模块只在父包的__path__里找。找到以后得到一个 spec,上面记着文件在哪、交给哪个 loader 加载。 - 建模块。 新建一个空的模块对象,按 spec 填上
__name__、__file__等属性,然后在执行代码之前就放进sys.modules。 - 取代码、执行。 三种文件从这里开始各走各的。
- 收尾。 把子模块挂到父包上,
a.b这个属性就是这时候设上的;再把名字绑定到当前作用域。import a.b绑定的名字是a,不是b。
2. 几个名词
- finder 负责回答"这个名字对应哪个文件",结果是一个 spec。
- loader 负责拿到代码并执行。
.py用SourceFileLoader,没有源码的.pyc用SourcelessFileLoader,.pye用 pyconcrete 的PyeLoader。 - spec 是 finder 交给 loader 的那张单子,写着模块名、loader、文件位置、是不是包。任何模块的
__spec__都可以直接打印出来看。
finder 是分层的。sys.meta_path 里放着几个顶层 finder,导入时按顺序一个个问,普通文件归其中的 PathFinder 管。PathFinder 拿到一个路径条目,会用 sys.path_hooks 里的工厂为它造一个 finder:目录造 FileFinder,zip 包造 zipimporter。造好的 finder 缓存在 sys.path_importer_cache 里,下次直接用。pyconcrete 就是从 path_hooks 这里插进来的,第 6 节会讲。
3. 在一个目录里怎么挑文件
PathFinder 按顺序遍历搜索路径,排在前面的路径先命中。每个目录交给一个 FileFinder,规则是:
- 有
名字/__init__.*,就是普通包; - 没有的话,看有没有
名字.后缀,有就是模块; - 都没有,但有个同名目录,就把它记成命名空间包的候选,接着去后面的路径找。整条搜索路径走完还没有正式结果,才用这些候选目录拼出一个命名空间包。所以命名空间包的优先级最低。
同一种情况下,后缀按下面的顺序试:
| 顺序 | 后缀(Windows) | 加载器 |
|---|---|---|
| 0 | .pye(装了 pyconcrete 才有,排在最前) |
PyeLoader |
| 1 | .cp312-win_amd64.pyd、.pyd(Linux 上是 .so) |
扩展模块加载器 |
| 2 | .py、.pyw |
SourceFileLoader |
| 3 | .pyc |
SourcelessFileLoader |
我在一个目录里放了几组同名文件,实际导入结果是这样的:
| 目录里有 | 导入结果 | 原因 |
|---|---|---|
dup.py + dup.pyc + dup.pye(已装 pyconcrete) |
用 dup.pye |
.pye 排第一 |
pyfirst.py + pyfirst.pyc |
用 pyfirst.py |
.py 排在 .pyc 前面 |
pkgdup/__init__.py + pkgdup.py |
用包 pkgdup/ |
先找包,后找模块 |
只有 __pycache__/ghost.cpython-312.pyc |
ModuleNotFoundError |
finder 不会进 __pycache__ 找模块 |
old310.pyc(3.10 编译,没有源码) |
ImportError: bad magic number |
没有源码的 .pyc 必须版本一致 |
4. 为什么执行之前就放进 sys.modules
这一步不起眼,但循环导入能"半成功"靠的就是它。模块还没执行完,sys.modules 里已经有它了,所以:
- 包的
__init__.py里写from . import x,导入x时发现父包已经在了,不会绕回去再导一遍父包; a导b、b又导a时,b拿到的是执行到一半的a,不会无限递归;- 另一个线程同时导入它,会看到它还在初始化,等它执行完再用。
如果执行过程中抛了异常,这条记录会被删掉,父包上也不会多出这个属性。下次再 import,会从头重新来一遍。
5. 三种文件怎么拿到代码
三种文件真正不同的地方在这里:
.py |
.pyc(没有源码) |
.pye(pyconcrete 加密) |
|
|---|---|---|---|
| 加载器 | SourceFileLoader |
SourcelessFileLoader |
PyeLoader(zip 里是 PyeZipImporter) |
| 代码从哪来 | 有可用的缓存就读缓存,否则编译源码 | 直接反序列化文件内容 | 读密文,解密,再反序列化 |
写 __pycache__ |
写 | 不写 | 不写 |
| Python 版本对不上 | 重新编译,不报错 | ImportError |
ValueError |
| traceback 里显示源码行 | 能 | 不能 | 不能 |
5.1 .py 和 __pycache__
__pycache__/x.cpython-312.pyc 开头有 16 字节的文件头:
| 字节 | 内容 |
|---|---|
| 0–3 | magic,标识 Python 版本,3.12 是 cb 0d 0d 0a |
| 4–7 | flags:0 表示按时间戳校验,1 和 3 表示按源码 hash 校验 |
| 8–15 | 时间戳型存源码的 mtime 和大小,hash 型存源码的 hash |
导入 .py 时,按缓存的状态分几种情况:
| 缓存状态 | 处理 |
|---|---|
| 没有缓存 | 编译源码,写出缓存 |
| 时间戳型,mtime 和大小都对得上 | 直接用缓存,源码文件只 stat 不读 |
| 时间戳型,对不上(源码改过) | 重新编译,覆盖缓存 |
| hash 型,flags=3 | 读源码算 hash,一致才用缓存 |
| hash 型,flags=1 | 不校验,直接用缓存 |
| 别的 Python 版本编出来的 | 重新编译,覆盖缓存 |
缓存用不上的时候,Python 不会给任何提示,加 -v 运行才能看到 bytecode is stale 这类信息。缓存写不进去也一样不报错,比如目录只读、用了 -B 或者设置了 PYTHONDONTWRITEBYTECODE,结果只是每次都重新编译。
缓存是在执行模块代码之前写的,模块执行时抛异常,缓存照样留着。写的方式是先写临时文件再原子替换,几个进程同时导入同一个模块,也不会读到写了一半的文件。
5.2 没有源码的 .pyc
把 .pyc 放在原来 .py 所在的目录(不是 __pycache__ 里),删掉 .py,它就能直接导入。这种 .pyc 不校验、不重新编译、也不写缓存,带来两个后果:
- 编译它的 Python 和运行它的 Python,3.x 的版本号必须一样,否则直接报
bad magic number; - traceback 里的文件路径是编译那台机器上的路径,也显示不出源码行。
6. .pye:pyconcrete 改了什么
import pyconcrete 这一行做了三件事:
- 在
sys.path_hooks最前面插入两个钩子,一个是认识.pye并把它排第一的目录 finder,另一个是能读 zip 里.pye的PyeZipImporter; - 清空
sys.path_importer_cache,已经造好的旧 finder 全部作废,下次导入时由新钩子重新造; - 把
.pye加进源码后缀列表。
sys.meta_path 和 __import__ 它都没动。所以整个导入流程只变了两处:找文件时认识 .pye,而且排在 .py 前面;取代码时先把密文解密成一份完整的 .pyc 内容,校验版本,再反序列化执行。解密出来的东西只在内存里,不落盘。
用的时候容易碰到这几个问题:
import pyconcrete 写晚了。 这个最隐蔽,因为不报错。在它之前导入的加密包,目录里只有 __init__.pye,当时的 finder 不认识这个后缀,于是把目录当成了命名空间包,记进了 sys.modules。之后再装 pyconcrete,子模块能正常导入,但 __init__.pye 再也不会执行,包里该有的东西就是没有。解决办法是把 import pyconcrete 放在入口脚本的第一行。已经出问题的进程,可以从 sys.modules 里删掉这个包和它的子模块,再重新导入。
except ImportError 接不住版本错误。 .pye 的 Python 版本对不上时,pyconcrete 抛的是 ValueError。
zip 里的模块没有 __file__。 PyeZipImporter 不提供文件位置,靠 os.path.dirname(__file__) 找资源文件的代码会直接报错。
__cached__ 指向一个不存在的文件。 .pye 被登记成了源码后缀,所以 Python 照常给它算了一个缓存路径,但 pyconcrete 从来不写缓存。
同一份代码,装不装 pyconcrete 行为不同。 目录里 .py 和 .pye 并存时,装了用 .pye,没装用 .py。
7. 几个常被问到的现象
| 现象 | 原因 |
|---|---|
import a.b 之后要写 a.b.xxx |
绑定的名字是 a,b 是导入时挂到 a 上的属性 |
只写了 import a,有时也能用 a.b |
别的地方导入过 a.b,它已经挂在 a 上了;import a 本身不会导入子模块 |
第二次 import 不执行模块代码 |
sys.modules 里已经有了。想重新执行用 importlib.reload |
循环导入有时行,有时报 cannot import name |
拿到的是执行到一半的模块。import a 能拿到,from a import x 要求 x 那时已经定义 |
程序运行中生成的 .py 导入不到 |
FileFinder 缓存了目录列表,按目录的修改时间刷新,可能来不及。生成后调一次 importlib.invalidate_caches() |
Windows 上 import App 找不到 app.py |
导入区分大小写,即使文件系统不区分 |
| 同名的普通模块和命名空间目录,用了普通模块 | 命名空间包优先级最低 |
from pkg import * 没把子模块导进来 |
只有包的 __all__ 里列了子模块名,* 才会去导入它们 |
| 想让某个模块导入不了 | 设 sys.modules['x'] = None,之后 import x 报 ModuleNotFoundError |
8. 想自己看一看
| 命令或代码 | 能看到什么 |
|---|---|
python -v -c "import x" |
每个模块用的加载器,以及缓存命中(matches)、过期(bytecode is stale)、写出(created) |
python -vv -c "import x" |
再加上每个目录里试过哪些文件名(trying ...) |
python -X importtime -c "import x" |
每个模块的导入耗时 |
print(x.__spec__) |
这个模块是谁找到的、由谁加载、文件在哪 |
print(sys.path_hooks, sys.meta_path) |
当前装了哪些导入钩子,比如 pyconcrete 到底装上没有 |

浙公网安备 33010602011771号