mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-03 14:37:36 +08:00
feat(runtime): 为旧门面补上分阶段退役,与命中观测形成闭环 (#6430)
This commit is contained in:
+14
-3
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6546,
|
||||
"edge_sha256": "9799edea47a44d52ae4d5d4bf3e38614b1d1a255f850891b3cc1d8fb85b40735",
|
||||
"edge_count": 6554,
|
||||
"edge_sha256": "1c72744be67d95f98eac1c7b1a72bdbf7b6d1d4f337556b8e22293c3a9525c67",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -5588,6 +5588,11 @@
|
||||
"app.runtime.config -> app.schemas.types",
|
||||
"app.runtime.debounce -> app.runtime",
|
||||
"app.runtime.debounce -> app.runtime.log",
|
||||
"app.runtime.deprecation.policy -> app.runtime",
|
||||
"app.runtime.deprecation.policy -> app.runtime.deprecation",
|
||||
"app.runtime.deprecation.policy -> app.runtime.deprecation.notices",
|
||||
"app.runtime.deprecation.policy -> app.runtime.log",
|
||||
"app.runtime.deprecation.policy -> app.runtime.settings",
|
||||
"app.runtime.event.binding -> app.runtime",
|
||||
"app.runtime.event.binding -> app.runtime.event",
|
||||
"app.runtime.event.binding -> app.runtime.event.registry",
|
||||
@@ -5772,6 +5777,9 @@
|
||||
"app.runtime.extensions.service_config -> app.schemas",
|
||||
"app.runtime.extensions.service_config -> app.schemas.system",
|
||||
"app.runtime.extensions.service_config -> app.schemas.types",
|
||||
"app.runtime.observability -> app.runtime",
|
||||
"app.runtime.observability -> app.runtime.deprecation",
|
||||
"app.runtime.observability -> app.runtime.deprecation.policy",
|
||||
"app.runtime.progress -> app.runtime",
|
||||
"app.runtime.progress -> app.runtime.cache",
|
||||
"app.runtime.progress -> app.runtime.localization",
|
||||
@@ -6563,7 +6571,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 806,
|
||||
"module_count": 809,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -7218,6 +7226,9 @@
|
||||
"app.runtime.config",
|
||||
"app.runtime.correlation",
|
||||
"app.runtime.debounce",
|
||||
"app.runtime.deprecation",
|
||||
"app.runtime.deprecation.notices",
|
||||
"app.runtime.deprecation.policy",
|
||||
"app.runtime.event",
|
||||
"app.runtime.event.binding",
|
||||
"app.runtime.event.contracts",
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"""渐进式废弃登记的阶段行为,以及与旧 Facade 命中观测的联动测试。"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Mapping, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.deprecation import notices as notices_module
|
||||
from app.runtime.deprecation import policy
|
||||
from app.runtime.deprecation.notices import NOTICES, DeprecationNotice, DeprecationStage
|
||||
from app.runtime.deprecation.policy import (
|
||||
DeprecatedFeatureError,
|
||||
all_notices,
|
||||
deprecated,
|
||||
enforce_facade,
|
||||
guard,
|
||||
is_active,
|
||||
warn,
|
||||
)
|
||||
from app.runtime.observability import (
|
||||
MetricSpec,
|
||||
configure_observation,
|
||||
observe_compat_facade,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RecordingLogger:
|
||||
"""只记录 warning 文本的日志替身。"""
|
||||
|
||||
messages: List[str] = field(default_factory=list)
|
||||
|
||||
def warning(self, message: str) -> None:
|
||||
"""记录一条告警文本。"""
|
||||
self.messages.append(message)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RecordingObservationPort:
|
||||
"""保存已经通过标签合同校验的指标写入。"""
|
||||
|
||||
records: List[Tuple[str, Dict[str, str]]] = field(default_factory=list)
|
||||
|
||||
def record(self, spec: MetricSpec, value: float, labels: Mapping[str, str]) -> None:
|
||||
"""追加一条不可变测试快照。"""
|
||||
self.records.append((spec.name, dict(labels)))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_warned():
|
||||
"""避免告警去重记录在用例间泄漏。"""
|
||||
policy.reset_warned()
|
||||
yield
|
||||
policy.reset_warned()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def warnings_log(monkeypatch) -> _RecordingLogger:
|
||||
"""把废弃告警接到可断言的日志替身上。"""
|
||||
recorder = _RecordingLogger()
|
||||
monkeypatch.setattr(policy, "logger", recorder)
|
||||
return recorder
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def observation_port():
|
||||
"""安装可断言的观测端口,用例结束恢复 no-op。"""
|
||||
port = _RecordingObservationPort()
|
||||
configure_observation(port)
|
||||
yield port
|
||||
configure_observation(None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry(monkeypatch):
|
||||
"""
|
||||
用可控登记表替换全局登记表,避免用例依赖真实登记内容。
|
||||
|
||||
:return: ``register(stage, key) -> DeprecationNotice``
|
||||
"""
|
||||
table: Dict[str, DeprecationNotice] = {}
|
||||
monkeypatch.setattr(notices_module, "NOTICES", table)
|
||||
|
||||
def register(stage: DeprecationStage, key: str = "demo.legacy") -> DeprecationNotice:
|
||||
"""
|
||||
登记一条指定阶段的废弃通告
|
||||
:param stage: 所处阶段
|
||||
:param key: 废弃标识
|
||||
:return: 登记
|
||||
"""
|
||||
notice = DeprecationNotice(
|
||||
key=key,
|
||||
subject="示例旧入口",
|
||||
stage=stage,
|
||||
since="v3.0.0",
|
||||
replacement="示例新入口",
|
||||
reason="仅用于测试",
|
||||
)
|
||||
table[key] = notice
|
||||
return notice
|
||||
|
||||
return register
|
||||
|
||||
|
||||
def test_silent_stage_stays_active_and_quiet(registry, warnings_log) -> None:
|
||||
"""仅登记阶段不改变行为,也不产生任何告警。"""
|
||||
registry(DeprecationStage.SILENT)
|
||||
|
||||
assert is_active("demo.legacy") is True
|
||||
guard("demo.legacy")
|
||||
warn("demo.legacy")
|
||||
|
||||
assert warnings_log.messages == []
|
||||
|
||||
|
||||
def test_warn_stage_logs_once_per_context(registry, warnings_log) -> None:
|
||||
"""预警阶段功能照常,同一来源只留一次痕迹,不同来源各留一次。"""
|
||||
registry(DeprecationStage.WARN)
|
||||
|
||||
assert is_active("demo.legacy") is True
|
||||
warn("demo.legacy", context="PluginA")
|
||||
warn("demo.legacy", context="PluginA")
|
||||
warn("demo.legacy", context="PluginB")
|
||||
|
||||
assert len(warnings_log.messages) == 2
|
||||
assert "示例旧入口 自 v3.0.0 起进入废弃流程" in warnings_log.messages[0]
|
||||
assert "移除版本待定" in warnings_log.messages[0]
|
||||
assert "触发来源:PluginA" in warnings_log.messages[0]
|
||||
assert "请改用:示例新入口" in warnings_log.messages[0]
|
||||
assert "触发来源:PluginB" in warnings_log.messages[1]
|
||||
|
||||
|
||||
def test_disabled_stage_blocks_until_explicitly_enabled(registry, monkeypatch) -> None:
|
||||
"""停用阶段默认抛错,仅在标识写进 DEPRECATION_ENABLED 后恢复。"""
|
||||
registry(DeprecationStage.DISABLED)
|
||||
monkeypatch.setattr(settings, "DEPRECATION_ENABLED", None)
|
||||
|
||||
assert is_active("demo.legacy") is False
|
||||
with pytest.raises(DeprecatedFeatureError, match="已默认停用"):
|
||||
guard("demo.legacy")
|
||||
|
||||
monkeypatch.setattr(settings, "DEPRECATION_ENABLED", "other.key, demo.legacy")
|
||||
|
||||
assert is_active("demo.legacy") is True
|
||||
guard("demo.legacy")
|
||||
|
||||
|
||||
def test_removed_stage_cannot_be_restored(registry, monkeypatch) -> None:
|
||||
"""移除阶段无视开关,一律抛错并说明无法恢复。"""
|
||||
registry(DeprecationStage.REMOVED)
|
||||
monkeypatch.setattr(settings, "DEPRECATION_ENABLED", "demo.legacy")
|
||||
|
||||
assert is_active("demo.legacy") is False
|
||||
with pytest.raises(DeprecatedFeatureError, match="已彻底移除"):
|
||||
guard("demo.legacy")
|
||||
|
||||
|
||||
def test_unregistered_key_is_rejected_by_lookups(registry) -> None:
|
||||
"""未登记标识在查询入口上直接报错,避免登记表与调用点写法漂移。"""
|
||||
registry(DeprecationStage.WARN)
|
||||
|
||||
with pytest.raises(KeyError, match="未登记的废弃标识"):
|
||||
is_active("demo.missing")
|
||||
with pytest.raises(KeyError, match="未登记的废弃标识"):
|
||||
warn("demo.missing")
|
||||
with pytest.raises(KeyError, match="未登记的废弃标识"):
|
||||
guard("demo.missing")
|
||||
|
||||
|
||||
def test_unregistered_key_passes_through_enforcement(registry, warnings_log) -> None:
|
||||
"""未纳入废弃流程的触达点原样放行,既不报错也不留痕。"""
|
||||
registry(DeprecationStage.DISABLED)
|
||||
|
||||
policy.enforce("demo.missing")
|
||||
enforce_facade("OtherFacade", "any_method")
|
||||
|
||||
assert warnings_log.messages == []
|
||||
|
||||
|
||||
def test_deprecated_decorator_keeps_call_and_metadata(registry, warnings_log) -> None:
|
||||
"""预警阶段的装饰器留痕后照常执行,并保留原函数元数据。"""
|
||||
registry(DeprecationStage.WARN)
|
||||
|
||||
@deprecated("demo.legacy")
|
||||
def legacy_call(value: int) -> int:
|
||||
"""返回入参本身。"""
|
||||
return value
|
||||
|
||||
assert legacy_call(3) == 3
|
||||
assert legacy_call(4) == 4
|
||||
assert legacy_call.__name__ == "legacy_call"
|
||||
assert len(warnings_log.messages) == 1
|
||||
|
||||
|
||||
def test_deprecated_decorator_blocks_disabled_stage(registry, monkeypatch) -> None:
|
||||
"""停用阶段的装饰器直接拦截,不再执行原实现。"""
|
||||
registry(DeprecationStage.DISABLED)
|
||||
monkeypatch.setattr(settings, "DEPRECATION_ENABLED", None)
|
||||
calls: List[int] = []
|
||||
|
||||
@deprecated("demo.legacy")
|
||||
def legacy_call() -> None:
|
||||
"""记录一次实际执行。"""
|
||||
calls.append(1)
|
||||
|
||||
with pytest.raises(DeprecatedFeatureError):
|
||||
legacy_call()
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_deprecated_decorator_keeps_async_call_shape(registry, warnings_log) -> None:
|
||||
"""异步函数经装饰后仍可被调度器识别为协程函数。"""
|
||||
registry(DeprecationStage.WARN)
|
||||
|
||||
@deprecated("demo.legacy")
|
||||
async def legacy_call(value: int) -> int:
|
||||
"""返回入参本身。"""
|
||||
return value
|
||||
|
||||
assert inspect.iscoroutinefunction(legacy_call) is True
|
||||
assert asyncio.run(legacy_call(3)) == 3
|
||||
assert len(warnings_log.messages) == 1
|
||||
|
||||
|
||||
def test_compat_facade_hit_is_recorded_before_stage_is_applied(
|
||||
registry, monkeypatch, observation_port
|
||||
) -> None:
|
||||
"""停用阶段仍先记账再拦截,命中数不会因为收口而丢失。"""
|
||||
registry(DeprecationStage.DISABLED, key="DemoFacade.legacy_call")
|
||||
monkeypatch.setattr(settings, "DEPRECATION_ENABLED", None)
|
||||
|
||||
@observe_compat_facade("DemoFacade")
|
||||
class DemoFacade:
|
||||
"""被观测的旧 Facade 替身。"""
|
||||
|
||||
@staticmethod
|
||||
def legacy_call() -> str:
|
||||
"""返回固定结果。"""
|
||||
return "called"
|
||||
|
||||
with pytest.raises(DeprecatedFeatureError):
|
||||
DemoFacade.legacy_call()
|
||||
|
||||
assert observation_port.records == [
|
||||
(
|
||||
"compat.facade.hit",
|
||||
{
|
||||
"facade": "DemoFacade",
|
||||
"operation": "legacy_call",
|
||||
"visibility": "public",
|
||||
"abi_source": "legacy_facade",
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_facade_notice_covers_every_operation(registry, warnings_log) -> None:
|
||||
"""整个 Facade 的登记覆盖其所有方法,且每个方法各留一次痕迹。"""
|
||||
registry(DeprecationStage.WARN, key="DemoFacade")
|
||||
|
||||
@observe_compat_facade("DemoFacade")
|
||||
class DemoFacade:
|
||||
"""被观测的旧 Facade 替身。"""
|
||||
|
||||
@staticmethod
|
||||
def first() -> None:
|
||||
"""空实现。"""
|
||||
|
||||
@staticmethod
|
||||
def second() -> None:
|
||||
"""空实现。"""
|
||||
|
||||
DemoFacade.first()
|
||||
DemoFacade.first()
|
||||
DemoFacade.second()
|
||||
|
||||
assert len(warnings_log.messages) == 2
|
||||
assert "触发来源:DemoFacade.first" in warnings_log.messages[0]
|
||||
assert "触发来源:DemoFacade.second" in warnings_log.messages[1]
|
||||
|
||||
|
||||
def test_method_notice_takes_precedence_over_facade_notice(registry, warnings_log) -> None:
|
||||
"""同时登记 Facade 与其单个方法时,按方法级登记处置。"""
|
||||
registry(DeprecationStage.WARN, key="DemoFacade")
|
||||
registry(DeprecationStage.REMOVED, key="DemoFacade.legacy_call")
|
||||
|
||||
@observe_compat_facade("DemoFacade")
|
||||
class DemoFacade:
|
||||
"""被观测的旧 Facade 替身。"""
|
||||
|
||||
@staticmethod
|
||||
def legacy_call() -> None:
|
||||
"""空实现。"""
|
||||
|
||||
@staticmethod
|
||||
def other_call() -> None:
|
||||
"""空实现。"""
|
||||
|
||||
with pytest.raises(DeprecatedFeatureError, match="已彻底移除"):
|
||||
DemoFacade.legacy_call()
|
||||
DemoFacade.other_call()
|
||||
|
||||
assert "触发来源:DemoFacade.other_call" in warnings_log.messages[0]
|
||||
|
||||
|
||||
def test_async_facade_method_applies_stage(registry, monkeypatch, observation_port) -> None:
|
||||
"""异步旧 Facade 方法同样先记账再按阶段拦截。"""
|
||||
registry(DeprecationStage.DISABLED, key="DemoFacade.async_call")
|
||||
monkeypatch.setattr(settings, "DEPRECATION_ENABLED", None)
|
||||
|
||||
@observe_compat_facade("DemoFacade")
|
||||
class DemoFacade:
|
||||
"""被观测的旧 Facade 替身。"""
|
||||
|
||||
@staticmethod
|
||||
async def async_call() -> str:
|
||||
"""返回固定结果。"""
|
||||
return "called"
|
||||
|
||||
with pytest.raises(DeprecatedFeatureError):
|
||||
asyncio.run(DemoFacade.async_call())
|
||||
|
||||
assert observation_port.records[0][1]["operation"] == "async_call"
|
||||
|
||||
|
||||
def test_registered_notices_are_self_consistent() -> None:
|
||||
"""真实登记表的键与登记本身一致,且每条都给出替代方案与原因。"""
|
||||
assert NOTICES
|
||||
|
||||
for key, notice in NOTICES.items():
|
||||
assert key == notice.key
|
||||
assert notice.since
|
||||
assert notice.replacement
|
||||
assert notice.reason
|
||||
assert isinstance(notice.stage, DeprecationStage)
|
||||
|
||||
assert all_notices() == tuple(NOTICES[key] for key in sorted(NOTICES))
|
||||
Reference in New Issue
Block a user