From d58c8d2b17f94fb9ab1c5dc04a4769bd7a413d49 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:05:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E5=A4=87=E4=BB=BD=E7=AE=A1=E7=90=86=E4=B8=8E=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E8=AF=BB=E5=8F=96=20(#6450)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 13 +- app/adapters/external/market.py | 10 +- app/adapters/external/server.py | 25 +- app/adapters/system/backup/files.py | 18 +- app/adapters/system/host.py | 4 +- app/adapters/system/update.py | 16 +- app/api/endpoints/mcp.py | 9 +- app/api/endpoints/system.py | 118 +- app/api/servarr.py | 4 +- app/application/backup.py | 69 +- app/application/database.py | 4 + app/chain/system.py | 30 +- app/cli.py | 25 +- app/doctor/runner.py | 4 +- app/factory.py | 4 +- app/foundation/version.py | 4 + app/locales/en-US.json | 11 + app/locales/zh-TW.json | 11 + app/runtime/config.py | 4 +- app/runtime/version.py | 42 + app/schemas/exports.py | 2 + app/schemas/system.py | 17 + docs/cli.md | 11 +- scripts/architecture/mypy_ratchet.py | 9 +- .../architecture/dependency-baseline.json | 31 +- .../fixtures/architecture/mypy-baseline.json | 1405 +++++++++++++---- .../startup-performance-baseline.json | 6 +- tests/test_api_authorization.py | 4 + tests/test_dashboard_system_info.py | 4 +- tests/test_database_backup_cli_sdk.py | 2 +- tests/test_database_backup_service.py | 87 +- tests/test_database_migration_startup.py | 2 +- tests/test_doctor.py | 14 +- tests/test_locale_helper.py | 57 + tests/test_mypy_gate.py | 18 +- tests/test_system_database_backup_api.py | 167 ++ tests/test_system_update_manager.py | 2 +- tests/test_system_version.py | 42 + 38 files changed, 1846 insertions(+), 459 deletions(-) create mode 100644 app/runtime/version.py create mode 100644 tests/test_system_database_backup_api.py create mode 100644 tests/test_system_version.py diff --git a/.gitignore b/.gitignore index 377067eb3..ed587a3c5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,17 +15,8 @@ app/application/site/*.bin app/helper/*.bin app/plugins/** !app/plugins/__init__.py -config/cookies/ -config/app.env -config/user.db* -config/systemconfig.db* -config/sites/** -config/agent/ -config/logs/ -config/plugins/ -config/temp/ -config/cache/ -config/.cache/ +config/* +!config/category.yaml # 运行期设置持久化目录(settings 写回 app.env 的落点)与本地验证产物 app/config/ .verify_tmp/ diff --git a/app/adapters/external/market.py b/app/adapters/external/market.py index 0ddc57aa8..1467ae326 100644 --- a/app/adapters/external/market.py +++ b/app/adapters/external/market.py @@ -62,7 +62,7 @@ from app.foundation.singleton import WeakSingleton from app.foundation.version import compare_version from app.adapters.system.host import SystemUtils from app.foundation.url import UrlUtils -from version import APP_VERSION +from app.runtime.version import get_app_version # 保留模块级可替换入口,代理默认读取组合根的最新 runtime 配置。 settings = RuntimeSettingsCompat() @@ -289,9 +289,9 @@ class PluginHelper(metaclass=WeakSingleton): 解析当前主程序版本,供插件 package 中的系统版本范围匹配使用。 """ try: - return Version(str(APP_VERSION)) + return Version(get_app_version()) except InvalidVersion: - logger.error(f"当前主程序版本号无法解析:{APP_VERSION}") + logger.error(f"当前主程序版本号无法解析:{get_app_version()}") return None @classmethod @@ -402,7 +402,7 @@ class PluginHelper(metaclass=WeakSingleton): system_version = cls.get_current_system_version() if system_version is None: - return False, f"当前 MoviePilot 版本 {APP_VERSION} 无法解析,已拒绝安装带版本限制的插件" + return False, f"当前 MoviePilot 版本 {get_app_version()} 无法解析,已拒绝安装带版本限制的插件" try: specifier_set = SpecifierSet(raw_specifier) @@ -416,7 +416,7 @@ class PluginHelper(metaclass=WeakSingleton): return True, "" return False, ( - f"插件要求 MoviePilot 版本 {raw_specifier},当前版本 {APP_VERSION} 不满足,已拒绝安装" + f"插件要求 MoviePilot 版本 {raw_specifier},当前版本 {get_app_version()} 不满足,已拒绝安装" ) @classmethod diff --git a/app/adapters/external/server.py b/app/adapters/external/server.py index 8cc31aa87..49d170f82 100644 --- a/app/adapters/external/server.py +++ b/app/adapters/external/server.py @@ -2,7 +2,6 @@ import asyncio import json import platform from collections.abc import Coroutine -from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple, Union from urllib.parse import parse_qs, quote, urlparse, urlsplit @@ -24,7 +23,7 @@ from app.adapters.network.http import AsyncRequestUtils, RequestUtils from app.domain.media import normalize_music_type from app.schemas.media import resolve_media_identity from app.adapters.system.host import SystemUtils -from version import APP_VERSION, FRONTEND_VERSION +from app.runtime.version import get_app_version, get_frontend_version # 保留旧插件可覆盖的模块级入口,默认通过 runtime 代理动态读取配置。 @@ -270,24 +269,6 @@ class MoviePilotServerHelper: or permissions.get("workflow_share_manage") ) - @staticmethod - def get_frontend_version() -> str: - """ - 获取当前前端版本。 - """ - if SystemUtils.is_frozen() and SystemUtils.is_windows(): - version_file = settings.CONFIG_PATH.parent / "nginx" / "html" / "version.txt" - else: - version_file = Path(settings.FRONTEND_PATH) / "version.txt" - if version_file.exists(): - try: - with open(version_file, "r", encoding="utf-8", errors="replace") as file: - version = str(file.read()).strip() - return version or FRONTEND_VERSION - except Exception as err: - logger.debug(f"加载版本文件 {version_file} 出错:{str(err)}") - return FRONTEND_VERSION - @classmethod def build_usage_payload(cls) -> Dict[str, Any]: """ @@ -295,8 +276,8 @@ class MoviePilotServerHelper: """ return { "user_uid": cls.get_user_uid(), - "backend_version": APP_VERSION, - "frontend_version": cls.get_frontend_version(), + "backend_version": get_app_version(), + "frontend_version": get_frontend_version(), "version_flag": settings.VERSION_FLAG, "platform": f"{platform.system()} {platform.release()}".strip(), "arch": SystemUtils.cpu_arch(), diff --git a/app/adapters/system/backup/files.py b/app/adapters/system/backup/files.py index 44bce5184..d9b43ee8a 100644 --- a/app/adapters/system/backup/files.py +++ b/app/adapters/system/backup/files.py @@ -8,13 +8,16 @@ import tempfile from datetime import datetime from pathlib import Path +from app.runtime.version import get_app_version _BACKUP_NAME = re.compile( - r"^(?Psqlite|postgresql)_" + r"^(?:moviepilot_(?Pv\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)_)?" + r"(?Psqlite|postgresql)_" r"(?P\d{8}_\d{6})" r"(?:_(?P\d+))?" r"(?P\.db|\.dump)$" ) +_RELEASE_VERSION = re.compile(r"^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$") class BackupFiles: @@ -68,10 +71,19 @@ class BackupFiles: """删除一个已通过名称约束的备份文件。""" self.resolve(name).unlink() - def available_name(self, *, db_type: str, created_at: datetime, suffix: str) -> str: + def available_name( + self, + *, + db_type: str, + created_at: datetime, + suffix: str, + ) -> str: """生成包含数据库类型和秒级时间的简短可读文件名。""" timestamp = created_at.strftime("%Y%m%d_%H%M%S") - base = f"{db_type}_{timestamp}" + version = get_app_version().strip() + if _RELEASE_VERSION.fullmatch(version) is None: + raise ValueError("程序版本号无法用于数据库备份命名") + base = f"moviepilot_{version}_{db_type}_{timestamp}" candidate = f"{base}{suffix}" sequence = 1 while (self.root / candidate).exists(): diff --git a/app/adapters/system/host.py b/app/adapters/system/host.py index d7cb00870..d2d394a17 100644 --- a/app/adapters/system/host.py +++ b/app/adapters/system/host.py @@ -26,7 +26,7 @@ import psutil from app.schemas.dashboard import DashboardMemoryInfo as _SchemaDashboardMemoryInfo from app.schemas.dashboard import DashboardSystemInfo as _SchemaDashboardSystemInfo from app.schemas.dashboard import ProcessInfo as _SchemaProcessInfo -from version import APP_VERSION +from app.runtime.version import get_app_version from app.foundation.environment import ( is_aarch, is_aarch64, @@ -948,7 +948,7 @@ class SystemUtils: hostname=socket.gethostname(), operating_system=SystemUtils._operating_system_name(), runtime=max(0, int(time.time() - psutil.Process().create_time())), - version=APP_VERSION, + version=get_app_version(), ) @staticmethod diff --git a/app/adapters/system/update.py b/app/adapters/system/update.py index 69f0905a7..49351fe4c 100644 --- a/app/adapters/system/update.py +++ b/app/adapters/system/update.py @@ -16,12 +16,12 @@ from typing import Any, Optional from app.adapters.network.http import RequestUtils from app.foundation.singleton import SingletonClass from app.foundation.version import compare_version +from app.runtime.version import get_app_version from app.foundation.environment import is_docker from app.runtime.log import logger from app.runtime.settings import get_runtime_setting from app.runtime.thread import ThreadHelper from app.schemas.system import SystemUpdateStatus -from version import APP_VERSION class SystemUpdateManager(metaclass=SingletonClass): @@ -66,13 +66,17 @@ class SystemUpdateManager(metaclass=SingletonClass): return datetime.now(timezone.utc).isoformat() def _default_state(self) -> dict[str, Any]: - return SystemUpdateStatus(current_version=APP_VERSION).model_dump() + return SystemUpdateStatus(current_version=get_app_version()).model_dump() def _read_state(self) -> dict[str, Any]: try: payload = json.loads(self._state_file.read_text(encoding="utf-8")) if isinstance(payload, dict): - return {**self._default_state(), **payload, "current_version": APP_VERSION} + return { + **self._default_state(), + **payload, + "current_version": get_app_version(), + } except (OSError, json.JSONDecodeError): pass return self._default_state() @@ -81,7 +85,7 @@ class SystemUpdateManager(metaclass=SingletonClass): with self._lock: state = self._read_state() state.update(changes) - state["current_version"] = APP_VERSION + state["current_version"] = get_app_version() state["progress"] = self._progress( state.get("downloaded_bytes", 0), state.get("total_bytes", 0) ) @@ -117,7 +121,7 @@ class SystemUpdateManager(metaclass=SingletonClass): can_update=True, can_install=False, ) - if state.get("state") == "installing" and target == APP_VERSION: + if state.get("state") == "installing" and target == get_app_version(): self._install_file.unlink(missing_ok=True) state = self._write_state( state="idle", @@ -161,7 +165,7 @@ class SystemUpdateManager(metaclass=SingletonClass): if not release: raise RuntimeError("未找到可用的 v3 稳定版本") version = str(release["tag_name"]) - has_update = compare_version(version, "gt", APP_VERSION) is True + has_update = compare_version(version, "gt", get_app_version()) is True return SystemUpdateStatus.model_validate( self._write_state( state="available" if has_update else "idle", diff --git a/app/api/endpoints/mcp.py b/app/api/endpoints/mcp.py index 827fc8c93..e8c7c34f0 100644 --- a/app/api/endpoints/mcp.py +++ b/app/api/endpoints/mcp.py @@ -14,14 +14,9 @@ from app.schemas.response import Response as _SchemaResponse from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter from app.agent.tools.manager import moviepilot_tool_manager from app.adapters.web.security.access import verify_apikey +from app.runtime.version import get_app_version from app.runtime.log import logger -# 导入版本号 -try: - from version import APP_VERSION -except ImportError: - APP_VERSION = "unknown" - router = ResponseAPIRouter() # MCP 协议版本 @@ -213,7 +208,7 @@ async def handle_initialize(params: Dict[str, Any]) -> Dict[str, Any]: }, "serverInfo": { "name": "MoviePilot", - "version": APP_VERSION, + "version": get_app_version(), "description": "MoviePilot MCP Server - 电影自动化管理工具", }, "instructions": "MoviePilot MCP 服务器,提供媒体管理、订阅、下载等工具。", diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index fbce88a38..ec01a4bbe 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -23,6 +23,8 @@ from app.schemas.common import TimeData as _SchemaTimeData from app.schemas.common import ValueData as _SchemaValueData from app.schemas.response import Response as _SchemaResponse from app.schemas.system import NetTestTarget as _SchemaNetTestTarget +from app.schemas.system import DatabaseBackupArtifactData as _SchemaDatabaseBackupArtifactData +from app.schemas.system import DatabaseBackupVerificationData as _SchemaDatabaseBackupVerificationData from app.schemas.system import PluginMarketSyncData as _SchemaPluginMarketSyncData from app.schemas.system import PluginMarketSyncRequest as _SchemaPluginMarketSyncRequest from app.schemas.system import RuleTestData as _SchemaRuleTestData @@ -46,6 +48,8 @@ from app.application.configuration import ( get_configured_system_config, get_runtime_settings, ) +from app.application.backup import DatabaseBackupInProgressError +from app.application.database import get_database_governance from app.application.plugin.runtime import plugin_system_config_mutation from app.api.dependencies.auth import ( get_current_active_superuser, @@ -67,6 +71,7 @@ from app.application.rules import RuleHelper from app.adapters.external.server import MoviePilotServerHelper from app.runtime.state import SystemHelper from app.runtime.log import logger +from app.runtime.execution import run_in_threadpool_to_completion from app.application.scheduling import get_scheduler from app.schemas.event import ConfigChangeEventData from app.schemas.exception import PluginMutationRejectedError @@ -79,7 +84,7 @@ from app.adapters.system.update import system_update_manager from app.application.security.url import SecurityUtils from app.application.network import NetworkTestService from app.foundation.url import UrlUtils -from version import APP_VERSION +from app.runtime.version import get_app_version, get_frontend_version router = ResponseAPIRouter() @@ -110,6 +115,16 @@ _DATABASE_BACKUP_SETTING_KEYS = { } +def _database_backup_artifact_data(artifact: Any) -> _SchemaDatabaseBackupArtifactData: + """将内部备份制品映射为不含宿主路径的 Web DTO。""" + return _SchemaDatabaseBackupArtifactData( + name=artifact.name, + db_type=artifact.db_type, + created_at=artifact.created_at, + size=artifact.size, + ) + + def _validate_llm_server_tool_config(env: dict) -> Optional[str]: """校验强制服务端联网搜索配置,返回用户可读错误信息。""" from app.agent.llm.server_tools import ( @@ -759,8 +774,8 @@ def get_global_setting(token: str): # 追加版本信息(用于版本检查) info.update( { - "FRONTEND_VERSION": SystemChain.get_frontend_version(), - "BACKEND_VERSION": APP_VERSION, + "FRONTEND_VERSION": get_frontend_version(), + "BACKEND_VERSION": get_app_version(), } ) # 仅在后端开发模式下返回该标记,避免生产环境暴露无意义运行态信息 @@ -812,6 +827,99 @@ async def get_user_global_setting(_: ApiPrincipal = Depends(get_current_active_u return _SchemaResponse(success=True, data=info) +@router.get( + "/database/backups", + summary="查询受管数据库备份", + response_model=list[_SchemaDatabaseBackupArtifactData], +) +async def list_database_backups( + _: ApiPrincipal = Depends(get_current_active_superuser_async), +) -> list[_SchemaDatabaseBackupArtifactData]: + """列出当前备份目录中的正式制品,不触发内容校验。""" + try: + artifacts = await run_in_threadpool_to_completion( + get_database_governance().list_backups + ) + except Exception as error: + logger.exception("读取数据库备份列表失败") + raise HTTPException(status_code=500, detail="读取数据库备份列表失败,请查看日志") from error + return [_database_backup_artifact_data(artifact) for artifact in artifacts] + + +@router.post( + "/database/backups", + summary="立即创建数据库备份", + response_model=_SchemaDatabaseBackupArtifactData, +) +async def create_database_backup( + _: ApiPrincipal = Depends(get_current_active_superuser_async), +) -> _SchemaDatabaseBackupArtifactData: + """创建、校验并原子发布当前活动数据库的一致快照。""" + try: + artifact = await run_in_threadpool_to_completion( + get_database_governance().create_backup + ) + except DatabaseBackupInProgressError as error: + raise HTTPException(status_code=409, detail=str(error)) from error + except Exception as error: + logger.exception("创建数据库备份失败") + raise HTTPException(status_code=500, detail="创建数据库备份失败,请查看日志") from error + return _database_backup_artifact_data(artifact) + + +@router.post( + "/database/backups/{name}/verify", + summary="校验受管数据库备份", + response_model=_SchemaDatabaseBackupVerificationData, +) +async def verify_database_backup( + name: str, + _: ApiPrincipal = Depends(get_current_active_superuser_async), +) -> _SchemaDatabaseBackupVerificationData: + """校验一个受管制品,响应不包含宿主路径或适配器错误明细。""" + try: + verification = await run_in_threadpool_to_completion( + get_database_governance().verify_backup, + name, + ) + except ValueError as error: + raise HTTPException(status_code=400, detail="数据库备份文件名无效") from error + except FileNotFoundError as error: + raise HTTPException(status_code=404, detail="数据库备份不存在") from error + except Exception as error: + logger.exception("校验数据库备份失败:%s", name) + raise HTTPException(status_code=500, detail="校验数据库备份失败,请查看日志") from error + return _SchemaDatabaseBackupVerificationData( + valid=verification.valid, + method=verification.method, + ) + + +@router.delete( + "/database/backups/{name}", + summary="删除受管数据库备份", + response_model=_SchemaResponse[None], +) +async def delete_database_backup( + name: str, + _: ApiPrincipal = Depends(get_current_active_superuser_async), +) -> _SchemaResponse[None]: + """删除一个受管制品,只接受备份目录内的合法文件名。""" + try: + await run_in_threadpool_to_completion( + get_database_governance().delete_backup, + name, + ) + except ValueError as error: + raise HTTPException(status_code=400, detail="数据库备份文件名无效") from error + except FileNotFoundError as error: + raise HTTPException(status_code=404, detail="数据库备份不存在") from error + except Exception as error: + logger.exception("删除数据库备份失败:%s", name) + raise HTTPException(status_code=500, detail="删除数据库备份失败,请查看日志") from error + return _SchemaResponse(success=True) + + @router.get( "/env", summary="查询系统配置", @@ -828,10 +936,10 @@ async def get_env_setting( ) info.update( { - "VERSION": APP_VERSION, + "VERSION": get_app_version(), "AUTH_VERSION": SitesHelper().auth_version, "INDEXER_VERSION": SitesHelper().indexer_version, - "FRONTEND_VERSION": SystemChain().get_frontend_version(), + "FRONTEND_VERSION": get_frontend_version(), "RUST_ACCEL": rust_accel.is_config_enabled(), "RUST_ACCEL_AVAILABLE": rust_accel.is_available(), "RUST_ACCEL_ENABLED": rust_accel.is_enabled(), diff --git a/app/api/servarr.py b/app/api/servarr.py index f09b299a9..ace59ebf3 100644 --- a/app/api/servarr.py +++ b/app/api/servarr.py @@ -23,7 +23,7 @@ from app.api.dependencies.subscription import get_servarr_subscription_service from app.schemas.servarr import RadarrMovie from app.schemas.servarr import SonarrSeries from app.schemas.types import MediaSource, MediaType -from version import APP_VERSION +from app.runtime.version import get_app_version arr_router = APIRouter(tags=["servarr"], responses=ERROR_RESPONSES) @@ -74,7 +74,7 @@ async def arr_system_status( return _SchemaServarrSystemStatus.model_validate({ "appName": "MoviePilot", "instanceName": "moviepilot", - "version": APP_VERSION, + "version": get_app_version(), "buildTime": "", "isDebug": False, "isProduction": True, diff --git a/app/application/backup.py b/app/application/backup.py index 03654dc7c..fcb2f1e7c 100644 --- a/app/application/backup.py +++ b/app/application/backup.py @@ -5,6 +5,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta from pathlib import Path +from threading import Lock from typing import Callable, Protocol from app.adapters.system.backup.files import BackupFiles @@ -44,6 +45,10 @@ class BackupVerification: detail: str | None = None +class DatabaseBackupInProgressError(RuntimeError): + """同一宿主进程已有数据库备份正在创建。""" + + class BackupCheck(Protocol): """数据库适配器校验结果的结构合同。""" @@ -81,38 +86,44 @@ class DatabaseBackupService: self._backend = backend self._policy_reader = policy_reader self._clock = clock + self._create_lock = Lock() def create(self) -> BackupArtifact: """创建、校验并发布一个在线一致快照。""" - policy = self._policy_reader() - files = BackupFiles(policy.root) - created_at = self._clock() - name = files.available_name( - db_type=self._backend.db_type, - created_at=created_at, - suffix=self._backend.suffix, - ) - temporary = files.create_temporary(self._backend.suffix) + if not self._create_lock.acquire(blocking=False): + raise DatabaseBackupInProgressError("已有数据库备份任务正在执行") try: - self._backend.create(temporary) - verification = self._backend.verify(temporary) - if not verification.valid: - detail = f":{verification.detail}" if verification.detail else "" - raise RuntimeError( - f"数据库备份校验失败({verification.method}){detail}" - ) - path = files.publish(temporary, name) - except Exception: - files.discard(temporary) - raise + policy = self._policy_reader() + files = BackupFiles(policy.root) + created_at = self._clock() + name = files.available_name( + db_type=self._backend.db_type, + created_at=created_at, + suffix=self._backend.suffix, + ) + temporary = files.create_temporary(self._backend.suffix) + try: + self._backend.create(temporary) + verification = self._backend.verify(temporary) + if not verification.valid: + detail = f":{verification.detail}" if verification.detail else "" + raise RuntimeError( + f"数据库备份校验失败({verification.method}){detail}" + ) + path = files.publish(temporary, name) + except Exception: + files.discard(temporary) + raise - artifact = self._artifact(path, created_at=created_at) - self._prune(files, policy, keep=artifact.name) - logger.info( - f"数据库备份完成:文件={artifact.name},类型={artifact.db_type}," - f"大小={artifact.size} bytes" - ) - return artifact + artifact = self._artifact(path, created_at=created_at) + self._prune(files, policy, keep=artifact.name) + logger.info( + f"数据库备份完成:文件={artifact.name},类型={artifact.db_type}," + f"大小={artifact.size} bytes" + ) + return artifact + finally: + self._create_lock.release() def list(self) -> tuple[BackupArtifact, ...]: """按创建时间倒序列出受管数据库备份文件。""" @@ -126,6 +137,10 @@ class DatabaseBackupService: result = self._backend.verify(path) return BackupVerification(result.valid, result.method, result.detail) + def delete(self, name: str) -> None: + """删除一个受管数据库备份文件。""" + BackupFiles(self._policy_reader().root).delete(name) + def restore(self, name: str) -> BackupArtifact: """校验后将受管制品还原到当前 CLI 解析出的离线数据库目标。""" path = BackupFiles(self._policy_reader().root).resolve(name) diff --git a/app/application/database.py b/app/application/database.py index d69414d27..70a0ddde1 100644 --- a/app/application/database.py +++ b/app/application/database.py @@ -79,6 +79,10 @@ class DatabaseGovernance: """校验一个受管数据库备份文件。""" return self._backup.verify(name) + def delete_backup(self, name: str) -> None: + """删除一个受管数据库备份文件。""" + self._backup.delete(name) + def restore_backup(self, name: str) -> BackupArtifact: """在离线 CLI 进程中还原一个受管数据库备份。""" return self._backup.restore(name) diff --git a/app/chain/system.py b/app/chain/system.py index 2992b49b7..55c5169d4 100644 --- a/app/chain/system.py +++ b/app/chain/system.py @@ -14,7 +14,7 @@ from app.schemas.message import Message from app.schemas.notification import NotificationChannel from app.adapters.network.http import RequestUtils from app.adapters.system.host import SystemUtils -from version import FRONTEND_VERSION, APP_VERSION +from app.runtime import version as runtime_version class SystemChain(ChainBase): @@ -313,8 +313,8 @@ class SystemChain(ChainBase): """ server_release_version = self.__get_server_release_version() front_release_version = self.__get_front_release_version() - server_local_version = self.get_server_local_version() - front_local_version = self.get_frontend_version() + server_local_version = runtime_version.get_app_version() + front_local_version = runtime_version.get_frontend_version() if server_release_version == server_local_version: title = f"当前后端版本:{server_local_version},已是最新版本\n" else: @@ -417,26 +417,10 @@ class SystemChain(ChainBase): @staticmethod def get_server_local_version(): - """ - 查看当前版本 - """ - return APP_VERSION + """返回当前后端构建版本。""" + return runtime_version.get_app_version() @staticmethod def get_frontend_version(): - """ - 获取前端版本 - """ - if SystemUtils.is_frozen() and SystemUtils.is_windows(): - config = get_chain_runtime_config_snapshot() - version_file = config.config_path.parent / "nginx" / "html" / "version.txt" - else: - version_file = get_chain_runtime_config_snapshot().frontend_path / "version.txt" - if version_file.exists(): - try: - with open(version_file, 'r', encoding='utf-8', errors='replace') as f: - version = str(f.read()).strip() - return version - except Exception as err: - logger.debug(f"加载版本文件 {version_file} 出错:{str(err)}") - return FRONTEND_VERSION + """返回当前部署的前端资源版本。""" + return runtime_version.get_frontend_version() diff --git a/app/cli.py b/app/cli.py index e8904426a..4b62d0634 100644 --- a/app/cli.py +++ b/app/cli.py @@ -23,7 +23,7 @@ settings = RuntimeSettingsCompat() from app.runtime.state import SystemHelper from app.application.backup import BackupArtifact from app.startup.composition.database import build_database_governance -from version import APP_VERSION +from app.runtime.version import get_app_version, get_frontend_version BACKEND_RUNTIME_FILE = settings.TEMP_PATH / "moviepilot.runtime.json" BACKEND_STDIO_LOG_FILE = settings.LOG_PATH / "moviepilot.stdout.log" @@ -334,7 +334,7 @@ def _apply_prepared_release_update() -> bool: def _resolve_auto_update_targets(mode: str) -> Optional[str]: if mode != "dev": return None - backend_prefix = _release_prefix(APP_VERSION) + backend_prefix = _release_prefix(get_app_version()) current_branch = _git_current_branch() backend_ref = "latest" if not current_branch or current_branch == "HEAD": @@ -876,12 +876,7 @@ def _stop_frontend_service(timeout: int, force: bool) -> Dict[str, Any]: def _installed_frontend_version() -> Optional[str]: - if not FRONTEND_VERSION_FILE.exists(): - return None - try: - return FRONTEND_VERSION_FILE.read_text(encoding="utf-8", errors="replace").strip() or None - except OSError: - return None + return get_frontend_version(fallback_to_declared=False) @click.group(context_settings=CONTEXT_SETTINGS) @@ -991,7 +986,9 @@ def start(timeout: int, safe: bool) -> None: raise backend_health = backend_result.get("health") or {} - backend_version = ((backend_health.get("data") or {}) if isinstance(backend_health, dict) else {}).get("BACKEND_VERSION", APP_VERSION) + backend_version = ((backend_health.get("data") or {}) if isinstance(backend_health, dict) else {}).get( + "BACKEND_VERSION", get_app_version() + ) frontend_version = ((frontend_result.get("health") or {}) if isinstance(frontend_result.get("health"), dict) else {}).get("version") or _installed_frontend_version() or "unknown" click.echo("MoviePilot 已启动" if backend_result.get("started") or frontend_result.get("started") else "MoviePilot 已在运行") @@ -1058,13 +1055,13 @@ def status() -> None: data = (backend_health or {}).get("data") or {} click.echo(" running (unmanaged)") click.echo(f" URL: {_backend_base_url()}") - click.echo(f" Version: {data.get('BACKEND_VERSION', APP_VERSION)}") + click.echo(f" Version: {data.get('BACKEND_VERSION', get_app_version())}") else: data = (backend_health or {}).get("data") or {} click.echo(f" {'running' if backend_state == 'running' else 'starting'}") click.echo(f" PID: {backend_process.pid}") click.echo(f" URL: {_backend_base_url(backend_runtime)}") - click.echo(f" Version: {data.get('BACKEND_VERSION', APP_VERSION)}") + click.echo(f" Version: {data.get('BACKEND_VERSION', get_app_version())}") click.echo(f" App Log: {BACKEND_APP_LOG_FILE}") click.echo(f" Stdout Log: {BACKEND_STDIO_LOG_FILE}") @@ -1305,12 +1302,14 @@ def scheduler_run(job_id: str) -> None: @cli.command(context_settings=CONTEXT_SETTINGS) def version() -> None: """显示版本信息""" - click.echo(f"MoviePilot CLI: {APP_VERSION}") + click.echo(f"MoviePilot CLI: {get_app_version()}") healthy_backend, payload = _backend_health(runtime=_backend_runtime()) if healthy_backend: data = (payload or {}).get("data") or {} - click.echo(f"Backend Service: {data.get('BACKEND_VERSION', APP_VERSION)}") + click.echo( + f"Backend Service: {data.get('BACKEND_VERSION', get_app_version())}" + ) else: click.echo("Backend Service: not running") diff --git a/app/doctor/runner.py b/app/doctor/runner.py index a9f162c4d..b73bc4ab9 100644 --- a/app/doctor/runner.py +++ b/app/doctor/runner.py @@ -17,7 +17,7 @@ from app.doctor.models import ( DoctorSeverity, ) from app.adapters.system.host import SystemUtils -from version import APP_VERSION +from app.runtime.version import get_app_version class DoctorRunner: @@ -36,7 +36,7 @@ class DoctorRunner: self.deep = deep self.report = DoctorReport( generated_at=datetime.now(), - version=APP_VERSION, + version=get_app_version(), environment=self._environment(), ) diff --git a/app/factory.py b/app/factory.py index 138622513..4938fa6e6 100644 --- a/app/factory.py +++ b/app/factory.py @@ -41,7 +41,7 @@ from app.schemas.openai import ( from app.schemas.mcp import McpJsonRpcError, McpJsonRpcErrorDetail from app.schemas.response import Response as ApiResponse, ValidationIssue from app.startup.lifecycle import lifespan -from version import APP_VERSION +from app.runtime.version import get_app_version def _get_http_exception_message(detail: Any) -> str: @@ -328,7 +328,7 @@ def create_app() -> FastAPI: configure_observation(build_observation_port()) _app = FastAPI( title=settings.PROJECT_NAME, - version=APP_VERSION, + version=get_app_version(), openapi_url=f"{settings.API_V1_STR}/openapi.json", lifespan=lifespan ) diff --git a/app/foundation/version.py b/app/foundation/version.py index feb648220..9166dde73 100644 --- a/app/foundation/version.py +++ b/app/foundation/version.py @@ -1,6 +1,10 @@ +"""历史版本比较规则。""" + import re from typing import Optional, Tuple +__all__ = ["compare_version"] + _VERSION_LABELS = {"stable": -1, "rc": -2, "beta": -3, "alpha": -4} _UNKNOWN_VERSION_LABEL = -5 diff --git a/app/locales/en-US.json b/app/locales/en-US.json index a0e992162..cb3efa4fe 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.json @@ -125,6 +125,17 @@ "音乐实体类型无效,仅支持 recording 或 album": "Invalid music entity type; only recording or album is supported", "音乐下载只能使用音乐元数据源": "Music downloads can only use music metadata sources", "音乐重新识别只能使用音乐元数据源": "Music re-identification can only use music metadata sources", + "读取数据库备份列表失败,请查看日志": "Failed to load database backups. Check the logs for details", + "创建数据库备份失败,请查看日志": "Failed to create the database backup. Check the logs for details", + "已有数据库备份任务正在执行": "A database backup task is already in progress", + "数据库备份文件名无效": "The database backup file name is invalid", + "数据库备份不存在": "The database backup does not exist", + "校验数据库备份失败,请查看日志": "Failed to verify the database backup. Check the logs for details", + "删除数据库备份失败,请查看日志": "Failed to delete the database backup. Check the logs for details", + "数据库备份周期格式不正确": "The database backup schedule format is invalid", + "数据库备份目录必须是路径字符串": "The database backup directory must be a path string", + "数据库备份过期天数必须是大于等于 0 的整数": "Database backup retention days must be an integer greater than or equal to 0", + "数据库备份最大保留份数必须是大于等于 0 的整数": "The maximum number of database backups must be an integer greater than or equal to 0", "记录不存在": "Record does not exist", "MoviePilot智能助手未启用": "MoviePilot Assistant is not enabled", "整理记录不存在": "Organization record does not exist", diff --git a/app/locales/zh-TW.json b/app/locales/zh-TW.json index 0c093e863..c1d34e3f3 100644 --- a/app/locales/zh-TW.json +++ b/app/locales/zh-TW.json @@ -121,6 +121,17 @@ "音乐实体类型无效,仅支持 recording 或 album": "音樂實體類型無效,僅支援 recording 或 album", "音乐下载只能使用音乐元数据源": "音樂下載只能使用音樂中繼資料來源", "音乐重新识别只能使用音乐元数据源": "音樂重新識別只能使用音樂中繼資料來源", + "读取数据库备份列表失败,请查看日志": "讀取資料庫備份清單失敗,請查看日誌", + "创建数据库备份失败,请查看日志": "建立資料庫備份失敗,請查看日誌", + "已有数据库备份任务正在执行": "已有資料庫備份任務正在執行", + "数据库备份文件名无效": "資料庫備份檔名無效", + "数据库备份不存在": "資料庫備份不存在", + "校验数据库备份失败,请查看日志": "驗證資料庫備份失敗,請查看日誌", + "删除数据库备份失败,请查看日志": "刪除資料庫備份失敗,請查看日誌", + "数据库备份周期格式不正确": "資料庫備份週期格式不正確", + "数据库备份目录必须是路径字符串": "資料庫備份目錄必須是路徑字串", + "数据库备份过期天数必须是大于等于 0 的整数": "資料庫備份過期天數必須是大於等於 0 的整數", + "数据库备份最大保留份数必须是大于等于 0 的整数": "資料庫備份最大保留份數必須是大於等於 0 的整數", "记录不存在": "記錄不存在", "MoviePilot智能助手未启用": "MoviePilot 智慧助手未啟用", "整理记录不存在": "整理記錄不存在", diff --git a/app/runtime/config.py b/app/runtime/config.py index 0ca305caf..9db98cae7 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -33,7 +33,7 @@ from app.foundation.environment import ( is_frozen, ) from app.foundation.url import UrlUtils -from version import APP_VERSION +from app.runtime.version import get_app_version class SystemConfModel(BaseModel): @@ -1061,7 +1061,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): 全局用户代理字符串 """ return ( - f"{self.PROJECT_NAME}/{APP_VERSION[1:]} " + f"{self.PROJECT_NAME}/{get_app_version()[1:]} " f"({platform.system()} {platform.release()}; {cpu_arch()})" ) diff --git a/app/runtime/version.py b/app/runtime/version.py new file mode 100644 index 000000000..a4c9be3df --- /dev/null +++ b/app/runtime/version.py @@ -0,0 +1,42 @@ +"""当前 MoviePilot 部署的产品版本读取入口。""" + +from pathlib import Path + +from app.foundation.environment import is_frozen, is_windows +from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting +from version import APP_VERSION as _APP_VERSION +from version import FRONTEND_VERSION as _FRONTEND_VERSION + + +def get_app_version() -> str: + """返回当前后端构建的发布版本。""" + return str(_APP_VERSION) + + +def _read_version_file(path: Path) -> str | None: + """读取版本文件,文件缺失、内容为空或读取失败时返回空值。""" + try: + version = path.read_text(encoding="utf-8", errors="replace").strip() + return version or None + except OSError as error: + if path.exists(): + logger.debug(f"加载版本文件 {path} 出错:{error}") + return None + + +def get_frontend_version(*, fallback_to_declared: bool = True) -> str | None: + """返回当前部署的前端资源版本,并可关闭发布声明回退。""" + if is_frozen() and is_windows(): + version_file = ( + Path(get_runtime_setting("CONFIG_PATH")).parent + / "nginx" + / "html" + / "version.txt" + ) + else: + version_file = Path(get_runtime_setting("FRONTEND_PATH")) / "version.txt" + installed_version = _read_version_file(version_file) + if installed_version or not fallback_to_declared: + return installed_version + return str(_FRONTEND_VERSION) diff --git a/app/schemas/exports.py b/app/schemas/exports.py index 66200ea2a..dd88f9b39 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -81,6 +81,8 @@ SCHEMA_EXPORTS = { 'DashboardMemoryInfo': ('app.schemas.dashboard', 'DashboardMemoryInfo'), 'DashboardSystemInfo': ('app.schemas.dashboard', 'DashboardSystemInfo'), 'DataT': ('app.schemas.response', 'DataT'), + 'DatabaseBackupArtifactData': ('app.schemas.system', 'DatabaseBackupArtifactData'), + 'DatabaseBackupVerificationData': ('app.schemas.system', 'DatabaseBackupVerificationData'), 'Dict': ('app.schemas.mcp', 'Dict'), 'DiscoverMediaSource': ('app.schemas.event', 'DiscoverMediaSource'), 'DiscoverSourceEventData': ('app.schemas.event', 'DiscoverSourceEventData'), diff --git a/app/schemas/system.py b/app/schemas/system.py index d5935e51c..e7f95b667 100644 --- a/app/schemas/system.py +++ b/app/schemas/system.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from datetime import datetime as _DateTime from typing import Optional, Any, Literal from pydantic import BaseModel, Field, field_validator @@ -216,6 +217,22 @@ class SystemModuleListData(BaseModel): modules: list[SystemModuleInfo] = Field(default_factory=list) +class DatabaseBackupArtifactData(BaseModel): # type: ignore[misc] + """Web 管理端可见的受管数据库备份摘要。""" + + name: str # 受管文件名,不包含宿主目录 + db_type: str # 创建制品的数据库类型 + created_at: _DateTime # 从受管文件名解析出的创建时间 + size: int # 备份文件字节数 + + +class DatabaseBackupVerificationData(BaseModel): # type: ignore[misc] + """受管数据库备份的脱敏校验结果。""" + + valid: bool # 是否通过当前数据库类型的内容校验 + method: str # SQLite integrity_check 或 PostgreSQL 归档目录校验 + + class TransferDirectoryConf(BaseModel): """ 文件整理目录配置 diff --git a/docs/cli.md b/docs/cli.md index 3c89594e5..293c5d88d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -493,12 +493,21 @@ MoviePilot 停止运行后,可通过明确确认执行离线还原: moviepilot database restore --confirm ``` +Docker Compose 部署应复用原服务的环境变量和 `/config` 挂载,在服务停止后运行一次性 CLI: + +```shell +docker compose stop +docker compose run --rm --no-deps --entrypoint moviepilot database restore --confirm +docker compose start +``` + +`` 是 Compose 文件中的 MoviePilot 服务名,不是容器名。 + 说明: - SQLite 使用在线备份 API,PostgreSQL 使用镜像内置的 `pg_dump` custom format - 源码部署使用 PostgreSQL 时,宿主机需安装 `pg_dump` 和 `pg_restore` 并加入 `PATH`;Docker 镜像已内置 - 默认目录为配置目录下的 `database_backup/`,可通过 `DB_BACKUP_PATH` 调整 -- 文件名包含数据库类型和创建时间,例如 `sqlite_20260819_030000.db` - 备份、列举和校验可独立通过 CLI 执行 - 还原会覆盖当前数据库,执行前必须停止 MoviePilot;运行中的 Web API 和插件 SDK 不提供还原入口 diff --git a/scripts/architecture/mypy_ratchet.py b/scripts/architecture/mypy_ratchet.py index da00f30cf..5fb33e0bc 100644 --- a/scripts/architecture/mypy_ratchet.py +++ b/scripts/architecture/mypy_ratchet.py @@ -29,7 +29,14 @@ _ERROR_LINE = re.compile(r"^(?P.+?):\d+(?::\d+)?: error: .+?(?:\s+\[(?P str: """以当前解释器运行全量 mypy 并返回 stdout(非零退出码属于预期结果)。""" - command = [sys.executable, "-m", "mypy", *MYPY_TARGETS] + command = [ + sys.executable, + "-m", + "mypy", + "--no-incremental", + "--no-pretty", + *MYPY_TARGETS, + ] for pattern in MYPY_EXCLUDES: command += ["--exclude", pattern] result = subprocess.run( diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index cb8cb88cb..aa64fae70 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -13,8 +13,8 @@ "runtime_to_db": [], "workflow_to_db": [] }, - "edge_count": 6620, - "edge_sha256": "da3e1ea903ff03ab300e43cd517e141365c65bf0304c091c22ff989ec8b9e1ae", + "edge_count": 6644, + "edge_sha256": "6d011ba65df8afe21dec208fd09a3a2a314743ae282db5ce411307b9d967fd00", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -68,6 +68,7 @@ "app.adapters.external.market -> app.runtime.observability", "app.adapters.external.market -> app.runtime.settings", "app.adapters.external.market -> app.runtime.tasks", + "app.adapters.external.market -> app.runtime.version", "app.adapters.external.ocr -> app.adapters", "app.adapters.external.ocr -> app.adapters.network", "app.adapters.external.ocr -> app.adapters.network.http", @@ -95,6 +96,7 @@ "app.adapters.external.server -> app.runtime.observability", "app.adapters.external.server -> app.runtime.settings", "app.adapters.external.server -> app.runtime.tasks", + "app.adapters.external.server -> app.runtime.version", "app.adapters.external.server -> app.schemas", "app.adapters.external.server -> app.schemas.media", "app.adapters.external.server -> app.schemas.types", @@ -118,6 +120,8 @@ "app.adapters.network.http -> app.runtime.correlation", "app.adapters.observability.otel -> app.runtime", "app.adapters.observability.otel -> app.runtime.observability", + "app.adapters.system.backup.files -> app.runtime", + "app.adapters.system.backup.files -> app.runtime.version", "app.adapters.system.display -> app.foundation", "app.adapters.system.display -> app.foundation.singleton", "app.adapters.system.display -> app.runtime", @@ -133,6 +137,8 @@ "app.adapters.system.fsproxy -> app.runtime.settings", "app.adapters.system.host -> app.foundation", "app.adapters.system.host -> app.foundation.environment", + "app.adapters.system.host -> app.runtime", + "app.adapters.system.host -> app.runtime.version", "app.adapters.system.host -> app.schemas", "app.adapters.system.host -> app.schemas.dashboard", "app.adapters.system.package -> app.runtime", @@ -182,6 +188,7 @@ "app.adapters.system.update -> app.runtime.log", "app.adapters.system.update -> app.runtime.settings", "app.adapters.system.update -> app.runtime.thread", + "app.adapters.system.update -> app.runtime.version", "app.adapters.system.update -> app.schemas", "app.adapters.system.update -> app.schemas.system", "app.adapters.web.correlation -> app.runtime", @@ -1924,6 +1931,7 @@ "app.api.endpoints.mcp -> app.api.response", "app.api.endpoints.mcp -> app.runtime", "app.api.endpoints.mcp -> app.runtime.log", + "app.api.endpoints.mcp -> app.runtime.version", "app.api.endpoints.mcp -> app.schemas", "app.api.endpoints.mcp -> app.schemas.mcp", "app.api.endpoints.mcp -> app.schemas.response", @@ -2289,7 +2297,9 @@ "app.api.endpoints.system -> app.api.principal", "app.api.endpoints.system -> app.api.response", "app.api.endpoints.system -> app.application", + "app.api.endpoints.system -> app.application.backup", "app.api.endpoints.system -> app.application.configuration", + "app.api.endpoints.system -> app.application.database", "app.api.endpoints.system -> app.application.image", "app.api.endpoints.system -> app.application.messaging", "app.api.endpoints.system -> app.application.messaging.message", @@ -2316,11 +2326,13 @@ "app.api.endpoints.system -> app.runtime", "app.api.endpoints.system -> app.runtime.config", "app.api.endpoints.system -> app.runtime.events", + "app.api.endpoints.system -> app.runtime.execution", "app.api.endpoints.system -> app.runtime.localization", "app.api.endpoints.system -> app.runtime.log", "app.api.endpoints.system -> app.runtime.progress", "app.api.endpoints.system -> app.runtime.scheduling", "app.api.endpoints.system -> app.runtime.state", + "app.api.endpoints.system -> app.runtime.version", "app.api.endpoints.system -> app.schemas", "app.api.endpoints.system -> app.schemas.common", "app.api.endpoints.system -> app.schemas.event", @@ -2491,6 +2503,8 @@ "app.api.servarr -> app.domain", "app.api.servarr -> app.domain.context", "app.api.servarr -> app.domain.metainfo", + "app.api.servarr -> app.runtime", + "app.api.servarr -> app.runtime.version", "app.api.servarr -> app.schemas", "app.api.servarr -> app.schemas.response", "app.api.servarr -> app.schemas.servarr", @@ -3432,6 +3446,7 @@ "app.chain.system -> app.runtime", "app.chain.system -> app.runtime.log", "app.chain.system -> app.runtime.state", + "app.chain.system -> app.runtime.version", "app.chain.system -> app.schemas", "app.chain.system -> app.schemas.message", "app.chain.system -> app.schemas.notification", @@ -3545,6 +3560,7 @@ "app.cli -> app.runtime.config", "app.cli -> app.runtime.settings", "app.cli -> app.runtime.state", + "app.cli -> app.runtime.version", "app.cli -> app.startup", "app.cli -> app.startup.composition", "app.cli -> app.startup.composition.database", @@ -3846,6 +3862,7 @@ "app.doctor.runner -> app.doctor.models", "app.doctor.runner -> app.runtime", "app.doctor.runner -> app.runtime.settings", + "app.doctor.runner -> app.runtime.version", "app.domain.context -> app.domain", "app.domain.context -> app.domain.meta", "app.domain.context -> app.domain.meta.metabase", @@ -3960,6 +3977,7 @@ "app.factory -> app.runtime.log", "app.factory -> app.runtime.observability", "app.factory -> app.runtime.settings", + "app.factory -> app.runtime.version", "app.factory -> app.schemas", "app.factory -> app.schemas.exception", "app.factory -> app.schemas.mcp", @@ -5635,6 +5653,7 @@ "app.runtime.config -> app.foundation.url", "app.runtime.config -> app.runtime", "app.runtime.config -> app.runtime.log", + "app.runtime.config -> app.runtime.version", "app.runtime.config -> app.schemas", "app.runtime.config -> app.schemas.types", "app.runtime.debounce -> app.runtime", @@ -5859,6 +5878,11 @@ "app.runtime.thread -> app.runtime", "app.runtime.thread -> app.runtime.execution", "app.runtime.thread -> app.runtime.settings", + "app.runtime.version -> app.foundation", + "app.runtime.version -> app.foundation.environment", + "app.runtime.version -> app.runtime", + "app.runtime.version -> app.runtime.log", + "app.runtime.version -> app.runtime.settings", "app.scheduler -> app.adapters", "app.scheduler -> app.adapters.external", "app.scheduler -> app.adapters.external.server", @@ -6637,7 +6661,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 815, + "module_count": 816, "modules": [ "app", "app.adapters", @@ -7352,6 +7376,7 @@ "app.runtime.tasks", "app.runtime.thread", "app.runtime.topology", + "app.runtime.version", "app.scheduler", "app.schemas", "app.schemas.agent", diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index b460fac5a..7edb706dd 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -3,129 +3,242 @@ "import-untyped": 1 }, "app/adapters/external/cookiecloud.py": { - "unknown": 9 + "arg-type": 1, + "no-any-return": 1, + "no-untyped-call": 2, + "no-untyped-def": 2, + "type-arg": 2, + "var-annotated": 1 }, "app/adapters/external/location.py": { - "unknown": 3 + "no-untyped-def": 3 }, "app/adapters/external/market.py": { - "import-untyped": 1 + "arg-type": 3, + "assignment": 3, + "attr-defined": 4, + "import-untyped": 1, + "misc": 4, + "no-any-return": 8, + "no-untyped-call": 6, + "no-untyped-def": 10, + "operator": 1, + "return-value": 6, + "type-arg": 41, + "union-attr": 2, + "var-annotated": 5 }, "app/adapters/external/ocr.py": { - "unknown": 2 + "arg-type": 2 + }, + "app/adapters/external/plugin/client.py": { + "arg-type": 1, + "no-any-return": 2, + "type-arg": 6 + }, + "app/adapters/external/server.py": { + "arg-type": 7, + "assignment": 1, + "misc": 9, + "no-any-return": 23, + "no-untyped-call": 6, + "no-untyped-def": 59, + "type-arg": 40 }, "app/adapters/external/wechat_crypt.py": { - "unknown": 29 + "no-untyped-call": 14, + "no-untyped-def": 14, + "union-attr": 1 }, "app/adapters/network/browser.py": { - "unknown": 24 + "arg-type": 4, + "no-any-return": 2, + "no-untyped-def": 1, + "operator": 2, + "type-arg": 8, + "union-attr": 5, + "var-annotated": 2 }, "app/adapters/network/cloudflare.py": { - "unknown": 1 + "no-untyped-def": 1 }, "app/adapters/network/http.py": { - "unknown": 142 + "assignment": 43, + "misc": 1, + "no-any-return": 15, + "no-untyped-call": 7, + "no-untyped-def": 35, + "type-arg": 41 }, "app/adapters/network/ip.py": { - "unknown": 10 + "no-untyped-call": 4, + "no-untyped-def": 6 }, "app/adapters/system/backup/database.py": { - "unknown": 2 + "assignment": 2 }, "app/adapters/system/fsproxy.py": { - "unknown": 33 + "arg-type": 1, + "assignment": 1, + "no-any-return": 7, + "no-untyped-call": 8, + "no-untyped-def": 4, + "type-arg": 1, + "union-attr": 11 }, "app/adapters/system/fsworker.py": { - "unknown": 13 + "no-untyped-call": 3, + "no-untyped-def": 10 }, "app/adapters/system/host.py": { + "assignment": 4, "misc": 1, + "no-any-return": 2, + "no-untyped-call": 3, + "no-untyped-def": 8, "operator": 1, - "unknown": 23 + "type-arg": 5, + "var-annotated": 1 + }, + "app/adapters/system/plugin/dependency.py": { + "no-any-return": 3 + }, + "app/adapters/system/plugin/package.py": { + "no-any-return": 1 }, "app/adapters/system/resource.py": { - "unknown": 4 + "no-untyped-call": 1, + "no-untyped-def": 2, + "type-arg": 1 }, "app/adapters/system/rust.py": { - "unknown": 28 + "assignment": 1, + "no-any-return": 7, + "no-untyped-def": 1, + "type-arg": 19 }, "app/adapters/system/stdio.py": { - "unknown": 1 + "override": 1 }, "app/adapters/system/update.py": { - "unknown": 6 + "no-any-return": 6 }, "app/adapters/web/correlation.py": { - "unknown": 2 + "type-arg": 2 + }, + "app/adapters/web/metrics.py": { + "no-any-return": 1, + "type-arg": 3, + "union-attr": 1 }, "app/adapters/web/plugin/routes.py": { - "unknown": 2 + "type-arg": 2 }, "app/adapters/web/security/access.py": { - "unknown": 3 + "misc": 1, + "no-any-return": 2 }, "app/agent/contracts.py": { - "unknown": 1 + "type-arg": 1 }, "app/agent/llm/capability.py": { - "unknown": 12 + "no-any-return": 4, + "no-untyped-def": 6, + "type-arg": 1, + "var-annotated": 1 }, "app/agent/llm/helper.py": { - "unknown": 32 + "arg-type": 7, + "assignment": 1, + "misc": 1, + "no-any-return": 2, + "no-untyped-call": 5, + "no-untyped-def": 13, + "type-arg": 1, + "union-attr": 2 }, "app/agent/llm/provider.py": { "import-untyped": 1 }, "app/agent/middleware/activity_log.py": { - "unknown": 9 + "misc": 3, + "type-arg": 5, + "valid-type": 1 }, "app/agent/middleware/jobs.py": { - "unknown": 4 + "import-untyped": 1, + "misc": 2, + "valid-type": 1 }, "app/agent/middleware/memory.py": { - "unknown": 4 + "misc": 2, + "valid-type": 2 }, "app/agent/middleware/patch_tool_calls.py": { - "unknown": 1 + "misc": 1 }, "app/agent/middleware/skills.py": { - "unknown": 5 + "import-untyped": 1, + "misc": 3, + "valid-type": 1 }, "app/agent/middleware/summarization.py": { - "unknown": 10 + "misc": 2, + "no-any-return": 6, + "union-attr": 2 }, "app/agent/middleware/tool_selection.py": { - "unknown": 4 + "misc": 2, + "no-any-return": 1, + "valid-type": 1 }, "app/agent/middleware/usage.py": { - "unknown": 3 + "misc": 1, + "no-any-return": 2 }, "app/agent/policy/orchestrator.py": { - "unknown": 1 + "no-any-return": 1 }, "app/agent/policy/sanitizer.py": { - "unknown": 1 + "var-annotated": 1 }, "app/agent/prompt/__init__.py": { - "import-untyped": 1 + "arg-type": 2, + "assignment": 4, + "attr-defined": 1, + "has-type": 3, + "import-untyped": 1, + "no-untyped-def": 1 }, "app/agent/runtime.py": { "import-untyped": 1 }, "app/agent/skills/metadata.py": { - "unknown": 1 + "import-untyped": 1 }, "app/agent/skills/registry.py": { - "unknown": 44 + "arg-type": 1, + "attr-defined": 1, + "misc": 3, + "no-any-return": 7, + "no-untyped-def": 5, + "type-arg": 14, + "union-attr": 13 }, "app/agent/tools/catalog.py": { - "unknown": 1 + "attr-defined": 1 }, "app/agent/tools/impl/_terminal_session.py": { - "unknown": 5 + "assignment": 2, + "attr-defined": 1, + "type-arg": 2 }, "app/agent/tools/impl/_torrent_search_utils.py": { - "unknown": 10 + "arg-type": 2, + "misc": 1, + "operator": 4, + "type-arg": 2, + "var-annotated": 1 }, "app/agent/tools/impl/create_agent_task.py": { "import-untyped": 1 @@ -143,61 +256,85 @@ "import-untyped": 1 }, "app/api/response.py": { - "unknown": 4 + "arg-type": 1, + "misc": 2, + "valid-type": 1 }, "app/api/servcookie.py": { "import-untyped": 1 }, "app/application/agent.py": { - "unknown": 4 + "no-any-return": 4 }, "app/application/agentdata.py": { - "unknown": 10 + "attr-defined": 10 }, "app/application/agenttask.py": { - "unknown": 1 + "type-arg": 1 }, "app/application/commands.py": { - "unknown": 1 + "no-any-return": 1 }, "app/application/configuration.py": { - "unknown": 1 + "redundant-cast": 1 }, "app/application/dashboard.py": { - "unknown": 5 + "operator": 5 }, "app/application/directory.py": { - "unknown": 13 + "arg-type": 6, + "assignment": 2, + "return-value": 2, + "type-arg": 1, + "union-attr": 2 }, "app/application/download/tasks.py": { - "unknown": 2 + "assignment": 1, + "type-arg": 1 }, "app/application/downloader.py": { - "unknown": 2 + "arg-type": 1, + "no-untyped-def": 1 }, "app/application/formatting.py": { - "unknown": 26 + "arg-type": 4, + "assignment": 9, + "attr-defined": 1, + "no-untyped-def": 7, + "operator": 3, + "type-arg": 2 }, "app/application/history.py": { - "unknown": 9 + "assignment": 2, + "attr-defined": 1, + "no-any-return": 1, + "type-arg": 5 }, "app/application/maintenance.py": { - "unknown": 1 + "assignment": 1 }, "app/application/mediaserver.py": { - "unknown": 3 + "arg-type": 2, + "operator": 1 }, "app/application/messaging/agent.py": { - "unknown": 20 + "arg-type": 1, + "attr-defined": 1, + "no-untyped-call": 1, + "no-untyped-def": 1, + "type-arg": 15, + "var-annotated": 1 }, "app/application/messaging/ingress.py": { - "unknown": 2 + "arg-type": 2 }, "app/application/messaging/interaction.py": { - "unknown": 6 + "no-untyped-def": 4, + "type-arg": 2 }, "app/application/messaging/media.py": { - "unknown": 2 + "no-untyped-call": 1, + "no-untyped-def": 1 }, "app/application/messaging/message.py": { "arg-type": 5, @@ -212,605 +349,1245 @@ "var-annotated": 1 }, "app/application/messaging/plugin.py": { - "unknown": 9 + "arg-type": 5, + "no-untyped-call": 1, + "no-untyped-def": 2, + "type-arg": 1 }, "app/application/messaging/site.py": { - "unknown": 7 + "arg-type": 1, + "assignment": 2, + "no-untyped-call": 1, + "no-untyped-def": 2, + "type-arg": 1 }, "app/application/messaging/skill.py": { - "unknown": 14 + "no-untyped-call": 4, + "no-untyped-def": 4, + "type-arg": 6 }, "app/application/messaging/subscribe.py": { - "unknown": 6 + "arg-type": 1, + "assignment": 2, + "no-untyped-call": 1, + "no-untyped-def": 2 }, "app/application/module.py": { - "unknown": 1 + "misc": 1 }, "app/application/music/catalog.py": { - "unknown": 2 + "assignment": 1, + "no-any-return": 1 }, "app/application/notification.py": { - "unknown": 3 + "arg-type": 1, + "no-untyped-call": 1, + "no-untyped-def": 1 }, "app/application/plugin/catalog.py": { - "unknown": 11 + "type-arg": 5, + "var-annotated": 6 }, "app/application/plugin/config.py": { - "unknown": 3 + "type-arg": 3 }, "app/application/plugin/install.py": { - "unknown": 2 + "arg-type": 1, + "var-annotated": 1 }, "app/application/plugin/lifecycle.py": { - "unknown": 2 + "no-untyped-def": 2 }, "app/application/rss.py": { - "unknown": 21 + "arg-type": 2, + "assignment": 5, + "attr-defined": 4, + "import-untyped": 2, + "no-untyped-def": 3, + "type-arg": 5 }, "app/application/rules.py": { - "unknown": 10 + "attr-defined": 1, + "no-untyped-call": 1, + "no-untyped-def": 1, + "type-arg": 7 }, "app/application/security/cookie.py": { - "unknown": 25 + "arg-type": 3, + "assignment": 2, + "attr-defined": 3, + "no-any-return": 1, + "type-arg": 3, + "union-attr": 13 }, "app/application/security/otp.py": { - "unknown": 3 + "no-any-return": 3 }, "app/application/security/passkeys.py": { - "unknown": 2 + "valid-type": 2 }, "app/application/security/token.py": { - "unknown": 4 + "no-any-return": 3, + "operator": 1 }, "app/application/security/twofactor.py": { - "unknown": 4 + "assignment": 2, + "return-value": 1, + "str-bytes-safe": 1 }, "app/application/security/url.py": { - "unknown": 15 + "attr-defined": 2, + "has-type": 2, + "misc": 1, + "no-any-return": 1, + "return-value": 1, + "type-arg": 8 }, "app/application/servarr.py": { - "unknown": 2 + "valid-type": 2 }, "app/application/server/report.py": { - "unknown": 5 + "type-arg": 5 }, "app/application/server/share.py": { - "unknown": 7 + "no-any-return": 1, + "type-arg": 6 }, "app/application/service.py": { - "unknown": 6 + "attr-defined": 5, + "misc": 1 }, "app/application/site/mutation.py": { - "unknown": 4 + "type-arg": 4 }, "app/application/site/query.py": { - "unknown": 7 + "attr-defined": 2, + "valid-type": 5 }, "app/application/site/sites.pyi": { - "unknown": 7 + "type-arg": 7 }, "app/application/storage.py": { - "unknown": 6 + "no-untyped-def": 3, + "type-arg": 3 }, "app/application/subscription/complete.py": { - "unknown": 3 + "arg-type": 3 }, "app/application/subscription/contract.py": { - "unknown": 5 + "arg-type": 2, + "assignment": 2, + "misc": 1 }, "app/application/subscription/query.py": { - "unknown": 2 + "attr-defined": 1, + "type-arg": 1 }, "app/application/subscription/write.py": { - "unknown": 22 + "arg-type": 2, + "no-untyped-def": 2, + "type-arg": 18 }, "app/application/torrent.py": { - "unknown": 39 + "arg-type": 14, + "assignment": 3, + "attr-defined": 2, + "no-untyped-call": 1, + "no-untyped-def": 3, + "operator": 2, + "return-value": 1, + "type-arg": 4, + "union-attr": 8, + "var-annotated": 1 }, "app/application/torrent_cache.py": { - "unknown": 1 + "type-arg": 1 }, "app/application/transfer.py": { - "unknown": 104 + "arg-type": 16, + "assignment": 3, + "attr-defined": 4, + "misc": 6, + "no-any-return": 1, + "no-untyped-call": 2, + "no-untyped-def": 10, + "return-value": 1, + "type-arg": 14, + "union-attr": 47 }, "app/chain/_interaction.py": { - "unknown": 12 + "assignment": 3, + "attr-defined": 1, + "no-any-return": 3, + "no-untyped-call": 3, + "no-untyped-def": 2 + }, + "app/chain/_recognition.py": { + "arg-type": 6, + "assignment": 7, + "attr-defined": 12, + "no-any-return": 4, + "no-untyped-def": 5, + "type-arg": 4 + }, + "app/db/engine.py": { + "no-untyped-def": 3, + "type-arg": 1 }, "app/db/models/agentchat.py": { - "unknown": 2 + "no-any-return": 2 }, "app/db/models/agenttask.py": { - "unknown": 5 + "no-any-return": 2, + "no-untyped-def": 2, + "type-arg": 1 + }, + "app/db/models/agenttaskrun.py": { + "no-any-return": 1 }, "app/db/models/downloadfailure.py": { - "unknown": 1 + "no-any-return": 1 }, "app/db/models/downloadhistory.py": { - "unknown": 19 + "no-untyped-def": 19 }, "app/db/models/mediaserver.py": { - "unknown": 10 + "no-untyped-def": 10 }, "app/db/models/message.py": { - "unknown": 1 + "type-arg": 1 }, "app/db/models/passkey.py": { - "unknown": 12 + "no-untyped-def": 12 }, "app/db/models/plugindata.py": { - "unknown": 8 + "no-untyped-def": 8 }, "app/db/models/site.py": { - "unknown": 10 + "no-untyped-def": 10 }, "app/db/models/siteicon.py": { - "unknown": 2 + "no-untyped-def": 2 }, "app/db/models/sitestatistic.py": { - "unknown": 3 + "no-untyped-def": 3 }, "app/db/models/siteuserdata.py": { - "unknown": 5 + "no-untyped-def": 5 }, "app/db/models/subscribe.py": { - "unknown": 26 + "arg-type": 8, + "no-untyped-def": 18 }, "app/db/models/subscribehistory.py": { - "unknown": 6 + "no-untyped-def": 6 }, "app/db/models/systemconfig.py": { - "unknown": 3 + "no-untyped-def": 3 }, "app/db/models/transferhistory.py": { - "unknown": 24 + "no-any-return": 3, + "no-untyped-def": 20, + "type-arg": 1 }, "app/db/models/transferpending.py": { - "unknown": 1 + "no-any-return": 1 }, "app/db/models/user.py": { - "unknown": 10 + "no-untyped-def": 10 }, "app/db/models/userconfig.py": { - "unknown": 2 + "no-untyped-def": 2 }, "app/db/models/workflow.py": { - "unknown": 31 + "no-untyped-def": 27, + "type-arg": 4 + }, + "app/db/oper/agentchat.py": { + "type-arg": 6 + }, + "app/db/oper/downloadhistory.py": { + "no-any-return": 3, + "no-untyped-def": 9, + "type-arg": 3, + "union-attr": 2 + }, + "app/db/oper/mediaserver.py": { + "arg-type": 12, + "no-any-return": 4, + "no-untyped-def": 7, + "type-arg": 1 + }, + "app/db/oper/message.py": { + "attr-defined": 2, + "no-untyped-def": 2, + "type-arg": 5 + }, + "app/db/oper/passkey.py": { + "no-any-return": 1, + "override": 1 + }, + "app/db/oper/plugindata.py": { + "no-untyped-def": 2 + }, + "app/db/oper/site.py": { + "arg-type": 1, + "no-any-return": 4, + "no-untyped-def": 6, + "type-arg": 4, + "union-attr": 3 + }, + "app/db/oper/subscribe.py": { + "arg-type": 4, + "no-any-return": 3, + "no-untyped-def": 3, + "type-arg": 13, + "union-attr": 1 + }, + "app/db/oper/subscribehistory.py": { + "no-any-return": 1 + }, + "app/db/oper/systemconfig.py": { + "no-any-return": 1, + "no-untyped-call": 1, + "no-untyped-def": 4 + }, + "app/db/oper/transferhistory.py": { + "arg-type": 1, + "no-any-return": 2, + "no-untyped-def": 7, + "union-attr": 2 + }, + "app/db/oper/user.py": { + "no-any-return": 6, + "no-untyped-def": 2, + "type-arg": 4 + }, + "app/db/oper/userconfig.py": { + "no-untyped-def": 4 + }, + "app/db/oper/workflow.py": { + "arg-type": 1, + "no-any-return": 6, + "no-untyped-call": 3, + "no-untyped-def": 1, + "union-attr": 3 + }, + "app/db/session.py": { + "no-untyped-call": 1, + "no-untyped-def": 2, + "type-arg": 1 }, "app/doctor/checks.py": { - "unknown": 7 + "no-any-return": 7 }, "app/doctor/runner.py": { - "unknown": 1 + "arg-type": 1 }, "app/domain/context.py": { - "unknown": 225 + "arg-type": 7, + "assignment": 133, + "call-overload": 1, + "index": 3, + "no-any-return": 2, + "no-untyped-call": 5, + "no-untyped-def": 27, + "operator": 1, + "type-arg": 37, + "union-attr": 7, + "var-annotated": 2 }, "app/domain/meta/customization.py": { - "unknown": 8 + "assignment": 1, + "no-untyped-call": 2, + "no-untyped-def": 4, + "var-annotated": 1 }, "app/domain/meta/metaanime.py": { - "unknown": 11 + "arg-type": 1, + "assignment": 1, + "no-untyped-call": 5, + "no-untyped-def": 3, + "type-arg": 1 }, "app/domain/meta/metabase.py": { - "unknown": 19 + "arg-type": 2, + "assignment": 1, + "list-item": 1, + "no-untyped-def": 8, + "operator": 2, + "return-value": 1, + "type-arg": 4 }, "app/domain/meta/metamusic.py": { - "unknown": 16 + "arg-type": 4, + "assignment": 7, + "list-item": 1, + "override": 4 }, "app/domain/meta/metavideo.py": { - "unknown": 33 + "arg-type": 2, + "assignment": 2, + "attr-defined": 1, + "no-untyped-call": 11, + "no-untyped-def": 1, + "type-arg": 2 }, "app/domain/meta/releasegroup.py": { - "unknown": 7 + "arg-type": 1, + "assignment": 2, + "no-untyped-def": 3, + "type-arg": 1 }, "app/domain/meta/runtime.py": { - "unknown": 6 + "type-arg": 6 }, "app/domain/meta/streamingplatform.py": { - "unknown": 2 + "no-untyped-def": 1, + "type-arg": 1 }, "app/domain/meta/words.py": { - "unknown": 6 + "assignment": 2, + "no-any-return": 1, + "no-untyped-def": 2, + "operator": 1 }, "app/domain/metainfo.py": { - "unknown": 24 + "arg-type": 6, + "assignment": 4, + "no-untyped-call": 3, + "no-untyped-def": 1, + "type-arg": 10 }, "app/domain/scraper.py": { - "unknown": 5 + "arg-type": 1, + "no-untyped-def": 2, + "return-value": 1, + "type-arg": 1 }, "app/domain/title.py": { - "unknown": 1 + "assignment": 1 }, "app/domain/tokens.py": { - "unknown": 9 + "no-untyped-call": 2, + "no-untyped-def": 6, + "type-arg": 1 }, "app/foundation/crypto.py": { - "unknown": 4 + "no-any-return": 1, + "no-untyped-def": 1, + "union-attr": 2 }, "app/foundation/dom.py": { - "unknown": 7 + "assignment": 2, + "no-untyped-def": 5 }, "app/foundation/environment.py": { - "unknown": 1 + "no-any-return": 1 }, "app/foundation/reflection.py": { - "unknown": 12 + "index": 1, + "no-untyped-call": 2, + "no-untyped-def": 5, + "operator": 1, + "type-arg": 2, + "unused-ignore": 1 }, "app/foundation/singleton.py": { - "unknown": 14 + "arg-type": 2, + "misc": 2, + "no-untyped-def": 5, + "type-arg": 3, + "var-annotated": 2 }, "app/foundation/temporal.py": { - "unknown": 6 + "import-untyped": 3, + "no-any-return": 1, + "operator": 2 }, "app/foundation/text.py": { - "unknown": 13 + "arg-type": 1, + "no-any-return": 2, + "no-untyped-def": 3, + "return-value": 1, + "type-arg": 5, + "union-attr": 1 }, "app/foundation/url.py": { - "unknown": 1 + "type-arg": 1 }, "app/modules/anilist/anilist.py": { - "unknown": 60 + "arg-type": 1, + "misc": 15, + "no-any-return": 8, + "no-untyped-def": 5, + "type-arg": 31 }, "app/modules/bangumi/bangumi.py": { - "unknown": 26 + "misc": 2, + "no-untyped-def": 22, + "var-annotated": 2 }, "app/modules/dingtalk/dingtalk.py": { - "unknown": 1 + "no-untyped-def": 1 }, "app/modules/discord/discord.py": { - "unknown": 48 + "assignment": 1, + "dict-item": 1, + "misc": 2, + "no-untyped-call": 2, + "no-untyped-def": 8, + "return-value": 2, + "type-arg": 14, + "union-attr": 18 }, "app/modules/douban/apiv2.py": { - "unknown": 170 + "misc": 6, + "no-any-return": 4, + "no-untyped-def": 132, + "return-value": 1, + "type-arg": 26, + "var-annotated": 1 }, "app/modules/douban/scraper.py": { - "unknown": 7 + "arg-type": 2, + "no-any-return": 2, + "no-untyped-def": 1, + "return-value": 1, + "type-arg": 1 }, "app/modules/emby/emby.py": { - "unknown": 37 + "arg-type": 9, + "assignment": 2, + "dict-item": 1, + "no-any-return": 9, + "no-untyped-call": 2, + "no-untyped-def": 4, + "type-arg": 6, + "union-attr": 1, + "var-annotated": 3 }, "app/modules/feishu/feishu.py": { - "unknown": 47 + "arg-type": 5, + "assignment": 1, + "call-overload": 1, + "method-assign": 2, + "no-untyped-def": 4, + "return-value": 1, + "type-arg": 25, + "union-attr": 8 }, "app/modules/imdb/api.py": { - "unknown": 27 + "arg-type": 2, + "misc": 5, + "no-any-return": 2, + "type-arg": 18 }, "app/modules/indexer/parser/__init__.py": { - "unknown": 53 + "arg-type": 20, + "assignment": 5, + "comparison-overlap": 1, + "no-untyped-call": 5, + "no-untyped-def": 15, + "return-value": 1, + "type-arg": 3, + "var-annotated": 3 }, "app/modules/indexer/parser/bitpt.py": { - "unknown": 45 + "arg-type": 4, + "assignment": 25, + "attr-defined": 1, + "empty-body": 1, + "no-untyped-call": 1, + "no-untyped-def": 9, + "override": 1, + "type-arg": 1, + "union-attr": 2 }, "app/modules/indexer/parser/discuz.py": { - "unknown": 10 + "assignment": 3, + "no-untyped-call": 1, + "no-untyped-def": 5, + "type-arg": 1 }, "app/modules/indexer/parser/file_list.py": { - "unknown": 14 + "assignment": 5, + "no-untyped-call": 3, + "no-untyped-def": 5, + "type-arg": 1 }, "app/modules/indexer/parser/gazelle.py": { - "unknown": 13 + "assignment": 6, + "no-untyped-call": 1, + "no-untyped-def": 5, + "type-arg": 1 + }, + "app/modules/indexer/parser/hddolby.py": { + "assignment": 14, + "no-untyped-call": 1, + "no-untyped-def": 7, + "type-arg": 1 }, "app/modules/indexer/parser/ipt_project.py": { - "unknown": 13 + "assignment": 5, + "no-untyped-call": 1, + "no-untyped-def": 5, + "return": 1, + "type-arg": 1 }, "app/modules/indexer/parser/mtorrent.py": { - "unknown": 21 + "assignment": 11, + "empty-body": 1, + "index": 2, + "no-untyped-def": 6, + "type-arg": 1 + }, + "app/modules/indexer/parser/nexus_audiences.py": { + "arg-type": 6, + "assignment": 7, + "no-any-return": 1, + "no-untyped-call": 8, + "no-untyped-def": 34, + "return-value": 1, + "type-arg": 6, + "var-annotated": 1 + }, + "app/modules/indexer/parser/nexus_hhanclub.py": { + "assignment": 2, + "no-untyped-call": 3, + "no-untyped-def": 3 }, "app/modules/indexer/parser/nexus_php.py": { - "unknown": 31 + "assignment": 12, + "no-untyped-call": 8, + "no-untyped-def": 10, + "type-arg": 1 + }, + "app/modules/indexer/parser/nexus_project.py": { + "assignment": 4, + "no-untyped-call": 2, + "no-untyped-def": 2 }, "app/modules/indexer/parser/nexus_rabbit.py": { - "unknown": 19 + "assignment": 10, + "call-overload": 1, + "no-untyped-call": 1, + "no-untyped-def": 5, + "return": 1, + "type-arg": 1 }, "app/modules/indexer/parser/rousi.py": { - "unknown": 23 + "arg-type": 2, + "assignment": 12, + "no-untyped-def": 8, + "type-arg": 1 }, "app/modules/indexer/parser/small_horse.py": { - "unknown": 16 + "assignment": 7, + "no-untyped-call": 3, + "no-untyped-def": 5, + "type-arg": 1 }, "app/modules/indexer/parser/sunnypt.py": { - "unknown": 18 + "arg-type": 2, + "assignment": 14, + "type-arg": 2 }, "app/modules/indexer/parser/tnode.py": { - "unknown": 12 + "assignment": 4, + "no-untyped-call": 1, + "no-untyped-def": 6, + "type-arg": 1 }, "app/modules/indexer/parser/torrent_leech.py": { - "unknown": 11 + "assignment": 8, + "no-untyped-call": 2, + "type-arg": 1 }, "app/modules/indexer/parser/unit3d.py": { - "unknown": 12 + "assignment": 4, + "no-untyped-call": 2, + "no-untyped-def": 5, + "type-arg": 1 }, "app/modules/indexer/parser/yema.py": { - "unknown": 18 + "assignment": 17, + "type-arg": 1 }, "app/modules/indexer/parser/zhixing.py": { - "unknown": 47 + "arg-type": 7, + "assignment": 28, + "empty-body": 1, + "no-untyped-call": 1, + "no-untyped-def": 9, + "type-arg": 1 }, "app/modules/indexer/spider/__init__.py": { - "unknown": 93 + "arg-type": 16, + "assignment": 3, + "dict-item": 3, + "misc": 1, + "no-any-return": 7, + "no-untyped-call": 3, + "no-untyped-def": 26, + "operator": 6, + "override": 1, + "return-value": 1, + "type-arg": 19, + "union-attr": 5, + "var-annotated": 2 }, "app/modules/indexer/spider/haidan.py": { - "unknown": 19 + "arg-type": 5, + "assignment": 5, + "no-any-return": 1, + "no-untyped-def": 2, + "type-arg": 6 }, "app/modules/indexer/spider/hddolby.py": { - "unknown": 21 + "arg-type": 11, + "assignment": 4, + "operator": 1, + "type-arg": 5 }, "app/modules/indexer/spider/mtorrent.py": { - "unknown": 20 + "arg-type": 9, + "assignment": 4, + "no-untyped-def": 1, + "type-arg": 5, + "var-annotated": 1 }, "app/modules/indexer/spider/rousi.py": { - "unknown": 24 + "arg-type": 6, + "assignment": 8, + "no-untyped-def": 1, + "type-arg": 7, + "var-annotated": 2 }, "app/modules/indexer/spider/sunnypt.py": { - "unknown": 31 + "arg-type": 7, + "assignment": 2, + "no-any-return": 1, + "no-untyped-def": 3, + "type-arg": 15, + "var-annotated": 3 }, "app/modules/indexer/spider/tnode.py": { - "unknown": 23 + "arg-type": 12, + "assignment": 1, + "misc": 2, + "return-value": 2, + "type-arg": 5, + "var-annotated": 1 }, "app/modules/indexer/spider/torrentleech.py": { - "unknown": 31 + "arg-type": 7, + "assignment": 5, + "type-arg": 4, + "union-attr": 14, + "var-annotated": 1 }, "app/modules/indexer/spider/yema.py": { - "unknown": 21 + "arg-type": 7, + "assignment": 5, + "no-untyped-def": 1, + "type-arg": 8 }, "app/modules/jellyfin/jellyfin.py": { + "arg-type": 7, + "assignment": 4, + "dict-item": 1, + "no-any-return": 11, + "no-untyped-call": 2, + "no-untyped-def": 5, + "operator": 4, "return": 1, - "unknown": 47 + "type-arg": 9, + "union-attr": 1, + "var-annotated": 3 }, "app/modules/musicbrainz/music_cache.py": { - "unknown": 10 + "no-any-return": 1, + "no-untyped-call": 2, + "no-untyped-def": 3, + "type-arg": 3, + "union-attr": 1 }, "app/modules/navidrome/navidrome.py": { - "unknown": 12 + "no-any-return": 1, + "type-arg": 11 }, "app/modules/plex/plex.py": { - "unknown": 34 + "arg-type": 2, + "assignment": 3, + "misc": 1, + "no-any-return": 1, + "no-untyped-def": 10, + "operator": 5, + "return-value": 1, + "type-arg": 4, + "union-attr": 4, + "var-annotated": 3 }, "app/modules/qbittorrent/qbittorrent.py": { - "unknown": 42 + "assignment": 10, + "no-any-return": 3, + "no-untyped-def": 6, + "return-value": 1, + "type-arg": 22 }, "app/modules/qqbot/api.py": { - "unknown": 17 + "no-any-return": 4, + "type-arg": 13 }, "app/modules/qqbot/gateway.py": { - "unknown": 6 + "no-untyped-def": 4, + "type-arg": 2 }, "app/modules/qqbot/qqbot.py": { - "unknown": 16 + "assignment": 1, + "attr-defined": 1, + "no-untyped-def": 4, + "type-arg": 10 }, "app/modules/rtorrent/rtorrent.py": { - "unknown": 182 + "arg-type": 15, + "assignment": 6, + "index": 28, + "misc": 1, + "no-untyped-def": 6, + "operator": 62, + "type-arg": 16, + "union-attr": 48 }, "app/modules/slack/slack.py": { - "unknown": 31 + "assignment": 1, + "dict-item": 2, + "list-item": 2, + "no-untyped-call": 5, + "no-untyped-def": 9, + "type-arg": 7, + "union-attr": 2, + "var-annotated": 3 }, "app/modules/synologychat/synologychat.py": { - "unknown": 14 + "assignment": 1, + "no-any-return": 3, + "no-untyped-call": 7, + "no-untyped-def": 3 }, "app/modules/themoviedb/category.py": { - "unknown": 13 + "assignment": 2, + "no-any-return": 2, + "no-untyped-call": 2, + "no-untyped-def": 4, + "type-arg": 3 + }, + "app/modules/themoviedb/scraper.py": { + "arg-type": 4, + "index": 2, + "no-any-return": 4, + "no-untyped-def": 3, + "return-value": 1, + "type-arg": 6 }, "app/modules/themoviedb/tmdb_cache.py": { - "unknown": 19 + "no-any-return": 3, + "no-untyped-call": 2, + "no-untyped-def": 4, + "type-arg": 5, + "union-attr": 5 + }, + "app/modules/themoviedb/tmdbapi.py": { + "arg-type": 13, + "assignment": 6, + "attr-defined": 11, + "no-any-return": 24, + "no-untyped-call": 74, + "no-untyped-def": 11, + "operator": 24, + "return-value": 2, + "type-arg": 80, + "union-attr": 4, + "var-annotated": 2 }, "app/modules/themoviedb/tmdbv3api/as_obj.py": { - "unknown": 41 + "assignment": 1, + "no-untyped-call": 17, + "no-untyped-def": 23 }, "app/modules/themoviedb/tmdbv3api/objs/account.py": { - "unknown": 59 + "attr-defined": 1, + "no-untyped-call": 29, + "no-untyped-def": 29 }, "app/modules/themoviedb/tmdbv3api/objs/auth.py": { - "unknown": 13 + "no-untyped-call": 8, + "no-untyped-def": 5 }, "app/modules/themoviedb/tmdbv3api/objs/certification.py": { - "unknown": 8 + "no-untyped-call": 4, + "no-untyped-def": 4 }, "app/modules/themoviedb/tmdbv3api/objs/change.py": { - "unknown": 16 + "no-untyped-call": 8, + "no-untyped-def": 8 }, "app/modules/themoviedb/tmdbv3api/objs/collection.py": { - "unknown": 12 + "no-untyped-call": 6, + "no-untyped-def": 6 }, "app/modules/themoviedb/tmdbv3api/objs/company.py": { - "unknown": 16 + "no-untyped-call": 8, + "no-untyped-def": 8 }, "app/modules/themoviedb/tmdbv3api/objs/configuration.py": { - "unknown": 26 + "no-untyped-call": 13, + "no-untyped-def": 13 }, "app/modules/themoviedb/tmdbv3api/objs/credit.py": { - "unknown": 4 + "no-untyped-call": 2, + "no-untyped-def": 2 }, "app/modules/themoviedb/tmdbv3api/objs/discover.py": { - "unknown": 9 + "attr-defined": 1, + "no-untyped-call": 4, + "no-untyped-def": 4 }, "app/modules/themoviedb/tmdbv3api/objs/episode.py": { - "unknown": 40 + "no-untyped-call": 20, + "no-untyped-def": 20 }, "app/modules/themoviedb/tmdbv3api/objs/find.py": { - "unknown": 36 + "no-untyped-call": 18, + "no-untyped-def": 18 }, "app/modules/themoviedb/tmdbv3api/objs/genre.py": { - "unknown": 8 + "no-untyped-call": 4, + "no-untyped-def": 4 }, "app/modules/themoviedb/tmdbv3api/objs/group.py": { - "unknown": 4 + "no-untyped-call": 2, + "no-untyped-def": 2 }, "app/modules/themoviedb/tmdbv3api/objs/keyword.py": { - "unknown": 8 + "no-untyped-call": 4, + "no-untyped-def": 4 }, "app/modules/themoviedb/tmdbv3api/objs/list.py": { - "unknown": 28 + "no-untyped-call": 14, + "no-untyped-def": 14 }, "app/modules/themoviedb/tmdbv3api/objs/movie.py": { - "unknown": 92 + "no-untyped-call": 46, + "no-untyped-def": 46 }, "app/modules/themoviedb/tmdbv3api/objs/network.py": { - "unknown": 12 + "no-untyped-call": 6, + "no-untyped-def": 6 }, "app/modules/themoviedb/tmdbv3api/objs/person.py": { - "unknown": 44 + "no-untyped-call": 22, + "no-untyped-def": 22 }, "app/modules/themoviedb/tmdbv3api/objs/provider.py": { - "unknown": 12 + "no-untyped-call": 6, + "no-untyped-def": 6 }, "app/modules/themoviedb/tmdbv3api/objs/review.py": { - "unknown": 4 + "no-untyped-call": 2, + "no-untyped-def": 2 }, "app/modules/themoviedb/tmdbv3api/objs/search.py": { - "unknown": 29 + "attr-defined": 1, + "no-untyped-call": 14, + "no-untyped-def": 14 }, "app/modules/themoviedb/tmdbv3api/objs/season.py": { - "unknown": 36 + "no-untyped-call": 18, + "no-untyped-def": 18 }, "app/modules/themoviedb/tmdbv3api/objs/trending.py": { - "unknown": 36 + "no-untyped-call": 18, + "no-untyped-def": 18 }, "app/modules/themoviedb/tmdbv3api/objs/tv.py": { - "unknown": 104 + "no-untyped-call": 52, + "no-untyped-def": 52 }, "app/modules/themoviedb/tmdbv3api/tmdb.py": { - "unknown": 87 + "assignment": 1, + "attr-defined": 3, + "no-redef": 2, + "no-untyped-call": 36, + "no-untyped-def": 44, + "operator": 1 }, "app/modules/thetvdb/tvdb_v4_official.py": { - "unknown": 274 + "arg-type": 4, + "assignment": 118, + "misc": 1, + "no-any-return": 64, + "no-untyped-call": 2, + "no-untyped-def": 16, + "return-value": 1, + "type-arg": 68 }, "app/modules/transmission/transmission.py": { - "unknown": 41 + "assignment": 19, + "misc": 1, + "no-any-return": 2, + "no-untyped-def": 4, + "type-arg": 14, + "union-attr": 1 }, "app/modules/trimemedia/api.py": { - "unknown": 21 + "attr-defined": 1, + "misc": 2, + "no-any-return": 1, + "no-untyped-def": 11, + "type-arg": 6 }, "app/modules/ugreen/api.py": { - "unknown": 31 + "arg-type": 1, + "no-any-return": 2, + "return-value": 7, + "type-arg": 21 }, "app/modules/ugreen/crypto.py": { - "unknown": 1 + "no-any-return": 1 }, "app/modules/ugreen/ugreen.py": { - "unknown": 45 + "arg-type": 4, + "no-any-return": 1, + "no-untyped-call": 7, + "no-untyped-def": 9, + "operator": 2, + "type-arg": 12, + "type-var": 1, + "union-attr": 7, + "var-annotated": 2 }, "app/modules/vocechat/vocechat.py": { - "unknown": 8 + "misc": 1, + "no-any-return": 3, + "no-untyped-call": 1, + "no-untyped-def": 3 }, "app/modules/wechat/wechat.py": { - "unknown": 26 + "arg-type": 1, + "assignment": 9, + "misc": 1, + "no-any-return": 6, + "no-untyped-def": 5, + "type-arg": 2, + "union-attr": 1, + "var-annotated": 1 }, "app/modules/wechat/wechatbot.py": { - "unknown": 22 + "no-untyped-def": 7, + "type-arg": 11, + "union-attr": 3, + "var-annotated": 1 }, "app/modules/wechatclawbot/wechatclawbot.py": { - "unknown": 15 + "arg-type": 2, + "assignment": 1, + "comparison-overlap": 3, + "no-any-return": 3, + "no-untyped-def": 4, + "type-arg": 1, + "union-attr": 1 }, "app/modules/zspace/zspace.py": { - "unknown": 44 + "arg-type": 10, + "assignment": 3, + "no-any-return": 12, + "no-untyped-call": 2, + "no-untyped-def": 3, + "operator": 1, + "type-arg": 8, + "union-attr": 1, + "var-annotated": 4 }, "app/monitor/snapshot.py": { - "unknown": 7 + "type-arg": 5, + "valid-type": 1, + "var-annotated": 1 }, "app/monitor/syslimits.py": { - "unknown": 1 + "attr-defined": 1 }, "app/monitor/watcher.py": { - "unknown": 18 + "no-untyped-call": 6, + "no-untyped-def": 11, + "type-arg": 1 }, "app/runtime/cache.py": { - "unknown": 89 + "arg-type": 8, + "assignment": 1, + "attr-defined": 12, + "misc": 2, + "no-any-return": 1, + "no-untyped-def": 54, + "override": 17, + "type-arg": 3, + "unused-coroutine": 3 }, "app/runtime/capabilities/runtime.py": { - "unknown": 10 + "arg-type": 2, + "misc": 6, + "no-untyped-def": 2 }, "app/runtime/compat/imports.py": { - "unknown": 20 + "assignment": 1, + "attr-defined": 2, + "method-assign": 4, + "no-untyped-call": 1, + "no-untyped-def": 12 }, "app/runtime/config.py": { - "unknown": 52 + "assignment": 7, + "comparison-overlap": 1, + "misc": 4, + "no-any-return": 1, + "no-untyped-call": 1, + "no-untyped-def": 24, + "type-arg": 14 }, "app/runtime/debounce.py": { - "unknown": 37 + "assignment": 1, + "attr-defined": 2, + "no-untyped-call": 8, + "no-untyped-def": 21, + "override": 2, + "type-arg": 4, + "unused-coroutine": 1 }, "app/runtime/deprecation/policy.py": { - "unknown": 2 + "type-arg": 2 }, "app/runtime/event/binding.py": { - "unknown": 7 + "assignment": 1, + "no-redef": 1, + "type-arg": 5 }, "app/runtime/event/contracts.py": { - "unknown": 2 + "arg-type": 2 }, "app/runtime/event/dispatch.py": { - "unknown": 5 + "type-arg": 5 }, "app/runtime/event/registry.py": { - "unknown": 14 + "type-arg": 14 + }, + "app/runtime/events.py": { + "list-item": 1, + "misc": 2, + "no-any-return": 2, + "no-untyped-call": 3, + "no-untyped-def": 19, + "type-arg": 23, + "var-annotated": 3 }, "app/runtime/execution.py": { - "unknown": 9 + "no-untyped-def": 8, + "type-arg": 1 }, "app/runtime/extensions/host_module_adapter.py": { - "unknown": 3 + "arg-type": 1, + "union-attr": 1, + "var-annotated": 1 }, "app/runtime/extensions/module/dispatcher.py": { - "unknown": 1 + "arg-type": 1 + }, + "app/runtime/extensions/module_manager.py": { + "attr-defined": 1, + "type-arg": 4 }, "app/runtime/extensions/plugin/access.py": { - "unknown": 1 + "no-any-return": 1 }, "app/runtime/extensions/plugin/admission.py": { - "unknown": 3 + "union-attr": 3 }, "app/runtime/extensions/plugin/catalog.py": { - "unknown": 5 + "no-any-return": 5 }, "app/runtime/extensions/plugin/clone.py": { - "unknown": 2 + "type-arg": 2 }, "app/runtime/extensions/plugin/lifecycle.py": { - "unknown": 2 + "type-arg": 2 }, "app/runtime/extensions/plugin/metadata.py": { - "unknown": 4 + "type-arg": 4 }, "app/runtime/extensions/plugin/monitor.py": { - "unknown": 4 + "index": 1, + "type-arg": 3 }, "app/runtime/extensions/plugin/paths.py": { - "unknown": 3 + "arg-type": 1, + "type-arg": 2 }, "app/runtime/extensions/plugin/projection.py": { - "unknown": 10 + "assignment": 1, + "type-arg": 8, + "var-annotated": 1 }, "app/runtime/extensions/plugin/storage.py": { - "unknown": 3 + "type-arg": 3 }, "app/runtime/extensions/plugin/sync.py": { - "unknown": 3 + "arg-type": 1, + "type-arg": 2 }, "app/runtime/extensions/plugin/system.py": { - "unknown": 10 + "no-any-return": 6, + "type-arg": 4 }, "app/runtime/extensions/service_config.py": { - "unknown": 2 + "type-arg": 2 }, "app/runtime/gc.py": { - "unknown": 7 + "no-any-return": 1, + "no-untyped-def": 2, + "type-arg": 4 }, "app/runtime/localization.py": { - "unknown": 1 + "no-any-return": 1 }, "app/runtime/managed_resources.py": { - "unknown": 5 + "arg-type": 2, + "union-attr": 3 }, "app/runtime/progress.py": { - "unknown": 10 + "no-any-return": 4, + "type-arg": 6 }, "app/runtime/rate.py": { - "unknown": 29 + "assignment": 1, + "no-untyped-call": 8, + "no-untyped-def": 14, + "type-arg": 5, + "var-annotated": 1 + }, + "app/runtime/reload.py": { + "misc": 1, + "no-untyped-call": 5, + "no-untyped-def": 6 }, "app/runtime/scheduling.py": { - "unknown": 6 + "import-untyped": 1, + "no-any-return": 1, + "operator": 1, + "type-arg": 1, + "valid-type": 2 }, "app/runtime/settings.py": { - "unknown": 6 + "no-any-return": 6 }, "app/runtime/thread.py": { "unused-ignore": 1 @@ -819,125 +1596,155 @@ "import-untyped": 1 }, "app/schemas/agent.py": { - "unknown": 26 + "misc": 26 }, "app/schemas/cache.py": { - "unknown": 3 + "misc": 3 }, "app/schemas/category.py": { - "unknown": 3 + "misc": 3 }, "app/schemas/common.py": { - "unknown": 13 + "misc": 13 }, "app/schemas/context.py": { - "unknown": 18 + "misc": 18 }, "app/schemas/dashboard.py": { - "unknown": 10 + "misc": 10 }, "app/schemas/download.py": { - "unknown": 4 + "misc": 4 }, "app/schemas/event.py": { - "unknown": 17 + "misc": 7, + "no-untyped-def": 3, + "type-arg": 7 }, "app/schemas/file.py": { - "unknown": 4 + "misc": 3, + "return-value": 1 }, "app/schemas/history.py": { - "unknown": 4 + "misc": 4 }, "app/schemas/llm.py": { - "unknown": 9 + "misc": 9 }, "app/schemas/mcp.py": { - "misc": 1, - "unknown": 22 + "misc": 23 }, "app/schemas/media.py": { - "unknown": 9 + "attr-defined": 5, + "has-type": 1, + "misc": 1, + "no-untyped-def": 2 }, "app/schemas/mediaserver.py": { - "unknown": 14 + "misc": 14 }, "app/schemas/message.py": { - "unknown": 21 + "misc": 15, + "no-redef": 1, + "no-untyped-def": 2, + "type-arg": 3 }, "app/schemas/mfa.py": { - "misc": 1, - "unknown": 4 + "misc": 5 }, "app/schemas/monitoring.py": { - "unknown": 6 + "misc": 6 }, "app/schemas/music.py": { - "unknown": 10 + "misc": 10 }, "app/schemas/notification.py": { - "unknown": 2 + "misc": 2 }, "app/schemas/openai.py": { - "unknown": 23 + "misc": 23 }, "app/schemas/plugin.py": { - "unknown": 18 + "assignment": 2, + "misc": 16 }, "app/schemas/response.py": { - "unknown": 3 + "misc": 3 }, "app/schemas/rule.py": { - "unknown": 2 + "misc": 2 }, "app/schemas/search.py": { - "unknown": 2 + "misc": 2 }, "app/schemas/servarr.py": { - "unknown": 18 + "misc": 18 }, "app/schemas/servcookie.py": { - "unknown": 5 + "misc": 5 }, "app/schemas/site.py": { - "unknown": 8 + "misc": 8 }, "app/schemas/storage.py": { - "unknown": 3 + "misc": 3 }, "app/schemas/subscribe.py": { - "unknown": 11 + "misc": 10, + "no-any-return": 1 }, "app/schemas/system.py": { - "unknown": 22 + "assignment": 1, + "misc": 15, + "type-arg": 6 }, "app/schemas/tmdb.py": { - "unknown": 5 + "misc": 5 }, "app/schemas/token.py": { - "unknown": 3 + "misc": 3 }, "app/schemas/transfer.py": { - "unknown": 21 + "assignment": 1, + "misc": 16, + "no-untyped-def": 1, + "type-arg": 3 }, "app/schemas/types.py": { - "unknown": 6 + "no-any-return": 1, + "no-untyped-def": 2, + "return-value": 2, + "type-arg": 1 }, "app/schemas/user.py": { - "unknown": 3 + "misc": 3 }, "app/schemas/workflow.py": { - "unknown": 19 + "misc": 19 }, "app/sdk/_legacy/transfer.py": { - "unknown": 1 + "no-untyped-def": 1 }, "app/sdk/string.py": { - "unknown": 3 + "attr-defined": 1, + "no-untyped-def": 2, + "type-arg": 1 }, "app/testing/network_guard.py": { - "unknown": 6 + "no-untyped-def": 6 }, "app/testing/stub.py": { - "unknown": 2 + "no-untyped-call": 2 + }, + "app/workflow/__init__.py": { + "arg-type": 2, + "assignment": 2, + "index": 1, + "no-any-return": 1, + "no-untyped-call": 1, + "no-untyped-def": 9, + "type-arg": 9, + "union-attr": 2, + "valid-type": 1 } } diff --git a/tests/fixtures/architecture/startup-performance-baseline.json b/tests/fixtures/architecture/startup-performance-baseline.json index bbd195456..7063a9b68 100644 --- a/tests/fixtures/architecture/startup-performance-baseline.json +++ b/tests/fixtures/architecture/startup-performance-baseline.json @@ -6,7 +6,7 @@ "repeat": 3, "targets": { "app.startup.lifecycle": { - "loaded_app_module_count": 361, + "loaded_app_module_count": 362, "max_ms": 904.069, "median_ms": 898.164, "min_ms": 896.39, @@ -17,7 +17,7 @@ ] }, "app.factory": { - "loaded_app_module_count": 373, + "loaded_app_module_count": 374, "max_ms": 923.165, "median_ms": 921.768, "min_ms": 921.249, @@ -28,7 +28,7 @@ ] }, "app.main": { - "loaded_app_module_count": 375, + "loaded_app_module_count": 376, "max_ms": 1069.392, "median_ms": 1037.036, "min_ms": 1027.603, diff --git a/tests/test_api_authorization.py b/tests/test_api_authorization.py index 2b85f0d8b..623646d39 100644 --- a/tests/test_api_authorization.py +++ b/tests/test_api_authorization.py @@ -52,6 +52,10 @@ def test_system_sensitive_read_endpoints_require_superuser(): """系统敏感读取接口必须只允许管理员访问。""" assert _dependency_of(system_endpoint.get_env_setting, "_") is get_current_active_superuser_async assert _dependency_of(system_endpoint.get_setting, "_") is get_current_active_superuser_async + assert _dependency_of(system_endpoint.list_database_backups, "_") is get_current_active_superuser_async + assert _dependency_of(system_endpoint.create_database_backup, "_") is get_current_active_superuser_async + assert _dependency_of(system_endpoint.verify_database_backup, "_") is get_current_active_superuser_async + assert _dependency_of(system_endpoint.delete_database_backup, "_") is get_current_active_superuser_async def test_system_public_read_endpoints_require_active_user(): diff --git a/tests/test_dashboard_system_info.py b/tests/test_dashboard_system_info.py index dd9a16c0a..cf86e7858 100644 --- a/tests/test_dashboard_system_info.py +++ b/tests/test_dashboard_system_info.py @@ -1,4 +1,4 @@ -from app.db import SessionFactory +from app.db.session import SessionFactory from app.db.models.transferhistory import TransferHistory from app.schemas.types import MediaSource, MediaType from app.adapters.system import host as system_module @@ -20,7 +20,7 @@ def test_dashboard_system_info_returns_runtime_environment(monkeypatch): monkeypatch.setattr(system_module.time, "time", lambda: 1000.0) monkeypatch.setattr(system_module.psutil, "Process", FakeProcess) monkeypatch.setattr(SystemUtils, "_operating_system_name", staticmethod(lambda: "Ubuntu 24.04.4 LTS")) - monkeypatch.setattr(system_module, "APP_VERSION", "v2.13.16") + monkeypatch.setattr(system_module, "get_app_version", lambda: "v2.13.16") result = SystemUtils.dashboard_system_info() diff --git a/tests/test_database_backup_cli_sdk.py b/tests/test_database_backup_cli_sdk.py index e4b4e1a86..ef1b0d31d 100644 --- a/tests/test_database_backup_cli_sdk.py +++ b/tests/test_database_backup_cli_sdk.py @@ -11,7 +11,7 @@ from app.cli import cli from app.sdk import database as database_sdk -NAME = "sqlite_20260819_030000.db" +NAME = "moviepilot_v3.0.0_sqlite_20260819_030000.db" def _artifact(tmp_path: Path) -> BackupArtifact: diff --git a/tests/test_database_backup_service.py b/tests/test_database_backup_service.py index 5c77a38f8..b3bf6c75c 100644 --- a/tests/test_database_backup_service.py +++ b/tests/test_database_backup_service.py @@ -1,13 +1,19 @@ from __future__ import annotations import stat +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from datetime import datetime from pathlib import Path +from threading import Event import pytest -from app.application.backup import BackupPolicy, DatabaseBackupService +from app.application.backup import ( + BackupPolicy, + DatabaseBackupInProgressError, + DatabaseBackupService, +) @dataclass(frozen=True, slots=True) @@ -35,6 +41,21 @@ class _Backend: self.restored = artifact +class _BlockingBackend(_Backend): + """让首个创建停在后端写入阶段,以验证共享服务的并发约束。""" + + def __init__(self) -> None: + super().__init__() + self.started = Event() + self.release = Event() + + def create(self, destination: Path) -> None: + self.started.set() + if not self.release.wait(timeout=2): + raise TimeoutError("测试未释放数据库备份") + super().create(destination) + + def _service( root: Path, *, @@ -53,7 +74,7 @@ def _service( def test_create_publishes_one_readable_private_file(tmp_path: Path) -> None: artifact = _service(tmp_path).create() - assert artifact.name == "sqlite_20260819_134526.db" + assert artifact.name == "moviepilot_v3.0.0_sqlite_20260819_134526.db" assert artifact.path.read_bytes() == b"database snapshot" assert stat.S_IMODE(tmp_path.stat().st_mode) == 0o700 assert stat.S_IMODE(artifact.path.stat().st_mode) == 0o600 @@ -61,10 +82,29 @@ def test_create_publishes_one_readable_private_file(tmp_path: Path) -> None: def test_failed_verification_does_not_publish_artifact(tmp_path: Path) -> None: + backend = _Backend(valid=False) + service = _service(tmp_path, backend=backend) with pytest.raises(RuntimeError, match="数据库备份校验失败"): - _service(tmp_path, backend=_Backend(valid=False)).create() + service.create() assert list(tmp_path.iterdir()) == [] + backend.valid = True + assert service.create().path.is_file() + + +def test_concurrent_create_is_rejected_without_waiting(tmp_path: Path) -> None: + backend = _BlockingBackend() + service = _service(tmp_path, backend=backend) + + with ThreadPoolExecutor(max_workers=1) as executor: + first = executor.submit(service.create) + assert backend.started.wait(timeout=1) + with pytest.raises(DatabaseBackupInProgressError, match="正在执行"): + service.create() + backend.release.set() + artifact = first.result(timeout=2) + + assert artifact.path.is_file() def test_same_second_backups_receive_short_sequence_suffix(tmp_path: Path) -> None: @@ -73,8 +113,17 @@ def test_same_second_backups_receive_short_sequence_suffix(tmp_path: Path) -> No first = service.create() second = service.create() - assert first.name == "sqlite_20260819_134526.db" - assert second.name == "sqlite_20260819_134526_1.db" + assert first.name == "moviepilot_v3.0.0_sqlite_20260819_134526.db" + assert second.name == "moviepilot_v3.0.0_sqlite_20260819_134526_1.db" + + +def test_backup_name_uses_application_release_version(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr( + "app.adapters.system.backup.files.get_app_version", + lambda: "v4.2.1", + ) + + assert _service(tmp_path).create().name == "moviepilot_v4.2.1_sqlite_20260819_134526.db" def test_retention_applies_after_new_artifact_is_available(tmp_path: Path) -> None: @@ -99,6 +148,32 @@ def test_list_ignores_unmanaged_files_and_rejects_paths(tmp_path: Path) -> None: _service(tmp_path).verify("../user.db") +def test_legacy_backup_name_remains_managed(tmp_path: Path) -> None: + """升级前生成的无代际前缀备份仍可列出、校验和删除。""" + legacy = tmp_path / "sqlite_20260818_030000.db" + legacy.write_bytes(b"database snapshot") + service = _service(tmp_path) + + assert [item.name for item in service.list()] == [legacy.name] + assert service.verify(legacy.name).valid is True + service.delete(legacy.name) + assert legacy.exists() is False + + +def test_delete_removes_only_named_managed_backup(tmp_path: Path) -> None: + service = _service(tmp_path) + artifact = service.create() + unmanaged = tmp_path / "notes.txt" + unmanaged.write_text("keep", encoding="utf-8") + + service.delete(artifact.name) + + assert artifact.path.exists() is False + assert unmanaged.exists() is True + with pytest.raises(ValueError, match="文件名"): + service.delete("../notes.txt") + + def test_restore_requires_matching_database_type(tmp_path: Path) -> None: backend = _Backend() service = _service(tmp_path, backend=backend) @@ -109,7 +184,7 @@ def test_restore_requires_matching_database_type(tmp_path: Path) -> None: assert restored.name == artifact.name assert backend.restored == artifact.path - postgres = tmp_path / "postgresql_20260819_134526.dump" + postgres = tmp_path / "moviepilot_v3.0.0_postgresql_20260819_134526.dump" postgres.write_bytes(b"database snapshot") with pytest.raises(ValueError, match="当前数据库类型"): service.restore(postgres.name) diff --git a/tests/test_database_migration_startup.py b/tests/test_database_migration_startup.py index 2ac2fb296..cd6065d23 100644 --- a/tests/test_database_migration_startup.py +++ b/tests/test_database_migration_startup.py @@ -437,7 +437,7 @@ def downgrade(): db_init.prepare_database() - artifacts = sorted((tmp_path / "backups").glob("sqlite_*.db")) + artifacts = sorted((tmp_path / "backups").glob("moviepilot_*_sqlite_*.db")) assert len(artifacts) == 1 with create_engine(f"sqlite:///{artifacts[0]}").connect() as connection: backup_revision = connection.execute( diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 043e11b0e..0ba7177c6 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -43,10 +43,10 @@ def test_doctor_reports_valid_backup_when_sqlite_database_is_corrupt( (tmp_path / "user.db").write_bytes(b"not a sqlite database") backup_dir = settings.DATABASE_BACKUP_PATH backup_dir.mkdir(parents=True) - backup = backup_dir / "sqlite_20260822_030000.db" + backup = backup_dir / "moviepilot_v3.0.0_sqlite_20260822_030000.db" with sqlite3.connect(backup) as connection: connection.execute("CREATE TABLE entries (value TEXT NOT NULL)") - (backup_dir / "sqlite_20260822_040000.db").write_bytes(b"invalid newer backup") + (backup_dir / "moviepilot_v3.0.0_sqlite_20260822_040000.db").write_bytes(b"invalid newer backup") runner = DoctorRunner() checks._check_database(runner) @@ -58,7 +58,7 @@ def test_doctor_reports_valid_backup_when_sqlite_database_is_corrupt( assert finding.context["backups"][0]["valid"] is False assert finding.context["backups"][1]["valid"] is True assert finding.context["restore_command"] == ( - "moviepilot database restore sqlite_20260822_030000.db --confirm" + "moviepilot database restore moviepilot_v3.0.0_sqlite_20260822_030000.db --confirm" ) assert finding.context["restore_command"] in finding.recommendation @@ -74,13 +74,13 @@ def test_doctor_distinguishes_missing_and_mismatched_backups(tmp_path, monkeypat backup_dir = settings.DATABASE_BACKUP_PATH backup_dir.mkdir(parents=True) - (backup_dir / "postgresql_20260822_030000.dump").write_bytes(b"PGDMP") + (backup_dir / "moviepilot_v3.0.0_postgresql_20260822_030000.dump").write_bytes(b"PGDMP") runner = DoctorRunner() checks._check_database_backups(runner) mismatched = runner.report.find("database.backup_recovery") assert mismatched is not None assert mismatched.status == DoctorFindingStatus.Degraded - assert mismatched.context["mismatched"] == ["postgresql_20260822_030000.dump"] + assert mismatched.context["mismatched"] == ["moviepilot_v3.0.0_postgresql_20260822_030000.dump"] def test_doctor_reports_invalid_backup_without_modifying_it(tmp_path, monkeypatch): @@ -88,7 +88,7 @@ def test_doctor_reports_invalid_backup_without_modifying_it(tmp_path, monkeypatc monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path)) backup_dir = settings.DATABASE_BACKUP_PATH backup_dir.mkdir(parents=True) - backup = backup_dir / "sqlite_20260822_030000.db" + backup = backup_dir / "moviepilot_v3.0.0_sqlite_20260822_030000.db" original = b"invalid sqlite backup" backup.write_bytes(original) @@ -113,7 +113,7 @@ def test_doctor_exposes_missing_pg_restore_in_text_finding(tmp_path, monkeypatch monkeypatch.setattr(settings, "DB_TYPE", "postgresql") backup_dir = settings.DATABASE_BACKUP_PATH backup_dir.mkdir(parents=True) - (backup_dir / "postgresql_20260822_030000.dump").write_bytes(b"PGDMP") + (backup_dir / "moviepilot_v3.0.0_postgresql_20260822_030000.dump").write_bytes(b"PGDMP") monkeypatch.setattr(checks, "verify_database_backup", missing_pg_restore) runner = DoctorRunner() diff --git a/tests/test_locale_helper.py b/tests/test_locale_helper.py index 73c0f7558..ade347726 100644 --- a/tests/test_locale_helper.py +++ b/tests/test_locale_helper.py @@ -236,6 +236,63 @@ def test_locale_helper_translates_common_backend_response_messages(): assert LocaleHelper.translate_text(message, locale="en-US") == expected +def test_database_backup_messages_have_english_and_traditional_chinese() -> None: + """数据库备份 API 与配置校验文案应覆盖所有受支持的非默认语言。""" + samples = { + "读取数据库备份列表失败,请查看日志": ( + "Failed to load database backups. Check the logs for details", + "讀取資料庫備份清單失敗,請查看日誌", + ), + "创建数据库备份失败,请查看日志": ( + "Failed to create the database backup. Check the logs for details", + "建立資料庫備份失敗,請查看日誌", + ), + "已有数据库备份任务正在执行": ( + "A database backup task is already in progress", + "已有資料庫備份任務正在執行", + ), + "数据库备份文件名无效": ( + "The database backup file name is invalid", + "資料庫備份檔名無效", + ), + "数据库备份不存在": ( + "The database backup does not exist", + "資料庫備份不存在", + ), + "校验数据库备份失败,请查看日志": ( + "Failed to verify the database backup. Check the logs for details", + "驗證資料庫備份失敗,請查看日誌", + ), + "删除数据库备份失败,请查看日志": ( + "Failed to delete the database backup. Check the logs for details", + "刪除資料庫備份失敗,請查看日誌", + ), + "数据库备份周期格式不正确": ( + "The database backup schedule format is invalid", + "資料庫備份週期格式不正確", + ), + "数据库备份目录必须是路径字符串": ( + "The database backup directory must be a path string", + "資料庫備份目錄必須是路徑字串", + ), + "数据库备份过期天数必须是大于等于 0 的整数": ( + "Database backup retention days must be an integer greater than or equal to 0", + "資料庫備份過期天數必須是大於等於 0 的整數", + ), + "数据库备份最大保留份数必须是大于等于 0 的整数": ( + "The maximum number of database backups must be an integer greater than or equal to 0", + "資料庫備份最大保留份數必須是大於等於 0 的整數", + ), + } + + for message, (english, traditional_chinese) in samples.items(): + assert LocaleHelper.translate_text(message, locale="en-US") == english + assert ( + LocaleHelper.translate_text(message, locale="zh-TW") + == traditional_chinese + ) + + def test_response_localizes_message_from_locale_context(): """通用 Response 应根据请求语言上下文直接翻译消息。""" token = LocaleHelper.set_current_locale("en-US") diff --git a/tests/test_mypy_gate.py b/tests/test_mypy_gate.py index 3122e37c3..b72de56d8 100644 --- a/tests/test_mypy_gate.py +++ b/tests/test_mypy_gate.py @@ -1,6 +1,8 @@ -"""mypy 错误数只降不增 ratchet 的解析与对比逻辑测试。""" +"""mypy 错误数只降不增 ratchet 的执行、解析与对比逻辑测试。""" -from scripts.architecture.mypy_ratchet import compare_counts, parse_errors +from unittest.mock import patch + +from scripts.architecture.mypy_ratchet import compare_counts, parse_errors, run_mypy MYPY_SAMPLE = """ @@ -13,6 +15,18 @@ Found 3 errors in 1 file (checked 500 source files) """ +def test_run_mypy_uses_stable_full_analysis() -> None: + """全量门禁不得复用前序检查缓存,也不得输出换行错误码。""" + with patch("scripts.architecture.mypy_ratchet.subprocess.run") as run: + run.return_value.stdout = "" + + run_mypy() + + command = run.call_args.args[0] + assert "--no-incremental" in command + assert "--no-pretty" in command + + def test_parse_errors_aggregates_per_file_and_code() -> None: """错误行按文件与错误码聚合,源码上下文与摘要行不计入。""" report = parse_errors(MYPY_SAMPLE) diff --git a/tests/test_system_database_backup_api.py b/tests/test_system_database_backup_api.py new file mode 100644 index 000000000..649ecfb4f --- /dev/null +++ b/tests/test_system_database_backup_api.py @@ -0,0 +1,167 @@ +"""系统数据库备份 Web 管理端点测试。""" + +from __future__ import annotations + +import asyncio +from datetime import datetime +from pathlib import Path +from threading import Event +from types import SimpleNamespace + +import pytest +import httpx +from fastapi import FastAPI +from fastapi import HTTPException + +from app.api.endpoints import system as system_endpoint +from app.api.dependencies.auth import get_current_active_superuser_async +from app.application.backup import ( + BackupVerification, + DatabaseBackupInProgressError, +) + + +class _Governance: + """提供端点测试所需的最小数据库治理合同。""" + + def __init__(self) -> None: + self.artifact = SimpleNamespace( + name="moviepilot_v3.0.0_sqlite_20260825_120000.db", + db_type="sqlite", + created_at=datetime(2026, 8, 25, 12, 0, 0), + path=Path("/private/database_backup/moviepilot_v3.0.0_sqlite_20260825_120000.db"), + size=4096, + ) + + def list_backups(self): + return (self.artifact,) + + def create_backup(self): + return self.artifact + + def verify_backup(self, name: str): + if "/" in name: + raise ValueError("数据库备份文件名不能包含路径") + return BackupVerification(True, "PRAGMA integrity_check", "private detail") + + def delete_backup(self, name: str): + if "/" in name: + raise ValueError("数据库备份文件名不能包含路径") + if name == "missing.db": + raise FileNotFoundError(name) + + +@pytest.mark.asyncio +async def test_list_database_backups_maps_public_fields(monkeypatch) -> None: + """列表不得把内部备份路径投影到 Web 响应。""" + monkeypatch.setattr(system_endpoint, "get_database_governance", _Governance) + + result = await system_endpoint.list_database_backups(_=object()) + + assert [item.model_dump() for item in result] == [ + { + "name": "moviepilot_v3.0.0_sqlite_20260825_120000.db", + "db_type": "sqlite", + "created_at": datetime(2026, 8, 25, 12, 0, 0), + "size": 4096, + } + ] + + +@pytest.mark.asyncio +async def test_create_database_backup_reports_busy_state(monkeypatch) -> None: + """重复创建应返回冲突,不排队创建第二份备份。""" + governance = _Governance() + monkeypatch.setattr( + governance, + "create_backup", + lambda: (_ for _ in ()).throw( + DatabaseBackupInProgressError("已有数据库备份任务正在执行") + ), + ) + monkeypatch.setattr(system_endpoint, "get_database_governance", lambda: governance) + + with pytest.raises(HTTPException) as error: + await system_endpoint.create_database_backup(_=object()) + + assert error.value.status_code == 409 + assert error.value.detail == "已有数据库备份任务正在执行" + + +@pytest.mark.asyncio +async def test_verify_database_backup_rejects_path_input(monkeypatch) -> None: + """校验端点只接受受管文件名。""" + monkeypatch.setattr(system_endpoint, "get_database_governance", _Governance) + + with pytest.raises(HTTPException) as error: + await system_endpoint.verify_database_backup("../user.db", _=object()) + + assert error.value.status_code == 400 + assert error.value.detail == "数据库备份文件名无效" + + +@pytest.mark.asyncio +async def test_delete_database_backup_accepts_only_managed_name(monkeypatch) -> None: + governance = _Governance() + monkeypatch.setattr(system_endpoint, "get_database_governance", lambda: governance) + + response = await system_endpoint.delete_database_backup( + governance.artifact.name, + _=object(), + ) + + assert response.success is True + + with pytest.raises(HTTPException) as invalid: + await system_endpoint.delete_database_backup("../user.db", _=object()) + assert invalid.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_delete_database_backup_uses_host_response_envelope(monkeypatch) -> None: + """Web 客户端必须收到统一响应,不能把成功删除误判为空响应错误。""" + governance = _Governance() + monkeypatch.setattr(system_endpoint, "get_database_governance", lambda: governance) + app = FastAPI() + app.include_router(system_endpoint.router, prefix="/api/v1/system") + app.dependency_overrides[get_current_active_superuser_async] = lambda: object() + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://test", + ) as client: + response = await client.delete( + f"/api/v1/system/database/backups/{governance.artifact.name}" + ) + + assert response.status_code == 200 + assert response.json() == {"success": True, "message": "", "data": None} + + +@pytest.mark.asyncio +async def test_list_database_backups_does_not_block_event_loop(monkeypatch) -> None: + """文件系统或数据库工具等待必须在线程边界内执行。""" + started = Event() + release = Event() + governance = _Governance() + + def blocking_list(): + started.set() + if not release.wait(timeout=2): + raise TimeoutError("测试未释放数据库备份列表") + return (governance.artifact,) + + monkeypatch.setattr(governance, "list_backups", blocking_list) + monkeypatch.setattr(system_endpoint, "get_database_governance", lambda: governance) + + task = asyncio.create_task(system_endpoint.list_database_backups(_=object())) + for _ in range(100): + if started.is_set(): + break + await asyncio.sleep(0.001) + assert started.is_set() + assert task.done() is False + release.set() + + assert len(await task) == 1 diff --git a/tests/test_system_update_manager.py b/tests/test_system_update_manager.py index 29d20f3a9..6ce6f180a 100644 --- a/tests/test_system_update_manager.py +++ b/tests/test_system_update_manager.py @@ -39,7 +39,7 @@ def test_check_exposes_new_stable_release(monkeypatch, tmp_path): }, ] monkeypatch.setattr(manager, "_request", lambda: SimpleNamespace(get_res=lambda _url: _response(releases))) - monkeypatch.setattr(update_module, "APP_VERSION", "v3.0.0") + monkeypatch.setattr(update_module, "get_app_version", lambda: "v3.0.0") status = manager.check() diff --git a/tests/test_system_version.py b/tests/test_system_version.py new file mode 100644 index 000000000..1b1069523 --- /dev/null +++ b/tests/test_system_version.py @@ -0,0 +1,42 @@ +from pathlib import Path + +from app.chain.system import SystemChain +from app.runtime import version as runtime_version + + +def test_installed_frontend_version_prefers_deployed_resource( + tmp_path: Path, + monkeypatch, +) -> None: + frontend_path = tmp_path / "public" + frontend_path.mkdir() + (frontend_path / "version.txt").write_text("v3.2.1\n", encoding="utf-8") + monkeypatch.setattr(runtime_version, "is_frozen", lambda: False) + monkeypatch.setattr(runtime_version, "is_windows", lambda: False) + monkeypatch.setattr( + runtime_version, + "get_runtime_setting", + lambda key: frontend_path if key == "FRONTEND_PATH" else tmp_path / "config", + ) + + assert runtime_version.get_frontend_version() == "v3.2.1" + assert SystemChain.get_frontend_version() == "v3.2.1" + + +def test_installed_frontend_version_falls_back_to_release_declaration( + tmp_path: Path, + monkeypatch, +) -> None: + monkeypatch.setattr(runtime_version, "is_frozen", lambda: False) + monkeypatch.setattr(runtime_version, "is_windows", lambda: False) + monkeypatch.setattr(runtime_version, "_FRONTEND_VERSION", "v3.0.0") + monkeypatch.setattr( + runtime_version, + "get_runtime_setting", + lambda key: tmp_path / key.lower(), + ) + + assert runtime_version.get_frontend_version() == "v3.0.0" + assert ( + runtime_version.get_frontend_version(fallback_to_declared=False) is None + )