Skip to content

fix(core): 惰性导出 Session 类型以打破 plugin ↔ core 循环导入 - #55

Merged
GEYUANwuqi merged 1 commit into
ncatbot:mainfrom
lyt2011:fix/circular-import-plugin-core
Sep 16, 2026
Merged

GEYUANwuqi merged 1 commit into
ncatbot:mainfrom
lyt2011:fix/circular-import-plugin-core

Conversation

@lyt2011

@lyt2011 lyt2011 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

问题

ncatbot/core/__init__.py 在模块顶层直接 re-export plugin 层的 Session 便利类型:

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)

也就是说,导入顺序会决定成败:import ncatbot.core 在前没事,import ncatbot.plugin
在前就会炸。这对插件开发者和测试环境(例如先加载插件系统再访问 core)很不友好。

复现

python -c "import ncatbot.plugin; import ncatbot.core as c; print(c.SessionCancelled)"
ImportError: cannot import name 'SessionCancelled' from partially initialized module 'ncatbot.plugin'

修复

把这两个 Session 类型的 re-export 改成 PEP 562 模块级 __getattr__ 惰性导入,
只在真正访问 ncatbot.core.SessionCancelled / ncatbot.core.SessionResult 时才去导入
plugin 层,避免在 ncatbot.core 初始化期间触发 plugin 包的初始化。

# Session 便利类型(从 plugin 层 re-export,方便用户导入)
# 延迟到 __getattr__ 惰性导入,打破 plugin ↔ core 初始化时的循环导入
def __getattr__(name: str):
    if name in ("SessionCancelled", "SessionResult"):
        from ncatbot.plugin import SessionCancelled, SessionResult
        return SessionCancelled if name == "SessionCancelled" else SessionResult
    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

兼容性

  • from ncatbot.core import SessionCancelled, SessionResult 依然可用(首次访问触发惰性导入)。
  • __all__ 不变;dir(ncatbot.core) 依旧包含这两个名字。
  • 任意导入顺序(core-first / plugin-first)都能正常初始化。

验证

# 修复前
$ python -c "import ncatbot.plugin; import ncatbot.core as c; print(c.SessionCancelled)"
ImportError: cannot import name 'SessionCancelled' from partially initialized module 'ncatbot.plugin'

# 修复后
$ python -c "import ncatbot.plugin; import ncatbot.core as c; print(c.SessionCancelled)"
<class 'ncatbot.plugin.mixin.session_types.SessionCancelled'>

$ 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'>

改动范围:ncatbot/core/__init__.py,+6 / -1,无其他文件改动。

`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'>
@lyt2011

lyt2011 commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor Author

补充完整验证证据,并说明为什么这个问题在上游自己的 pytest 里测不出来。

触发条件(只有一个)

ncatbot.plugin 必须是进程里第一个被导入的 ncatbot 子包,即:

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

也就是说:导入顺序决定成败,这是一个隐藏的顺序耦合,而且它取决于用户 main.py 的写法,不是框架能控制的。

为什么上游 CI 是绿的

这一点值得单独说明,因为它解释了为什么这个 bug 能一直存活。

pytest 在收集任何测试文件之前,会先加载各级 conftest.py。而 tests/conftest.py 第 9 行是:

# tests/conftest.py:8-9
from ncatbot.adapter.mock import MockAdapter
from ncatbot.testing import TestHarness        # ← 这一行挡住了这个 bug

于是导入链变成(先 core,不是先 plugin):

tests/conftest.py:9               from ncatbot.testing import TestHarness
  → ncatbot/testing/__init__.py:1       from .harness import TestHarness
  → ncatbot/testing/harness.py:14       from ncatbot.app import BotClient
  → ncatbot/app/__init__.py:1           from .client import BotClient
  → ncatbot/app/client.py:18            from ncatbot.core import (
  → ncatbot/core/__init__.py:74         from ncatbot.plugin import SessionCancelled, ...

ncatbot.core 在任何测试文件被 import 之前就已经完整初始化完毕,SessionCancelled 在那个时刻绑定成功。之后测试文件再 import ncatbot.plugin 时,core 已在 sys.modules 里完整存在 ——
「plugin 半初始化 → 回头找 core → 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
运行方式 结果
正常跑(pytest 自动加载 conftest.py) ✅ 1 passed
--noconftest(禁用所有 conftest) ❌ ImportError: ... partially initialized module 'ncatbot.plugin'

同一份未修复的代码,有没有 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

全测试套件中 circular / partially initialized / SessionCancelled 关键字0 命中。

三个结构性原因

  1. conftest.py 强制先行 —— pytest 的加载顺序让 ncatbot.core 总是先于 ncatbot.plugin 初始化。这是偶然的架构性质,不是有意设计。
  2. 测试代码里不存在 plugin-first 入口 —— 130 个测试文件中没有任何一处把 import ncatbot.plugin 放在第一个 ncatbot 导入位置。
  3. 没有任何测试使用全新解释器 —— subprocess 仅出现在 test_napcat_setup.py 中对 Popen 的 mock,没有真实起新进程。而「新进程 + plugin-first」正是唯一能触发该 bug 的现实路径(例如用户 main.py 先加载插件系统、再访问 core)。

所以它不是在测试里「消失了」,而是被 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'>
导入顺序 修复前 修复后
import ncatbot.core → import ncatbot.plugin ✅ 正常 ✅ 正常
import ncatbot.plugin → import ncatbot.core ❌ ImportError ✅ 正常

修复方式为 PEP 562 模块级 __getattr__ 惰性导入,__all__ 与 dir() 语义均不变,from ncatbot.core import SessionCancelled, SessionResult 依旧可用。

注:CI 因 first-time contributor 需要维护者批准才会跑;以上结论均来自本地实测,不依赖 CI 结果。

@GEYUANwuqi
GEYUANwuqi merged commit 7f649ff into ncatbot:main Sep 16, 2026
@lyt2011
lyt2011 deleted the fix/circular-import-plugin-core branch September 16, 2026 11:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants