mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
351 lines
11 KiB
Python
351 lines
11 KiB
Python
"""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
|