mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
refactor(architecture): 修复模块依赖违规并强化架构守护
- 字幕编排上移 DownloadChain.download_site_subtitles,SubtitleModule 仅保留站点链接解析 - TransferChain.recommend_name 上移 TV episodes_info 获取,filemanager 模块不再导入 TmdbChain - endpoint 穿透修复:WXBizMsgCrypt3 迁至 adapters/external/wechat_crypt.py; music/tmdb 缓存管理、listenbrainz 常量、TMDbException、WechatClawBot 辅助统一经 chain 包装 - RuleParser 与 builtin_rules 合并为 application/filter_rules.py; fsproxy/fsworker 迁至 adapters/system/ - chain/__init__.py 删除 qbittorrentapi/transmission_rpc 导入,消除后端协议类型泄漏 - 架构守护测试新增三项检查:模块间隔离、入口层穿透、下载器 SDK 泄漏 - 文档同步:05-architecture.md 记录 DB/Oper 聚合例外与迁移文件位置,AGENTS.md 更新所有权表
This commit is contained in:
@@ -428,3 +428,65 @@ def test_resource_adapter_does_not_restart_process():
|
||||
set(modules),
|
||||
)
|
||||
assert "app.runtime.state" not in dependencies
|
||||
|
||||
|
||||
def test_modules_do_not_import_other_modules_or_chain():
|
||||
"""模块之间以及模块对链层的直接依赖被禁止,跨模块编排归链层。"""
|
||||
modules = _discover_modules()
|
||||
known_modules = set(modules)
|
||||
violations: dict[str, set[str]] = {}
|
||||
for module_name, path in modules.items():
|
||||
if not module_name.startswith("app.modules."):
|
||||
continue
|
||||
own_package = module_name.split(".")[2]
|
||||
dependencies = _resolve_imports(module_name, path, known_modules)
|
||||
forbidden = {
|
||||
dependency
|
||||
for dependency in dependencies
|
||||
if dependency.startswith("app.chain")
|
||||
or (
|
||||
dependency.startswith("app.modules.")
|
||||
and dependency.split(".")[2] != own_package
|
||||
)
|
||||
}
|
||||
if forbidden:
|
||||
violations[module_name] = forbidden
|
||||
assert violations == {}
|
||||
|
||||
|
||||
def test_entrypoints_do_not_import_module_internals():
|
||||
"""入口层不得穿透导入具体模块实现,应经由链层或应用服务。"""
|
||||
modules = _discover_modules()
|
||||
known_modules = set(modules)
|
||||
entrypoint_roots = ("app.api", "app.agent", "app.monitor", "app.workflow", "app.doctor")
|
||||
violations: dict[str, set[str]] = {}
|
||||
for module_name, path in modules.items():
|
||||
if not module_name.startswith(entrypoint_roots):
|
||||
continue
|
||||
dependencies = _resolve_imports(module_name, path, known_modules)
|
||||
forbidden = {
|
||||
dependency
|
||||
for dependency in dependencies
|
||||
if dependency.startswith("app.modules.")
|
||||
}
|
||||
if forbidden:
|
||||
violations[module_name] = forbidden
|
||||
assert violations == {}
|
||||
|
||||
|
||||
def test_chain_does_not_import_downloader_sdks():
|
||||
"""链层不得引入下载器后端协议类型,避免后端细节泄漏到编排层。"""
|
||||
forbidden_sdks = {"qbittorrentapi", "transmission_rpc"}
|
||||
violations: list[str] = []
|
||||
for path in (APP_ROOT / "chain").rglob("*.py"):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
|
||||
for node in ast.walk(tree):
|
||||
names: list[str] = []
|
||||
if isinstance(node, ast.Import):
|
||||
names.extend(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
||||
names.append(node.module)
|
||||
if any(name.split(".")[0] in forbidden_sdks for name in names):
|
||||
violations.append(str(path.relative_to(PROJECT_ROOT)))
|
||||
break
|
||||
assert violations == []
|
||||
|
||||
@@ -148,6 +148,7 @@ def test_download_single_submits_download_added_to_background(monkeypatch):
|
||||
chain = DownloadChain.__new__(DownloadChain)
|
||||
chain.download = MagicMock(return_value=("qb", "hash123", "Original", "添加下载成功"))
|
||||
chain.download_added = MagicMock()
|
||||
chain.download_site_subtitles = MagicMock()
|
||||
chain.eventmanager = MagicMock()
|
||||
chain.eventmanager.send_event.return_value = None
|
||||
chain.post_message = MagicMock()
|
||||
@@ -191,6 +192,11 @@ def test_download_single_submits_download_added_to_background(monkeypatch):
|
||||
download_dir=Path("/downloads"),
|
||||
torrent_content=b"torrent-content",
|
||||
)
|
||||
chain.download_site_subtitles.assert_called_once_with(
|
||||
context=context,
|
||||
download_dir=Path("/downloads"),
|
||||
torrent_content=b"torrent-content",
|
||||
)
|
||||
|
||||
|
||||
def test_download_single_supplements_category_before_download_event(monkeypatch):
|
||||
@@ -268,6 +274,7 @@ def test_download_single_persists_custom_words_snapshot(monkeypatch):
|
||||
chain = DownloadChain.__new__(DownloadChain)
|
||||
chain.download = MagicMock(return_value=("qb", "hash123", "Original", "添加下载成功"))
|
||||
chain.download_added = MagicMock()
|
||||
chain.download_site_subtitles = MagicMock()
|
||||
chain.eventmanager = MagicMock()
|
||||
chain.eventmanager.send_event.return_value = None
|
||||
chain.post_message = MagicMock()
|
||||
|
||||
@@ -17,7 +17,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.filemanager.fsproxy import FileSystemProxy, FileSystemTimeout
|
||||
from app.adapters.system.fsproxy import FileSystemProxy, FileSystemTimeout
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -119,7 +119,7 @@ def test_timeout_raises_and_kills_worker(tmp_path, monkeypatch):
|
||||
这是整个代理存在的意义:挂载不返回时,调用方必须在有限时间内拿到异常,
|
||||
而且冻住的进程要被真正杀掉——不能像线程那样永久悬挂。
|
||||
"""
|
||||
import app.modules.filemanager.fsproxy as fsproxy_module
|
||||
import app.adapters.system.fsproxy as fsproxy_module
|
||||
|
||||
# 用一个必定挂死的 worker 替身,模拟 stat 永不返回的挂载
|
||||
stuck_worker = tmp_path / "stuck_worker.py"
|
||||
@@ -154,7 +154,7 @@ def test_proxy_recovers_after_timeout(tmp_path, monkeypatch):
|
||||
超时杀掉代理后,下一次调用要能用新代理正常工作——否则一次挂载抖动
|
||||
就会让文件操作永久不可用。
|
||||
"""
|
||||
import app.modules.filemanager.fsproxy as fsproxy_module
|
||||
import app.adapters.system.fsproxy as fsproxy_module
|
||||
|
||||
stuck_worker = tmp_path / "stuck_worker.py"
|
||||
stuck_worker.write_text("import time\nwhile True:\n time.sleep(60)\n", encoding="utf-8")
|
||||
@@ -195,7 +195,7 @@ def test_worker_does_not_import_app_package():
|
||||
worker 必须只依赖标准库:一旦触发 app/__init__.py 的导入链,启动成本会从
|
||||
毫秒级涨到秒级,代理被强杀后的重启就不再可行。
|
||||
"""
|
||||
worker = Path("app/modules/filemanager/fsworker.py").read_text(encoding="utf-8")
|
||||
worker = Path("app/adapters/system/fsworker.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "from app." not in worker
|
||||
assert "import app" not in worker
|
||||
@@ -206,7 +206,7 @@ def test_worker_runs_standalone_without_app_on_path(tmp_path):
|
||||
直接执行 worker 脚本必须成功,且不依赖项目根在 sys.path 上
|
||||
——这是「绕开 app 导入链」这一设计前提的实证。
|
||||
"""
|
||||
worker_path = Path("app/modules/filemanager/fsworker.py").resolve()
|
||||
worker_path = Path("app/adapters/system/fsworker.py").resolve()
|
||||
media = tmp_path / "x.mkv"
|
||||
media.write_bytes(b"xyz")
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.filemanager.fsproxy import FileSystemProxy, FileSystemTimeout
|
||||
from app.adapters.system.fsproxy import FileSystemProxy, FileSystemTimeout
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -105,7 +105,7 @@ def test_stalled_transfer_is_detected_and_killed(tmp_path, monkeypatch):
|
||||
"""
|
||||
关键区分之二:传输彻底不推进时必须被判定并强杀,而不是永久等待。
|
||||
"""
|
||||
import app.modules.filemanager.fsproxy as fsproxy_module
|
||||
import app.adapters.system.fsproxy as fsproxy_module
|
||||
|
||||
# worker 替身:读到请求后完全不响应,模拟卡死在挂载上的传输
|
||||
stuck = tmp_path / "stuck_worker.py"
|
||||
@@ -148,7 +148,7 @@ def test_copy_falls_back_to_direct_when_disabled(tmp_path, monkeypatch):
|
||||
"""
|
||||
代理关闭时复制退回进程内直接执行,行为与引入代理之前一致。
|
||||
"""
|
||||
import app.modules.filemanager.fsproxy as fsproxy_module
|
||||
import app.adapters.system.fsproxy as fsproxy_module
|
||||
|
||||
monkeypatch.setattr(fsproxy_module.settings, "FS_PROXY_ENABLED", False, raising=False)
|
||||
src = tmp_path / "a.mkv"
|
||||
@@ -169,7 +169,7 @@ def test_direct_copy_honours_cancel(tmp_path, monkeypatch):
|
||||
"""
|
||||
关闭代理时取消同样要生效,否则关掉开关就丢了取消能力。
|
||||
"""
|
||||
import app.modules.filemanager.fsproxy as fsproxy_module
|
||||
import app.adapters.system.fsproxy as fsproxy_module
|
||||
|
||||
monkeypatch.setattr(fsproxy_module.settings, "FS_PROXY_ENABLED", False, raising=False)
|
||||
src = tmp_path / "a.mkv"
|
||||
@@ -188,7 +188,7 @@ def test_worker_still_standalone_after_streaming_support():
|
||||
加了流式协议之后 worker 仍须只依赖标准库——一旦引入 app 导入链,
|
||||
强杀后的重启成本会从毫秒级涨到秒级,整个代理方案就不成立了。
|
||||
"""
|
||||
worker = Path("app/modules/filemanager/fsworker.py").read_text(encoding="utf-8")
|
||||
worker = Path("app/adapters/system/fsworker.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "from app." not in worker
|
||||
assert "import app" not in worker
|
||||
|
||||
@@ -253,7 +253,7 @@ def test_watchdog_survives_blocking_exists_in_real_rebuild(tmp_path, monkeypatch
|
||||
return {"size": 0, "mtime": 0.0, "is_dir": True, "is_file": False}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.modules.filemanager.fsproxy.fsproxy.stat", blocking_stat
|
||||
"app.adapters.system.fsproxy.fsproxy.stat", blocking_stat
|
||||
)
|
||||
monkeypatch.setattr("app.monitor.monitor.probe_path", lambda *_a, **_kw: False)
|
||||
|
||||
|
||||
@@ -314,7 +314,9 @@ def test_music_cache_endpoint_returns_management_statistics(monkeypatch):
|
||||
"recognized": {"media_id": "rec-1", "title": "晴天"},
|
||||
"unrecognized": {"media_id": "", "title": "未知曲目"},
|
||||
})
|
||||
monkeypatch.setattr(music_endpoint, "MusicBrainzCache", lambda: cache)
|
||||
monkeypatch.setattr(
|
||||
music_endpoint.MusicBrainzChain, "cache_items", staticmethod(cache.list_items)
|
||||
)
|
||||
|
||||
response = asyncio.run(music_endpoint.music_recognition_cache(None))
|
||||
|
||||
@@ -328,7 +330,9 @@ def test_music_cache_endpoint_returns_management_statistics(monkeypatch):
|
||||
def test_music_cache_delete_endpoint_reports_missing_item(monkeypatch):
|
||||
"""删除接口应区分成功删除与缓存不存在。"""
|
||||
cache = _build_music_cache({"existing": {"media_id": "rec-1"}})
|
||||
monkeypatch.setattr(music_endpoint, "MusicBrainzCache", lambda: cache)
|
||||
monkeypatch.setattr(
|
||||
music_endpoint.MusicBrainzChain, "delete_cache", staticmethod(cache.delete)
|
||||
)
|
||||
|
||||
deleted_response = asyncio.run(
|
||||
music_endpoint.delete_music_recognition_cache("existing", None)
|
||||
@@ -344,7 +348,9 @@ def test_music_cache_delete_endpoint_reports_missing_item(monkeypatch):
|
||||
def test_music_cache_clear_endpoint_removes_all_items(monkeypatch):
|
||||
"""清空接口应删除全部音乐识别缓存。"""
|
||||
cache = _build_music_cache({"existing": {"media_id": "rec-1"}})
|
||||
monkeypatch.setattr(music_endpoint, "MusicBrainzCache", lambda: cache)
|
||||
monkeypatch.setattr(
|
||||
music_endpoint.MusicBrainzChain, "clear_cache", staticmethod(cache.clear)
|
||||
)
|
||||
|
||||
response = asyncio.run(music_endpoint.clear_music_recognition_cache(None))
|
||||
|
||||
|
||||
@@ -274,7 +274,9 @@ def test_tmdb_cache_endpoint_returns_management_statistics(monkeypatch):
|
||||
"unrecognized": {"id": 0},
|
||||
})
|
||||
get_system_config = Mock(return_value=7)
|
||||
monkeypatch.setattr(tmdb_endpoint, "TmdbCache", lambda: cache)
|
||||
monkeypatch.setattr(
|
||||
tmdb_endpoint.TmdbChain, "cache_items", staticmethod(cache.list_items)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
tmdb_endpoint,
|
||||
"SystemConfigOper",
|
||||
@@ -298,7 +300,9 @@ def test_tmdb_cache_endpoint_returns_management_statistics(monkeypatch):
|
||||
def test_tmdb_cache_delete_endpoint_reports_missing_item(monkeypatch):
|
||||
"""删除接口应区分成功删除与缓存不存在。"""
|
||||
cache = _build_tmdb_cache({"existing": {"id": 1}})
|
||||
monkeypatch.setattr(tmdb_endpoint, "TmdbCache", lambda: cache)
|
||||
monkeypatch.setattr(
|
||||
tmdb_endpoint.TmdbChain, "delete_cache", staticmethod(cache.delete)
|
||||
)
|
||||
|
||||
deleted_response = asyncio.run(
|
||||
tmdb_endpoint.delete_tmdb_recognition_cache("existing", None)
|
||||
@@ -314,7 +318,9 @@ def test_tmdb_cache_delete_endpoint_reports_missing_item(monkeypatch):
|
||||
def test_tmdb_cache_clear_endpoint_removes_all_items(monkeypatch):
|
||||
"""清空接口应删除全部识别缓存。"""
|
||||
cache = _build_tmdb_cache({"existing": {"id": 1}})
|
||||
monkeypatch.setattr(tmdb_endpoint, "TmdbCache", lambda: cache)
|
||||
monkeypatch.setattr(
|
||||
tmdb_endpoint.TmdbChain, "clear_cache", staticmethod(cache.clear)
|
||||
)
|
||||
|
||||
response = asyncio.run(tmdb_endpoint.clear_tmdb_recognition_cache(None))
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from unittest.mock import patch
|
||||
from app.domain.context import MediaInfo, TorrentInfo
|
||||
from app.application.torrent import TorrentHelper
|
||||
from app.modules.filter import FilterModule
|
||||
from app.modules.filter.builtin_rules import BUILTIN_RULE_SET
|
||||
from app.application.filter_rules import BUILTIN_RULE_SET
|
||||
from app.adapters.system import rust as rust_accel
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user