fix(core): 惰性导出 Session 类型以打破 plugin ↔ core 循环导入 - #55
Conversation
`ncatbot/core/__init__.py` 在模块顶层直接 `from ncatbot.plugin import
SessionCancelled, SessionResult`,而 `ncatbot/plugin/loader/core.py` 又会
`from ncatbot.core import ...`,从而形成 plugin ↔ core 的循环导入。
当用户(或任何入口)先 `import ncatbot.plugin` 时,plugin 包尚未初始化完
成就会触发 core 的顶层导入,导致:
ImportError: cannot import name 'SessionCancelled' from partially
initialized module 'ncatbot.plugin' (most likely due to a circular import)
修复方式:把这两个 Session 便利类型的 re-export 改为 PEP 562 模块级
`__getattr__` 惰性导入,仅在真正访问 `ncatbot.core.SessionCancelled` /
`ncatbot.core.SessionResult` 时才导入 plugin,从而消除模块初始化期的循环。
兼容性:
- `from ncatbot.core import SessionCancelled, SessionResult` 仍然可用(首次
访问触发惰性导入)。
- `dir()` 语义不变,`__all__` 保持不变。
- 任意导入顺序(core-first / plugin-first)均可正常初始化。
验证:
python -c "import ncatbot.plugin; import ncatbot.core as c; print(c.SessionCancelled)"
# 修复前: ImportError (circular import)
# 修复后: <class 'ncatbot.plugin.mixin.session_types.SessionCancelled'>
|
补充完整验证证据,并说明为什么这个问题在上游自己的 pytest 里测不出来。 触发条件(只有一个)
import ncatbot.plugin # ← 第一个 ncatbot 导入
import ncatbot.core as c$ python -c "import ncatbot.plugin; import ncatbot.core as c; print(c.SessionCancelled)"
Traceback (most recent call last):
File "<string>", line 1, in <module>
File ".../ncatbot/plugin/__init__.py", line 9, in <module>
from .loader import PluginLoader, check_requirements, install_packages
File ".../ncatbot/plugin/loader/__init__.py", line 3, in <module>
from .core import PluginLoader
File ".../ncatbot/plugin/loader/core.py", line 20, in <module>
from ncatbot.core import (
...<5 lines>...
)
File ".../ncatbot/core/__init__.py", line 74, in <module>
from ncatbot.plugin import SessionCancelled, SessionResult
ImportError: cannot import name 'SessionCancelled' from partially initialized module 'ncatbot.plugin' (most likely due to a circular import) (.../ncatbot/plugin/__init__.py)反过来 core-first 就没问题: $ python -c "import ncatbot.core; import ncatbot.plugin; print('OK')"
OK也就是说:导入顺序决定成败,这是一个隐藏的顺序耦合,而且它取决于用户 为什么上游 CI 是绿的这一点值得单独说明,因为它解释了为什么这个 bug 能一直存活。 pytest 在收集任何测试文件之前,会先加载各级 # tests/conftest.py:8-9
from ncatbot.adapter.mock import MockAdapter
from ncatbot.testing import TestHarness # ← 这一行挡住了这个 bug于是导入链变成(先 core,不是先 plugin):
决定性实验在未修复的代码上加一个 plugin-first 探针测试: # tests/unit/zzprobe/test_plugin_first.py
import ncatbot.plugin # plugin-first
import ncatbot.core as c
def test_session_types_available():
assert c.SessionCancelled is not None
同一份未修复的代码,有没有 conftest 就是「过」和「崩」的差别。 数据佐证(均在未修复的上游代码上执行)$ pytest tests/unit -q
31 failed, 785 passed, 5 skipped, 1 xfailed
# 31 个失败全部是 ModuleNotFoundError: No module named 'litellm',与导入无关
$ pytest tests/unit/plugin tests/unit/core -q
239 passed全测试套件中 三个结构性原因
所以它不是在测试里「消失了」,而是被 conftest 挡住了。这也正是它值得修的理由:测试环境与真实使用环境的导入顺序不同,CI 绿并不代表用户不会踩到。 修复后验证$ python -c "import ncatbot.plugin; import ncatbot.core as c; print(c.SessionCancelled)"
<class 'ncatbot.plugin.mixin.session_types.SessionCancelled'>
$ python -c "import ncatbot.plugin; import ncatbot.core as c; print(c.SessionResult)"
<class 'ncatbot.plugin.mixin.session_types.SessionResult'>
$ python -c "from ncatbot.core import SessionCancelled, SessionResult; print(SessionCancelled, SessionResult)"
<class 'ncatbot.plugin.mixin.session_types.SessionCancelled'> <class 'ncatbot.plugin.mixin.session_types.SessionResult'>
修复方式为 PEP 562 模块级
|
问题
ncatbot/core/__init__.py在模块顶层直接 re-export plugin 层的 Session 便利类型:而
ncatbot/plugin/loader/core.py又会from ncatbot.core import (...),于是形成 plugin ↔ core 的循环导入。
当入口先
import ncatbot.plugin时,plugin 包尚未初始化完成就被迫触发 core 的顶层导入,得到:
也就是说,导入顺序会决定成败:
import ncatbot.core在前没事,import ncatbot.plugin在前就会炸。这对插件开发者和测试环境(例如先加载插件系统再访问 core)很不友好。
复现
python -c "import ncatbot.plugin; import ncatbot.core as c; print(c.SessionCancelled)"修复
把这两个 Session 类型的 re-export 改成 PEP 562 模块级
__getattr__惰性导入,只在真正访问
ncatbot.core.SessionCancelled/ncatbot.core.SessionResult时才去导入plugin 层,避免在
ncatbot.core初始化期间触发 plugin 包的初始化。兼容性
from ncatbot.core import SessionCancelled, SessionResult依然可用(首次访问触发惰性导入)。__all__不变;dir(ncatbot.core)依旧包含这两个名字。验证
改动范围:
ncatbot/core/__init__.py,+6 / -1,无其他文件改动。