mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-28 19:06:36 +08:00
fix: make local setup and bundled skill operations recoverable (#7150)
* fix: make local setup and bundled skill operations recoverable * test: verify user updates with real transaction runners
This commit is contained in:
@@ -13,6 +13,7 @@ from typing import Any, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.user import User
|
||||
@@ -53,6 +54,25 @@ class UserOper(DbOper):
|
||||
lambda session: User.get_by_id(session, user_id)
|
||||
)
|
||||
|
||||
def update(self, user_id: int, payload: dict[str, Any]) -> Optional[User]:
|
||||
"""
|
||||
在调用方或独占短事务内重新查询并更新用户,不使用游离对象的旧会话。
|
||||
|
||||
:param user_id: 要更新的用户主键
|
||||
:param payload: 本次要修改的用户字段
|
||||
:return: 更新后的用户;已删除时返回 None
|
||||
"""
|
||||
def stage(session: Session) -> Optional[User]:
|
||||
"""
|
||||
在同一有效会话内查询并暂存更新。
|
||||
"""
|
||||
user = User.get_by_id(session, user_id)
|
||||
if user is not None:
|
||||
user.update(session, payload)
|
||||
return user
|
||||
|
||||
return self._execute_sync_write(stage)
|
||||
|
||||
async def async_list(self) -> List[User]:
|
||||
"""
|
||||
异步获取用户列表。
|
||||
|
||||
@@ -3,7 +3,8 @@ SQLAlchemy 请求级事务适配器与无会话 Oper 事务执行端口。
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Protocol, TypeVar
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator, Protocol, TypeVar
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -70,6 +71,26 @@ def reset_transaction_runners() -> None:
|
||||
_async_transaction_runner = None
|
||||
|
||||
|
||||
@contextmanager
|
||||
def transaction_runner_scope(
|
||||
*, sync: SyncTransactionRunner, async_: AsyncTransactionRunner,
|
||||
) -> Iterator[None]:
|
||||
"""
|
||||
为独立初始化命令登记事务执行器,并在退出时恢复进入前的端口。
|
||||
|
||||
:param sync: 当前命令使用的独占同步事务执行器
|
||||
:param async_: 当前命令使用的独占异步事务执行器
|
||||
:return: 退出后恢复原登记状态的同步上下文
|
||||
"""
|
||||
global _sync_transaction_runner, _async_transaction_runner
|
||||
previous = (_sync_transaction_runner, _async_transaction_runner)
|
||||
configure_transaction_runners(sync=sync, async_=async_)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_sync_transaction_runner, _async_transaction_runner = previous
|
||||
|
||||
|
||||
def run_sync_transaction(operation: Callable[[Session], T]) -> T:
|
||||
"""
|
||||
委托组合根在独占同步事务中执行 Oper 操作。
|
||||
|
||||
218
app/startup/composition/cliagent.py
Normal file
218
app/startup/composition/cliagent.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
为一次命令行 Agent 请求装配数据、配置和能力运行时,并按依赖顺序收口。
|
||||
|
||||
此组合根不启动 Web、前端、站点认证、插件同步或定时服务;按需工具仍使用宿主端口。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import AsyncExitStack
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.runtime.config import Settings
|
||||
from app.runtime.tasks import TaskRegistry
|
||||
from app.startup.composition.agent import AgentComposition
|
||||
from app.startup.composition.classification import ClassificationComposition
|
||||
from app.startup.composition.configuration import ConfigurationComposition
|
||||
from app.startup.composition.database import DatabaseRuntime
|
||||
|
||||
|
||||
def _compose_cli_agent(
|
||||
database: DatabaseRuntime,
|
||||
configuration: ConfigurationComposition,
|
||||
classification: ClassificationComposition,
|
||||
tasks: TaskRegistry,
|
||||
) -> AgentComposition:
|
||||
"""
|
||||
在线程内复用宿主组合函数,为 Agent 和 Chain 发布同一批持久化端口。
|
||||
"""
|
||||
from app.agent.loader import get_agent_manager
|
||||
from app.runtime.extensions.plugin.manager import configure_plugin_runtime_factory
|
||||
from app.startup.composition.agent import compose_agent, publish_agent_services
|
||||
from app.startup.composition.chain import configure_chain_runtime_context
|
||||
from app.startup.composition.database import compose_database_services, publish_database_services
|
||||
from app.startup.composition.runtime import compose_runtime_dependencies
|
||||
from app.startup.composition.subscription import async_rule_group_mutation_scope
|
||||
from app.startup.composition.system import compose_system_service
|
||||
from app.startup.initializers.plugins import build_plugin_runtime_graph
|
||||
|
||||
configure_plugin_runtime_factory(build_plugin_runtime_graph)
|
||||
publish_database_services(compose_database_services(runtime=database, system_config=configuration.system_config))
|
||||
dependencies = compose_runtime_dependencies()
|
||||
composition = compose_agent(
|
||||
runtime=database,
|
||||
system_config=configuration.system_config,
|
||||
dependencies=dependencies,
|
||||
tasks=tasks,
|
||||
)
|
||||
system = compose_system_service(
|
||||
settings=configuration.settings,
|
||||
system_config=configuration.system_service,
|
||||
rule_group_mutation=partial(async_rule_group_mutation_scope, configuration.system_config.publish_many),
|
||||
)
|
||||
configure_chain_runtime_context(
|
||||
dependencies=dependencies,
|
||||
system_config=configuration.system_config,
|
||||
configuration=configuration.runtime.chain,
|
||||
classification_service=classification.execution,
|
||||
system_service=system,
|
||||
)
|
||||
publish_agent_services(composition, data_context_registrar=get_agent_manager().configure_data_context)
|
||||
return composition
|
||||
|
||||
|
||||
async def _close_cli_extensions(*, events_started: bool) -> None:
|
||||
"""
|
||||
只关闭工具按需物化的宿主模块、插件及共享线程,不反向创建运行时对象。
|
||||
"""
|
||||
from app.runtime.events import EventManager
|
||||
from app.runtime.execution import run_in_threadpool_to_completion
|
||||
from app.runtime.extensions.module.manager import ModuleManager
|
||||
from app.runtime.extensions.plugin.manager import PluginManager
|
||||
from app.runtime.thread import ThreadHelper
|
||||
|
||||
plugin = PluginManager.get_existing_instance()
|
||||
events = EventManager.get_existing_instance()
|
||||
if plugin is not None and not await plugin.quiesce_plugins(timeout=30):
|
||||
raise RuntimeError("CLI Agent 插件任务尚未收敛")
|
||||
if events_started and events is not None and await events.drain_async(timeout=30, seal=False) is False:
|
||||
raise RuntimeError("CLI Agent 在途事件尚未收敛")
|
||||
if plugin is not None:
|
||||
if not await plugin.quiesce_plugin_services(timeout=30):
|
||||
raise RuntimeError("CLI Agent 插件服务尚未收敛")
|
||||
if await run_in_threadpool_to_completion(plugin.finalize_plugins) is False:
|
||||
raise RuntimeError("CLI Agent 插件 owner 尚未释放")
|
||||
modules = ModuleManager.get_existing_instance()
|
||||
if modules is not None and await run_in_threadpool_to_completion(modules.shutdown) is False:
|
||||
raise RuntimeError("CLI Agent 模块 owner 尚未释放")
|
||||
if events is not None:
|
||||
await events.stop_async()
|
||||
threads = ThreadHelper.get_existing_instance()
|
||||
if threads is not None and await run_in_threadpool_to_completion(threads.shutdown) is False:
|
||||
raise RuntimeError("CLI Agent 共享线程尚未收敛")
|
||||
|
||||
|
||||
async def run_cli_agent_request(*, message: str, session_id: str, user_id: str, settings: Settings) -> dict[str, str]:
|
||||
"""
|
||||
在独占 CLI 进程完成一轮对话;成功、模型异常或装配失败均按 owner 收尾。
|
||||
|
||||
:param message: 已去除首尾空白的用户输入
|
||||
:param session_id: 本次使用的会话标识,可复用同一用户的持久历史
|
||||
:param user_id: 本地执行者的用户标识
|
||||
:param settings: CLI 入口读取并显式注入的部署配置对象
|
||||
:return: 实际会话标识及最终捕获文本
|
||||
:raises RuntimeError: Agent 未启用、进程已有数据库 owner,或关键关闭未收敛
|
||||
"""
|
||||
from app.startup.initializers.cache import configure_cache_dependencies
|
||||
|
||||
configure_cache_dependencies()
|
||||
|
||||
from app.adapters.network.http import aclose_shared_async_transports
|
||||
from app.agent.loader import activate_agent_service
|
||||
from app.db.session import close_database
|
||||
from app.runtime.events import EventManager
|
||||
from app.runtime.execution import run_in_threadpool_to_completion
|
||||
from app.runtime.extensions.plugin.manager import reset_plugin_runtime_factory
|
||||
from app.runtime.loop import main_loop_registry
|
||||
from app.runtime.tasks import TaskRegistry, configure_task_registry
|
||||
from app.schemas.types import NotificationChannel, ReplyMode
|
||||
from app.startup.composition.agent import reset_agent_services
|
||||
from app.startup.composition.chain import reset_chain_services
|
||||
from app.startup.composition.classification import compose_classification
|
||||
from app.startup.composition.configuration import compose_configuration, publish_configuration, reset_configuration
|
||||
from app.startup.composition.database import (
|
||||
database_runtime_active,
|
||||
reset_database_services,
|
||||
start_database_runtime,
|
||||
stop_database_runtime,
|
||||
)
|
||||
from app.startup.composition.network import configure_application_network_ports, reset_application_network_ports
|
||||
from app.startup.initializers.agent import configure_agent_ports, reset_agent_ports, stop_agent
|
||||
from app.startup.initializers.chain import init_chain_ports, reset_chain_ports
|
||||
from app.startup.initializers.database import prepare_database
|
||||
from app.startup.initializers.domain import configure_domain_dependencies
|
||||
from app.startup.initializers.modules import configure_runtime_data_providers, reset_runtime_data_providers
|
||||
from app.startup.initializers.network import init_chain_network_ports, reset_chain_network_ports
|
||||
from app.startup.initializers.site import init_site_access_ports, reset_site_access_ports
|
||||
|
||||
if not settings.AI_AGENT_ENABLE:
|
||||
raise RuntimeError("MoviePilot 智能体未启用,请先在配置中打开 AI_AGENT_ENABLE")
|
||||
if database_runtime_active():
|
||||
raise RuntimeError("CLI Agent 需要独占进程,不能复用已运行的数据库 runtime")
|
||||
|
||||
resources = AsyncExitStack()
|
||||
composition = None
|
||||
agent_owned = False
|
||||
database_owned = False
|
||||
events_started = False
|
||||
tasks = TaskRegistry()
|
||||
loop_owner = main_loop_registry.register(asyncio.get_running_loop())
|
||||
configure_task_registry(tasks)
|
||||
resources.callback(main_loop_registry.release, loop_owner)
|
||||
resources.callback(configure_task_registry, None)
|
||||
resources.push_async_callback(aclose_shared_async_transports)
|
||||
resources.push_async_callback(close_database)
|
||||
try:
|
||||
await run_in_threadpool_to_completion(prepare_database)
|
||||
database = await start_database_runtime()
|
||||
database_owned = True
|
||||
configuration = await compose_configuration(executor=database.worker, settings=settings)
|
||||
resources.callback(reset_configuration)
|
||||
publish_configuration(configuration, settings)
|
||||
configure_domain_dependencies()
|
||||
classification = await compose_classification(
|
||||
executor=database.worker,
|
||||
settings=settings,
|
||||
system_config=configuration.system_config,
|
||||
)
|
||||
resources.callback(reset_chain_services)
|
||||
resources.callback(reset_agent_services, data_context_resetter=reset_agent_ports)
|
||||
resources.callback(reset_plugin_runtime_factory)
|
||||
resources.callback(reset_database_services)
|
||||
composition = await run_in_threadpool_to_completion(_compose_cli_agent, database, configuration, classification, tasks)
|
||||
resources.callback(reset_runtime_data_providers)
|
||||
configure_runtime_data_providers()
|
||||
resources.callback(reset_application_network_ports)
|
||||
configure_application_network_ports()
|
||||
resources.callback(reset_chain_ports)
|
||||
init_chain_ports()
|
||||
resources.callback(reset_chain_network_ports)
|
||||
init_chain_network_ports()
|
||||
resources.callback(reset_site_access_ports)
|
||||
init_site_access_ports()
|
||||
EventManager().start()
|
||||
events_started = True
|
||||
configure_agent_ports()
|
||||
agent_owned = True
|
||||
manager = await activate_agent_service()
|
||||
if manager is None:
|
||||
raise RuntimeError("MoviePilot 智能体启动失败")
|
||||
output: list[str] = []
|
||||
result = await manager.process_message(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
message=message,
|
||||
channel=NotificationChannel.Web.value,
|
||||
source="cli",
|
||||
reply_mode=ReplyMode.CAPTURE_ONLY,
|
||||
output_callback=output.append,
|
||||
allow_message_tools=False,
|
||||
wait_for_completion=True,
|
||||
)
|
||||
return {"session_id": session_id, "result": (result if isinstance(result, str) else "".join(output)).strip()}
|
||||
finally:
|
||||
# 必须先释放 Agent 及后台任务,才能关闭它们仍可能使用的写入与数据库依赖。
|
||||
if agent_owned and await stop_agent() is False:
|
||||
raise RuntimeError("CLI Agent 尚有任务未收敛,保留其数据库依赖")
|
||||
if await tasks.shutdown(timeout_seconds=30) is False:
|
||||
raise RuntimeError("CLI Agent 后台任务未收敛,保留其数据库依赖")
|
||||
if composition is not None:
|
||||
composition.persistence.begin_shutdown()
|
||||
await composition.persistence.shutdown()
|
||||
await _close_cli_extensions(events_started=events_started)
|
||||
if database_owned:
|
||||
await stop_database_runtime()
|
||||
await resources.aclose()
|
||||
@@ -4,17 +4,14 @@
|
||||
本入口调用 Alembic 生成版本文件,不执行生成的升级或降级操作。
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
|
||||
from alembic.config import Config as AlembicConfig
|
||||
from alembic.command import revision as alembic_revision
|
||||
|
||||
from app.runtime.config import settings
|
||||
from app.db.models import load_all_models
|
||||
|
||||
# 导入模块,避免建表缺失
|
||||
for module in Path(__file__).with_name("models").glob("*.py"):
|
||||
importlib.import_module(f"app.db.models.{module.stem}")
|
||||
load_all_models()
|
||||
|
||||
db_version = input("请输入版本号:")
|
||||
db_location = settings.CONFIG_PATH / 'user.db'
|
||||
|
||||
@@ -26,7 +26,7 @@ def upgrade() -> None:
|
||||
|
||||
select_stmt = text(
|
||||
"""
|
||||
SELECT id, key, value
|
||||
SELECT id, key
|
||||
FROM SystemConfig
|
||||
WHERE id NOT IN (
|
||||
SELECT MAX(id)
|
||||
@@ -38,7 +38,7 @@ def upgrade() -> None:
|
||||
to_delete = connection.execute(select_stmt).fetchall()
|
||||
for row in to_delete:
|
||||
logger.warn(
|
||||
f"已删除重复的 SystemConfig 项:key={row.key}, value={row.value}, id={row.id}"
|
||||
f"已删除重复的 SystemConfig 项:key={row.key}, id={row.id}"
|
||||
)
|
||||
delete_stmt = text("DELETE FROM SystemConfig WHERE id = :id")
|
||||
connection.execute(delete_stmt, {"id": row.id})
|
||||
|
||||
@@ -507,8 +507,7 @@ fi
|
||||
maybe_reexec_control_bundle
|
||||
cd /app || exit
|
||||
|
||||
if [ "${MOVIEPILOT_BOOTSTRAP_UPDATE_DONE:-0}" != "1" ] \
|
||||
&& [ -f "${CONFIG_DIR}/temp/moviepilot-update/install.json" ]; then
|
||||
if [ -f "${CONFIG_DIR}/temp/moviepilot-update/install.json" ]; then
|
||||
if apply_pending_release_update_at_startup; then
|
||||
INFO "→ 未完成的 Release 更新已安装,重新执行入口加载新代码。"
|
||||
reexec_entrypoint
|
||||
|
||||
@@ -816,8 +816,8 @@ flowchart LR
|
||||
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 1128 |
|
||||
| 内部导入边 | 9,649 |
|
||||
| Python 模块 | 1129 |
|
||||
| 内部导入边 | 9,690 |
|
||||
| 非平凡 SCC | 1(29 个模块的 TMDB vendor 精确 containment) |
|
||||
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
||||
| Direct egress | 51(精确 policy 准入,Application / Chain 均为 0) |
|
||||
|
||||
@@ -219,6 +219,7 @@ moviepilot install frontend --config-dir /path/to/moviepilot-config
|
||||
- 如需覆盖默认行为,仍可显式传入 `--version latest` 或指定具体 tag
|
||||
- 会自动安装本地 Node 运行时
|
||||
- 会自动安装 `service.js` 所需的运行依赖
|
||||
- 新版前端及其依赖全部准备成功后才替换当前版本;依赖安装失败时保留已有页面和依赖
|
||||
|
||||
安装资源文件:
|
||||
|
||||
@@ -313,6 +314,8 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v3/scripts/bootst
|
||||
|
||||
- `--superuser-password` 更适合自动化场景,命令可能会出现在 shell 历史中
|
||||
- 交互式 `--wizard` 会在初始化过程中提示输入超级管理员用户名和密码
|
||||
- 重复初始化会更新已有账号的权限和显式指定的密码;向导留空保留原密码中的引号与反斜杠
|
||||
- 引导脚本的 `--non-interactive` 即使连接终端也跳过向导,`--link-path` 保留显式指定的命令路径
|
||||
|
||||
## 开机自启命令
|
||||
|
||||
@@ -408,7 +411,9 @@ moviepilot agent --new-session 帮我总结当前系统配置有什么明显问
|
||||
说明:
|
||||
|
||||
- `moviepilot agent` 直接在本地环境里发起一次智能体请求
|
||||
- 请在独立 CLI 进程中执行;入口会装配正式数据、配置及 Agent/Chain 能力,完成后按依赖顺序关闭资源
|
||||
- 默认每次可自动创建新会话,也可以通过 `--session` 指定会话 ID
|
||||
- 使用相同用户和 `--session` 可在下一次 CLI 调用恢复持久化历史;模型异常或取消也会执行资源关闭
|
||||
- 使用前需要先正确配置 LLM 相关参数,并打开 `AI_AGENT_ENABLE`
|
||||
|
||||
## 服务管理命令
|
||||
|
||||
@@ -67,6 +67,8 @@ UV_PROJECT_ENVIRONMENT="${MOVIEPILOT_WORKSPACE}/.venv-test" \
|
||||
|
||||
不需要打开 IDE 时,可以直接使用仓库内的启动脚本。脚本会自动定位项目根目录和虚拟环境,并以模块方式启动后端,避免 `ModuleNotFoundError: No module named 'app'`。
|
||||
|
||||
后端开发入口优先使用 `uv sync` 创建的 `.venv/bin/python`,没有该解释器时使用旧的 `venv/bin/python`。`./scripts/start-local.sh help` 不要求先安装虚拟环境。
|
||||
|
||||
```bash
|
||||
# 默认启动后端开发服务,前台运行,按 Ctrl+C 停止
|
||||
./scripts/start-local.sh
|
||||
|
||||
@@ -173,6 +173,15 @@ composition constructs and injects cross-layer dependencies, initializers expose
|
||||
domain-scoped startup/shutdown hooks, and lifecycle orders those hooks and decides
|
||||
restart policy. Reusable persistence implementations belong in `app/db/adapters/`,
|
||||
not startup. Lower-level runtime modules must not import startup.
|
||||
|
||||
`startup/composition/cliagent.py` owns one exclusive CLI request process. It receives
|
||||
deployment settings explicitly and reuses the database, configuration, Agent and
|
||||
Chain composition owners. It may hold and close module/plugin lifecycle owners;
|
||||
this permission applies to that exact module, not its siblings or a subtree. It
|
||||
does not start Web, frontend, external site authentication or plugin synchronization.
|
||||
Shutdown drains Agent work and persistence before closing dependent database
|
||||
owners; an unconverged owner must retain its downstream dependencies and report
|
||||
failure.
|
||||
`startup/composition/configuration.py` owns configuration snapshot loading, typed
|
||||
runtime/settings construction and publication. `startup/composition/database.py`
|
||||
owns the process database worker, compatibility transaction runner, query services,
|
||||
|
||||
@@ -142,10 +142,13 @@ def iter_runtime_import_nodes(tree: ast.AST):
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
continue
|
||||
parent = parents.get(node)
|
||||
child = node
|
||||
parent = parents.get(child)
|
||||
while parent is not None:
|
||||
if isinstance(parent, ast.If) and _is_type_checking_test(parent.test):
|
||||
if (isinstance(parent, ast.If) and _is_type_checking_test(parent.test)
|
||||
and child in parent.body):
|
||||
break
|
||||
child = parent
|
||||
parent = parents.get(parent)
|
||||
else:
|
||||
yield node
|
||||
|
||||
@@ -326,20 +326,12 @@ def _merge_baseline(
|
||||
baseline: Mapping[str, Any], current: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
只提升逐文件低水位,并移除已删除文件。
|
||||
使用同一份当前计数生成逐文件和汇总低水位;调用方已拒绝覆盖率回退。
|
||||
"""
|
||||
old_files = baseline.get("files", {})
|
||||
current_files = current["files"]
|
||||
merged: dict[str, Mapping[str, int | float]] = {}
|
||||
for path, values in current_files.items():
|
||||
previous = old_files.get(path) if isinstance(old_files, Mapping) else None
|
||||
if previous is not None and not _file_ratio_is_lower(values, previous):
|
||||
merged[path] = previous
|
||||
else:
|
||||
merged[path] = values
|
||||
result = dict(current)
|
||||
result["files"] = dict(sorted(merged.items()))
|
||||
return result
|
||||
regressions = compare_coverage(baseline, current)
|
||||
if regressions:
|
||||
raise ValueError("拒绝写入覆盖率回退:" + "; ".join(regressions))
|
||||
return _snapshot_from_files(current["files"])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -64,6 +64,7 @@ RUNTIME_FACADE_POLICIES = (
|
||||
{
|
||||
"app.sdk.plugin.manager",
|
||||
"app.startup.composition.chain",
|
||||
"app.startup.composition.cliagent",
|
||||
"app.startup.initializers.modules",
|
||||
}
|
||||
),
|
||||
@@ -75,6 +76,7 @@ RUNTIME_FACADE_POLICIES = (
|
||||
{
|
||||
"app.sdk.plugin.manager",
|
||||
"app.startup.composition.chain",
|
||||
"app.startup.composition.cliagent",
|
||||
"app.startup.initializers.plugins",
|
||||
}
|
||||
),
|
||||
|
||||
@@ -128,7 +128,9 @@ detect_os() {
|
||||
case "$uname_s" in
|
||||
Darwin)
|
||||
OS_NAME="macOS"
|
||||
if command -v brew >/dev/null 2>&1; then
|
||||
if [[ -n "$LINK_PATH" ]]; then
|
||||
:
|
||||
elif command -v brew >/dev/null 2>&1; then
|
||||
LINK_PATH="$(brew --prefix)/bin/moviepilot"
|
||||
else
|
||||
LINK_PATH="/usr/local/bin/moviepilot"
|
||||
@@ -140,14 +142,14 @@ detect_os() {
|
||||
else
|
||||
OS_NAME="Linux"
|
||||
fi
|
||||
LINK_PATH="/usr/local/bin/moviepilot"
|
||||
LINK_PATH="${LINK_PATH:-/usr/local/bin/moviepilot}"
|
||||
;;
|
||||
MINGW*|MSYS*|CYGWIN*)
|
||||
OS_NAME="Windows"
|
||||
;;
|
||||
*)
|
||||
OS_NAME="$uname_s"
|
||||
LINK_PATH="/usr/local/bin/moviepilot"
|
||||
LINK_PATH="${LINK_PATH:-/usr/local/bin/moviepilot}"
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -683,6 +685,8 @@ ensure_link_path
|
||||
if [[ "$NON_INTERACTIVE" != "true" && "$HAS_TTY" == "true" ]]; then
|
||||
run_interactive_guide
|
||||
ensure_link_path
|
||||
elif [[ "$NON_INTERACTIVE" == "true" ]]; then
|
||||
RUN_WIZARD="false"
|
||||
elif [[ "$RUN_WIZARD" == "true" && "$HAS_TTY" != "true" ]]; then
|
||||
echo "==> 未检测到可用终端输入,已跳过初始化向导。安装完成后可手动执行:moviepilot setup --wizard"
|
||||
RUN_WIZARD="false"
|
||||
|
||||
@@ -484,12 +484,12 @@ def _check_long_context_claims(
|
||||
event for event in ledger
|
||||
if event.get("operation_id") == "subscription.list" and event.get("outcome") == "succeeded"
|
||||
]
|
||||
pages = {
|
||||
pages = [
|
||||
event.get("request", {}).get("query", {}).get("page")
|
||||
for event in subscription_events
|
||||
if event.get("request", {}).get("query", {}).get("count") == 20
|
||||
}
|
||||
if pages != set(range(1, 7)):
|
||||
]
|
||||
if pages != list(range(1, 7)):
|
||||
violations.append("long_context_pages_not_verified")
|
||||
target_rows = [
|
||||
observation.get("record", {})
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import runpy
|
||||
import sys
|
||||
import textwrap
|
||||
@@ -17,6 +18,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
MOVIEPILOT_API_SKILL_VERSION = "59"
|
||||
DATABASE_OPERATION_SKILL_VERSION = "12"
|
||||
|
||||
|
||||
DATABASE_TABLE_GUIDES: dict[str, tuple[str, str, str]] = {
|
||||
@@ -1019,6 +1021,8 @@ def main() -> int:
|
||||
|
||||
database_path = PROJECT_ROOT / "skills/database-operation/SKILL.md"
|
||||
database_text = database_path.read_text(encoding="utf-8")
|
||||
database_text = re.sub(r"(?m)^version: \d+$", f"version: {DATABASE_OPERATION_SKILL_VERSION}",
|
||||
database_text, count=1)
|
||||
database_path.write_text(
|
||||
_replace_section(
|
||||
database_text,
|
||||
|
||||
@@ -27,6 +27,7 @@ import textwrap
|
||||
import urllib.parse
|
||||
import uuid
|
||||
import zipfile
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
@@ -801,8 +802,9 @@ def _serialize_env_value(value: Any) -> str:
|
||||
|
||||
def read_env_value(key: str) -> Optional[str]:
|
||||
"""
|
||||
读取 app.env 中首个匹配键,去除首尾空白与引号。
|
||||
读取 app.env 首个匹配键,反序列化本工具写入的 JSON 字符串并兼容旧单引号。
|
||||
|
||||
:param key: 要读取的配置键
|
||||
:return: 配置文本;文件不存在或键缺失时返回 None,不解析变量引用
|
||||
"""
|
||||
for line in _load_env_lines():
|
||||
@@ -811,7 +813,16 @@ def read_env_value(key: str) -> Optional[str]:
|
||||
continue
|
||||
env_key, value = line.split("=", 1)
|
||||
if env_key.strip() == key:
|
||||
return value.strip().strip('"').strip("'")
|
||||
value = value.strip()
|
||||
if value.startswith('"') and value.endswith('"'):
|
||||
try:
|
||||
decoded = json.loads(value)
|
||||
return decoded if isinstance(decoded, str) else value
|
||||
except json.JSONDecodeError:
|
||||
return value[1:-1]
|
||||
if value.startswith("'") and value.endswith("'"):
|
||||
return value[1:-1]
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
@@ -1136,7 +1147,8 @@ def install_frontend(
|
||||
if archive
|
||||
else f"下载前端发布包:{version_tag}"
|
||||
)
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
PUBLIC_DIR.parent.mkdir(parents=True, exist_ok=True)
|
||||
with TemporaryDirectory(prefix=".frontend-install-", dir=PUBLIC_DIR.parent) as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
archive_path = temp_path / "dist.zip"
|
||||
extract_dir = temp_path / "extract"
|
||||
@@ -1154,36 +1166,40 @@ def install_frontend(
|
||||
or packaged_version.read_text(encoding="utf-8").strip() != version_tag
|
||||
):
|
||||
raise RuntimeError("前端更新包版本与目标版本不一致")
|
||||
_remove_path(PUBLIC_DIR)
|
||||
shutil.move(str(dist_dir), str(PUBLIC_DIR))
|
||||
|
||||
_write_local_frontend_service_script(PUBLIC_DIR)
|
||||
|
||||
runtime_package = dict(RUNTIME_PACKAGE)
|
||||
runtime_package["version"] = version_tag
|
||||
(PUBLIC_DIR / "package.json").write_text(
|
||||
json.dumps(runtime_package, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
npm_bin = get_npm_bin()
|
||||
if not npm_bin.exists():
|
||||
raise RuntimeError("未找到 npm 可执行文件,Node 运行时可能损坏")
|
||||
|
||||
print_step("安装前端运行依赖")
|
||||
run(
|
||||
[
|
||||
str(npm_bin),
|
||||
"install",
|
||||
"--no-fund",
|
||||
"--no-audit",
|
||||
"--omit=dev",
|
||||
],
|
||||
cwd=PUBLIC_DIR,
|
||||
)
|
||||
_write_local_frontend_service_script(dist_dir)
|
||||
runtime_package = {**RUNTIME_PACKAGE, "version": version_tag}
|
||||
(dist_dir / "package.json").write_text(
|
||||
json.dumps(runtime_package, ensure_ascii=False, indent=2) + "\n", encoding="utf-8",
|
||||
)
|
||||
npm_bin = get_npm_bin()
|
||||
if not npm_bin.exists():
|
||||
raise RuntimeError("未找到 npm 可执行文件,Node 运行时可能损坏")
|
||||
print_step("安装前端运行依赖")
|
||||
run([str(npm_bin), "install", "--no-fund", "--no-audit", "--omit=dev"], cwd=dist_dir)
|
||||
_publish_frontend_installation(dist_dir)
|
||||
return {"version": version_tag, "node": str(node_bin)}
|
||||
|
||||
|
||||
def _publish_frontend_installation(staged: Path) -> None:
|
||||
"""
|
||||
同文件系统发布完整前端;发布失败恢复旧目录,恢复失败时保留独立备份。
|
||||
|
||||
:param staged: 已完成依赖安装且与目标在同一文件系统的候选前端目录
|
||||
"""
|
||||
backup = PUBLIC_DIR.with_name(f".{PUBLIC_DIR.name}.backup-{uuid.uuid4().hex}")
|
||||
had_previous = PUBLIC_DIR.exists() or PUBLIC_DIR.is_symlink()
|
||||
if had_previous:
|
||||
PUBLIC_DIR.rename(backup)
|
||||
try:
|
||||
staged.rename(PUBLIC_DIR)
|
||||
except BaseException:
|
||||
if had_previous:
|
||||
backup.rename(PUBLIC_DIR)
|
||||
raise
|
||||
if had_previous:
|
||||
_remove_path(backup)
|
||||
|
||||
|
||||
def local_resource_status() -> bool:
|
||||
"""
|
||||
检查当前平台和 Python 版本所需的全部站点资源文件是否存在。
|
||||
@@ -2852,7 +2868,31 @@ def _merge_notification_switches(existing_items: list[dict]) -> list[dict]:
|
||||
return [merged[key] for key in [*preferred_order, *extras]]
|
||||
|
||||
|
||||
def _run_with_local_database_transactions(operation: Callable[[], None]) -> None:
|
||||
"""
|
||||
为独立安装命令装配真实短事务,并在成功或异常后恢复原事务端口。
|
||||
|
||||
:param operation: 在当前命令事务端口已装配时执行的同步初始化操作
|
||||
"""
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from app.db.adapters.transaction import TransactionalWriteRunner
|
||||
from app.db.session import SessionFactory, async_session_scope
|
||||
from app.db.uow import transaction_runner_scope
|
||||
|
||||
runner = TransactionalWriteRunner(sync_session=SessionFactory, async_session=async_session_scope)
|
||||
with transaction_runner_scope(sync=runner.sync, async_=runner.async_):
|
||||
operation()
|
||||
|
||||
|
||||
def _apply_local_system_config_inner(config_payload: dict[str, Any]) -> None:
|
||||
"""
|
||||
在独立命令事务作用域内应用向导配置。
|
||||
"""
|
||||
_run_with_local_database_transactions(lambda: _apply_local_system_config_transactional(config_payload))
|
||||
|
||||
|
||||
def _apply_local_system_config_transactional(config_payload: dict[str, Any]) -> None:
|
||||
"""
|
||||
应用向导配置并确保本地媒体库路径存在,由下载器管理下载路径。
|
||||
"""
|
||||
@@ -2870,10 +2910,7 @@ def _apply_local_system_config_inner(config_payload: dict[str, Any]) -> None:
|
||||
)
|
||||
from app.application.configuration import SystemConfigService
|
||||
from app.application.directory import normalize_directory_system_config_value
|
||||
from app.db.adapters.transaction import TransactionalWriteRunner
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.db.session import SessionFactory, async_session_scope
|
||||
from app.db.uow import configure_transaction_runners
|
||||
from app.schemas.category import ClassificationPolicyState
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.startup.initializers.database import prepare_database
|
||||
@@ -2882,15 +2919,6 @@ def _apply_local_system_config_inner(config_payload: dict[str, Any]) -> None:
|
||||
"当前环境尚未安装 MoviePilot 运行依赖,请先执行 moviepilot install deps 或 moviepilot setup"
|
||||
) from exc
|
||||
|
||||
transaction_runner = TransactionalWriteRunner(
|
||||
sync_session=SessionFactory,
|
||||
async_session=async_session_scope,
|
||||
)
|
||||
configure_transaction_runners(
|
||||
sync=transaction_runner.sync,
|
||||
async_=transaction_runner.async_,
|
||||
)
|
||||
|
||||
generated_password = None
|
||||
|
||||
def prepare_superuser_password() -> None:
|
||||
@@ -3061,7 +3089,7 @@ def _ensure_superuser_account_inner() -> None:
|
||||
update_payload["hashed_password"] = get_password_hash(password)
|
||||
|
||||
if update_payload:
|
||||
user.update(user_oper._db, update_payload)
|
||||
user_oper.update(user.id, update_payload)
|
||||
if password:
|
||||
print_step(f"已同步超级管理员账号与密码:{username}")
|
||||
else:
|
||||
@@ -3096,6 +3124,13 @@ def _prepare_superuser_password_for_bootstrap() -> Optional[str]:
|
||||
|
||||
|
||||
def _sync_superuser_account_inner() -> None:
|
||||
"""
|
||||
在独立命令事务作用域内迁移数据库并同步管理员。
|
||||
"""
|
||||
_run_with_local_database_transactions(_sync_superuser_account_transactional)
|
||||
|
||||
|
||||
def _sync_superuser_account_transactional() -> None:
|
||||
"""
|
||||
在数据库迁移前准备初始密码,迁移后同步管理员并展示新生成密码。
|
||||
"""
|
||||
@@ -4482,36 +4517,20 @@ def run_agent_request(
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
try:
|
||||
from app.agent.orchestrator import MoviePilotAgent
|
||||
from app.runtime.config import settings
|
||||
from app.startup.initializers.database import prepare_database
|
||||
from app.startup.composition.cliagent import run_cli_agent_request
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"当前环境尚未安装 MoviePilot 运行依赖,请先执行 moviepilot install deps 或 moviepilot setup"
|
||||
) from exc
|
||||
|
||||
if not settings.AI_AGENT_ENABLE:
|
||||
raise RuntimeError("MoviePilot 智能体未启用,请先在配置中打开 AI_AGENT_ENABLE")
|
||||
|
||||
prepare_database()
|
||||
|
||||
session = (session_id or "").strip()
|
||||
if new_session or not session:
|
||||
session = f"cli-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
async def _run_agent() -> dict[str, str]:
|
||||
"""
|
||||
在当前会话执行智能体,抑制主动渠道回复并收集流式输出。
|
||||
"""
|
||||
agent = MoviePilotAgent(session_id=session, user_id=user_id or "cli")
|
||||
agent.suppress_user_reply = True
|
||||
await agent.process(message.strip())
|
||||
return {
|
||||
"session_id": session,
|
||||
"result": (agent._streamed_output or "").strip(),
|
||||
}
|
||||
|
||||
return asyncio.run(_run_agent())
|
||||
return asyncio.run(run_cli_agent_request(
|
||||
message=message.strip(), session_id=session, user_id=user_id or "cli", settings=settings,
|
||||
))
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
@@ -704,7 +704,8 @@ def assert_owned(resource, args: argparse.Namespace, kind: str) -> None:
|
||||
"""
|
||||
删除或复用资源前验证实验标签,避免误伤用户资源。
|
||||
"""
|
||||
resource_labels = resource.attrs.get("Labels") or {}
|
||||
attributes = resource.attrs.get("Config", {}) if kind == "container" else resource.attrs
|
||||
resource_labels = attributes.get("Labels") or {}
|
||||
if resource_labels.get(CAMPAIGN_LABEL) != args.campaign:
|
||||
raise HarnessError(f"拒绝操作非本 campaign 的 {kind}:{resource.name}")
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ SCRIPT_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
MOVIEPILOT_BIN="$PROJECT_ROOT/moviepilot"
|
||||
VENV_PYTHON="$PROJECT_ROOT/venv/bin/python"
|
||||
if [[ -x "$PROJECT_ROOT/.venv/bin/python" ]]; then
|
||||
VENV_PYTHON="$PROJECT_ROOT/.venv/bin/python"
|
||||
fi
|
||||
|
||||
show_usage() {
|
||||
cat <<'EOF'
|
||||
@@ -20,6 +23,11 @@ show_usage() {
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "help" || "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
||||
show_usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -x "$MOVIEPILOT_BIN" ]]; then
|
||||
printf '未找到本地 CLI:%s\n' "$MOVIEPILOT_BIN" >&2
|
||||
exit 1
|
||||
|
||||
@@ -38,6 +38,10 @@ unzip anysearch-skill.zip
|
||||
|
||||
## API Key Configuration
|
||||
|
||||
Missing option values and MCP tool errors exit with a nonzero status and write
|
||||
the error to stderr. A successful HTTP response can still contain a tool error;
|
||||
check the CLI exit status before using its output.
|
||||
|
||||
An API key is **optional but strongly recommended**. Without a key, you can still use all search features via anonymous access, but with **lower rate limits and quota**.
|
||||
|
||||
### How to configure
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: anysearch
|
||||
description: Real-time search engine supporting web search, vertical domain search, parallel batch search, and URL content extraction.
|
||||
version: 2
|
||||
version: 3
|
||||
authors:
|
||||
- AnySearch Team
|
||||
credentials:
|
||||
|
||||
@@ -68,6 +68,13 @@ function httpRequest(url, payload, apikey) {
|
||||
return;
|
||||
}
|
||||
const content = json.result && json.result.content;
|
||||
if (json.result && json.result.isError) {
|
||||
const text = Array.isArray(content)
|
||||
? content.filter((item) => item.type === "text").map((item) => item.text).join("\n")
|
||||
: "";
|
||||
reject(new Error(`Tool Error: ${text || "MCP tool failed"}`));
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
const textItem = content.find((c) => c.type === "text");
|
||||
if (textItem) {
|
||||
|
||||
@@ -97,6 +97,15 @@ function Call-Api {
|
||||
$result = $null
|
||||
try { $result = $resp.result } catch { $result = $resp }
|
||||
|
||||
$toolFailed = $false
|
||||
try { $toolFailed = ($result.isError -eq $true) } catch { }
|
||||
if ($toolFailed) {
|
||||
$errMsg = ($result.content | Where-Object { $_.type -eq "text" } | ForEach-Object { $_.text }) -join "`n"
|
||||
if (-not $errMsg) { $errMsg = "MCP tool failed" }
|
||||
Write-Error "Tool Error: $errMsg"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($result -and $result.content) {
|
||||
foreach ($item in $result.content) {
|
||||
if ($item.type -eq "text") {
|
||||
|
||||
@@ -93,6 +93,10 @@ def _call_api(tool_name: str, arguments: dict, api_key: str) -> str:
|
||||
sys.exit(1)
|
||||
result = data.get("result", {})
|
||||
content = result.get("content", [])
|
||||
if result.get("isError"):
|
||||
message = "\n".join(item.get("text", "") for item in content if item.get("type") == "text")
|
||||
print(f"Tool Error: {message or json.dumps(result, ensure_ascii=False)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
for item in content:
|
||||
if item.get("type") == "text":
|
||||
return item.get("text", "")
|
||||
|
||||
@@ -30,6 +30,13 @@ _load_env
|
||||
|
||||
API_KEY="${ANYSEARCH_API_KEY:-}"
|
||||
|
||||
_require_option_value() {
|
||||
if [[ $# -lt 2 || -z "$2" || "$2" == -* ]]; then
|
||||
echo "Error: $1 requires a value" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# BEGIN GENERATED:CONSTANTS
|
||||
AVAILABLE_DOMAINS=("general" "resource" "social_media" "finance" "academic" "legal" "health" "business" "security" "ip" "code" "energy" "environment" "agriculture" "travel" "film" "gaming")
|
||||
# END GENERATED:CONSTANTS
|
||||
@@ -65,6 +72,12 @@ _call_api() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if printf '%s' "$response" | jq -e '.result.isError == true' >/dev/null 2>&1; then
|
||||
error_msg=$(printf '%s' "$response" | jq -r '[.result.content[]? | select(.type == "text") | .text] | join("\n")')
|
||||
echo "Tool Error: ${error_msg:-MCP tool failed}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local text_block
|
||||
text_block=$(printf '%s' "$response" | jq -r '.result.content[0].text // empty' 2>/dev/null)
|
||||
if [[ -n "$text_block" ]]; then
|
||||
@@ -82,6 +95,7 @@ _cmd_search() {
|
||||
local max_results=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
[[ "$1" == -* ]] && _require_option_value "$@"
|
||||
case "$1" in
|
||||
--domain|-d) domain="$2"; shift 2 ;;
|
||||
--sub_domain|-s) sub_domain="$2"; shift 2 ;;
|
||||
@@ -126,6 +140,7 @@ _cmd_get_sub_domains() {
|
||||
local domains=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
[[ "$1" == -* ]] && _require_option_value "$@"
|
||||
case "$1" in
|
||||
--domains) domains="$2"; shift 2 ;;
|
||||
--domain) domain="$2"; shift 2 ;;
|
||||
@@ -158,6 +173,7 @@ _cmd_extract() {
|
||||
local url=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
[[ "$1" == -* ]] && _require_option_value "$@"
|
||||
case "$1" in
|
||||
--url|-u) url="$2"; shift 2 ;;
|
||||
--api_key) API_KEY="$2"; shift 2 ;;
|
||||
@@ -181,6 +197,7 @@ _cmd_batch_search() {
|
||||
local query_items=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
[[ "$1" == -* ]] && _require_option_value "$@"
|
||||
case "$1" in
|
||||
--queries|-q) queries="$2"; shift 2 ;;
|
||||
--query) query_items+=("$2"); shift 2 ;;
|
||||
@@ -250,7 +267,7 @@ _cmd_doc() {
|
||||
domains=$(jq -r '.available_domains | join(" ")' "$shared/constants.json")
|
||||
tpl="${tpl//\{\{LANG_NAME\}\}/Bash}"
|
||||
tpl="${tpl//\{\{LANG_CODEBLOCK\}\}/bash}"
|
||||
tpl="${tpl//\{\{LANG_INVOKE\}\}\}/bash scripts/anysearch_cli.sh}"
|
||||
tpl="${tpl//\{\{LANG_INVOKE\}\}/bash scripts/anysearch_cli.sh}"
|
||||
tpl="${tpl//\{\{DOMAINS_SPACE\}\}/$domains}"
|
||||
printf '%s\n' "$tpl"
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ def render_doc_block(ext, constants):
|
||||
domains=$(jq -r '.available_domains | join(" ")' "$shared/constants.json")
|
||||
tpl="${tpl//\{\{LANG_NAME\}\}/Bash}"
|
||||
tpl="${tpl//\{\{LANG_CODEBLOCK\}\}/bash}"
|
||||
tpl="${tpl//\{\{LANG_INVOKE\}\}\}/bash scripts/anysearch_cli.sh}"
|
||||
tpl="${tpl//\{\{LANG_INVOKE\}\}/bash scripts/anysearch_cli.sh}"
|
||||
tpl="${tpl//\{\{DOMAINS_SPACE\}\}/$domains}"
|
||||
printf '%s\n' "$tpl"
|
||||
}'''
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: database-operation
|
||||
version: 11
|
||||
version: 12
|
||||
description: >-
|
||||
Use this skill when you need to inspect, query, maintain, or carefully modify
|
||||
the MoviePilot database. This skill uses the bundled scripts/mp-db.py helper,
|
||||
@@ -117,6 +117,9 @@ python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123"
|
||||
- `write` executes data updates and schema-changing statements directly.
|
||||
- `query --write` remains available as a compatibility alias for write statements.
|
||||
- Multiple SQL statements in one invocation are rejected.
|
||||
- Keywords and semicolons inside SQL literals, quoted identifiers, and comments
|
||||
do not change a query into a write or a multi-statement request. Unclosed
|
||||
lexical structures remain invalid.
|
||||
- Plain `SELECT` queries get a default `LIMIT 100` if no limit is present.
|
||||
- Query results are returned exactly as stored. The agent may use sensitive values internally when needed, but must not echo secrets in the final user-facing response unless the user explicitly asks to inspect that value.
|
||||
|
||||
|
||||
@@ -224,23 +224,109 @@ def _build_engine() -> Engine:
|
||||
|
||||
def _normalize_sql(sql: str) -> str:
|
||||
"""
|
||||
去除 SQL 首尾空白和末尾分号。
|
||||
去除 SQL 首尾空白及注释前唯一的语句终止符,保留字面值内容。
|
||||
"""
|
||||
return sql.strip().rstrip(";")
|
||||
sql = sql.strip()
|
||||
code = _masked_sql(sql)
|
||||
if code is not None and code.count(";") == 1 and code.rstrip().endswith(";"):
|
||||
position = code.rfind(";")
|
||||
return sql[:position] + sql[position + 1:]
|
||||
return sql
|
||||
|
||||
|
||||
def _quoted_end(sql: str, start: int, quote: str, *, escaped: bool = False) -> Optional[int]:
|
||||
"""
|
||||
寻找带成对转义引号的词法项末尾;仅 PostgreSQL E 字符串解析反斜杠。
|
||||
"""
|
||||
index = start + 1
|
||||
closing = "]" if quote == "[" else quote
|
||||
while index < len(sql):
|
||||
if escaped and sql[index] == "\\":
|
||||
index += 2
|
||||
elif sql[index] == closing:
|
||||
if quote != "[" and sql[index:index + 2] == closing * 2:
|
||||
index += 2
|
||||
else:
|
||||
return index + 1
|
||||
else:
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _block_comment_end(sql: str, start: int) -> Optional[int]:
|
||||
"""
|
||||
定位块注释末尾,同时支持 PostgreSQL 的嵌套注释。
|
||||
"""
|
||||
depth = 1
|
||||
index = start + 2
|
||||
while index < len(sql) and depth:
|
||||
if sql[index:index + 2] == "/*":
|
||||
depth += 1
|
||||
index += 2
|
||||
elif sql[index:index + 2] == "*/":
|
||||
depth -= 1
|
||||
index += 2
|
||||
else:
|
||||
index += 1
|
||||
return index if depth == 0 else None
|
||||
|
||||
|
||||
def _sql_literal_end(sql: str, index: int) -> Optional[int]:
|
||||
"""
|
||||
返回引号或美元引号项的结束位置;不是词法项时返回当前位置。
|
||||
"""
|
||||
char = sql[index]
|
||||
if char in "'\"`[":
|
||||
escaped = char == "'" and index > 0 and sql[index - 1] in "eE" and (
|
||||
index < 2 or not (sql[index - 2].isalnum() or sql[index - 2] == "_")
|
||||
)
|
||||
return _quoted_end(sql, index, char, escaped=escaped)
|
||||
if char == "$":
|
||||
match = re.match(r"\$(?:[A-Za-z_][A-Za-z_0-9]*)?\$", sql[index:])
|
||||
if match:
|
||||
end = sql.find(match.group(), index + len(match.group()))
|
||||
return end + len(match.group()) if end >= 0 else None
|
||||
return index
|
||||
|
||||
|
||||
def _masked_sql(sql: str) -> Optional[str]:
|
||||
"""
|
||||
用等长空白遮蔽字面值、标识符与注释,未闭合结构按无效 SQL 拒绝。
|
||||
"""
|
||||
code = list(sql)
|
||||
index = 0
|
||||
while index < len(sql):
|
||||
if sql[index:index + 2] == "--":
|
||||
end = sql.find("\n", index)
|
||||
end = len(sql) if end < 0 else end
|
||||
elif sql[index:index + 2] == "/*":
|
||||
end = _block_comment_end(sql, index)
|
||||
else:
|
||||
end = _sql_literal_end(sql, index)
|
||||
if end is None:
|
||||
return None
|
||||
if end > index:
|
||||
code[index:end] = " " * (end - index)
|
||||
index = end
|
||||
else:
|
||||
index += 1
|
||||
return "".join(code)
|
||||
|
||||
|
||||
def _contains_multiple_statements(sql: str) -> bool:
|
||||
"""
|
||||
判断 SQL 是否包含多语句分隔符。
|
||||
"""
|
||||
return ";" in sql
|
||||
code = _masked_sql(sql)
|
||||
return code is None or ";" in code.rstrip().removesuffix(";")
|
||||
|
||||
|
||||
def _is_write_statement(sql: str) -> bool:
|
||||
"""
|
||||
判断 SQL 是否为写操作或结构变更操作。
|
||||
"""
|
||||
return bool(WRITE_STATEMENT_RE.match(sql))
|
||||
code = _masked_sql(sql)
|
||||
return code is not None and bool(WRITE_STATEMENT_RE.match(code))
|
||||
|
||||
|
||||
def _is_supported_statement(sql: str, allow_write: bool) -> bool:
|
||||
@@ -249,9 +335,12 @@ def _is_supported_statement(sql: str, allow_write: bool) -> bool:
|
||||
"""
|
||||
if _contains_multiple_statements(sql):
|
||||
return False
|
||||
code = _masked_sql(sql)
|
||||
if code is None or not code.strip().strip(";"):
|
||||
return False
|
||||
if allow_write:
|
||||
return True
|
||||
return bool(SELECT_STATEMENT_RE.match(sql)) and not WRITE_KEYWORD_RE.search(sql)
|
||||
return bool(SELECT_STATEMENT_RE.match(code)) and not WRITE_KEYWORD_RE.search(code)
|
||||
|
||||
|
||||
def _append_limit(sql: str, limit: int) -> str:
|
||||
@@ -260,12 +349,12 @@ def _append_limit(sql: str, limit: int) -> str:
|
||||
"""
|
||||
if limit <= 0:
|
||||
return sql
|
||||
lowered = sql.lower()
|
||||
lowered = (_masked_sql(sql) or "").lower()
|
||||
if not lowered.lstrip().startswith("select"):
|
||||
return sql
|
||||
if re.search(r"\blimit\s+\d+\b", lowered):
|
||||
return sql
|
||||
return f"{sql} LIMIT {limit}"
|
||||
return f"{sql}\nLIMIT {limit}"
|
||||
|
||||
|
||||
def _row_to_dict(row: Any) -> dict[str, Any]:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: feedback-issue
|
||||
version: 11
|
||||
version: 12
|
||||
description: >-
|
||||
Use this skill ONLY when the user EXPLICITLY requests filing an
|
||||
upstream issue for MoviePilot core, frontend, or an installed plugin,
|
||||
|
||||
@@ -33,7 +33,8 @@ _MAX_TIME_WINDOW_MINUTES = 24 * 60
|
||||
_LOG_TIMESTAMP_RE = re.compile(r"(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})")
|
||||
_LOG_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
_LOG_MODULE_RE = re.compile(
|
||||
r"^【[^】]+】\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2},\d+\s+-\s+([^\s][^\-]*?)\s+-\s+"
|
||||
r"^【[^】]+】\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2},\d+\s+"
|
||||
r"(?:\[[^\]\r\n]*\]\s+)?-\s+([^\s][^\-]*?)\s+-\s+"
|
||||
)
|
||||
_LOG_DYNAMIC_VALUE_RE = re.compile(
|
||||
r"(?<![A-Za-z])(?:[0-9a-f]{8,}|\d+(?:\.\d+)?)(?![A-Za-z])",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: publish-moviepilot-plugin
|
||||
version: 3
|
||||
version: 4
|
||||
description: >-
|
||||
Use this skill when the user asks to publish, upload, sync, pull, push, diff,
|
||||
or maintain a MoviePilot local plugin in a GitHub repository. Covers using the
|
||||
@@ -135,8 +135,13 @@ Options:
|
||||
- Do not log or print GitHub token values.
|
||||
- For push operations, report created, updated, deleted, skipped, and rejected
|
||||
files separately.
|
||||
- For pull operations, preserve local-only ignored files and refuse to overwrite
|
||||
differing local files unless `--force` is used.
|
||||
- For pull operations, preserve excluded local files even with `--force`.
|
||||
Invalid local package JSON stops the pull before any local file is written,
|
||||
including in `--force` mode.
|
||||
Differing local plugin files or package metadata stop the entire pull unless
|
||||
`--force` is used. Package entries for other plugins remain untouched.
|
||||
- With the `auto` layout, inspect each configured local repository separately;
|
||||
the working directory does not determine the layout of other repositories.
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -691,7 +691,6 @@ def resolve_local_repo(local_repo: str, plugin_id: str, package_version: str) ->
|
||||
return Path(local_repo).expanduser().resolve()
|
||||
settings = load_moviepilot_settings()
|
||||
raw_paths = str(getattr(settings, "PLUGIN_LOCAL_REPO_PATHS", "") or "") if settings else ""
|
||||
layout = resolve_layout(package_version, Path.cwd(), plugin_id)
|
||||
matches: list[Path] = []
|
||||
for item in raw_paths.split(","):
|
||||
if not item.strip():
|
||||
@@ -700,6 +699,7 @@ def resolve_local_repo(local_repo: str, plugin_id: str, package_version: str) ->
|
||||
if not path.is_absolute() and settings:
|
||||
path = Path(getattr(settings, "ROOT_PATH")) / path
|
||||
path = path.resolve()
|
||||
layout = resolve_layout(package_version, path, plugin_id)
|
||||
if plugin_dir(path, layout, plugin_id).exists():
|
||||
matches.append(path)
|
||||
if len(matches) == 1:
|
||||
@@ -934,6 +934,8 @@ def build_pull_changes(
|
||||
"""
|
||||
changes = ChangeSet(rejected=rejected)
|
||||
for path, remote_state in remote_files.items():
|
||||
if path in rejected:
|
||||
continue
|
||||
local_state = local_files.get(path)
|
||||
if local_state is None:
|
||||
changes.create.append(path)
|
||||
@@ -1132,6 +1134,24 @@ def create_repo(args: argparse.Namespace) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _read_local_pull_package(local_repo: Path, layout: Layout) -> dict[str, Any]:
|
||||
"""
|
||||
拉取写入前验证本地package整体结构,保留其它插件的元数据。
|
||||
|
||||
:param local_repo: 本地插件仓库目录
|
||||
:param layout: 当前仓库布局及package文件名
|
||||
:return: 完整package对象;文件不存在时返回空对象
|
||||
:raises ValueError: 现有文件不是有效JSON对象,不能安全合并
|
||||
"""
|
||||
package_path = local_repo / layout.package_file
|
||||
if not package_path.exists():
|
||||
return {}
|
||||
package_data = json.loads(package_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(package_data, dict):
|
||||
raise ValueError(f"本地 package 文件不是 JSON 对象:{package_path}")
|
||||
return package_data
|
||||
|
||||
|
||||
def pull(args: argparse.Namespace) -> dict[str, Any]:
|
||||
"""
|
||||
从 GitHub 拉取插件文件到本地仓库。
|
||||
@@ -1140,6 +1160,8 @@ def pull(args: argparse.Namespace) -> dict[str, Any]:
|
||||
:return: 拉取结果
|
||||
"""
|
||||
context = build_context(args)
|
||||
local_package = _read_local_pull_package(context["local_repo"], context["layout"])
|
||||
local_entry = local_package.get(context["plugin_id"])
|
||||
client: GitHubClient = context["client"]
|
||||
local_files, rejected = collect_local_files(
|
||||
context["local_repo"],
|
||||
@@ -1157,16 +1179,9 @@ def pull(args: argparse.Namespace) -> dict[str, Any]:
|
||||
package_data = json.loads(package_file.content.decode("utf-8"))
|
||||
if isinstance(package_data, dict):
|
||||
package_entry = package_data.get(context["plugin_id"])
|
||||
local_entry = None
|
||||
try:
|
||||
local_entry = read_package_entry(
|
||||
context["local_repo"],
|
||||
context["layout"],
|
||||
context["plugin_id"],
|
||||
)
|
||||
except (FileNotFoundError, ValueError, json.JSONDecodeError):
|
||||
local_entry = None
|
||||
package_change = "same" if package_entry == local_entry else "update"
|
||||
if package_entry is not None and local_entry is not None and package_change == "update" and not args.force:
|
||||
changes.conflicts.append(context["layout"].package_file)
|
||||
payload = result_payload(context, changes, package_change=package_change)
|
||||
if changes.conflicts and not args.force:
|
||||
payload["message"] = "本地存在未发布改动,已拒绝覆盖。确认后可使用 --force。"
|
||||
|
||||
@@ -640,6 +640,13 @@
|
||||
},
|
||||
"target": "asyncio.to_thread"
|
||||
},
|
||||
"app/startup/composition/cliagent.py:app.runtime.execution.run_in_threadpool_to_completion": {
|
||||
"owners": {
|
||||
"_close_cli_extensions": 3,
|
||||
"run_cli_agent_request": 2
|
||||
},
|
||||
"target": "app.runtime.execution.run_in_threadpool_to_completion"
|
||||
},
|
||||
"app/startup/composition/network.py:asyncio.to_thread": {
|
||||
"owners": {
|
||||
"_NetworkTestTransportAdapter.websocket": 1
|
||||
|
||||
@@ -961,8 +961,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 9649,
|
||||
"edge_sha256": "3038db090f87166a7cae3e63e78e30def7d3466252c846bdab3d6996a8f65999",
|
||||
"edge_count": 9690,
|
||||
"edge_sha256": "2acb67e3a6c9e7c10938a8dfde7571359a644b174e8ac2505f71fd1202976633",
|
||||
"edges": [
|
||||
"app -> app.foundation",
|
||||
"app -> app.foundation.environment",
|
||||
@@ -9760,6 +9760,47 @@
|
||||
"app.startup.composition.classification -> app.schemas",
|
||||
"app.startup.composition.classification -> app.schemas.category",
|
||||
"app.startup.composition.classification -> app.schemas.types",
|
||||
"app.startup.composition.cliagent -> app.adapters",
|
||||
"app.startup.composition.cliagent -> app.adapters.network",
|
||||
"app.startup.composition.cliagent -> app.adapters.network.http",
|
||||
"app.startup.composition.cliagent -> app.agent",
|
||||
"app.startup.composition.cliagent -> app.agent.loader",
|
||||
"app.startup.composition.cliagent -> app.db",
|
||||
"app.startup.composition.cliagent -> app.db.session",
|
||||
"app.startup.composition.cliagent -> app.runtime",
|
||||
"app.startup.composition.cliagent -> app.runtime.events",
|
||||
"app.startup.composition.cliagent -> app.runtime.execution",
|
||||
"app.startup.composition.cliagent -> app.runtime.extensions",
|
||||
"app.startup.composition.cliagent -> app.runtime.extensions.module",
|
||||
"app.startup.composition.cliagent -> app.runtime.extensions.module.manager",
|
||||
"app.startup.composition.cliagent -> app.runtime.extensions.plugin",
|
||||
"app.startup.composition.cliagent -> app.runtime.extensions.plugin.manager",
|
||||
"app.startup.composition.cliagent -> app.runtime.loop",
|
||||
"app.startup.composition.cliagent -> app.runtime.tasks",
|
||||
"app.startup.composition.cliagent -> app.runtime.thread",
|
||||
"app.startup.composition.cliagent -> app.schemas",
|
||||
"app.startup.composition.cliagent -> app.schemas.types",
|
||||
"app.startup.composition.cliagent -> app.startup",
|
||||
"app.startup.composition.cliagent -> app.startup.composition",
|
||||
"app.startup.composition.cliagent -> app.startup.composition.agent",
|
||||
"app.startup.composition.cliagent -> app.startup.composition.chain",
|
||||
"app.startup.composition.cliagent -> app.startup.composition.classification",
|
||||
"app.startup.composition.cliagent -> app.startup.composition.configuration",
|
||||
"app.startup.composition.cliagent -> app.startup.composition.database",
|
||||
"app.startup.composition.cliagent -> app.startup.composition.network",
|
||||
"app.startup.composition.cliagent -> app.startup.composition.runtime",
|
||||
"app.startup.composition.cliagent -> app.startup.composition.subscription",
|
||||
"app.startup.composition.cliagent -> app.startup.composition.system",
|
||||
"app.startup.composition.cliagent -> app.startup.initializers",
|
||||
"app.startup.composition.cliagent -> app.startup.initializers.agent",
|
||||
"app.startup.composition.cliagent -> app.startup.initializers.cache",
|
||||
"app.startup.composition.cliagent -> app.startup.initializers.chain",
|
||||
"app.startup.composition.cliagent -> app.startup.initializers.database",
|
||||
"app.startup.composition.cliagent -> app.startup.initializers.domain",
|
||||
"app.startup.composition.cliagent -> app.startup.initializers.modules",
|
||||
"app.startup.composition.cliagent -> app.startup.initializers.network",
|
||||
"app.startup.composition.cliagent -> app.startup.initializers.plugins",
|
||||
"app.startup.composition.cliagent -> app.startup.initializers.site",
|
||||
"app.startup.composition.configuration -> app.application",
|
||||
"app.startup.composition.configuration -> app.application.configuration",
|
||||
"app.startup.composition.configuration -> app.application.database",
|
||||
@@ -10614,7 +10655,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 1128,
|
||||
"module_count": 1129,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -11689,6 +11730,7 @@
|
||||
"app.startup.composition.cache",
|
||||
"app.startup.composition.chain",
|
||||
"app.startup.composition.classification",
|
||||
"app.startup.composition.cliagent",
|
||||
"app.startup.composition.configuration",
|
||||
"app.startup.composition.context",
|
||||
"app.startup.composition.database",
|
||||
|
||||
@@ -84,6 +84,9 @@ def test_full_regeneration_is_current_and_idempotent(
|
||||
path.relative_to(tmp_path): path.read_bytes()
|
||||
for path in (tmp_path / "skills").rglob("*.md")
|
||||
}
|
||||
database_skill = tmp_path / "skills/database-operation/SKILL.md"
|
||||
database_skill.write_text(re.sub(r"(?m)^version: \d+$", "version: 11",
|
||||
database_skill.read_text(encoding="utf-8"), count=1), encoding="utf-8")
|
||||
|
||||
for _ in range(2):
|
||||
assert generator.main() == 0
|
||||
|
||||
@@ -127,8 +127,9 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
|
||||
expected_versions = {
|
||||
"browser-use": "4",
|
||||
"command-dispatch": "2",
|
||||
"database-operation": "11",
|
||||
"feedback-issue": "11",
|
||||
"database-operation": "12",
|
||||
"feedback-issue": "12",
|
||||
"anysearch": "3",
|
||||
"moviepilot-api": "59",
|
||||
"moviepilot-update": "7",
|
||||
"organize-files": "5",
|
||||
@@ -136,7 +137,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
|
||||
"generate-identifiers": "4",
|
||||
"create-moviepilot-plugin": "7",
|
||||
"create-moviepilot-skill": "3",
|
||||
"publish-moviepilot-plugin": "3",
|
||||
"publish-moviepilot-plugin": "4",
|
||||
"downloader-operation": "3",
|
||||
"mediaserver-operation": "3",
|
||||
}
|
||||
|
||||
199
tests/test_cli_agent_review_runtime.py
Normal file
199
tests/test_cli_agent_review_runtime.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
命令行 Agent 正式组合根、持久化和资源收口的隔离进程回归。
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import coverage
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", ["", "model", "configuration", "cancel"])
|
||||
def test_cli_agent_owns_real_runtime_and_closes_resources(tmp_path, failure):
|
||||
"""
|
||||
临时 SQLite 和真实 Agent 入口只替换模型图,成功及失败后均释放运行时 owner。
|
||||
"""
|
||||
code = r'''
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
if os.environ.get("CLI_REVIEW_COVERAGE_FILE"):
|
||||
import atexit
|
||||
import coverage
|
||||
measurement = coverage.Coverage(
|
||||
data_file=os.environ["CLI_REVIEW_COVERAGE_FILE"],
|
||||
data_suffix=True,
|
||||
config_file=os.environ["CLI_REVIEW_COVERAGE_CONFIG"],
|
||||
source=["app"],
|
||||
branch=True,
|
||||
)
|
||||
measurement.start()
|
||||
|
||||
def save_coverage():
|
||||
"""
|
||||
子进程退出时保存真实执行轨迹,唯一后缀由父pytest-cov统一合并。
|
||||
"""
|
||||
measurement.stop()
|
||||
measurement.save()
|
||||
|
||||
atexit.register(save_coverage)
|
||||
|
||||
from app.testing.bootstrap import ensure_sites_stub, isolate_config_dir
|
||||
|
||||
isolate_config_dir()
|
||||
ensure_sites_stub()
|
||||
import pytest
|
||||
from app.testing.network import block_real_network
|
||||
network_patch = pytest.MonkeyPatch()
|
||||
network_guard = block_real_network.__wrapped__(network_patch)
|
||||
next(network_guard)
|
||||
from app.startup.initializers.cache import configure_cache_dependencies
|
||||
configure_cache_dependencies()
|
||||
|
||||
from app.runtime.config import settings
|
||||
settings.AI_AGENT_ENABLE = True
|
||||
settings.LLM_PROVIDER = "openai"
|
||||
settings.LLM_MODEL = "review"
|
||||
settings.LLM_API_KEY = "review-not-a-secret"
|
||||
|
||||
from langchain_core.messages import AIMessage
|
||||
from app.agent.loader import get_agent_manager, get_running_agent_manager
|
||||
from app.agent.orchestrator import MoviePilotAgent
|
||||
from app.application.configuration import get_api_runtime_config_snapshot
|
||||
from app.application.chain.context import get_chain_runtime_context
|
||||
from app.application.messaging.chat import get_configured_agent_chat_persistence
|
||||
from app.db.oper.agentchat import AgentChatOper
|
||||
from app.db import session as database_session
|
||||
from app.db.session import SessionFactory
|
||||
from app.runtime.loop import main_loop_registry
|
||||
from app.startup.composition import configuration
|
||||
from app.startup.composition.database import database_runtime_active
|
||||
from scripts.local_setup import run_agent_request
|
||||
|
||||
seen = []
|
||||
closed = []
|
||||
failure = os.environ["CLI_REVIEW_FAILURE"]
|
||||
original_close_database = database_session.close_database
|
||||
|
||||
async def observe_database_close():
|
||||
"""
|
||||
在真实引擎释放前读取持久消息,观察后继续调用实际关闭 owner。
|
||||
"""
|
||||
with SessionFactory() as session:
|
||||
record = AgentChatOper(session).get("cli-review", "review")
|
||||
closed.append(bool(record and record.agent_messages))
|
||||
await original_close_database()
|
||||
|
||||
database_session.close_database = observe_database_close
|
||||
|
||||
async def graph(agent, streaming=False):
|
||||
"""
|
||||
模型边界核对真实组合根身份,并返回确定的 LangChain 消息。
|
||||
"""
|
||||
del streaming
|
||||
manager = get_running_agent_manager()
|
||||
seen.append({
|
||||
"data": agent._data is not None,
|
||||
"canonical": manager is get_agent_manager(),
|
||||
"memory": manager is not None and agent._memory is manager._memory,
|
||||
"worker": database_runtime_active(),
|
||||
"configuration": get_api_runtime_config_snapshot().ai_agent_enable,
|
||||
"persistence": agent._data is not None and agent._data.chat_persistence is get_configured_agent_chat_persistence(),
|
||||
"loop": main_loop_registry.require() is asyncio.get_running_loop(),
|
||||
"chain": get_chain_runtime_context().configuration.ai_agent_enable,
|
||||
})
|
||||
agent._last_agent_cache_hit = False
|
||||
state = {}
|
||||
|
||||
async def invoke(payload, config):
|
||||
del config
|
||||
if failure == "model":
|
||||
raise RuntimeError("review model failure")
|
||||
if failure == "cancel":
|
||||
raise asyncio.CancelledError()
|
||||
if os.environ.get("CLI_REVIEW_HISTORY"):
|
||||
assert len(payload["messages"]) >= 3, payload
|
||||
state["messages"] = [*payload["messages"], AIMessage(content="review answer")]
|
||||
return state
|
||||
|
||||
return SimpleNamespace(ainvoke=invoke, get_state=lambda _config: SimpleNamespace(values=state))
|
||||
|
||||
async def no_title(_self, _message):
|
||||
"""
|
||||
标题生成同样属于模型边界,本回归只验证主对话。
|
||||
"""
|
||||
|
||||
MoviePilotAgent._create_agent = graph
|
||||
MoviePilotAgent.prepare_chat_title = no_title
|
||||
MoviePilotAgent._should_stream = lambda _self: False
|
||||
MoviePilotAgent._learning_enabled = lambda _self: False
|
||||
if failure == "configuration":
|
||||
async def fail_configuration(**_kwargs):
|
||||
raise RuntimeError("review configuration failure")
|
||||
configuration.compose_configuration = fail_configuration
|
||||
|
||||
try:
|
||||
result = run_agent_request(message=" review prompt ", session_id="cli-review", new_session=False, user_id="review")
|
||||
except RuntimeError as error:
|
||||
assert failure == "configuration" and "review configuration failure" in str(error), str(error)
|
||||
except asyncio.CancelledError:
|
||||
assert failure == "cancel"
|
||||
else:
|
||||
assert failure != "configuration", "正式配置组合根没有执行"
|
||||
assert len(seen) == 1 and all(seen[0].values()), seen
|
||||
if not failure:
|
||||
assert result == {"session_id":"cli-review", "result":"review answer"}, result
|
||||
assert closed == [True], "CLI 对话未提交到临时 SQLite"
|
||||
else:
|
||||
assert result["result"], result
|
||||
|
||||
assert len(closed) == 1, "引擎关闭 owner 没有执行"
|
||||
assert not database_runtime_active(), "数据库 worker 未关闭"
|
||||
assert get_running_agent_manager() is None, "Agent 能力仍处于运行态"
|
||||
assert main_loop_registry.current is None, "CLI 主循环登记未释放"
|
||||
if seen:
|
||||
from app.agent.manager import agent_manager
|
||||
assert not agent_manager._accepting_tasks and not agent_manager.active_agents
|
||||
assert agent_manager._idle_cleanup_task is None
|
||||
assert agent_manager._memory.cleanup_task is None
|
||||
try:
|
||||
next(network_guard)
|
||||
except StopIteration:
|
||||
pass
|
||||
network_patch.undo()
|
||||
print("CLI_REVIEW_OK")
|
||||
'''
|
||||
environment = {**os.environ, "CONFIG_DIR": str(tmp_path / "config"), "CLI_REVIEW_FAILURE": failure}
|
||||
environment.pop("DEBUG", None)
|
||||
active_coverage = coverage.Coverage.current()
|
||||
if active_coverage is not None:
|
||||
environment["CLI_REVIEW_COVERAGE_FILE"] = active_coverage.get_data().data_filename()
|
||||
environment["CLI_REVIEW_COVERAGE_CONFIG"] = str(Path(__file__).parents[1] / ".coveragerc")
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
cwd=Path(__file__).parents[1],
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
assert completed.returncode == 0, completed.stdout + completed.stderr
|
||||
assert "CLI_REVIEW_OK" in completed.stdout
|
||||
if not failure:
|
||||
replay = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
cwd=Path(__file__).parents[1],
|
||||
env={**environment, "CLI_REVIEW_HISTORY": "1"},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
assert replay.returncode == 0, replay.stdout + replay.stderr
|
||||
assert "CLI_REVIEW_OK" in replay.stdout
|
||||
66
tests/test_dev_skills_review_boundaries.py
Normal file
66
tests/test_dev_skills_review_boundaries.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
独立验证插件拉取预检、SQL词法和前端发布失败时的数据边界。
|
||||
"""
|
||||
|
||||
import json
|
||||
from argparse import Namespace
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.test_dev_skills_review_regressions import _load_script
|
||||
|
||||
|
||||
def _pull_context(tmp_path, monkeypatch):
|
||||
"""
|
||||
准备真实临时插件目录,仅替换远端读取边界。
|
||||
"""
|
||||
module = _load_script("skills/publish-moviepilot-plugin/scripts/publish_plugin.py", monkeypatch)
|
||||
directory = tmp_path / "plugins.v2/myplugin"
|
||||
directory.mkdir(parents=True)
|
||||
(directory / "__init__.py").write_text("# 旧本地源码\n")
|
||||
package = tmp_path / "package.v2.json"
|
||||
prefix = "plugins.v2/myplugin"
|
||||
remote = {f"{prefix}/__init__.py": module.FileState(f"{prefix}/__init__.py", (directory / "__init__.py").read_bytes()),
|
||||
f"{prefix}/new.py": module.FileState(f"{prefix}/new.py", b"# new")}
|
||||
context = {"repo": "fixture/review", "branch": "v3", "plugin_id": "MyPlugin", "local_repo": tmp_path,
|
||||
"layout": module.Layout("package.v2.json", "plugins.v2"), "remote_prefix": prefix,
|
||||
"client": SimpleNamespace(list_files=lambda _prefix: remote, get_file=lambda path: module.FileState(
|
||||
path, json.dumps({"MyPlugin": {"version": "1.0.0"}}).encode())),
|
||||
"excludes": list(module.DEFAULT_EXCLUDES), "includes": []}
|
||||
monkeypatch.setattr(module, "build_context", lambda _args: context)
|
||||
return module, directory, package, context
|
||||
|
||||
|
||||
@pytest.mark.parametrize("package_content", ["{broken", "[]"])
|
||||
@pytest.mark.parametrize("force", [False, True])
|
||||
def test_pull_invalid_local_package_never_partially_writes_plugin(tmp_path, monkeypatch, package_content, force):
|
||||
"""
|
||||
本地package无效必须在任何文件写入前拒绝,强制模式也不能先创建文件再失败。
|
||||
"""
|
||||
module, directory, package, context = _pull_context(tmp_path, monkeypatch)
|
||||
package.write_text(package_content)
|
||||
try:
|
||||
result = module.pull(Namespace(force=force, dry_run=False))
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
assert context["layout"].package_file in result["changes"]["conflicts"]
|
||||
assert not (directory / "new.py").exists()
|
||||
assert package.read_text() == package_content
|
||||
|
||||
|
||||
@pytest.mark.parametrize("existing_package", [False, True])
|
||||
def test_pull_valid_missing_entry_preserves_other_plugin_metadata(tmp_path, monkeypatch, existing_package):
|
||||
"""
|
||||
缺少本地package或目标条目仍可正常拉取,新条目不能覆盖其它插件的元数据。
|
||||
"""
|
||||
module, directory, package, _ = _pull_context(tmp_path, monkeypatch)
|
||||
other = {"Other": {"version": "3.0.0", "name": "其它插件"}}
|
||||
if existing_package:
|
||||
package.write_text(json.dumps(other))
|
||||
result = module.pull(Namespace(force=False, dry_run=False))
|
||||
assert not result["changes"]["conflicts"]
|
||||
assert (directory / "new.py").read_bytes() == b"# new"
|
||||
expected = {**(other if existing_package else {}), "MyPlugin": {"version": "1.0.0"}}
|
||||
assert json.loads(package.read_text()) == expected
|
||||
284
tests/test_dev_skills_review_regressions.py
Normal file
284
tests/test_dev_skills_review_regressions.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
本地开发、审查工具和技能脚本的隔离回归。
|
||||
"""
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
from scripts.architecture import baseline, coverage_ratchet
|
||||
from scripts.evaluation.score import evaluate
|
||||
from scripts.evaluation.world import EvaluationWorld
|
||||
from scripts.perf import moviepilot_docker_ab as perf
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _load_script(relative, monkeypatch):
|
||||
"""
|
||||
加载当前工作树脚本并在结束时恢复导入登记,不执行命令行主函数。
|
||||
"""
|
||||
path = ROOT / relative
|
||||
spec = importlib.util.spec_from_file_location("review_" + path.stem.replace("-", "_"), path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
monkeypatch.setitem(sys.modules, spec.name, module)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_coverage_write_snapshot_is_internally_consistent(tmp_path, monkeypatch) -> None:
|
||||
"""
|
||||
相同比例但语句数变化的合法更新,逐文件与汇总必须使用同一份当前计数。
|
||||
"""
|
||||
path = tmp_path / "baseline.json"
|
||||
report = tmp_path / "coverage.json"
|
||||
path.write_text(json.dumps(coverage_ratchet._snapshot_from_files({"app/demo.py": coverage_ratchet._metric(10, 8)})))
|
||||
report.write_text(json.dumps({"files": {"app/demo.py": {"summary": {"num_statements": 20, "covered_lines": 16}}}}))
|
||||
monkeypatch.setattr(sys, "argv", ["coverage", "--write", "--report", str(report), "--baseline", str(path)])
|
||||
assert coverage_ratchet.main() == 0
|
||||
written = json.loads(path.read_text())
|
||||
assert coverage_ratchet.validate_coverage(written) == []
|
||||
assert written["files"]["app/demo.py"]["statements"] == 20
|
||||
|
||||
|
||||
@pytest.mark.parametrize("guard", ["TYPE_CHECKING", "typing.TYPE_CHECKING"])
|
||||
def test_type_checking_else_imports_are_runtime_dependencies(tmp_path, guard) -> None:
|
||||
"""
|
||||
仅类型分支排除,else 内实际运行的依赖仍纳入架构检查。
|
||||
"""
|
||||
file = tmp_path / "branch.py"
|
||||
file.write_text(f"if {guard}:\n from app.adapters.system.host import SystemUtils\nelse:\n from app.adapters.network.http import RequestUtils\n")
|
||||
imports = list(baseline.iter_runtime_import_nodes(ast.parse(file.read_text())))
|
||||
assert [node.module for node in imports] == ["app.adapters.network.http"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pages", [(6, 5, 4, 3, 2, 1), (1, 2, 3, 4, 5, 6, 1)])
|
||||
def test_long_context_score_rejects_reordered_or_repeated_pages(pages) -> None:
|
||||
"""
|
||||
分页证据不能丢失读取顺序和重复次数。
|
||||
"""
|
||||
world = EvaluationWorld("long_context")
|
||||
for page in pages:
|
||||
assert world.execute("subscription.list", query={"page": page, "count": 20})["success"]
|
||||
report = {"status": "completed", "completed": ["subscription"], "unresolved": [],
|
||||
"subscription_ids": [9001], "download_ids": [], "enabled_site_ids": []}
|
||||
assert evaluate(world, report).to_dict()["passed"] is False
|
||||
|
||||
|
||||
def test_perf_container_reads_config_labels() -> None:
|
||||
"""
|
||||
同 campaign 容器的 Docker inspect 标签位于 Config 下,可受控复用。
|
||||
"""
|
||||
container = SimpleNamespace(name="isolated-review", attrs={"Config": {"Labels": {perf.CAMPAIGN_LABEL: "review"}}})
|
||||
perf.assert_owned(container, Namespace(campaign="review"), "container")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command,flag", [("search", "--domain"), ("get_sub_domains", "--domain"),
|
||||
("extract", "--url"), ("batch_search", "--query")])
|
||||
def test_anysearch_missing_flag_value_exits_without_loop(command, flag) -> None:
|
||||
"""
|
||||
缺少参数值应立即错误退出,不启动请求或持续占用 CPU。
|
||||
"""
|
||||
result = subprocess.run(["bash", str(ROOT / "skills/anysearch/scripts/anysearch_cli.sh"), command, flag],
|
||||
capture_output=True, text=True, timeout=2)
|
||||
assert result.returncode != 0
|
||||
assert result.stderr.strip()
|
||||
|
||||
|
||||
def test_anysearch_help_resolves_invocation_template() -> None:
|
||||
"""
|
||||
离线帮助包含可直接执行的 Bash 命令。
|
||||
"""
|
||||
result = subprocess.run(["bash", str(ROOT / "skills/anysearch/scripts/anysearch_cli.sh"), "--help"],
|
||||
capture_output=True, text=True, timeout=2)
|
||||
assert result.returncode == 0
|
||||
assert "{{LANG_INVOKE}}" not in result.stdout
|
||||
assert "bash scripts/anysearch_cli.sh" in result.stdout
|
||||
|
||||
|
||||
def test_anysearch_propagates_mcp_tool_error(tmp_path) -> None:
|
||||
"""
|
||||
HTTP 成功但 MCP isError 时,CLI 以失败退出并说明工具错误。
|
||||
"""
|
||||
(tmp_path / "requests.py").write_text(
|
||||
"from types import SimpleNamespace\n"
|
||||
"def post(*args, **kwargs):\n"
|
||||
" return SimpleNamespace(raise_for_status=lambda: None, json=lambda: "
|
||||
"{'result': {'isError': True, 'content': [{'type': 'text', 'text': 'fixture tool failure'}]}})\n"
|
||||
)
|
||||
result = subprocess.run([sys.executable, str(ROOT / "skills/anysearch/scripts/anysearch_cli.py"),
|
||||
"search", "review"], env={**os.environ, "PYTHONPATH": str(tmp_path)},
|
||||
capture_output=True, text=True, timeout=5)
|
||||
assert result.returncode != 0
|
||||
assert "fixture tool failure" in result.stderr
|
||||
|
||||
|
||||
def test_anysearch_bash_propagates_mcp_tool_error(tmp_path):
|
||||
"""
|
||||
Bash 入口也把HTTP成功的工具失败作为错误退出,curl边界固定为本地响应。
|
||||
"""
|
||||
curl = tmp_path / "curl"
|
||||
curl.write_text("#!/bin/bash\nprintf '%s\\n' '{\"result\":{\"isError\":true,\"content\":[{\"type\":\"text\",\"text\":\"fixture tool failure\"}]}}'\n")
|
||||
curl.chmod(0o755)
|
||||
result = subprocess.run(["bash", str(ROOT / "skills/anysearch/scripts/anysearch_cli.sh"), "search", "review"],
|
||||
env={**os.environ, "PATH": str(tmp_path) + os.pathsep + os.environ["PATH"]},
|
||||
capture_output=True, text=True, timeout=5)
|
||||
assert result.returncode != 0
|
||||
assert "fixture tool failure" in result.stderr
|
||||
|
||||
|
||||
def test_anysearch_node_propagates_mcp_tool_error():
|
||||
"""
|
||||
Node CLI只替换HTTPS进程边界,工具错误不能返回成功状态。
|
||||
"""
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
pytest.skip("Node runtime is unavailable")
|
||||
path = str(ROOT / "skills/anysearch/scripts/anysearch_cli.js")
|
||||
source = '''
|
||||
const https = require('https');
|
||||
const { EventEmitter } = require('events');
|
||||
https.request = (_options, callback) => {
|
||||
const request = new EventEmitter();
|
||||
request.setTimeout = () => {};
|
||||
request.write = () => {};
|
||||
request.end = () => {
|
||||
const response = new EventEmitter();
|
||||
response.statusCode = 200;
|
||||
callback(response);
|
||||
response.emit('data', JSON.stringify({result: {isError: true, content: [{type: 'text', text: 'fixture tool failure'}]}}));
|
||||
response.emit('end');
|
||||
};
|
||||
return request;
|
||||
};
|
||||
''' + "process.argv = ['node', " + json.dumps(path) + ", 'search', 'review'];\nrequire(" + json.dumps(path) + ");\n"
|
||||
result = subprocess.run([node, "-e", source], capture_output=True, text=True, timeout=5)
|
||||
assert result.returncode != 0
|
||||
assert "fixture tool failure" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql", ["SELECT 'update' AS action", "SELECT 'chapter; title' AS title",
|
||||
"SELECT 1 /* update; ignored */", "SELECT 'it''s delete' AS text"])
|
||||
def test_readonly_sql_ignores_literals_and_comments(monkeypatch, sql) -> None:
|
||||
"""
|
||||
字符串和注释内的写关键词或分号不能提高普通查询所需权限。
|
||||
"""
|
||||
module = _load_script("skills/database-operation/scripts/mp-db.py", monkeypatch)
|
||||
assert module._is_supported_statement(sql, False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sql", ["DELETE FROM sample", "WITH removed AS (DELETE FROM sample RETURNING *) SELECT * FROM removed",
|
||||
"SELECT 1; DELETE FROM sample", "SELECT 'unclosed", "SELECT 1 /* unclosed",
|
||||
"SELECT 1; SELECT 2", "SELECT 1;;", "/* read */ UPDATE sample SET value=1"])
|
||||
def test_readonly_sql_still_rejects_writes_or_invalid_statements(monkeypatch, sql):
|
||||
"""
|
||||
忽略字面值后仍拒绝真实写入、多语句及未闭合词法结构。
|
||||
"""
|
||||
module = _load_script("skills/database-operation/scripts/mp-db.py", monkeypatch)
|
||||
assert not module._is_supported_statement(sql, False)
|
||||
|
||||
|
||||
def test_readonly_sql_executes_one_real_query_and_rejects_mutation(tmp_path, monkeypatch, capsys):
|
||||
"""
|
||||
实际 SQLite 查询保留字面值并正确终止注释,拒绝写入后数据仍完整。
|
||||
"""
|
||||
module = _load_script("skills/database-operation/scripts/mp-db.py", monkeypatch)
|
||||
engine = create_engine("sqlite:///" + str(tmp_path / "review.db"))
|
||||
monkeypatch.setattr(module, "_build_engine", lambda: engine)
|
||||
try:
|
||||
assert module.run_query("SELECT 'chapter; update' AS title; -- fixture comment") == 0
|
||||
assert json.loads(capsys.readouterr().out)["rows"] == [{"title": "chapter; update"}]
|
||||
assert module.run_query("CREATE TABLE sample (id INTEGER)") == 1
|
||||
assert "require --write" in capsys.readouterr().err
|
||||
assert module.run_query("SELECT 1; CREATE TABLE sample (id INTEGER)", allow_write=True) == 1
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_readonly_query_literal_limit_does_not_bypass_row_bound(tmp_path, monkeypatch, capsys):
|
||||
"""
|
||||
字面值与注释内的LIMIT不影响实际查询默认行数限制。
|
||||
"""
|
||||
module = _load_script("skills/database-operation/scripts/mp-db.py", monkeypatch)
|
||||
engine = create_engine("sqlite:///" + str(tmp_path / "bounded.db"))
|
||||
monkeypatch.setattr(module, "_build_engine", lambda: engine)
|
||||
try:
|
||||
assert module.run_query("/* review */ SELECT 'limit 999' AS value UNION ALL SELECT 'second'", limit=1) == 0
|
||||
assert json.loads(capsys.readouterr().out)["row_count"] == 1
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("force", [False, True])
|
||||
def test_plugin_pull_protects_excluded_local_file(tmp_path, monkeypatch, force) -> None:
|
||||
"""
|
||||
用户排除的本地路径不受远端同路径写入覆盖,即便强制拉取也保留。
|
||||
"""
|
||||
module = _load_script("skills/publish-moviepilot-plugin/scripts/publish_plugin.py", monkeypatch)
|
||||
directory = tmp_path / "plugins.v2/myplugin"
|
||||
directory.mkdir(parents=True)
|
||||
(directory / "__init__.py").write_text("# review\n")
|
||||
local = directory / ".env"
|
||||
local.write_text("local-fixture\n")
|
||||
prefix = "plugins.v2/myplugin"
|
||||
remote = {f"{prefix}/__init__.py": module.FileState(f"{prefix}/__init__.py", b"# review\n"),
|
||||
f"{prefix}/.env": module.FileState(f"{prefix}/.env", b"remote-fixture\n")}
|
||||
context = {"repo": "fixture/review", "branch": "v3", "plugin_id": "MyPlugin", "local_repo": tmp_path,
|
||||
"layout": module.Layout("package.v2.json", "plugins.v2"), "remote_prefix": prefix,
|
||||
"client": SimpleNamespace(list_files=lambda _prefix: remote, get_file=lambda _path: None),
|
||||
"excludes": list(module.DEFAULT_EXCLUDES), "includes": []}
|
||||
monkeypatch.setattr(module, "build_context", lambda _args: context)
|
||||
result = module.pull(Namespace(force=force, dry_run=False))
|
||||
assert local.read_text() == "local-fixture\n"
|
||||
assert f"{prefix}/.env" in result["changes"]["rejected"]
|
||||
assert f"{prefix}/.env" not in result["changes"]["create"]
|
||||
|
||||
|
||||
def test_plugin_pull_preserves_unpublished_package_entry(tmp_path, monkeypatch) -> None:
|
||||
"""
|
||||
未强制拉取不能覆盖待发布的插件 package 元数据。
|
||||
"""
|
||||
module = _load_script("skills/publish-moviepilot-plugin/scripts/publish_plugin.py", monkeypatch)
|
||||
directory = tmp_path / "plugins.v2/myplugin"
|
||||
directory.mkdir(parents=True)
|
||||
(directory / "__init__.py").write_text("# review\n")
|
||||
package = tmp_path / "package.v2.json"
|
||||
original = {"MyPlugin": {"version": "2.0.0"}, "Other": {"version": "3.0.0"}}
|
||||
package.write_text(json.dumps(original))
|
||||
prefix = "plugins.v2/myplugin"
|
||||
remote = {f"{prefix}/__init__.py": module.FileState(f"{prefix}/__init__.py", b"# review\n")}
|
||||
context = {"repo": "fixture/review", "branch": "v3", "plugin_id": "MyPlugin", "local_repo": tmp_path,
|
||||
"layout": module.Layout("package.v2.json", "plugins.v2"), "remote_prefix": prefix,
|
||||
"client": SimpleNamespace(list_files=lambda _prefix: remote, get_file=lambda path: module.FileState(
|
||||
path, json.dumps({"MyPlugin": {"version": "1.0.0"}}).encode())),
|
||||
"excludes": list(module.DEFAULT_EXCLUDES), "includes": []}
|
||||
monkeypatch.setattr(module, "build_context", lambda _args: context)
|
||||
result = module.pull(Namespace(force=False, dry_run=False))
|
||||
assert result["changes"]["conflicts"]
|
||||
assert json.loads(package.read_text()) == original
|
||||
|
||||
|
||||
def test_plugin_discovery_resolves_each_candidate_layout(tmp_path, monkeypatch) -> None:
|
||||
"""
|
||||
auto 布局按候选仓实际文件判断,工作目录不能决定全部仓库布局。
|
||||
"""
|
||||
module = _load_script("skills/publish-moviepilot-plugin/scripts/publish_plugin.py", monkeypatch)
|
||||
legacy = tmp_path / "legacy"
|
||||
(legacy / "plugins/myplugin").mkdir(parents=True)
|
||||
(legacy / "package.json").write_text('{"MyPlugin": {"version": "1.0.0"}}')
|
||||
empty = tmp_path / "backend"
|
||||
empty.mkdir()
|
||||
monkeypatch.chdir(empty)
|
||||
monkeypatch.setattr(module, "load_moviepilot_settings", lambda: SimpleNamespace(
|
||||
PLUGIN_LOCAL_REPO_PATHS=str(legacy), ROOT_PATH=tmp_path))
|
||||
assert module.resolve_local_repo("", "MyPlugin", "auto") == legacy
|
||||
@@ -80,6 +80,22 @@ def test_filter_lines_compacts_consecutive_repeated_templates(feedback_modules):
|
||||
assert matched_keywords == ["等待转存"]
|
||||
|
||||
|
||||
def test_filter_lines_excludes_current_correlation_id_noise(feedback_modules):
|
||||
"""
|
||||
当前带关联 ID 的 Agent 日志不能挤占有限的真实故障日志预算。
|
||||
"""
|
||||
_, collect = feedback_modules
|
||||
now = datetime.now()
|
||||
timestamp = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
real = f"【ERROR】{timestamp},001 [fixture-id] - transfer.py - review fixture failure"
|
||||
noise = f"【ERROR】{timestamp},002 [fixture-id] - message.py - review fixture failure"
|
||||
lines, _ = collect.filter_lines(
|
||||
real + "\n" + noise, keywords=["fixture"], max_lines=1,
|
||||
window_start=now - timedelta(minutes=5),
|
||||
)
|
||||
assert lines == [real]
|
||||
|
||||
|
||||
def test_doctor_summary_groups_legacy_duplicate_advisories(feedback_modules):
|
||||
"""
|
||||
旧版 Doctor 的重复插件发现也应在反馈摘要中合并展示。
|
||||
|
||||
@@ -230,11 +230,11 @@ def test_update_reinstalls_repacked_frontend_in_dev_mode(
|
||||
archive.writestr("dist/version.txt", "v3.0.11")
|
||||
archive.writestr("dist/index.html", f"build-{len(downloads)}")
|
||||
|
||||
def install_runtime_deps(*_args, **_kwargs):
|
||||
def install_runtime_deps(*_args, **kwargs):
|
||||
"""
|
||||
恢复已安装依赖标记,保证第二次确实面对完整的同版本前端。
|
||||
"""
|
||||
(public / "node_modules" / "express").mkdir(parents=True, exist_ok=True)
|
||||
(kwargs["cwd"] / "node_modules" / "express").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
monkeypatch.setattr(module, "download_file", download_repacked_release)
|
||||
monkeypatch.setattr(module, "run", install_runtime_deps)
|
||||
|
||||
318
tests/test_local_setup_review_regressions.py
Normal file
318
tests/test_local_setup_review_regressions.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
本地安装、迁移和 shell 启动入口的隔离审查回归。
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _load_script(relative, monkeypatch):
|
||||
"""
|
||||
加载待测脚本,并恢复临时模块登记。
|
||||
"""
|
||||
spec = importlib.util.spec_from_file_location("setup_review_" + Path(relative).stem, ROOT / relative)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
monkeypatch.setitem(sys.modules, spec.name, module)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _run_python(source, tmp_path):
|
||||
"""
|
||||
用全新进程和独立 SQLite 配置检验正式 CLI 装配。
|
||||
"""
|
||||
environment = {**os.environ, "CONFIG_DIR": str(tmp_path / "config"), "DEBUG": "false",
|
||||
"DB_BACKUP_ENABLE": "false", "SUPERUSER": "review_admin",
|
||||
"SUPERUSER_PASSWORD": "ReviewPassword123!", "API_TOKEN": "review-only-token-123456"}
|
||||
return subprocess.run([sys.executable, "-c", source], cwd=ROOT, env=environment,
|
||||
capture_output=True, text=True, timeout=45)
|
||||
|
||||
|
||||
def test_migration_generator_loads_all_canonical_models(tmp_path):
|
||||
"""
|
||||
生成器在全新进程加载完整模型;真实 Alembic 比较不把现有表识别为删除。
|
||||
"""
|
||||
result = _run_python('''
|
||||
import runpy
|
||||
from unittest.mock import patch
|
||||
from sqlalchemy import create_engine
|
||||
from alembic.autogenerate import compare_metadata
|
||||
from alembic.migration import MigrationContext
|
||||
from app.db.base import Base
|
||||
from app.db.models.user import User
|
||||
engine = create_engine("sqlite://")
|
||||
User.__table__.create(engine)
|
||||
def check_revision(config, version, autogenerate):
|
||||
assert autogenerate is True
|
||||
assert "site" in {name.lower() for name in Base.metadata.tables}
|
||||
with engine.connect() as connection:
|
||||
changes = compare_metadata(MigrationContext.configure(connection), Base.metadata)
|
||||
assert not any(change[0] == "remove_table" for change in changes)
|
||||
with patch("builtins.input", return_value="review-only"), patch("alembic.command.revision", check_revision):
|
||||
runpy.run_path("database/gen.py", run_name="__main__")
|
||||
engine.dispose()
|
||||
''', tmp_path)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wizard", [False, True])
|
||||
def test_admin_initialization_uses_valid_transaction(tmp_path, wizard):
|
||||
"""
|
||||
非向导完整初始化及向导更新已存在管理员均提交真实短事务。
|
||||
"""
|
||||
result = _run_python('''
|
||||
import importlib.util
|
||||
from sqlalchemy import select
|
||||
from app.runtime.config import settings
|
||||
from app.db.session import SessionFactory, async_session_scope
|
||||
from app.db.models.user import User
|
||||
from app.db.adapters.transaction import TransactionalWriteRunner
|
||||
from app.db import uow
|
||||
from app.application.security.token import get_password_hash, verify_password
|
||||
spec = importlib.util.spec_from_file_location("setup", "scripts/local_setup.py")
|
||||
setup = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(setup)
|
||||
''' + ('''
|
||||
from app.startup.initializers.database import prepare_database
|
||||
runner = TransactionalWriteRunner(sync_session=SessionFactory, async_session=async_session_scope)
|
||||
uow.configure_transaction_runners(sync=runner.sync, async_=runner.async_)
|
||||
prepare_database()
|
||||
from app.db.oper.user import UserOper
|
||||
if UserOper().get_by_name("review_admin") is None:
|
||||
UserOper().add(name="review_admin", email="review@example.invalid", hashed_password=get_password_hash("OldPassword123!"), is_active=False, is_superuser=False, avatar="")
|
||||
settings.SUPERUSER_PASSWORD = "ChangedPassword123!"
|
||||
setup._apply_local_system_config_inner({})
|
||||
expected_password = settings.SUPERUSER_PASSWORD
|
||||
''' if wizard else '''
|
||||
assert uow._sync_transaction_runner is None
|
||||
setup._sync_superuser_account_inner()
|
||||
expected_password = settings.SUPERUSER_PASSWORD
|
||||
''') + '''
|
||||
with SessionFactory() as session:
|
||||
user = session.execute(select(User).where(User.name == "review_admin")).scalar_one()
|
||||
assert user.is_active and user.is_superuser
|
||||
assert verify_password(expected_password, user.hashed_password)
|
||||
''', tmp_path)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_standalone_admin_cli_from_outside_checkout(tmp_path):
|
||||
"""
|
||||
真实脚本入口不依赖调用目录或PYTHONPATH,且只初始化临时配置库。
|
||||
"""
|
||||
config = tmp_path / "config"
|
||||
environment = {**os.environ, "PYTHONPATH": "", "CONFIG_DIR": str(config), "DEBUG": "false",
|
||||
"DB_BACKUP_ENABLE": "false", "SUPERUSER": "review_admin",
|
||||
"SUPERUSER_PASSWORD": "ReviewPassword123!", "API_TOKEN": "review-only-token-123456"}
|
||||
result = subprocess.run([sys.executable, str(ROOT / "scripts/local_setup.py"), "sync-superuser",
|
||||
"--config-dir", str(config)], cwd=tmp_path, env=environment,
|
||||
capture_output=True, text=True, timeout=45)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert (config / "user.db").is_file()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("password", ['quote"slash\\secret', " leading and trailing ", "single'quote"])
|
||||
def test_env_password_roundtrip_does_not_duplicate_escaping(tmp_path, monkeypatch, password):
|
||||
"""
|
||||
向导留空保留旧密码时,读取序列化值必须还原原始字符。
|
||||
"""
|
||||
module = _load_script("scripts/local_setup.py", monkeypatch)
|
||||
monkeypatch.setattr(module, "ENV_FILE", tmp_path / "app.env")
|
||||
monkeypatch.setattr(module, "ensure_local_dirs", lambda: None)
|
||||
module.write_env_value("SUPERUSER_PASSWORD", password)
|
||||
assert module.read_env_value("SUPERUSER_PASSWORD") == password
|
||||
|
||||
|
||||
def test_frontend_dependency_failure_preserves_existing_installation(tmp_path, monkeypatch):
|
||||
"""
|
||||
npm 安装失败保留旧静态文件和原 node_modules,不发布不完整的新版本。
|
||||
"""
|
||||
module = _load_script("scripts/local_setup.py", monkeypatch)
|
||||
public = tmp_path / "public"
|
||||
(public / "node_modules").mkdir(parents=True)
|
||||
(public / "index.html").write_text("old page")
|
||||
(public / "node_modules/old").write_text("old dependency")
|
||||
archive = tmp_path / "dist.zip"
|
||||
with zipfile.ZipFile(archive, "w") as bundle:
|
||||
bundle.writestr("dist/version.txt", "v9.9.9")
|
||||
bundle.writestr("dist/index.html", "new page")
|
||||
npm = tmp_path / "npm"
|
||||
npm.touch()
|
||||
monkeypatch.setattr(module, "PUBLIC_DIR", public)
|
||||
monkeypatch.setattr(module, "install_node_runtime", lambda _version: tmp_path / "node")
|
||||
monkeypatch.setattr(module, "get_npm_bin", lambda: npm)
|
||||
|
||||
def fail_dependencies(*_args, **_kwargs):
|
||||
"""
|
||||
替换 npm 进程边界,不执行安装或联网。
|
||||
"""
|
||||
raise subprocess.CalledProcessError(1, "isolated npm")
|
||||
|
||||
monkeypatch.setattr(module, "run", fail_dependencies)
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
module.install_frontend("v9.9.9", "20", archive=archive)
|
||||
assert (public / "index.html").read_text() == "old page"
|
||||
assert (public / "node_modules/old").read_text() == "old dependency"
|
||||
|
||||
|
||||
def test_frontend_publication_failure_restores_previous_directory(tmp_path, monkeypatch):
|
||||
"""
|
||||
候选已经准备完成但发布rename失败时,旧页面仍被恢复到原目录。
|
||||
"""
|
||||
module = _load_script("scripts/local_setup.py", monkeypatch)
|
||||
public, staged = tmp_path / "public", tmp_path / "staged"
|
||||
public.mkdir()
|
||||
staged.mkdir()
|
||||
(public / "index.html").write_text("old")
|
||||
(staged / "index.html").write_text("new")
|
||||
monkeypatch.setattr(module, "PUBLIC_DIR", public)
|
||||
original_rename = Path.rename
|
||||
|
||||
def fail_publication(path, destination):
|
||||
"""
|
||||
仅模拟候选目录的最终原子发布失败,旧备份恢复使用真实rename。
|
||||
"""
|
||||
if path == staged:
|
||||
raise OSError("fixture publication failure")
|
||||
return original_rename(path, destination)
|
||||
|
||||
monkeypatch.setattr(Path, "rename", fail_publication)
|
||||
with pytest.raises(OSError, match="fixture publication"):
|
||||
module._publish_frontend_installation(staged)
|
||||
assert (public / "index.html").read_text() == "old"
|
||||
assert not list(tmp_path.glob(".public.backup-*"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [False, True])
|
||||
def test_local_database_transaction_scope_restores_existing_runners(monkeypatch, failure):
|
||||
"""
|
||||
命令不遗留全局事务执行器,操作成功与异常都恢复进入时的原端口。
|
||||
"""
|
||||
from app.db import uow
|
||||
module = _load_script("scripts/local_setup.py", monkeypatch)
|
||||
previous = (uow._sync_transaction_runner, uow._async_transaction_runner)
|
||||
|
||||
def operation():
|
||||
"""
|
||||
观察执行范围内真实runner已装配,并按参数产生受控异常。
|
||||
"""
|
||||
assert uow._sync_transaction_runner is not None
|
||||
assert uow._sync_transaction_runner is not previous[0]
|
||||
if failure:
|
||||
raise RuntimeError("fixture operation failure")
|
||||
|
||||
if failure:
|
||||
with pytest.raises(RuntimeError, match="fixture operation"):
|
||||
module._run_with_local_database_transactions(operation)
|
||||
else:
|
||||
module._run_with_local_database_transactions(operation)
|
||||
assert (uow._sync_transaction_runner, uow._async_transaction_runner) == previous
|
||||
|
||||
|
||||
def test_duplicate_config_migration_never_logs_secret_values(tmp_path, monkeypatch):
|
||||
"""
|
||||
真实迁移仍删除旧重复项,日志仅含键与行号。
|
||||
"""
|
||||
module = _load_script("database/versions/41ef1dd7467c_2_2_2.py", monkeypatch)
|
||||
engine = create_engine("sqlite:///" + str(tmp_path / "migration.db"))
|
||||
logger = Mock()
|
||||
monkeypatch.setattr(module, "logger", logger)
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("CREATE TABLE SystemConfig (id INTEGER PRIMARY KEY, key TEXT, value TEXT)"))
|
||||
connection.execute(text("INSERT INTO SystemConfig VALUES (1, 'Password', 'fixture-secret'), (2, 'Password', 'new-secret')"))
|
||||
monkeypatch.setattr(module.op, "get_bind", lambda: connection)
|
||||
module.upgrade()
|
||||
assert connection.execute(text("SELECT id FROM SystemConfig")).scalars().all() == [2]
|
||||
assert "fixture-secret" not in str(logger.mock_calls)
|
||||
assert "Password" in str(logger.mock_calls)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_bootstrap_explicit_link_path_survives_os_detection(tmp_path):
|
||||
"""
|
||||
正式参数解析和系统检测保留用户显式指定的命令路径。
|
||||
"""
|
||||
prefix = (ROOT / "scripts/bootstrap-local.sh").read_text().split("\ndetect_os\n", 1)[0]
|
||||
path = tmp_path / "custom-bin/moviepilot"
|
||||
result = subprocess.run(["bash", "-c", prefix + '\ndetect_os\nprintf "%s\\n" "$LINK_PATH"',
|
||||
"review", "--link-path", str(path)], capture_output=True, text=True, timeout=5)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == str(path)
|
||||
|
||||
|
||||
def test_noninteractive_bootstrap_with_tty_skips_wizard():
|
||||
"""
|
||||
有终端的自动安装仍遵守 --non-interactive。
|
||||
"""
|
||||
source = (ROOT / "scripts/bootstrap-local.sh").read_text()
|
||||
prefix, main = source.split("\ndetect_os\n", 1)
|
||||
decision = main[main.index('if [[ "$NON_INTERACTIVE" != "true"'):main.index('\nmkdir -p "$WORKDIR"')]
|
||||
script = prefix + '\nHAS_TTY=true\n' + decision + '\nprintf "%s\\n" "$RUN_WIZARD"'
|
||||
result = subprocess.run(["bash", "-c", script, "review", "--non-interactive"],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip().splitlines()[-1] == "false"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("has_environment", [False, True])
|
||||
def test_start_local_help_and_documented_dotvenv(tmp_path, has_environment):
|
||||
"""
|
||||
无环境可查看帮助;uv sync 创建的 .venv 可执行实际启动分支。
|
||||
"""
|
||||
(tmp_path / "scripts").mkdir()
|
||||
start = tmp_path / "scripts/start-local.sh"
|
||||
start.write_bytes((ROOT / "scripts/start-local.sh").read_bytes())
|
||||
cli = tmp_path / "moviepilot"
|
||||
cli.write_text("#!/bin/bash\nexit 99\n")
|
||||
cli.chmod(0o755)
|
||||
if has_environment:
|
||||
python = tmp_path / ".venv/bin/python"
|
||||
python.parent.mkdir(parents=True)
|
||||
python.write_text('#!/bin/bash\nprintf "fixture python %s\\n" "$*"\n')
|
||||
python.chmod(0o755)
|
||||
result = subprocess.run(["bash", str(start), "backend" if has_environment else "help"],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert ("fixture python -m app.main" if has_environment else "用法") in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize("initial", ["0", "1"])
|
||||
def test_pending_release_install_is_reachable_before_workers(tmp_path, initial):
|
||||
"""
|
||||
不论自动更新是否已经完成,持久化 Release 安装清单都进入启动前恢复。
|
||||
"""
|
||||
config = tmp_path / "config"
|
||||
manifest = config / "temp/moviepilot-update/install.json"
|
||||
manifest.parent.mkdir(parents=True)
|
||||
manifest.write_text("{}")
|
||||
control = tmp_path / "control"
|
||||
control.mkdir()
|
||||
(control / "update.sh").write_text("recover_pending_update() { return 0; }\nrun_moviepilot_update() { :; }\n")
|
||||
source = (ROOT / "docker/entrypoint.sh").read_text()
|
||||
begin = source.index('source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/update.sh"')
|
||||
end = source.index('source "${MP_CONTROL_DIR:-/usr/local/lib/moviepilot/control}/browser.sh"', begin)
|
||||
script = '''
|
||||
INFO() { :; }
|
||||
ERROR() { echo "$*" >&2; }
|
||||
maybe_reexec_control_bundle() { :; }
|
||||
cd() { :; }
|
||||
apply_pending_release_update_at_startup() { echo APPLIED; return 0; }
|
||||
reexec_entrypoint() { echo REEXEC; }
|
||||
''' + source[begin:end]
|
||||
result = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=5,
|
||||
env={**os.environ, "CONFIG_DIR": str(config), "MP_CONTROL_DIR": str(control),
|
||||
"MOVIEPILOT_BOOTSTRAP_UPDATE_DONE": initial, "ONE_SHOT_DEV_UPDATE": "false"})
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.splitlines() == ["APPLIED", "REEXEC"]
|
||||
@@ -118,3 +118,24 @@ assert gateway._provider_runtime_factory is None
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
|
||||
|
||||
def test_cli_agent_is_an_exact_runtime_owner_without_subtree_exemption() -> None:
|
||||
"""
|
||||
只允许独占 CLI 组合根持有模块和插件 owner,相邻消费者及子模块仍须走门面。
|
||||
"""
|
||||
dependencies = {"app.runtime.extensions.module.manager", "app.runtime.extensions.plugin.manager"}
|
||||
graph = {
|
||||
"app.startup.composition.cliagent": dependencies,
|
||||
"app.startup.composition.cliagent.consumer": dependencies,
|
||||
"app.startup.composition.cliagent_tools": dependencies,
|
||||
}
|
||||
|
||||
violations = collect_service_locator_violations(graph)
|
||||
|
||||
assert [(item.source, item.policy) for item in violations] == [
|
||||
("app.startup.composition.cliagent.consumer", "module"),
|
||||
("app.startup.composition.cliagent_tools", "module"),
|
||||
("app.startup.composition.cliagent.consumer", "plugin"),
|
||||
("app.startup.composition.cliagent_tools", "plugin"),
|
||||
]
|
||||
|
||||
174
tests/test_user_oper_update.py
Normal file
174
tests/test_user_oper_update.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
使用临时 SQLite 验证用户同步更新的短事务、回滚和执行器作用域。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.db.adapters.transaction import TransactionalWriteRunner
|
||||
from app.db.models.user import User
|
||||
from app.db.oper.user import UserOper
|
||||
from app.db.uow import transaction_runner_scope
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_database_factory(tmp_path):
|
||||
"""
|
||||
按名称创建互不共享数据的临时用户表及真实事务执行器。
|
||||
"""
|
||||
engines = []
|
||||
|
||||
def create_database(name):
|
||||
"""
|
||||
为一个作用域创建数据库,异步入口按调用释放自己的引擎。
|
||||
"""
|
||||
path = tmp_path / f"{name}.db"
|
||||
engine = create_engine(f"sqlite:///{path}")
|
||||
engines.append(engine)
|
||||
User.__table__.create(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
|
||||
@asynccontextmanager
|
||||
async def async_session():
|
||||
"""
|
||||
为同一数据库提供可独立关闭的真实异步会话。
|
||||
"""
|
||||
async_engine = create_async_engine(f"sqlite+aiosqlite:///{path}")
|
||||
try:
|
||||
async with async_sessionmaker(bind=async_engine)() as session:
|
||||
yield session
|
||||
finally:
|
||||
await async_engine.dispose()
|
||||
|
||||
return factory, TransactionalWriteRunner(
|
||||
sync_session=factory,
|
||||
async_session=async_session,
|
||||
)
|
||||
|
||||
try:
|
||||
yield create_database
|
||||
finally:
|
||||
for engine in engines:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _insert_user(factory, name="admin"):
|
||||
"""
|
||||
提交一个启用的管理员,返回关闭会话后仍有效的主键。
|
||||
"""
|
||||
with factory() as session:
|
||||
user = User(
|
||||
name=name,
|
||||
email="before@example.invalid",
|
||||
hashed_password="old-hash",
|
||||
is_active=True,
|
||||
is_superuser=True,
|
||||
)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
user_id = user.id
|
||||
session.commit()
|
||||
return user_id
|
||||
|
||||
|
||||
def test_update_commits_fields_after_previous_query_session_closes(user_database_factory):
|
||||
"""
|
||||
更新重新查询已游离的管理员,提交全部字段并返回可读取的对象。
|
||||
"""
|
||||
factory, runner = user_database_factory("persist")
|
||||
user_id = _insert_user(factory)
|
||||
payload = {
|
||||
"email": "after@example.invalid",
|
||||
"hashed_password": "new-hash",
|
||||
"is_active": False,
|
||||
"is_superuser": False,
|
||||
"permissions": {"search": True},
|
||||
"settings": {"language": "zh"},
|
||||
}
|
||||
|
||||
with transaction_runner_scope(sync=runner.sync, async_=runner.async_):
|
||||
oper = UserOper()
|
||||
previous = oper.get_by_id(user_id)
|
||||
assert previous is not None
|
||||
assert inspect(previous).detached
|
||||
|
||||
updated = oper.update(user_id, payload)
|
||||
|
||||
assert updated is not None
|
||||
assert updated is not previous
|
||||
assert inspect(updated).detached
|
||||
assert updated.id == user_id
|
||||
assert previous.email == "before@example.invalid"
|
||||
for field, value in payload.items():
|
||||
assert getattr(updated, field) == value
|
||||
with factory() as session:
|
||||
persisted = session.get(User, user_id)
|
||||
assert persisted is not None
|
||||
for field, value in payload.items():
|
||||
assert getattr(persisted, field) == value
|
||||
|
||||
|
||||
def test_update_returns_none_when_previously_queried_user_was_deleted(user_database_factory):
|
||||
"""
|
||||
查询后已删除的用户不会被更新入口重新插入。
|
||||
"""
|
||||
factory, runner = user_database_factory("missing")
|
||||
user_id = _insert_user(factory)
|
||||
|
||||
with transaction_runner_scope(sync=runner.sync, async_=runner.async_):
|
||||
oper = UserOper()
|
||||
assert oper.get_by_id(user_id) is not None
|
||||
with factory() as session:
|
||||
session.delete(session.get(User, user_id))
|
||||
session.commit()
|
||||
|
||||
assert oper.update(user_id, {"hashed_password": "new-hash"}) is None
|
||||
assert oper.list() == []
|
||||
|
||||
with factory() as session:
|
||||
assert session.get(User, user_id) is None
|
||||
|
||||
|
||||
def test_update_failure_rolls_back_and_restores_previous_runners(user_database_factory):
|
||||
"""
|
||||
唯一约束失败回滚全部字段,退出嵌套作用域后恢复同步和异步执行器。
|
||||
"""
|
||||
outer_factory, outer_runner = user_database_factory("outer")
|
||||
inner_factory, inner_runner = user_database_factory("inner")
|
||||
outer_id = _insert_user(outer_factory, "outer-admin")
|
||||
inner_id = _insert_user(inner_factory, "inner-admin")
|
||||
_insert_user(inner_factory, "occupied-name")
|
||||
|
||||
with transaction_runner_scope(sync=outer_runner.sync, async_=outer_runner.async_):
|
||||
with pytest.raises(IntegrityError):
|
||||
with transaction_runner_scope(sync=inner_runner.sync, async_=inner_runner.async_):
|
||||
UserOper().update(inner_id, {
|
||||
"name": "occupied-name",
|
||||
"email": "must-rollback@example.invalid",
|
||||
"hashed_password": "must-rollback",
|
||||
})
|
||||
|
||||
with inner_factory() as session:
|
||||
persisted = session.get(User, inner_id)
|
||||
assert persisted is not None
|
||||
assert persisted.name == "inner-admin"
|
||||
assert persisted.email == "before@example.invalid"
|
||||
assert persisted.hashed_password == "old-hash"
|
||||
|
||||
restored = UserOper().update(outer_id, {"email": "restored@example.invalid"})
|
||||
assert restored is not None
|
||||
assert restored.name == "outer-admin"
|
||||
async_user = asyncio.run(UserOper().async_get_by_name("outer-admin"))
|
||||
assert async_user is not None
|
||||
assert async_user.email == "restored@example.invalid"
|
||||
|
||||
with outer_factory() as session:
|
||||
assert session.get(User, outer_id).email == "restored@example.invalid"
|
||||
with inner_factory() as session:
|
||||
assert session.get(User, inner_id).email == "before@example.invalid"
|
||||
Reference in New Issue
Block a user