"""sys.modules 临时打桩与快照还原工具。 测试常需在 import 目标模块前,用假模块替换其依赖(避免连真实库 / 外部服务 / 重依赖)。 若打桩后不还原,假模块会残留在 ``sys.modules`` 中污染后续测试的 import。本模块提供两类能力: 1. :func:`stub_modules` —— 上下文管理器,进入时替换、退出时精确还原; 2. :func:`snapshot_modules` / :func:`restore_modules` —— 快照与还原 ``sys.modules``, 供测试在 setUp/tearDown 做整体自隔离,消除测试间通过 ``sys.modules`` 传播的污染。 """ import sys from contextlib import contextmanager from typing import Any, Dict, Iterator, Optional @contextmanager def stub_modules(stubs: Dict[str, Any]) -> Iterator[None]: """在上下文内用假模块临时替换 ``sys.modules`` 中的指定项,退出时还原。 典型用法:在测试模块顶层包裹依赖打桩的 import,使打桩只在 import 期生效、 随后立即还原,从而既满足导入需求又不污染其他测试。 :param stubs: ``{模块全名: 假模块对象}``,假模块通常为 ``MagicMock()`` 或自建桩。 用例:: with stub_modules({"app.helper.sites": MagicMock()}): from app.chain.media import MediaChain # 此处 app.helper.sites 已还原为真实模块,MediaChain 已绑定可用 """ saved: Dict[str, Any] = {} for name, module in stubs.items(): saved[name] = sys.modules.get(name) sys.modules[name] = module try: yield finally: for name, original in saved.items(): if original is None: sys.modules.pop(name, None) else: sys.modules[name] = original def snapshot_modules(prefix: Optional[str] = None) -> Dict[str, Any]: """对当前 ``sys.modules`` 取浅快照,用于稍后还原。 :param prefix: 仅快照名称匹配该前缀的模块(如 ``"app."``);为 ``None`` 时快照全部。 还原以快照为准,能恢复被替换、删除的条目,并移除快照后新增的条目。 :return: 快照字典(模块名 -> 模块对象),传给 :func:`restore_modules`。 """ if prefix is None: return dict(sys.modules) # 归一去掉末尾点后按"精确父模块或其子模块路径"匹配,避免 prefix="app" 误配到 "apple" parent = prefix.rstrip(".") return {k: v for k, v in sys.modules.items() if k == parent or k.startswith(parent + ".")} def restore_modules(snapshot: Dict[str, Any], prefix: Optional[str] = None) -> None: """把 ``sys.modules`` 还原到 :func:`snapshot_modules` 的状态。 被替换 / 删除的恢复为快照值;快照之后新增的(同前缀范围内)移除,避免假桩残留。 :param snapshot: :func:`snapshot_modules` 返回的快照。 :param prefix: 还原范围前缀;须与取快照时一致。为 ``None`` 时按全量还原。 """ if prefix is None: in_scope = lambda name: True # noqa: E731 else: head = prefix.rstrip(".") # 同 snapshot_modules:精确父模块或其子模块路径,避免 prefix="app" 误配 "apple" in_scope = lambda name: name == head or name.startswith(head + ".") # noqa: E731 # 移除范围内、快照中没有的新增项(通常是测试塞入的假桩) for name in [n for n in sys.modules if in_scope(n) and n not in snapshot]: sys.modules.pop(name, None) # 恢复范围内被替换/删除的项 for name, module in snapshot.items(): if in_scope(name) and sys.modules.get(name) is not module: sys.modules[name] = module