mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
refactor: add configuration dependency ratchet
This commit is contained in:
@@ -16,7 +16,10 @@ from app.agent.tools.impl._system_setting_utils import (
|
||||
should_redact_setting,
|
||||
)
|
||||
from app.runtime.config import settings
|
||||
from app.application.configuration import get_configured_system_config as SystemConfigOper
|
||||
from app.application.configuration import (
|
||||
SystemConfigReader,
|
||||
get_configured_system_config as SystemConfigOper,
|
||||
)
|
||||
from app.runtime.log import logger
|
||||
|
||||
|
||||
@@ -84,6 +87,23 @@ class QuerySystemSettingsTool(MoviePilotTool):
|
||||
require_admin: bool = True
|
||||
args_schema: Type[BaseModel] = QuerySystemSettingsInput
|
||||
_secret_read_confirmed: bool = PrivateAttr(default=False)
|
||||
_system_config: Optional[SystemConfigReader] = PrivateAttr(default=None)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
system_config: Optional[SystemConfigReader] = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""注入授权范围内的配置读取端口,并兼容组合根默认服务。"""
|
||||
super().__init__(session_id=session_id, user_id=user_id, **kwargs)
|
||||
self._system_config = system_config
|
||||
|
||||
def _get_system_config(self) -> SystemConfigReader:
|
||||
"""返回显式注入端口,旧构造形态则延迟读取组合根服务。"""
|
||||
return self._system_config or SystemConfigOper()
|
||||
|
||||
async def _run_confirmed(self, **kwargs) -> str:
|
||||
"""仅供宿主在消费有效确认后执行一次未脱敏读取。"""
|
||||
@@ -105,12 +125,11 @@ class QuerySystemSettingsTool(MoviePilotTool):
|
||||
return f"筛选系统设置: {group} / {keyword}"
|
||||
return f"查询系统设置分组: {group}"
|
||||
|
||||
@staticmethod
|
||||
def _load_setting_value(spec: SettingSpec):
|
||||
def _load_setting_value(self, spec: SettingSpec):
|
||||
"""读取指定设置项的当前值。"""
|
||||
if spec.source == "settings":
|
||||
return getattr(settings, spec.key)
|
||||
return SystemConfigOper().get(spec.systemconfig_key)
|
||||
return self._get_system_config().get(spec.systemconfig_key)
|
||||
|
||||
@staticmethod
|
||||
def _summarize_value(value, *, redacted: bool = False) -> dict:
|
||||
|
||||
@@ -4,7 +4,7 @@ import copy
|
||||
import json
|
||||
from typing import Any, Literal, Optional, Type, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, PrivateAttr
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
@@ -18,7 +18,10 @@ from app.agent.tools.impl._system_setting_utils import (
|
||||
)
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.events import eventmanager
|
||||
from app.application.configuration import get_configured_system_config as SystemConfigOper
|
||||
from app.application.configuration import (
|
||||
SystemConfigService,
|
||||
get_configured_system_config as SystemConfigOper,
|
||||
)
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.event import ConfigChangeEventData
|
||||
from app.schemas.types import EventType
|
||||
@@ -74,6 +77,8 @@ class UpdateSystemSettingsInput(BaseModel):
|
||||
|
||||
|
||||
class UpdateSystemSettingsTool(MoviePilotTool):
|
||||
"""通过授权配置服务修改可登记系统设置。"""
|
||||
|
||||
name: str = "update_system_settings"
|
||||
tags: list[str] = [
|
||||
ToolTag.Write,
|
||||
@@ -87,6 +92,23 @@ class UpdateSystemSettingsTool(MoviePilotTool):
|
||||
)
|
||||
require_admin: bool = True
|
||||
args_schema: Type[BaseModel] = UpdateSystemSettingsInput
|
||||
_system_config: Optional[SystemConfigService] = PrivateAttr(default=None)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
system_config: Optional[SystemConfigService] = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""注入配置读写服务,并兼容组合根默认装配。"""
|
||||
super().__init__(session_id=session_id, user_id=user_id, **kwargs)
|
||||
self._system_config = system_config
|
||||
|
||||
def _get_system_config(self) -> SystemConfigService:
|
||||
"""返回显式注入服务,旧构造形态则延迟读取组合根服务。"""
|
||||
return self._system_config or SystemConfigOper()
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据更新参数生成友好的提示消息。"""
|
||||
@@ -101,12 +123,11 @@ class UpdateSystemSettingsTool(MoviePilotTool):
|
||||
}
|
||||
return f"{action_map.get(operation, '更新系统设置')}: {setting_key}"
|
||||
|
||||
@staticmethod
|
||||
def _load_setting_value(spec: SettingSpec):
|
||||
def _load_setting_value(self, spec: SettingSpec):
|
||||
"""读取指定设置项的当前值。"""
|
||||
if spec.source == "settings":
|
||||
return getattr(settings, spec.key)
|
||||
return SystemConfigOper().get(spec.systemconfig_key)
|
||||
return self._get_system_config().get(spec.systemconfig_key)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_systemconfig_value(value: Any):
|
||||
@@ -266,7 +287,7 @@ class UpdateSystemSettingsTool(MoviePilotTool):
|
||||
else:
|
||||
normalized_value = self._normalize_systemconfig_value(next_value)
|
||||
event_value = normalized_value
|
||||
success = await SystemConfigOper().async_set(
|
||||
success = await self._get_system_config().async_set(
|
||||
spec.systemconfig_key,
|
||||
normalized_value,
|
||||
)
|
||||
|
||||
@@ -2,21 +2,27 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class ConfigurationRepository(Protocol):
|
||||
"""配置服务所需的最小持久化端口。"""
|
||||
class SystemConfigReader(Protocol):
|
||||
"""持久化用户配置的最小只读端口。"""
|
||||
|
||||
def get(self, key: Any = None) -> Any:
|
||||
"""读取配置。"""
|
||||
|
||||
def set(self, key: Any, value: Any) -> bool | None:
|
||||
"""写入配置。"""
|
||||
|
||||
async def async_get(self, key: Any = None) -> Any:
|
||||
"""异步读取配置。"""
|
||||
|
||||
|
||||
class SystemConfigWriter(Protocol):
|
||||
"""持久化用户配置的最小写入端口。"""
|
||||
|
||||
def set(self, key: Any, value: Any) -> bool | None:
|
||||
"""写入配置。"""
|
||||
|
||||
async def async_set(self, key: Any, value: Any) -> bool | None:
|
||||
"""异步写入配置。"""
|
||||
|
||||
@@ -24,35 +30,58 @@ class ConfigurationRepository(Protocol):
|
||||
"""删除配置。"""
|
||||
|
||||
|
||||
class ConfigurationRepository(SystemConfigReader, SystemConfigWriter, Protocol):
|
||||
"""兼容同时提供读写能力的旧配置仓储。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TransferRetryConfig:
|
||||
"""整理失败重试用例在一次调用中使用的稳定配置快照。"""
|
||||
|
||||
max_failed_retries: Any
|
||||
|
||||
|
||||
class SystemConfigService:
|
||||
"""系统配置读写应用服务。"""
|
||||
|
||||
def __init__(self, repository: ConfigurationRepository) -> None:
|
||||
"""注入配置数据端口。"""
|
||||
self._repository = repository
|
||||
def __init__(
|
||||
self,
|
||||
repository: ConfigurationRepository | None = None,
|
||||
*,
|
||||
reader: SystemConfigReader | None = None,
|
||||
writer: SystemConfigWriter | None = None,
|
||||
) -> None:
|
||||
"""注入可分离的读写端口,并兼容旧的单仓储装配参数。"""
|
||||
resolved_reader = reader or repository
|
||||
resolved_writer = writer or repository
|
||||
if resolved_reader is None or resolved_writer is None:
|
||||
raise ValueError("系统配置服务必须同时提供 reader 与 writer")
|
||||
self._reader = resolved_reader
|
||||
self._writer = resolved_writer
|
||||
|
||||
def get(self, key: Any = None) -> Any:
|
||||
"""读取配置。"""
|
||||
return self._repository.get(key)
|
||||
return self._reader.get(key)
|
||||
|
||||
def set(self, key: Any, value: Any) -> bool | None:
|
||||
"""写入配置。"""
|
||||
return self._repository.set(key, value)
|
||||
return self._writer.set(key, value)
|
||||
|
||||
async def async_get(self, key: Any = None) -> Any:
|
||||
"""异步读取配置。"""
|
||||
return await self._repository.async_get(key)
|
||||
return await self._reader.async_get(key)
|
||||
|
||||
async def async_set(self, key: Any, value: Any) -> bool | None:
|
||||
"""异步写入配置。"""
|
||||
return await self._repository.async_set(key, value)
|
||||
return await self._writer.async_set(key, value)
|
||||
|
||||
def delete(self, key: Any) -> Any:
|
||||
"""删除配置。"""
|
||||
return self._repository.delete(key)
|
||||
return self._writer.delete(key)
|
||||
|
||||
|
||||
_configured_system_config: SystemConfigService | None = None
|
||||
_transfer_retry_config_provider: Callable[[], TransferRetryConfig] | None = None
|
||||
|
||||
|
||||
def configure_system_config(service: SystemConfigService) -> None:
|
||||
@@ -66,3 +95,18 @@ def get_configured_system_config() -> SystemConfigService:
|
||||
if _configured_system_config is None:
|
||||
raise RuntimeError("系统配置服务尚未配置")
|
||||
return _configured_system_config
|
||||
|
||||
|
||||
def configure_transfer_retry_config(
|
||||
provider: Callable[[], TransferRetryConfig],
|
||||
) -> None:
|
||||
"""由组合根登记整理失败重试快照工厂。"""
|
||||
global _transfer_retry_config_provider
|
||||
_transfer_retry_config_provider = provider
|
||||
|
||||
|
||||
def get_transfer_retry_config() -> TransferRetryConfig:
|
||||
"""为一次整理历史判定创建不可变配置快照。"""
|
||||
if _transfer_retry_config_provider is None:
|
||||
raise RuntimeError("整理失败重试配置尚未装配")
|
||||
return _transfer_retry_config_provider()
|
||||
|
||||
@@ -2,13 +2,13 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Optional, Protocol, Union
|
||||
|
||||
from app.application.configuration import TransferRetryConfig, get_transfer_retry_config
|
||||
from app.domain.context import MediaInfo, MusicInfo
|
||||
from app.schemas.media import resolve_media_identity
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
from app.foundation.text import cut as jieba_cut
|
||||
from app.runtime.cache import TTLCache
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.history import (
|
||||
DownloadHistory as DownloadHistoryView,
|
||||
@@ -480,7 +480,7 @@ def is_skip_action(action: str) -> bool:
|
||||
return action in (HistoryGateAction.SKIP, HistoryGateAction.SKIP_RETRY_EXHAUSTED)
|
||||
|
||||
|
||||
def max_failed_retries() -> int:
|
||||
def max_failed_retries(config: TransferRetryConfig | None = None) -> int:
|
||||
"""
|
||||
读取失败重试上限并钳制到合法区间。
|
||||
|
||||
@@ -488,7 +488,7 @@ def max_failed_retries() -> int:
|
||||
永久漏件,无限重试会让永久失败的文件反复刷通知,两端都不接受。
|
||||
:return: 合法的最大重试次数
|
||||
"""
|
||||
raw = settings.TRANSFER_MAX_FAILED_RETRIES
|
||||
raw = (config or get_transfer_retry_config()).max_failed_retries
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Optional, Tuple, Union
|
||||
|
||||
from app.schemas.context import MediaPerson as _SchemaMediaPerson
|
||||
from app.runtime.config import settings
|
||||
@@ -19,6 +20,13 @@ from app.adapters.network.http import RequestUtils
|
||||
from app.domain.media import is_media_source_enabled
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BangumiConfigSnapshot:
|
||||
"""Bangumi 模块一次配置 generation 使用的稳定网络快照。"""
|
||||
|
||||
proxy: Any
|
||||
|
||||
|
||||
class BangumiModule(_ModuleBase):
|
||||
"""
|
||||
Bangumi媒体信息匹配
|
||||
@@ -27,11 +35,13 @@ class BangumiModule(_ModuleBase):
|
||||
|
||||
bangumiapi: BangumiApi = None
|
||||
scraper: MediaScraperHelper = None
|
||||
_config: BangumiConfigSnapshot = BangumiConfigSnapshot(proxy=None)
|
||||
|
||||
def init_module(self) -> None:
|
||||
"""
|
||||
初始化Bangumi客户端
|
||||
"""
|
||||
self._config = BangumiConfigSnapshot(proxy=settings.PROXY)
|
||||
self.bangumiapi = BangumiApi()
|
||||
self.scraper = MediaScraperHelper()
|
||||
|
||||
@@ -46,7 +56,7 @@ class BangumiModule(_ModuleBase):
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
ret = RequestUtils(proxies=settings.PROXY).get_res("https://api.bgm.tv/")
|
||||
ret = RequestUtils(proxies=self._config.proxy).get_res("https://api.bgm.tv/")
|
||||
if ret and ret.status_code == 200:
|
||||
return True, ""
|
||||
elif ret:
|
||||
|
||||
@@ -35,7 +35,12 @@ from app.application.messaging.message import (
|
||||
MessageQueueManager,
|
||||
stop_message,
|
||||
)
|
||||
from app.application.configuration import SystemConfigService, configure_system_config
|
||||
from app.application.configuration import (
|
||||
SystemConfigService,
|
||||
TransferRetryConfig,
|
||||
configure_system_config,
|
||||
configure_transfer_retry_config,
|
||||
)
|
||||
from app.application.database import configure_database_governance
|
||||
from app.application.service import configure_service_directory
|
||||
from app.application.plugin.runtime import configure_plugin_runtime
|
||||
@@ -449,6 +454,11 @@ async def init_modules() -> HostRuntime:
|
||||
user=lambda: UserOper(),
|
||||
)
|
||||
configure_system_config(SystemConfigService(repository=SystemConfigOper()))
|
||||
configure_transfer_retry_config(
|
||||
lambda: TransferRetryConfig(
|
||||
max_failed_retries=settings.TRANSFER_MAX_FAILED_RETRIES,
|
||||
)
|
||||
)
|
||||
configure_database_governance(build_database_governance())
|
||||
configure_agent_chat_service(AgentChatService(repository=AgentChatOper()))
|
||||
configure_user_lookups(
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
> 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本
|
||||
> 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文
|
||||
> 相关文档:`docs/architecture-overview.md`、`docs/refactor/backend-architecture-governance.md`、`docs/refactor/backend-module-refactor-compatibility.md`
|
||||
> 实施进度:阶段 0(ARCH-201~203)、阶段 1(ARCH-210~212)、阶段 2(ARCH-220~222)与 ARCH-230~231 已完成,后续任务按 ID 独立提交和回滚
|
||||
> 实施进度:阶段 0(ARCH-201~203)、阶段 1(ARCH-210~212)、阶段 2(ARCH-220~222)与阶段 3(ARCH-230~232)已完成,后续任务按 ID 独立提交和回滚
|
||||
|
||||
## 1. 结论先行
|
||||
|
||||
@@ -502,6 +502,21 @@ app/api/dependencies/ # 按领域拆分依赖工厂
|
||||
5. Agent tool 通过注入的设置服务读取可授权字段,不直接构造 Oper。
|
||||
6. 保留 `app.sdk.config.settings` 给旧插件;宿主 canonical 新代码不得因此继续扩大直接依赖。
|
||||
|
||||
**实施记录(2026-08-21)**:
|
||||
|
||||
- 新增 `configuration-debt-baseline.json` 与单向 ratchet。基线排除 `app/plugins`、`app/sdk` 和
|
||||
`app/runtime/compat`,当前 canonical 宿主为 169 个直接导入 `settings` 的文件、15 个真实
|
||||
`app.db.oper.systemconfig.SystemConfigOper` 构造点;删除旧债务继续通过,新增或换位置均失败。
|
||||
- `SystemConfigReader` / `SystemConfigWriter` 已成为持久用户配置的窄端口;`SystemConfigService`
|
||||
支持分别注入 reader/writer,同时保留 `repository=` 兼容装配。Agent 系统设置查询与修改工具支持
|
||||
显式注入授权配置端口,旧工具构造签名与密钥确认/脱敏行为不变。
|
||||
- 整理失败重试从 Application 直接读取全局 `settings` 改为 `TransferRetryConfig` frozen snapshot;
|
||||
启动组合根和测试组合根负责生成每次用例快照,reload 后新调用读取新 generation,旧调用不漂移。
|
||||
- Bangumi 模块作为长生命周期样板,在 `init_module()` / `on_config_changed()` 时更新不可变网络快照,
|
||||
`test()` 不再逐次读取全局代理。该模式先验证后推广,不批量改写 169 个存量调用方。
|
||||
- 219 个架构、配置、Agent 安全、整理重试、Module reload 专项测试通过,Pylint 10/10;依赖基线
|
||||
仅把 `app.application.history -> app.runtime.config` 替换为窄配置端口边,禁止边不变。
|
||||
|
||||
### 阶段 4:把动态模块和事件变成可演进契约
|
||||
|
||||
#### ARCH-240:Module Contract V2
|
||||
|
||||
@@ -20,6 +20,7 @@ BASELINE_ROOT = PROJECT_ROOT / "tests" / "fixtures" / "architecture"
|
||||
DEPENDENCY_BASELINE_PATH = BASELINE_ROOT / "dependency-baseline.json"
|
||||
RUNTIME_BASELINE_PATH = BASELINE_ROOT / "runtime-contract-baseline.json"
|
||||
TRANSACTION_BASELINE_PATH = BASELINE_ROOT / "transaction-debt-baseline.json"
|
||||
CONFIGURATION_BASELINE_PATH = BASELINE_ROOT / "configuration-debt-baseline.json"
|
||||
PLUGIN_BASELINE_PATH = BASELINE_ROOT / "official-plugin-baseline.json"
|
||||
PLUGIN_HOOKS = (
|
||||
"get_actions",
|
||||
@@ -58,6 +59,12 @@ SESSION_FACTORY_NAMES = {
|
||||
"get_session_factory",
|
||||
}
|
||||
|
||||
CONFIGURATION_EXCLUDED_ROOTS = (
|
||||
APP_ROOT / "plugins",
|
||||
APP_ROOT / "sdk",
|
||||
APP_ROOT / "runtime" / "compat",
|
||||
)
|
||||
|
||||
|
||||
def discover_modules() -> dict[str, Path]:
|
||||
"""返回宿主 Python 模块与源码路径,排除运行时插件副本。"""
|
||||
@@ -78,6 +85,58 @@ def parse_source(path: Path) -> ast.Module:
|
||||
return ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
|
||||
|
||||
|
||||
def collect_configuration_debt_baseline() -> dict[str, Any]:
|
||||
"""收集宿主 canonical 代码直接读取 settings 和构造数据库配置适配器的债务。"""
|
||||
settings_files: list[str] = []
|
||||
oper_calls: list[dict[str, Any]] = []
|
||||
for path in sorted(APP_ROOT.rglob("*.py")):
|
||||
if any(path.is_relative_to(root) for root in CONFIGURATION_EXCLUDED_ROOTS):
|
||||
continue
|
||||
relative = path.relative_to(PROJECT_ROOT).as_posix()
|
||||
tree = parse_source(path)
|
||||
direct_oper_names: set[str] = set()
|
||||
imports_settings = False
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.ImportFrom):
|
||||
continue
|
||||
if node.module == "app.runtime.config" and any(
|
||||
alias.name == "settings" for alias in node.names
|
||||
):
|
||||
imports_settings = True
|
||||
if node.module == "app.db.oper.systemconfig":
|
||||
direct_oper_names.update(
|
||||
alias.asname or alias.name
|
||||
for alias in node.names
|
||||
if alias.name == "SystemConfigOper"
|
||||
)
|
||||
if imports_settings:
|
||||
settings_files.append(relative)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name):
|
||||
continue
|
||||
if node.func.id in direct_oper_names:
|
||||
oper_calls.append({"file": relative, "name": node.func.id})
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"scope": {
|
||||
"root": "app",
|
||||
"excluded": [
|
||||
"app/plugins",
|
||||
"app/sdk",
|
||||
"app/runtime/compat",
|
||||
],
|
||||
},
|
||||
"settings_imports": {
|
||||
"count": len(settings_files),
|
||||
"files": settings_files,
|
||||
},
|
||||
"system_config_oper_constructions": {
|
||||
"count": len(oper_calls),
|
||||
"calls": oper_calls,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def iter_import_candidates(
|
||||
module_name: str,
|
||||
path: Path,
|
||||
@@ -1052,6 +1111,37 @@ def transaction_ratchet_matches(
|
||||
)
|
||||
|
||||
|
||||
def configuration_ratchet_matches(
|
||||
expected: dict[str, Any],
|
||||
actual: dict[str, Any],
|
||||
) -> bool:
|
||||
"""配置债务只允许删除既有文件或构造点,不允许新增直接依赖。"""
|
||||
if expected.get("schema_version") != actual.get("schema_version"):
|
||||
return False
|
||||
if expected.get("scope") != actual.get("scope"):
|
||||
return False
|
||||
sections = (
|
||||
("settings_imports", "files"),
|
||||
("system_config_oper_constructions", "calls"),
|
||||
)
|
||||
for section, entries_key in sections:
|
||||
expected_section = expected.get(section, {})
|
||||
actual_section = actual.get(section, {})
|
||||
if actual_section.get("count", 0) > expected_section.get("count", 0):
|
||||
return False
|
||||
expected_entries = {
|
||||
json.dumps(item, ensure_ascii=False, sort_keys=True)
|
||||
for item in expected_section.get(entries_key, [])
|
||||
}
|
||||
actual_entries = {
|
||||
json.dumps(item, ensure_ascii=False, sort_keys=True)
|
||||
for item in actual_section.get(entries_key, [])
|
||||
}
|
||||
if not actual_entries.issubset(expected_entries):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _compare_semantic_values(
|
||||
expected: Any,
|
||||
actual: Any,
|
||||
@@ -1112,6 +1202,8 @@ def build_comparison_report(path: Path, actual: dict[str, Any]) -> dict[str, Any
|
||||
semantic_match = expected_semantic == actual_semantic
|
||||
if path.name == TRANSACTION_BASELINE_PATH.name:
|
||||
semantic_match = transaction_ratchet_matches(expected, actual)
|
||||
elif path.name == CONFIGURATION_BASELINE_PATH.name:
|
||||
semantic_match = configuration_ratchet_matches(expected, actual)
|
||||
return {
|
||||
"baseline": str(_display_path(path)),
|
||||
"semantic_match": semantic_match,
|
||||
@@ -1144,6 +1236,21 @@ def check_json(
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False
|
||||
if path.name == CONFIGURATION_BASELINE_PATH.name:
|
||||
if configuration_ratchet_matches(expected, actual):
|
||||
if expected != actual:
|
||||
print(
|
||||
"配置债务已下降;门禁继续通过,可在本任务提交中显式运行 "
|
||||
"scripts/architecture/baseline.py --write-host 固化新低水位",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return True
|
||||
print(
|
||||
f"配置债务出现新增:{_display_path(path)};"
|
||||
"宿主直接 settings 导入或 SystemConfigOper 构造不得增长",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False
|
||||
expected_semantic = semantic_baseline(path, expected)
|
||||
actual_semantic = semantic_baseline(path, actual)
|
||||
if expected_semantic == actual_semantic:
|
||||
@@ -1225,6 +1332,7 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
(DEPENDENCY_BASELINE_PATH, collect_dependency_baseline()),
|
||||
(RUNTIME_BASELINE_PATH, collect_runtime_baseline()),
|
||||
(TRANSACTION_BASELINE_PATH, collect_transaction_debt_baseline()),
|
||||
(CONFIGURATION_BASELINE_PATH, collect_configuration_debt_baseline()),
|
||||
]
|
||||
write_hint = "--write-host"
|
||||
else:
|
||||
|
||||
+12
-1
@@ -28,7 +28,13 @@ def configure_plugin_system_services():
|
||||
decode_access_token,
|
||||
)
|
||||
from app.api.data import configure_api_data_ports
|
||||
from app.application.configuration import SystemConfigService, configure_system_config
|
||||
from app.application.configuration import (
|
||||
SystemConfigService,
|
||||
TransferRetryConfig,
|
||||
configure_system_config,
|
||||
configure_transfer_retry_config,
|
||||
)
|
||||
from app.runtime.config import settings
|
||||
from app.application.service import configure_service_directory
|
||||
from app.db.session import (
|
||||
SessionFactory,
|
||||
@@ -41,6 +47,11 @@ def configure_plugin_system_services():
|
||||
|
||||
configure_token_codec(create_access_token, decode_access_token)
|
||||
configure_system_config(SystemConfigService(repository=SystemConfigOper()))
|
||||
configure_transfer_retry_config(
|
||||
lambda: TransferRetryConfig(
|
||||
max_failed_retries=settings.TRANSFER_MAX_FAILED_RETRIES,
|
||||
)
|
||||
)
|
||||
from app.application.chain.data import configure_chain_data_ports
|
||||
from app.application.subscription.write import configure_subscribe_writer
|
||||
from app.application.plugin.runtime import configure_plugin_runtime
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"scope": {
|
||||
"excluded": [
|
||||
"app/plugins",
|
||||
"app/sdk",
|
||||
"app/runtime/compat"
|
||||
],
|
||||
"root": "app"
|
||||
},
|
||||
"settings_imports": {
|
||||
"count": 169,
|
||||
"files": [
|
||||
"app/adapters/cache/backends.py",
|
||||
"app/adapters/cache/redis.py",
|
||||
"app/adapters/external/cookiecloud.py",
|
||||
"app/adapters/external/market.py",
|
||||
"app/adapters/external/ocr.py",
|
||||
"app/adapters/external/server.py",
|
||||
"app/adapters/network/browser.py",
|
||||
"app/adapters/network/doh.py",
|
||||
"app/adapters/system/fsproxy.py",
|
||||
"app/adapters/system/plugin/dependency.py",
|
||||
"app/adapters/system/plugin/package.py",
|
||||
"app/adapters/system/resource.py",
|
||||
"app/adapters/system/rust.py",
|
||||
"app/adapters/web/security/access.py",
|
||||
"app/agent/capabilities/adapter.py",
|
||||
"app/agent/llm/capability.py",
|
||||
"app/agent/llm/helper.py",
|
||||
"app/agent/llm/provider.py",
|
||||
"app/agent/memory/__init__.py",
|
||||
"app/agent/orchestrator.py",
|
||||
"app/agent/prompt/__init__.py",
|
||||
"app/agent/runtime.py",
|
||||
"app/agent/skills/registry.py",
|
||||
"app/agent/tools/base.py",
|
||||
"app/agent/tools/impl/_plugin_tool_utils.py",
|
||||
"app/agent/tools/impl/_terminal_session.py",
|
||||
"app/agent/tools/impl/add_download_tasks.py",
|
||||
"app/agent/tools/impl/create_agent_task.py",
|
||||
"app/agent/tools/impl/query_agent_tasks.py",
|
||||
"app/agent/tools/impl/query_system_settings.py",
|
||||
"app/agent/tools/impl/recognize_media.py",
|
||||
"app/agent/tools/impl/scrape_metadata.py",
|
||||
"app/agent/tools/impl/search_web.py",
|
||||
"app/agent/tools/impl/send_voice_message.py",
|
||||
"app/agent/tools/impl/update_agent_task.py",
|
||||
"app/agent/tools/impl/update_system_settings.py",
|
||||
"app/api/endpoints/agent.py",
|
||||
"app/api/endpoints/anthropic.py",
|
||||
"app/api/endpoints/dashboard.py",
|
||||
"app/api/endpoints/history.py",
|
||||
"app/api/endpoints/login.py",
|
||||
"app/api/endpoints/media.py",
|
||||
"app/api/endpoints/message.py",
|
||||
"app/api/endpoints/openai.py",
|
||||
"app/api/endpoints/plugin.py",
|
||||
"app/api/endpoints/storage.py",
|
||||
"app/api/endpoints/subscribe.py",
|
||||
"app/api/endpoints/system.py",
|
||||
"app/api/endpoints/tmdb.py",
|
||||
"app/api/endpoints/torrent.py",
|
||||
"app/api/endpoints/transfer.py",
|
||||
"app/api/servcookie.py",
|
||||
"app/application/formatting.py",
|
||||
"app/application/image.py",
|
||||
"app/application/maintenance.py",
|
||||
"app/application/rss.py",
|
||||
"app/application/security/auth.py",
|
||||
"app/application/security/passkey.py",
|
||||
"app/application/security/token.py",
|
||||
"app/application/security/url.py",
|
||||
"app/application/torrent.py",
|
||||
"app/chain/_messaging.py",
|
||||
"app/chain/_recognition.py",
|
||||
"app/chain/_transfer.py",
|
||||
"app/chain/download.py",
|
||||
"app/chain/interaction.py",
|
||||
"app/chain/media.py",
|
||||
"app/chain/message.py",
|
||||
"app/chain/recommend.py",
|
||||
"app/chain/scraping.py",
|
||||
"app/chain/search.py",
|
||||
"app/chain/site.py",
|
||||
"app/chain/storage.py",
|
||||
"app/chain/subscribe.py",
|
||||
"app/chain/system.py",
|
||||
"app/chain/torrents.py",
|
||||
"app/chain/transfer.py",
|
||||
"app/chain/user.py",
|
||||
"app/cli.py",
|
||||
"app/db/base.py",
|
||||
"app/db/engine.py",
|
||||
"app/db/session.py",
|
||||
"app/doctor/checks.py",
|
||||
"app/doctor/runner.py",
|
||||
"app/factory.py",
|
||||
"app/main.py",
|
||||
"app/modules/acoustid/__init__.py",
|
||||
"app/modules/anilist/__init__.py",
|
||||
"app/modules/anilist/anilist.py",
|
||||
"app/modules/bangumi/__init__.py",
|
||||
"app/modules/bangumi/bangumi.py",
|
||||
"app/modules/discord/discord.py",
|
||||
"app/modules/douban/__init__.py",
|
||||
"app/modules/douban/apiv2.py",
|
||||
"app/modules/emby/emby.py",
|
||||
"app/modules/fanart/__init__.py",
|
||||
"app/modules/feishu/feishu.py",
|
||||
"app/modules/filemanager/module.py",
|
||||
"app/modules/filemanager/storages/alipan.py",
|
||||
"app/modules/filemanager/storages/alist.py",
|
||||
"app/modules/filemanager/storages/local.py",
|
||||
"app/modules/filemanager/storages/rclone.py",
|
||||
"app/modules/filemanager/storages/smb.py",
|
||||
"app/modules/filemanager/storages/u115.py",
|
||||
"app/modules/filemanager/transhandler.py",
|
||||
"app/modules/indexer/parser/__init__.py",
|
||||
"app/modules/indexer/parser/rousi.py",
|
||||
"app/modules/indexer/spider/__init__.py",
|
||||
"app/modules/indexer/spider/haidan.py",
|
||||
"app/modules/indexer/spider/hddolby.py",
|
||||
"app/modules/indexer/spider/mtorrent.py",
|
||||
"app/modules/indexer/spider/rousi.py",
|
||||
"app/modules/indexer/spider/sunnypt.py",
|
||||
"app/modules/indexer/spider/tnode.py",
|
||||
"app/modules/indexer/spider/torrentleech.py",
|
||||
"app/modules/indexer/spider/yema.py",
|
||||
"app/modules/jellyfin/jellyfin.py",
|
||||
"app/modules/listenbrainz/__init__.py",
|
||||
"app/modules/lrclib/__init__.py",
|
||||
"app/modules/musicbrainz/__init__.py",
|
||||
"app/modules/musicbrainz/music_cache.py",
|
||||
"app/modules/postgresql/__init__.py",
|
||||
"app/modules/qbittorrent/__init__.py",
|
||||
"app/modules/qqbot/qqbot.py",
|
||||
"app/modules/redis/__init__.py",
|
||||
"app/modules/rtorrent/__init__.py",
|
||||
"app/modules/slack/slack.py",
|
||||
"app/modules/subtitle/__init__.py",
|
||||
"app/modules/telegram/telegram.py",
|
||||
"app/modules/theaudiodb/__init__.py",
|
||||
"app/modules/themoviedb/__init__.py",
|
||||
"app/modules/themoviedb/category.py",
|
||||
"app/modules/themoviedb/scraper.py",
|
||||
"app/modules/themoviedb/tmdb_cache.py",
|
||||
"app/modules/themoviedb/tmdbapi.py",
|
||||
"app/modules/themoviedb/tmdbv3api/objs/discover.py",
|
||||
"app/modules/themoviedb/tmdbv3api/tmdb.py",
|
||||
"app/modules/thetvdb/__init__.py",
|
||||
"app/modules/thetvdb/tvdb_v4_official.py",
|
||||
"app/modules/transmission/__init__.py",
|
||||
"app/modules/trimemedia/api.py",
|
||||
"app/modules/webpush/__init__.py",
|
||||
"app/modules/wechat/wechatbot.py",
|
||||
"app/modules/wechatclawbot/wechatclawbot.py",
|
||||
"app/monitor/dispatcher.py",
|
||||
"app/monitor/monitor.py",
|
||||
"app/monitor/snapshot.py",
|
||||
"app/monitor/syslimits.py",
|
||||
"app/monitor/watcher.py",
|
||||
"app/runtime/extensions/host_module_adapter.py",
|
||||
"app/runtime/extensions/module_manager.py",
|
||||
"app/runtime/extensions/plugin/catalog.py",
|
||||
"app/runtime/extensions/plugin_manager.py",
|
||||
"app/runtime/state.py",
|
||||
"app/runtime/thread.py",
|
||||
"app/scheduler.py",
|
||||
"app/startup/agent_initializer.py",
|
||||
"app/startup/database.py",
|
||||
"app/startup/database_initializer.py",
|
||||
"app/startup/domain_initializer.py",
|
||||
"app/startup/lifecycle/__init__.py",
|
||||
"app/startup/modules_initializer.py",
|
||||
"app/startup/plugins_initializer.py",
|
||||
"app/startup/routers_initializer.py",
|
||||
"app/workflow/actions/add_subscribe.py",
|
||||
"app/workflow/actions/fetch_medias.py",
|
||||
"app/workflow/actions/fetch_rss.py",
|
||||
"app/workflow/actions/scan_file.py",
|
||||
"app/workflow/actions/send_message.py"
|
||||
]
|
||||
},
|
||||
"system_config_oper_constructions": {
|
||||
"calls": [
|
||||
{
|
||||
"file": "app/scheduler.py",
|
||||
"name": "SystemConfigOper"
|
||||
},
|
||||
{
|
||||
"file": "app/startup/modules_initializer.py",
|
||||
"name": "SystemConfigOper"
|
||||
},
|
||||
{
|
||||
"file": "app/startup/modules_initializer.py",
|
||||
"name": "SystemConfigOper"
|
||||
},
|
||||
{
|
||||
"file": "app/startup/modules_initializer.py",
|
||||
"name": "SystemConfigOper"
|
||||
},
|
||||
{
|
||||
"file": "app/startup/modules_initializer.py",
|
||||
"name": "SystemConfigOper"
|
||||
},
|
||||
{
|
||||
"file": "app/startup/modules_initializer.py",
|
||||
"name": "SystemConfigOper"
|
||||
},
|
||||
{
|
||||
"file": "app/startup/modules_initializer.py",
|
||||
"name": "SystemConfigOper"
|
||||
},
|
||||
{
|
||||
"file": "app/startup/modules_initializer.py",
|
||||
"name": "SystemConfigOper"
|
||||
},
|
||||
{
|
||||
"file": "app/startup/plugins_initializer.py",
|
||||
"name": "SystemConfigOper"
|
||||
},
|
||||
{
|
||||
"file": "app/startup/plugins_initializer.py",
|
||||
"name": "SystemConfigOper"
|
||||
},
|
||||
{
|
||||
"file": "app/startup/plugins_initializer.py",
|
||||
"name": "SystemConfigOper"
|
||||
},
|
||||
{
|
||||
"file": "app/startup/plugins_initializer.py",
|
||||
"name": "SystemConfigOper"
|
||||
},
|
||||
{
|
||||
"file": "app/startup/plugins_initializer.py",
|
||||
"name": "SystemConfigOper"
|
||||
},
|
||||
{
|
||||
"file": "app/startup/plugins_initializer.py",
|
||||
"name": "SystemConfigOper"
|
||||
},
|
||||
{
|
||||
"file": "app/startup/plugins_initializer.py",
|
||||
"name": "SystemConfigOper"
|
||||
}
|
||||
],
|
||||
"count": 15
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6203,
|
||||
"edge_sha256": "75ac7a20854abb707ea6c851326a183873a64e93f67053ffcd79ca5b01a105f6",
|
||||
"edge_count": 6204,
|
||||
"edge_sha256": "9a988284502bb26fae5266321765fbf0e10b9fe608fd448c6868e67a824ea862",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -2464,6 +2464,8 @@
|
||||
"app.application.formatting -> app.schemas",
|
||||
"app.application.formatting -> app.schemas.transfer",
|
||||
"app.application.formatting -> app.schemas.workflow",
|
||||
"app.application.history -> app.application",
|
||||
"app.application.history -> app.application.configuration",
|
||||
"app.application.history -> app.domain",
|
||||
"app.application.history -> app.domain.context",
|
||||
"app.application.history -> app.domain.meta",
|
||||
@@ -2473,7 +2475,6 @@
|
||||
"app.application.history -> app.foundation.text",
|
||||
"app.application.history -> app.runtime",
|
||||
"app.application.history -> app.runtime.cache",
|
||||
"app.application.history -> app.runtime.config",
|
||||
"app.application.history -> app.runtime.log",
|
||||
"app.application.history -> app.schemas",
|
||||
"app.application.history -> app.schemas.history",
|
||||
|
||||
@@ -16,6 +16,21 @@ from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
class TestAgentSystemSettingsTools(unittest.TestCase):
|
||||
def test_query_system_settings_accepts_injected_reader(self):
|
||||
"""Agent 配置工具通过窄端口读取授权字段,无需自行构造数据库 Oper。"""
|
||||
reader = MagicMock()
|
||||
reader.get.return_value = [{"name": "qb", "enabled": True}]
|
||||
tool = QuerySystemSettingsTool(
|
||||
session_id="session-injected",
|
||||
user_id="10001",
|
||||
system_config=reader,
|
||||
)
|
||||
|
||||
payload = json.loads(asyncio.run(tool.run(setting_key="Downloaders")))
|
||||
|
||||
self.assertTrue(payload["success"])
|
||||
reader.get.assert_called_once_with(SystemConfigKey.Downloaders)
|
||||
|
||||
def test_query_system_settings_returns_exact_systemconfig_value(self):
|
||||
tool = QuerySystemSettingsTool(session_id="session-1", user_id="10001")
|
||||
|
||||
|
||||
@@ -67,6 +67,28 @@ def _transaction_sample(methods: list[dict[str, str]]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _configuration_sample(
|
||||
settings_files: list[str],
|
||||
oper_calls: list[dict[str, str]],
|
||||
) -> dict:
|
||||
"""构造最小配置债务 fixture,供单向 ratchet 行为测试。"""
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"scope": {
|
||||
"root": "app",
|
||||
"excluded": ["app/plugins", "app/sdk", "app/runtime/compat"],
|
||||
},
|
||||
"settings_imports": {
|
||||
"count": len(settings_files),
|
||||
"files": settings_files,
|
||||
},
|
||||
"system_config_oper_constructions": {
|
||||
"count": len(oper_calls),
|
||||
"calls": oper_calls,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_architecture_legacy_action_requires_scope(capsys):
|
||||
"""旧操作未明确宿主或插件范围时必须拒绝执行。"""
|
||||
with pytest.raises(SystemExit) as error:
|
||||
@@ -259,6 +281,29 @@ def test_transaction_ratchet_allows_removal_but_rejects_new_method() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_configuration_ratchet_allows_removal_but_rejects_new_access() -> None:
|
||||
"""配置债务低水位允许下降,但新增或换位置的直接访问必须失败。"""
|
||||
existing_call = {"file": "app/startup/demo.py", "name": "SystemConfigOper"}
|
||||
new_call = {"file": "app/application/demo.py", "name": "SystemConfigOper"}
|
||||
expected = _configuration_sample(["app/application/old.py"], [existing_call])
|
||||
|
||||
assert architecture_baseline.configuration_ratchet_matches(
|
||||
expected,
|
||||
_configuration_sample([], []),
|
||||
)
|
||||
assert not architecture_baseline.configuration_ratchet_matches(
|
||||
expected,
|
||||
_configuration_sample(
|
||||
["app/application/old.py", "app/application/new.py"],
|
||||
[existing_call],
|
||||
),
|
||||
)
|
||||
assert not architecture_baseline.configuration_ratchet_matches(
|
||||
expected,
|
||||
_configuration_sample(["app/application/old.py"], [new_call]),
|
||||
)
|
||||
|
||||
|
||||
def test_architecture_write_host_only_updates_host_files(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
|
||||
@@ -17,6 +17,7 @@ def test_architecture_contract_baselines_match_current_source():
|
||||
BASELINE_ROOT / "dependency-baseline.json",
|
||||
BASELINE_ROOT / "runtime-contract-baseline.json",
|
||||
BASELINE_ROOT / "transaction-debt-baseline.json",
|
||||
BASELINE_ROOT / "configuration-debt-baseline.json",
|
||||
)
|
||||
contents_before = {
|
||||
path: path.read_bytes()
|
||||
@@ -132,6 +133,25 @@ def test_transaction_debt_baseline_is_a_model_and_oper_ratchet() -> None:
|
||||
assert baseline["oper_session_factories"] == {"count": 0, "calls": []}
|
||||
|
||||
|
||||
def test_configuration_debt_baseline_tracks_canonical_direct_access() -> None:
|
||||
"""配置债务基线必须排除插件兼容面,并冻结两个可下降的直接访问集合。"""
|
||||
baseline_path = BASELINE_ROOT / "configuration-debt-baseline.json"
|
||||
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert baseline["schema_version"] == 1
|
||||
assert baseline["scope"]["excluded"] == [
|
||||
"app/plugins",
|
||||
"app/sdk",
|
||||
"app/runtime/compat",
|
||||
]
|
||||
assert baseline["settings_imports"]["count"] == len(
|
||||
baseline["settings_imports"]["files"]
|
||||
)
|
||||
assert baseline["system_config_oper_constructions"]["count"] == len(
|
||||
baseline["system_config_oper_constructions"]["calls"]
|
||||
)
|
||||
|
||||
|
||||
def test_startup_performance_baseline_records_normal_and_safe_lifecycle_resources():
|
||||
"""非功能基线必须同时记录正常/安全模式和隔离资源增量。"""
|
||||
baseline_path = BASELINE_ROOT / "startup-performance-baseline.json"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.modules.bangumi import BangumiModule
|
||||
from app.runtime.config import settings
|
||||
|
||||
|
||||
class _FakeBangumiApi:
|
||||
@@ -68,3 +70,22 @@ def test_async_bangumi_person_detail_normalizes_numeric_birthday():
|
||||
person = asyncio.run(module.async_bangumi_person_detail(1002))
|
||||
|
||||
assert person.birthday == "19"
|
||||
|
||||
|
||||
def test_bangumi_test_uses_generation_snapshot_until_reload(monkeypatch):
|
||||
"""长生命周期模块应在 init/reload 时换快照,而不是每次调用读取全局配置。"""
|
||||
module = BangumiModule()
|
||||
monkeypatch.setattr(settings, "PROXY_HOST", "http://old-proxy")
|
||||
module.init_module()
|
||||
old_proxy = settings.PROXY
|
||||
monkeypatch.setattr(settings, "PROXY_HOST", "http://new-proxy")
|
||||
new_proxy = settings.PROXY
|
||||
|
||||
with patch("app.modules.bangumi.RequestUtils") as request_utils:
|
||||
request_utils.return_value.get_res.return_value = MagicMock(status_code=200)
|
||||
module.test()
|
||||
assert request_utils.call_args.kwargs["proxies"] == old_proxy
|
||||
|
||||
module.on_config_changed()
|
||||
module.test()
|
||||
assert request_utils.call_args.kwargs["proxies"] == new_proxy
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""配置快照与窄读写端口测试。"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from app.application.configuration import (
|
||||
SystemConfigService,
|
||||
TransferRetryConfig,
|
||||
configure_transfer_retry_config,
|
||||
get_transfer_retry_config,
|
||||
)
|
||||
|
||||
|
||||
def test_system_config_service_supports_separate_reader_and_writer() -> None:
|
||||
"""应用服务可以分别注入只读与写入适配器。"""
|
||||
reader = MagicMock()
|
||||
reader.get.return_value = "old"
|
||||
reader.async_get = AsyncMock(return_value="async-old")
|
||||
writer = MagicMock()
|
||||
writer.set.return_value = True
|
||||
writer.async_set = AsyncMock(return_value=True)
|
||||
service = SystemConfigService(reader=reader, writer=writer)
|
||||
|
||||
assert service.get("key") == "old"
|
||||
assert service.set("key", "new") is True
|
||||
assert asyncio.run(service.async_get("key")) == "async-old"
|
||||
assert asyncio.run(service.async_set("key", "new")) is True
|
||||
service.delete("key")
|
||||
|
||||
reader.get.assert_called_once_with("key")
|
||||
writer.set.assert_called_once_with("key", "new")
|
||||
writer.delete.assert_called_once_with("key")
|
||||
|
||||
|
||||
def test_transfer_retry_provider_returns_frozen_snapshot_per_call() -> None:
|
||||
"""配置工厂在每次用例入口创建新快照,旧快照不受 reload 后状态影响。"""
|
||||
state = {"value": 2}
|
||||
configure_transfer_retry_config(
|
||||
lambda: TransferRetryConfig(max_failed_retries=state["value"])
|
||||
)
|
||||
|
||||
before_reload = get_transfer_retry_config()
|
||||
state["value"] = 4
|
||||
after_reload = get_transfer_retry_config()
|
||||
|
||||
assert before_reload.max_failed_retries == 2
|
||||
assert after_reload.max_failed_retries == 4
|
||||
Reference in New Issue
Block a user