两个文件各自从对方导入一个尚未定义的名称,python -c 'import alpha' 报 cannot import name 'ready' from partially initialized module 'alpha'。import 不是“把文件内容粘贴到这里”,它要执行模块顶层代码;执行到一半就请求另一个模块,另一个模块再回头读取尚未绑定的名称,就得到这种失败。

第18 篇的任务接口已经存在。本篇把内存适配器放进 tasklab.storage,把接口声明留在 tasklab.typed,让 CLI、业务和存储职责可分开阅读;这不是 SQLite 存储,每次启动仍创建独立内存任务。数据库以及跨进程查询在 21 验证。

包初始化也属于执行路径

在源码目录用 PYTHONPATH=src /usr/bin/python3.12 -m tasklab --text 'One two one' 启动,解释器先找到 tasklab 包,执行 __init__.py,再执行 __main__.py 调用 cli.main()。同一个进程里,已经加载的模块记录在 sys.modules:实验两次导入 tasklab.cli,拿到的是同一个模块对象;新开一个 Python 进程,缓存不沿用。这解释了为什么不能靠“这次只执行一次”推断模块顶层副作用永远安全。

导入 tasklab.app 会定义函数和类;CLI 只有在 main() 被调用时才解析参数、创建 store、提交任务。实验在导入前把 app.create_store 换成一旦被调用就抛错的函数,再导入 tasklab.cli 和 tasklab.storage;导入成功,说明这条导入路径没有创建 store。另一个子进程以 -m tasklab 运行才实际获得 task-0001、succeeded 和 {"one": 2, "two": 1}。检查的是业务副作用,不是说 Python 导入时“什么都没运行”:包和模块顶层的定义、导入、类型声明都已执行。

把循环依赖从业务路径移走

反例放在隔离的 fixtures/19_cycle/alpha.py 和 beta.py,不让它们与真实包共用模块名。alpha 请求 beta.ready,beta 立刻回头请求 alpha.ready;此时 alpha 尚未执行到自己的赋值语句。实验记录退出 1、完整 traceback 和空标准输出。并非一切相互导入都必然失败,失败取决于访问名称的时机;修复不能靠反复 import 赌初始化顺序。

真实包中 typed.py 可以从 storage.py 引入 MemoryTaskGateway,因为 storage.py 只依赖 app,不再反向读取 typed。__main__.py 则保持很薄,只在作为入口执行时调用 cli.main()。把执行任务写在包 __init__.py,会使单纯 import tasklab 也触发任务,这违反本系列的入口约束;当前代码未采用这种反例改动。python -m tasklab.cli 仍可调用17 篇的 CLI,python -m tasklab 提供包级入口,但都还需从源码目录设置 PYTHONPATH=src,安装后的入口等到 25 才验收。

在 examples/python-language/ 目录运行:

1
2
3
PYTHONPATH=src /usr/bin/python3.12 experiments/19_imports.py
PYTHONPATH=src /usr/bin/python3.12 -m unittest discover -s tests -v
PATH=/opt/cloudcli/node/bin:$PATH npx --yes --package pyright@1.1.404 pyright --pythonversion 3.12 src/tasklab/typed.py experiments/18_positive.py

实验的 import_only 记录为 cached: True、退出 0、无标准错误;包入口退出 0;隔离循环导入的子进程退出 1。累计 59 项测试与原先的类型检查正例保持通过;每项的原始输出、退出码和执行环境在 examples/python-language/evidence/19/20261007-import/。这里未核对不同进程的存储一致性,也不把捕获 ImportError 当作成功的模块依赖设计。

预测题

把 create_store() 移到 storage.py 的顶层,python -c 'import tasklab.storage' 会不会创建任务?上述导入检查能否捕获?

答案:顶层语句在首次导入时执行,至少会创建 store;当前实验把 app.create_store 替换成会抛错的函数,若 storage.py 的顶层调用它,则 import_only 不再退出 0。再次导入同一模块可能命中缓存,不能用第二次没有调用来证明第一遍无副作用。本章没有实际改坏源文件来运行这一变式。

变更题

让 storage.py 顶层从 typed.py 导入 TaskGateway,同时在 typed.py 的类定义之前就导入 MemoryTaskGateway。什么时候失败?怎样修改依赖方向?

答案:加载 typed 尚未定义 TaskGateway 就进入 storage,storage 回读该名称会遇到部分初始化模块并报 ImportError。使协议模块不依赖具体适配器,或保证适配器只依赖不回读协议的模块,调用端分别导入协议与实现;不要用导入顺序掩盖循环。当前包走的是 typed → storage → app 的单向路径。

20 在此基础上设计版本化 JSON 格式;包导入无副作用不能替代不可信 JSON 的解析和校验。系列导航仅含可打开的页面。

参考资料