Add uninstall workflow to local CLI

This commit is contained in:
jxxghp
2026-04-20 13:38:06 +08:00
parent 4ba8d42272
commit 93a19b467b
5 changed files with 600 additions and 4 deletions
+23
View File
@@ -41,6 +41,8 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootst
- macOS`~/Library/Application Support/MoviePilot` - macOS`~/Library/Application Support/MoviePilot`
- Linux`${XDG_CONFIG_HOME:-~/.config}/moviepilot` - Linux`${XDG_CONFIG_HOME:-~/.config}/moviepilot`
如果在交互式终端中执行一键安装脚本,或直接执行 `moviepilot setup` / `moviepilot init` 且未传入 `--config-dir`,程序会先询问配置目录,并把上面的默认路径作为默认值展示出来。
可以在安装或初始化时手动指定: 可以在安装或初始化时手动指定:
```shell ```shell
@@ -80,6 +82,7 @@ moviepilot commands
moviepilot help install moviepilot help install
moviepilot help init moviepilot help init
moviepilot help setup moviepilot help setup
moviepilot help uninstall
moviepilot help update moviepilot help update
moviepilot help agent moviepilot help agent
moviepilot help config moviepilot help config
@@ -111,6 +114,7 @@ moviepilot install frontend
moviepilot install resources moviepilot install resources
moviepilot init moviepilot init
moviepilot setup moviepilot setup
moviepilot uninstall
moviepilot update backend moviepilot update backend
moviepilot update frontend moviepilot update frontend
moviepilot update all moviepilot update all
@@ -244,6 +248,25 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootst
- `--superuser-password` 更适合自动化场景,命令可能会出现在 shell 历史中 - `--superuser-password` 更适合自动化场景,命令可能会出现在 shell 历史中
- 交互式 `--wizard` 会在初始化过程中提示输入超级管理员用户名和密码 - 交互式 `--wizard` 会在初始化过程中提示输入超级管理员用户名和密码
## 卸载命令
卸载本地安装产物:
```shell
moviepilot uninstall
moviepilot uninstall --venv /path/to/venv
moviepilot uninstall --config-dir /path/to/moviepilot-config
```
说明:
- 卸载时会先停止当前 CLI 管理的前后端服务
- 会删除本地虚拟环境、前端运行时、本地 Node 运行时、全局 `moviepilot` 软链接,以及同步到 `app/helper` 的资源文件
- 会询问是否同时删除配置目录,默认不删除
- 如果当前使用的是仓库内 legacy `config/` 目录,确认删除后其中的 `category.yaml` 等配置文件也会一起删除
- 整个卸载流程包含两次确认
- 源码目录会保留,如需彻底移除仓库请在确认后手动删除项目目录
## 更新命令 ## 更新命令
更新后端: 更新后端:
+30
View File
@@ -14,6 +14,7 @@ Bootstrap Commands:
moviepilot install resources [--resources-repo PATH] [--resource-dir PATH] [--config-dir PATH] moviepilot install resources [--resources-repo PATH] [--resource-dir PATH] [--config-dir PATH]
moviepilot init [--skip-resources] [--force-token] [--wizard] [--superuser NAME] [--superuser-password PASSWORD] [--config-dir PATH] moviepilot init [--skip-resources] [--force-token] [--wizard] [--superuser NAME] [--superuser-password PASSWORD] [--config-dir PATH]
moviepilot setup [--python PYTHON] [--venv PATH] [--recreate] [--frontend-version latest] [--node-version 20.12.1] [--wizard] [--superuser NAME] [--superuser-password PASSWORD] [--config-dir PATH] moviepilot setup [--python PYTHON] [--venv PATH] [--recreate] [--frontend-version latest] [--node-version 20.12.1] [--wizard] [--superuser NAME] [--superuser-password PASSWORD] [--config-dir PATH]
moviepilot uninstall [--venv PATH] [--config-dir PATH]
moviepilot update {backend|frontend|all} [OPTIONS] moviepilot update {backend|frontend|all} [OPTIONS]
moviepilot agent [OPTIONS] MESSAGE... moviepilot agent [OPTIONS] MESSAGE...
@@ -27,6 +28,7 @@ Discovery Commands:
moviepilot help moviepilot help
moviepilot help config moviepilot help config
moviepilot help install moviepilot help install
moviepilot help uninstall
moviepilot help update moviepilot help update
moviepilot commands moviepilot commands
@@ -35,6 +37,7 @@ Examples:
moviepilot install frontend moviepilot install frontend
moviepilot install resources moviepilot install resources
moviepilot setup --wizard moviepilot setup --wizard
moviepilot uninstall
moviepilot update all moviepilot update all
moviepilot agent 帮我分析最近一次搜索失败 moviepilot agent 帮我分析最近一次搜索失败
moviepilot help config moviepilot help config
@@ -52,6 +55,7 @@ Bootstrap Commands
install resources install resources
init init
setup setup
uninstall
update backend update backend
update frontend update frontend
update all update all
@@ -145,6 +149,22 @@ Options:
EOF EOF
} }
show_uninstall_help() {
cat <<'EOF'
Usage: moviepilot uninstall [OPTIONS]
Options:
--venv PATH 虚拟环境目录,默认 ./venv
--config-dir PATH 指定配置目录,默认使用当前安装配置
-h, --help 显示帮助
说明:
- 默认保留配置目录,过程中会询问是否删除
- 卸载时会进行两次确认
- 源码目录不会被删除
EOF
}
show_update_help() { show_update_help() {
cat <<'EOF' cat <<'EOF'
Usage: Usage:
@@ -296,6 +316,10 @@ show_command_help() {
show_setup_help show_setup_help
exit 0 exit 0
;; ;;
uninstall)
show_uninstall_help
exit 0
;;
agent) agent)
show_agent_help show_agent_help
exit 0 exit 0
@@ -397,6 +421,12 @@ case "${1:-}" in
require_bootstrap_python require_bootstrap_python
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" setup "$@" exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" setup "$@"
;; ;;
uninstall)
shift
require_bootstrap_python
COMMAND_PATH="$(command -v moviepilot 2>/dev/null || true)"
MOVIEPILOT_LAUNCH_PATH="$0" MOVIEPILOT_COMMAND_PATH="$COMMAND_PATH" exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" uninstall "$@"
;;
update) update)
shift shift
require_bootstrap_python require_bootstrap_python
+317 -4
View File
@@ -17,6 +17,7 @@ import sys
import tarfile import tarfile
import uuid import uuid
import zipfile import zipfile
from datetime import datetime
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import Any, Optional from typing import Any, Optional
@@ -85,6 +86,8 @@ NOTIFICATION_SWITCH_TYPES = [
"智能体", "智能体",
"其它", "其它",
] ]
UNINSTALL_CONFIRM_TEXT = "UNINSTALL"
RESOURCE_FILE_PATTERNS = ("sites*", "user.sites*.bin")
def _default_config_dir() -> Path: def _default_config_dir() -> Path:
@@ -201,6 +204,31 @@ def configure_config_dir(
return config_dir return config_dir
def resolve_config_dir(
explicit: Optional[Path] = None,
*,
prefer_external: bool = False,
) -> Path:
"""
解析当前命令应使用的配置目录,但不写入环境变量或安装元数据。
该函数用于交互式命令在真正持久化配置目录前,先给用户展示默认值。
"""
if explicit:
return explicit.expanduser().resolve()
if os.getenv("CONFIG_DIR"):
return Path(os.environ["CONFIG_DIR"]).expanduser().resolve()
install_env_dir = _read_install_env_config_dir()
if install_env_dir:
return install_env_dir.resolve()
if prefer_external:
return _default_config_dir().resolve()
if _legacy_runtime_config_exists():
return LEGACY_CONFIG_DIR.resolve()
return _default_config_dir().resolve()
configure_config_dir() configure_config_dir()
@@ -842,6 +870,23 @@ def _prompt_path(label: str, *, default: Path, allow_empty: bool = False) -> str
return str(Path(value).expanduser().resolve()) return str(Path(value).expanduser().resolve())
def _resolve_interactive_config_dir(
command: str, explicit_config_dir: Optional[Path]
) -> Optional[Path]:
"""
`setup` / `init` 是最常见的本地安装入口。
当用户没有显式传入 `--config-dir` 且当前终端可交互时,先询问一次配置目录,
并把程序外默认路径展示出来,避免用户安装后才发现配置写到了别处。
"""
if explicit_config_dir or command not in {"init", "setup"} or not _is_interactive():
return explicit_config_dir
default_config_dir = resolve_config_dir(prefer_external=True)
print_step("安装将使用程序目录外的配置目录,直接回车可接受默认值")
selected_path = _prompt_path("配置目录", default=default_config_dir)
return Path(selected_path) if selected_path else default_config_dir
def _validate_superuser_name(username: str) -> Optional[str]: def _validate_superuser_name(username: str) -> Optional[str]:
if not username: if not username:
return "超级管理员用户名不能为空。" return "超级管理员用户名不能为空。"
@@ -1843,6 +1888,22 @@ def _pid_exists(pid: int) -> bool:
return True return True
def _read_process_start_time(pid: int) -> Optional[float]:
try:
output = capture(["ps", "-p", str(pid), "-o", "lstart="])
except (OSError, subprocess.CalledProcessError):
return None
started = output.strip()
if not started:
return None
try:
return datetime.strptime(started, "%a %b %d %H:%M:%S %Y").timestamp()
except ValueError:
return None
def _services_running() -> list[str]: def _services_running() -> list[str]:
running: list[str] = [] running: list[str] = []
runtime_files = { runtime_files = {
@@ -1852,8 +1913,27 @@ def _services_running() -> list[str]:
for name, runtime_file in runtime_files.items(): for name, runtime_file in runtime_files.items():
payload = _read_runtime_file(runtime_file) payload = _read_runtime_file(runtime_file)
pid = payload.get("pid") if isinstance(payload, dict) else None pid = payload.get("pid") if isinstance(payload, dict) else None
if pid and _pid_exists(int(pid)): if not pid:
running.append(name) continue
try:
pid_int = int(pid)
except (TypeError, ValueError):
continue
if not _pid_exists(pid_int):
continue
runtime_start_time = payload.get("create_time") if isinstance(payload, dict) else None
process_start_time = _read_process_start_time(pid_int)
if runtime_start_time is not None and process_start_time is not None:
try:
if abs(process_start_time - float(runtime_start_time)) > 3:
continue
except (TypeError, ValueError):
pass
running.append(name)
return running return running
@@ -1866,6 +1946,208 @@ def ensure_services_stopped() -> None:
) )
def _stop_managed_services(venv_dir: Path) -> None:
venv_dir = venv_dir.expanduser().resolve()
venv_python = get_venv_python(venv_dir)
if venv_python.exists():
print_step("停止本地前后端服务")
run(
[str(venv_python), "-m", "app.cli", "stop", "--timeout", "30", "--force"],
cwd=ROOT,
)
return
running = _services_running()
if running:
raise RuntimeError(
"检测到本地服务仍在运行(%s),但当前未找到虚拟环境 %s,无法安全停止。"
" 请先执行 `moviepilot stop`,或在卸载时通过 `--venv PATH` 指定正确的虚拟环境目录。"
% (", ".join(running), venv_dir)
)
def _collect_cli_link_candidates(
*, command_path: Optional[str] = None, launch_path: Optional[str] = None
) -> list[Path]:
candidates: list[Path] = []
seen: set[str] = set()
raw_candidates = [
launch_path,
command_path,
os.getenv("MOVIEPILOT_LAUNCH_PATH"),
os.getenv("MOVIEPILOT_COMMAND_PATH"),
shutil.which("moviepilot"),
]
for raw_value in raw_candidates:
if not raw_value:
continue
candidate = Path(raw_value).expanduser()
if not candidate.is_absolute():
candidate = (ROOT / candidate).resolve()
try:
key = str(candidate.resolve())
except OSError:
key = str(candidate)
if key in seen:
continue
seen.add(key)
candidates.append(candidate)
return candidates
def _remove_cli_symlinks(
*, command_path: Optional[str] = None, launch_path: Optional[str] = None
) -> list[Path]:
removed: list[Path] = []
script_path = (ROOT / "moviepilot").resolve()
for candidate in _collect_cli_link_candidates(
command_path=command_path, launch_path=launch_path
):
if not candidate.is_symlink():
continue
try:
if candidate.resolve() != script_path:
continue
except OSError:
continue
candidate.unlink()
removed.append(candidate)
return removed
def _remove_runtime_state_files() -> list[Path]:
removed: list[Path] = []
for path in (
TEMP_DIR / "moviepilot.runtime.json",
TEMP_DIR / "moviepilot.frontend.runtime.json",
):
if not path.exists():
continue
_remove_path(path)
removed.append(path)
return removed
def _remove_installed_resource_files() -> list[Path]:
removed: list[Path] = []
seen: set[Path] = set()
for pattern in RESOURCE_FILE_PATTERNS:
for path in sorted(HELPER_DIR.glob(pattern)):
if path in seen or not path.exists() or path.is_dir():
continue
_remove_path(path)
removed.append(path)
seen.add(path)
return removed
def _remove_config_data(config_dir: Path) -> list[Path]:
config_dir = config_dir.expanduser().resolve()
removed: list[Path] = []
if config_dir.exists():
_remove_path(config_dir)
removed.append(config_dir)
return removed
def uninstall_local(
*,
venv_dir: Path,
config_dir: Path,
command_path: Optional[str] = None,
launch_path: Optional[str] = None,
) -> dict[str, Any]:
if not _is_interactive():
raise RuntimeError("卸载命令需要在交互式终端中运行,以完成两次确认。")
venv_dir = venv_dir.expanduser().resolve()
config_dir = config_dir.expanduser().resolve()
cli_links = _collect_cli_link_candidates(
command_path=command_path, launch_path=launch_path
)
script_path = (ROOT / "moviepilot").resolve()
linked_cli_paths = [
path
for path in cli_links
if path.is_symlink() and path.exists() and path.resolve() == script_path
]
delete_config = _prompt_yes_no(
f"是否同时删除配置目录 {config_dir}", default=False
)
print_step("卸载将执行以下操作")
print(f" - 保留源码目录:{ROOT}")
print(f" - 删除虚拟环境:{venv_dir}")
print(f" - 删除前端运行时目录:{PUBLIC_DIR}")
print(f" - 删除本地 Node 运行时目录:{RUNTIME_DIR}")
print(f" - 删除资源文件:{HELPER_DIR}/sites*、{HELPER_DIR}/user.sites*.bin")
if linked_cli_paths:
print(" - 删除全局 CLI 软链接:")
for path in linked_cli_paths:
print(f" {path}")
else:
print(" - 未检测到指向当前仓库的全局 CLI 软链接")
if delete_config:
print(f" - 删除配置目录:{config_dir}")
if config_dir == LEGACY_CONFIG_DIR.resolve():
print(" 包括 legacy config 目录中的 category.yaml 等配置文件")
else:
print(f" - 保留配置目录:{config_dir}")
if not _prompt_yes_no("第一次确认:是否继续卸载 MoviePilot", default=False):
print_step("已取消卸载")
return {"cancelled": True}
confirm_text = _prompt_text(
f"第二次确认:请输入 {UNINSTALL_CONFIRM_TEXT} 以继续",
allow_empty=False,
)
if confirm_text != UNINSTALL_CONFIRM_TEXT:
print_step("确认文本不匹配,已取消卸载")
return {"cancelled": True}
_stop_managed_services(venv_dir=venv_dir)
removed_paths: list[Path] = []
removed_paths.extend(
_remove_cli_symlinks(command_path=command_path, launch_path=launch_path)
)
removed_paths.extend(_remove_runtime_state_files())
removed_paths.extend(_remove_installed_resource_files())
for path in (venv_dir, RUNTIME_DIR, PUBLIC_DIR):
if not path.exists():
continue
_remove_path(path)
removed_paths.append(path)
removed_config_paths: list[Path] = []
if delete_config:
removed_config_paths = _remove_config_data(config_dir)
removed_paths.extend(removed_config_paths)
if INSTALL_ENV_FILE.exists():
_remove_path(INSTALL_ENV_FILE)
removed_paths.append(INSTALL_ENV_FILE)
print_step("卸载完成")
if delete_config:
print_step(f"已删除配置目录:{config_dir}")
else:
print_step(f"已保留配置目录:{config_dir}")
print_step(f"源码目录仍保留在:{ROOT}")
return {
"cancelled": False,
"config_deleted": delete_config,
"removed_paths": [str(path) for path in removed_paths],
"removed_config_paths": [str(path) for path in removed_config_paths],
}
def _git_output(*args: str) -> str: def _git_output(*args: str) -> str:
return capture(["git", *args], cwd=ROOT) return capture(["git", *args], cwd=ROOT)
@@ -2068,6 +2350,16 @@ def build_parser() -> argparse.ArgumentParser:
"--config-dir", help="配置目录,默认使用程序目录外的系统配置目录" "--config-dir", help="配置目录,默认使用程序目录外的系统配置目录"
) )
uninstall_parser = subparsers.add_parser(
"uninstall", help="卸载本地安装产物,并可选删除配置目录"
)
uninstall_parser.add_argument(
"--venv", default=str(ROOT / "venv"), help="虚拟环境目录"
)
uninstall_parser.add_argument(
"--config-dir", help="配置目录,默认使用当前安装配置"
)
agent_parser = subparsers.add_parser( agent_parser = subparsers.add_parser(
"agent", help="直接向 MoviePilot 智能体发送一次请求" "agent", help="直接向 MoviePilot 智能体发送一次请求"
) )
@@ -2140,10 +2432,22 @@ def main() -> int:
explicit_config_dir = ( explicit_config_dir = (
Path(args.config_dir) if getattr(args, "config_dir", None) else None Path(args.config_dir) if getattr(args, "config_dir", None) else None
) )
explicit_config_dir = _resolve_interactive_config_dir(
args.command, explicit_config_dir
)
persist_config_commands = {
"install-deps",
"install-frontend",
"install-resources",
"init",
"setup",
"agent",
"update",
}
config_dir = configure_config_dir( config_dir = configure_config_dir(
explicit=explicit_config_dir, explicit=explicit_config_dir,
persist=True, persist=args.command in persist_config_commands,
prefer_external=True, prefer_external=args.command in persist_config_commands,
) )
try: try:
@@ -2226,6 +2530,15 @@ def main() -> int:
print_step(f"当前配置目录:{config_dir}") print_step(f"当前配置目录:{config_dir}")
return 0 return 0
if args.command == "uninstall":
uninstall_local(
venv_dir=Path(args.venv),
config_dir=config_dir,
command_path=os.getenv("MOVIEPILOT_COMMAND_PATH"),
launch_path=os.getenv("MOVIEPILOT_LAUNCH_PATH"),
)
return 0
if args.command == "agent": if args.command == "agent":
result = run_agent_request( result = run_agent_request(
message=" ".join(args.message), message=" ".join(args.message),
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import importlib.util
import unittest
import uuid
from pathlib import Path
from unittest.mock import patch
MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "local_setup.py"
def load_local_setup_module():
module_name = f"moviepilot_local_setup_config_{uuid.uuid4().hex}"
spec = importlib.util.spec_from_file_location(module_name, MODULE_PATH)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
class LocalSetupConfigDirTests(unittest.TestCase):
def test_setup_prompts_for_config_dir_when_not_provided(self):
module = load_local_setup_module()
default_dir = Path("/tmp/default-moviepilot-config")
custom_dir = Path("/tmp/custom-moviepilot-config")
with patch.object(module, "_is_interactive", return_value=True), patch.object(
module, "resolve_config_dir", return_value=default_dir
), patch.object(
module, "_prompt_path", return_value=str(custom_dir)
):
result = module._resolve_interactive_config_dir("setup", None)
self.assertEqual(result, custom_dir)
def test_setup_keeps_default_config_dir_when_user_accepts_default(self):
module = load_local_setup_module()
default_dir = Path("/tmp/default-moviepilot-config")
with patch.object(module, "_is_interactive", return_value=True), patch.object(
module, "resolve_config_dir", return_value=default_dir
), patch.object(
module, "_prompt_path", return_value=str(default_dir)
):
result = module._resolve_interactive_config_dir("init", None)
self.assertEqual(result, default_dir)
def test_non_setup_command_does_not_prompt_for_config_dir(self):
module = load_local_setup_module()
with patch.object(module, "_is_interactive", return_value=True), patch.object(
module, "_prompt_path"
) as prompt_mock:
result = module._resolve_interactive_config_dir("install-deps", None)
self.assertIsNone(result)
prompt_mock.assert_not_called()
if __name__ == "__main__":
unittest.main()
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
import importlib.util
import tempfile
import unittest
import uuid
from contextlib import ExitStack
from pathlib import Path
from unittest.mock import patch
MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "local_setup.py"
def load_local_setup_module():
module_name = f"moviepilot_local_setup_{uuid.uuid4().hex}"
spec = importlib.util.spec_from_file_location(module_name, MODULE_PATH)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
class LocalSetupUninstallTests(unittest.TestCase):
def prepare_install_tree(self, *, legacy_config: bool = False):
module = load_local_setup_module()
temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(temp_dir.cleanup)
temp_path = Path(temp_dir.name)
root_dir = temp_path / "MoviePilot"
helper_dir = root_dir / "app" / "helper"
runtime_dir = root_dir / ".runtime"
public_dir = root_dir / "public"
venv_dir = root_dir / "venv"
install_env_file = root_dir / ".moviepilot.env"
config_dir = root_dir / "config" if legacy_config else temp_path / "moviepilot-config"
temp_config_dir = config_dir / "temp"
helper_dir.mkdir(parents=True)
runtime_dir.mkdir(parents=True)
public_dir.mkdir(parents=True)
venv_dir.mkdir(parents=True)
temp_config_dir.mkdir(parents=True)
install_env_file.write_text("CONFIG_DIR=/tmp/moviepilot-config\n", encoding="utf-8")
(root_dir / "moviepilot").write_text("#!/usr/bin/env bash\n", encoding="utf-8")
(helper_dir / "sites.py").write_text("generated\n", encoding="utf-8")
(helper_dir / "user.sites.v2.bin").write_bytes(b"binary")
(temp_config_dir / "moviepilot.runtime.json").write_text("{}", encoding="utf-8")
(temp_config_dir / "moviepilot.frontend.runtime.json").write_text(
"{}", encoding="utf-8"
)
stack = ExitStack()
self.addCleanup(stack.close)
stack.enter_context(patch.object(module, "ROOT", root_dir))
stack.enter_context(patch.object(module, "HELPER_DIR", helper_dir))
stack.enter_context(patch.object(module, "RUNTIME_DIR", runtime_dir))
stack.enter_context(patch.object(module, "PUBLIC_DIR", public_dir))
stack.enter_context(patch.object(module, "INSTALL_ENV_FILE", install_env_file))
stack.enter_context(patch.object(module, "LEGACY_CONFIG_DIR", root_dir / "config"))
stack.enter_context(patch.object(module, "CONFIG_DIR", config_dir))
stack.enter_context(patch.object(module, "TEMP_DIR", temp_config_dir))
return module, root_dir, config_dir, venv_dir, install_env_file
def test_remove_config_data_deletes_legacy_config_directory(self):
module, _, config_dir, _, _ = self.prepare_install_tree(legacy_config=True)
category_file = config_dir / "category.yaml"
category_file.write_text("seed\n", encoding="utf-8")
(config_dir / "logs").mkdir(exist_ok=True)
(config_dir / "user.db").write_text("db\n", encoding="utf-8")
removed = module._remove_config_data(config_dir)
self.assertFalse(config_dir.exists())
self.assertFalse(category_file.exists())
self.assertIn(
str(config_dir.resolve()),
{str(path.resolve()) for path in removed},
)
def test_uninstall_keeps_config_by_default(self):
module, root_dir, config_dir, venv_dir, install_env_file = self.prepare_install_tree()
cli_dir = root_dir.parent / "bin"
cli_dir.mkdir()
cli_link = cli_dir / "moviepilot"
cli_link.symlink_to(root_dir / "moviepilot")
yes_no_answers = iter([False, True])
with patch.object(module, "_is_interactive", return_value=True), patch.object(
module,
"_prompt_yes_no",
side_effect=lambda label, default=False: next(yes_no_answers),
), patch.object(
module,
"_prompt_text",
side_effect=lambda label, default=None, allow_empty=False, secret=False: module.UNINSTALL_CONFIRM_TEXT,
), patch.object(module, "_stop_managed_services", return_value=None):
result = module.uninstall_local(
venv_dir=venv_dir,
config_dir=config_dir,
launch_path=str(cli_link),
)
self.assertFalse(result["cancelled"])
self.assertTrue(config_dir.exists())
self.assertTrue(install_env_file.exists())
self.assertFalse(venv_dir.exists())
self.assertFalse((root_dir / ".runtime").exists())
self.assertFalse((root_dir / "public").exists())
self.assertFalse((root_dir / "app" / "helper" / "sites.py").exists())
self.assertFalse((root_dir / "app" / "helper" / "user.sites.v2.bin").exists())
self.assertFalse(cli_link.exists())
def test_uninstall_deletes_external_config_when_requested(self):
module, _, config_dir, venv_dir, install_env_file = self.prepare_install_tree()
yes_no_answers = iter([True, True])
with patch.object(module, "_is_interactive", return_value=True), patch.object(
module,
"_prompt_yes_no",
side_effect=lambda label, default=False: next(yes_no_answers),
), patch.object(
module,
"_prompt_text",
side_effect=lambda label, default=None, allow_empty=False, secret=False: module.UNINSTALL_CONFIRM_TEXT,
), patch.object(module, "_stop_managed_services", return_value=None):
result = module.uninstall_local(
venv_dir=venv_dir,
config_dir=config_dir,
)
self.assertFalse(result["cancelled"])
self.assertTrue(result["config_deleted"])
self.assertFalse(config_dir.exists())
self.assertFalse(install_env_file.exists())
def test_uninstall_deletes_legacy_config_when_requested(self):
module, _, config_dir, venv_dir, install_env_file = self.prepare_install_tree(
legacy_config=True
)
(config_dir / "category.yaml").write_text("seed\n", encoding="utf-8")
yes_no_answers = iter([True, True])
with patch.object(module, "_is_interactive", return_value=True), patch.object(
module,
"_prompt_yes_no",
side_effect=lambda label, default=False: next(yes_no_answers),
), patch.object(
module,
"_prompt_text",
side_effect=lambda label, default=None, allow_empty=False, secret=False: module.UNINSTALL_CONFIRM_TEXT,
), patch.object(module, "_stop_managed_services", return_value=None):
result = module.uninstall_local(
venv_dir=venv_dir,
config_dir=config_dir,
)
self.assertFalse(result["cancelled"])
self.assertTrue(result["config_deleted"])
self.assertFalse(config_dir.exists())
self.assertFalse(install_env_file.exists())
if __name__ == "__main__":
unittest.main()