"""async 阻塞调用 ratchet 与 debug 模式测试。""" import asyncio import textwrap from pathlib import Path import pytest from scripts.architecture.async_blocking import ( SCAN_ROOTS, collect_async_blocking, compare_async_blocking, ) def _scan_source( tmp_path: Path, source: str, *, oper_sources: dict[str, str] | None = None, ) -> dict[str, int]: """构造最小仓库并通过公开扫描入口验证源码,而非测试内部 AST 细节。""" api_root = tmp_path / "app/api" api_root.mkdir(parents=True) (api_root / "sample.py").write_text(textwrap.dedent(source), encoding="utf-8") for filename, oper_source in (oper_sources or {}).items(): oper_path = tmp_path / "app/db/oper" / filename oper_path.parent.mkdir(parents=True, exist_ok=True) oper_path.write_text(textwrap.dedent(oper_source), encoding="utf-8") return collect_async_blocking(tmp_path, scan_roots=("app/api",)) def test_async_blocking_scan_covers_runtime_entrypoints() -> None: """扫描范围必须覆盖全部 canonical 宿主目录和顶层运行入口。""" assert { "app/adapters", "app/api", "app/agent", "app/application", "app/chain", "app/db", "app/doctor", "app/domain", "app/foundation", "app/monitor", "app/modules", "app/runtime", "app/schemas", "app/startup", "app/workflow", "app/cli.py", "app/command.py", "app/factory.py", "app/main.py", "app/scheduler.py", }.issubset({str(path) for path in SCAN_ROOTS}) def test_async_blocking_ratchet_allows_removal_and_rejects_growth() -> None: """存量减少合法,新增或增加直接阻塞调用必须失败。""" baseline = { "app/api/a.py:stream:Path.read_text": 2, "app/agent/a.py:run:time.sleep": 1, } reduced = {"app/api/a.py:stream:Path.read_text": 1} increased = { "app/api/a.py:stream:Path.read_text": 3, "app/application/a.py:load:requests.get": 1, } assert compare_async_blocking(baseline, reduced) == [] problems = compare_async_blocking(baseline, increased) assert any("增长" in problem for problem in problems) assert any("新增" in problem for problem in problems) @pytest.mark.parametrize( ("source", "expected"), [ ( """ from app.adapters.network.http import RequestUtils as RU async def load(): client = RU() alias = client return alias.get_res("https://example.com") """, {"app/api/sample.py:load:RequestUtils.get_res": 1}, ), ( """ import app.adapters.network.http as http async def submit(): return http.RequestUtils().post_res("https://example.com") """, {"app/api/sample.py:submit:RequestUtils.post_res": 1}, ), ( """ from pathlib import Path as P async def inspect_file(): path = P("payload") / "item.json" return path.exists() """, {"app/api/sample.py:inspect_file:Path.exists": 1}, ), ( """ import shutil as files from subprocess import run as run_process async def cleanup(): files.rmtree("payload") run_process(["true"]) """, { "app/api/sample.py:cleanup:shutil.rmtree": 1, "app/api/sample.py:cleanup:subprocess.run": 1, }, ), ( """ import os as operating_system import time as clock from requests import Session as HttpSession async def legacy_io(): open("payload") operating_system.listdir(".") clock.sleep(0.1) HttpSession().get("https://example.com") """, { "app/api/sample.py:legacy_io:Session.get": 1, "app/api/sample.py:legacy_io:open": 1, "app/api/sample.py:legacy_io:os.listdir": 1, "app/api/sample.py:legacy_io:time.sleep": 1, }, ), ( """ from pathlib import Path async def outer(): async def inner(): return Path("payload").read_text() return await inner() """, {"app/api/sample.py:outer.inner:Path.read_text": 1}, ), ( """ from pathlib import Path async def inspect_files(files: list[Path]): for file in files: if file.is_file(): return file return None """, {"app/api/sample.py:inspect_files:Path.is_file": 1}, ), ], ) def test_async_blocking_scan_resolves_imports_aliases_and_nested_async( tmp_path: Path, source: str, expected: dict[str, int], ) -> None: """别名、简单局部传播和嵌套 async 都必须进入真实扫描结果。""" assert _scan_source(tmp_path, source) == expected def test_async_blocking_scan_uses_oper_source_method_kinds(tmp_path: Path) -> None: """Oper 仅按源码中真实同步方法报告,不依赖方法名前缀猜测。""" source = """ from app.db.oper import SiteOper from app.db.oper.site import SiteOper as SO async def load(oper: SO): oper.list() return await oper.get_by_id(1) async def load_from_facade(): return SiteOper().list() """ oper_sources = { "site.py": """ class SiteOper: def list(self): return [] async def get_by_id(self, site_id): return None """, } assert _scan_source(tmp_path, source, oper_sources=oper_sources) == { "app/api/sample.py:load:SiteOper.list": 1, "app/api/sample.py:load_from_facade:SiteOper.list": 1, } def test_async_blocking_scan_exempts_async_apis_and_memory_reads( tmp_path: Path, ) -> None: """异步实现、受控 worker 与内存配置读取不得成为阻塞债务。""" source = """ import asyncio import anyio import subprocess from anyio import Path as AsyncPath from app.agent.tools.base import run_agent_blocking from app.adapters.network.http import AsyncRequestUtils from app.db.oper.systemconfig import SystemConfigOper from app.runtime.execution import run_in_threadpool as runtime_worker from fastapi.concurrency import run_in_threadpool async def load(config: SystemConfigOper): await AsyncRequestUtils().get_res("https://example.com") await AsyncPath("payload").exists() await run_in_threadpool(lambda: subprocess.run(["true"])) await runtime_worker(lambda: subprocess.run(["true"])) await run_agent_blocking("plugin", lambda: subprocess.run(["true"])) await asyncio.to_thread(lambda: subprocess.run(["true"])) await anyio.to_thread.run_sync(lambda: subprocess.run(["true"])) deferred = lambda: subprocess.run(["true"]) assert deferred return config.get("key") def ordinary_sync(): subprocess.run(["true"]) """ oper_sources = { "systemconfig.py": """ class SystemConfigOper: def get(self, key): return key """, } assert _scan_source(tmp_path, source, oper_sources=oper_sources) == {} def test_async_blocking_scan_checks_worker_arguments_evaluated_on_loop( tmp_path: Path, ) -> None: """worker 调用前同步求值的普通参数仍在事件循环中执行。""" source = """ import asyncio from pathlib import Path async def load(): await asyncio.to_thread(print, Path("payload").read_text()) """ assert _scan_source(tmp_path, source) == { "app/api/sample.py:load:Path.read_text": 1, } def test_async_blocking_scan_merges_branch_bindings_conservatively( tmp_path: Path, ) -> None: """任一互斥分支可能产生同步对象时,合流调用仍属于阻塞风险。""" source = """ from anyio import Path as AsyncPath from pathlib import Path async def load(use_sync: bool): if use_sync: target = Path("payload") else: target = AsyncPath("payload") return target.read_text() """ assert _scan_source(tmp_path, source) == { "app/api/sample.py:load:Path.read_text": 1, } def test_nested_async_inherits_bindings_from_definition_scope(tmp_path: Path) -> None: """嵌套 async 使用定义点已有的局部 import 和别名。""" source = """ async def outer(): from pathlib import Path as LocalPath alias = LocalPath async def inner(): return alias("payload").read_text() return await inner() """ assert _scan_source(tmp_path, source) == { "app/api/sample.py:outer.inner:Path.read_text": 1, } def test_definition_time_expressions_remain_in_async_execution_body( tmp_path: Path, ) -> None: """延迟函数体不扫描,但默认值和 decorator 在定义时立即求值。""" source = """ import asyncio from pathlib import Path def register(value): return lambda function: function async def outer(): await asyncio.to_thread( lambda value=Path("lambda").read_text(): value ) @register(Path("decorator").read_text()) async def inner(value=Path("default").read_text()): return value return await inner() """ assert _scan_source(tmp_path, source) == { "app/api/sample.py:outer:Path.read_text": 3, } def test_local_shadowing_does_not_reuse_import_or_builtin_bindings( tmp_path: Path, ) -> None: """参数和 comprehension target 会遮蔽同名 builtin 或导入符号。""" source = """ from pathlib import Path async def invoke(open, items): open() return [Path.exists() for Path in items] """ assert _scan_source(tmp_path, source) == {} @pytest.mark.asyncio async def test_asyncio_debug_is_enabled_for_async_tests() -> None: """专项异步测试必须启用慢 callback 和阻塞诊断所需的 debug 模式。""" assert asyncio.get_running_loop().get_debug() is True