diff --git a/src/data/sources.py b/src/data/sources.py index f4f363a..c860b84 100644 --- a/src/data/sources.py +++ b/src/data/sources.py @@ -66,6 +66,11 @@ def _load_sources() -> None: 导入失败必须留下痕迹:静默吞掉 ImportError 会让注册表恒为空, 导致 get_source() 对所有数据源都报「未知数据源」,把导入错误 伪装成「不存在的名字」—— 这类静默失效极难定位,故在此显式记日志。 + + 失败时保持 _loaded=False:后续调用会重新尝试导入。正常应用流程中 + src.api.app 先导入本模块,此处的模块级预热是在 `src.data.bzzoiro` + 尚未初始化时发起的,导入链在本模块内成环,首次必然失败(bzzoiro + 仍在加载中),由后续 get_source() / list_sources() 调用完成真正的装载。 """ global _loaded if _loaded: @@ -74,7 +79,8 @@ def _load_sources() -> None: from src.data.bzzoiro import BzzoiroSource # noqa: F401 except Exception: logger.exception( - "数据源模块导入失败,注册表将为空 —— get_source() 会对所有名字报「未知数据源」" + "数据源模块导入失败(通常为初始化中途的环状导入),本次注册表为空;" + "下次 get_source()/list_sources() 调用会自动重试" ) return # 保持 _loaded=False,下次调用可重试 _loaded = True diff --git a/tests/test_bzzoiro_source_registry.py b/tests/test_bzzoiro_source_registry.py index e38aab2..e0842fa 100644 --- a/tests/test_bzzoiro_source_registry.py +++ b/tests/test_bzzoiro_source_registry.py @@ -78,6 +78,58 @@ class TestIngestContract: assert name in params, f"fetch_bzzoiro_events() 缺少 {name!r}" +class TestImportOrderSelfHeal: + """导入次序不得影响注册表 —— 这是本缺陷的第二个隐藏面。 + + 背景:src.api.app 先 `import src.data.sources`,其模块级预热在 + `src.data.bzzoiro` 尚未初始化时发起,导入链在 sources.py 内成环, + 首次导入必然失败(bzzoiro 仍在加载中)。修复前 `except Exception: pass` + 把这次失败同时变得「无声」且「不可恢复」,注册表就永久空掉了。 + + 这里用独立子进程验证真实导入次序,不 mock —— 因为该缺陷只在 + 真实的模块初始化时序下才成立,单元测试里的 mock 反而照不出来。 + """ + + @staticmethod + def _run(import_lines: list[str]) -> str: + """在全新解释器中按指定次序导入,返回 get_source/list_sources 结果。""" + import subprocess + import sys + + root = Path(__file__).resolve().parent.parent + code = ( + "import sys; sys.path.insert(0, r'%s')\n" % root + + "\n".join(import_lines) + + "\nfrom src.data.sources import get_source, list_sources\n" + "print('RESULT', get_source('bzzoiro').name, list_sources())\n" + ) + proc = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, text=True, timeout=120, cwd=str(root), + ) + assert proc.returncode == 0, ( + f"导入次序 {import_lines} 下 get_source('bzzoiro') 失败:\n{proc.stderr[-1500:]}" + ) + return proc.stdout.strip() + + def test_sources_first_then_bzzoiro(self): + """次序 B:sources 先导入(正常应用路径)。""" + out = self._run(["import src.data.sources"]) + assert out == "RESULT bzzoiro ['bzzoiro']", out + + def test_bzzoiro_first_then_sources(self): + """次序 A:bzzoiro 先导入 —— 注册表必须仍然可用(自愈)。""" + out = self._run(["import src.data.bzzoiro"]) + assert out == "RESULT bzzoiro ['bzzoiro']", ( + out + " —— 注册表为空说明首次装载失败后没有再重试(静默失效回归)" + ) + + def test_unknown_name_still_raises(self): + """真正未知的名字仍须抛 ValueError —— 修复不应放宽这一契约。""" + with pytest.raises(ValueError, match="未知数据源"): + get_source("no_such_source_xyz") + + class TestNoSilentImportSwallow: """sources.py 不许再用 `except Exception: pass` 吞掉导入失败。"""