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 为例:

%%{init: {"theme":"base","themeVariables":{"fontSize":"16px"},"flowchart":{"useMaxWidth":false}}}%% flowchart TD A["import a.b"] --> B{"sys.modules 里有 a.b 吗?"} B -- "有" --> G B -- "没有" --> C["先导入父包 a<br/>(a 也完整走一遍这个流程)"] C --> D["在 a.__path__ 里找 b<br/>得到 spec"] D -- "找不到" --> X1["ModuleNotFoundError"] D --> E["建空模块,填属性<br/>执行前先放进 sys.modules"] E --> F["取代码并执行<br/>.py / .pyc / .pye 在这里分开"] F -- "模块代码抛异常" --> X2["从 sys.modules 删掉<br/>异常继续往上抛"] F --> H["把 b 挂到 a 上<br/>绑定名字 a"] H --> G["完成"] style F fill:#fff4e5,stroke:#f29900 style X1 fill:#fdecea,stroke:#ea4335 style X2 fill:#fdecea,stroke:#ea4335

拆开说是六步:

  1. 查 sys.modules。 导过就直接拿来用,模块里的代码不会再跑一遍。
  2. 导父包。 import a.b.c 会先确保 a、a.b 都已经导好,每一层都是完整的一次导入。
  3. 找。 顶层模块在 sys.path 里找,子模块只在父包的 __path__ 里找。找到以后得到一个 spec,上面记着文件在哪、交给哪个 loader 加载。
  4. 建模块。 新建一个空的模块对象,按 spec 填上 __name__、__file__ 等属性,然后在执行代码之前就放进 sys.modules。
  5. 取代码、执行。 三种文件从这里开始各走各的。
  6. 收尾。 把子模块挂到父包上,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,规则是:

  1. 有 名字/__init__.*,就是普通包;
  2. 没有的话,看有没有 名字.后缀,有就是模块;
  3. 都没有,但有个同名目录,就把它记成命名空间包的候选,接着去后面的路径找。整条搜索路径走完还没有正式结果,才用这些候选目录拼出一个命名空间包。所以命名空间包的优先级最低。

同一种情况下,后缀按下面的顺序试:

顺序 后缀(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 这一行做了三件事:

  1. 在 sys.path_hooks 最前面插入两个钩子,一个是认识 .pye 并把它排第一的目录 finder,另一个是能读 zip 里 .pye 的 PyeZipImporter;
  2. 清空 sys.path_importer_cache,已经造好的旧 finder 全部作废,下次导入时由新钩子重新造;
  3. 把 .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 到底装上没有
posted @ 2026-11-03 16:44  起飞的乌龟  阅读(3)  评论(0)    收藏  举报