Files
imSp4rky c7ab04028a feat(setup): add unshackle setup command and cross-platform install scripts
setup downloads the external tools, writes a first config and imports CDM devices; install.sh and install.bat bootstrap uv and run it.
2026-10-01 12:21:24 -06:00

605 lines
24 KiB
Python

"""`unshackle setup`: asset choice, archive extraction, config writing and the user binaries folder."""
from __future__ import annotations
import io
import os
import subprocess
import sys
import tarfile
import zipfile
from pathlib import Path
from types import SimpleNamespace
from typing import Callable, Iterable, Iterator
import pytest
import yaml
from click.testing import CliRunner
from unshackle.commands import setup as setup_mod
from unshackle.commands.env import get_dependencies
from unshackle.core import binaries
from unshackle.core.config import Config, config
EXT = ".exe" if sys.platform == "win32" else ""
@pytest.mark.parametrize(
("tool", "system", "machine", "suffix"),
[
("shaka-packager", "linux", "x86_64", "/v2.6.1/packager-linux-x64"),
("shaka-packager", "linux", "aarch64", "/v2.6.1/packager-linux-arm64"),
("shaka-packager", "win32", "AMD64", "/v2.6.1/packager-win-x64.exe"),
("FFmpeg", "linux", "x86_64", "-linux64-gpl.tar.xz"),
("FFmpeg", "win32", "AMD64", "-win64-gpl.zip"),
("MKVToolNix", "win32", "AMD64", "mkvtoolnix-64-bit-102.0.zip"),
("dovi_tool", "linux", "arm64", "-aarch64-unknown-linux-musl.tar.gz"),
("CCExtractor", "linux", "x86_64", "/v0.96.6/ccextractor-minimal-x86_64.AppImage"),
("CCExtractor", "win32", "AMD64", "/v0.96.6/CCExtractor.0.96.6_win_portable.zip"),
],
)
def test_asset_url_matches_platform(tool: str, system: str, machine: str, suffix: str) -> None:
url = setup_mod.asset_url(tool, system, machine)
assert url is not None and url.startswith("https://") and url.endswith(suffix)
@pytest.mark.parametrize(
("tool", "system", "machine"),
[
("MKVToolNix", "linux", "x86_64"),
("CCExtractor", "linux", "aarch64"),
("mp4decrypt", "linux", "aarch64"),
("shaka-packager", "darwin", "arm64"),
("FFmpeg", "win32", "ARM64"),
],
)
def test_asset_url_is_none_without_a_portable_build(tool: str, system: str, machine: str) -> None:
assert setup_mod.asset_url(tool, system, machine) is None
def test_every_url_and_hint_is_filled_from_the_version() -> None:
for tool, spec in setup_mod.TOOLS.items():
hint = setup_mod.versioned(tool, spec["hint"])
assert hint and "{" not in hint
for key in spec["urls"]:
url = setup_mod.asset_url(tool, *key.split("-", 1))
assert url is not None and url.startswith("https://") and "{" not in url
assert spec["version"].replace(".", "-") in url or spec["version"] in url
def test_label_shows_pinned_versions_only() -> None:
assert setup_mod.label("shaka-packager") == "shaka-packager 2.6.1"
assert setup_mod.label("FFmpeg") == "FFmpeg"
assert setup_mod.label("CCExtractor", "linux") == "CCExtractor 0.96.6"
assert setup_mod.label("CCExtractor", "win32") == "CCExtractor 0.96.6, 88 MB"
def make_zip(path: Path, members: dict[str, bytes]) -> Path:
with zipfile.ZipFile(path, "w") as zf:
for name, data in members.items():
zf.writestr(name, data)
return path
def make_tar(path: Path, members: dict[str, bytes]) -> Path:
with tarfile.open(path, "w:gz") as tf:
for name, data in members.items():
info = tarfile.TarInfo(name)
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
return path
@pytest.mark.parametrize(("archive_name", "make"), [("tool.zip", make_zip), ("tool.tar.gz", make_tar)])
def test_extract_puts_named_members_flat(
tmp_path: Path, archive_name: str, make: Callable[[Path, dict[str, bytes]], Path]
) -> None:
archive = make(
tmp_path / archive_name,
{"pkg/bin/tool": b"tool", "pkg/bin/other": b"other", "pkg/README": b"readme"},
)
dest = tmp_path / "bin"
placed = setup_mod.extract(archive, dest, ("tool",))
assert placed == [dest / "tool"]
assert sorted(p.name for p in dest.iterdir()) == ["tool"]
assert (dest / "tool").read_bytes() == b"tool"
if sys.platform != "win32":
assert os.access(dest / "tool", os.X_OK)
@pytest.mark.parametrize(("archive_name", "make"), [("evil.zip", make_zip), ("evil.tar.gz", make_tar)])
def test_extract_never_writes_outside_dest(
tmp_path: Path, archive_name: str, make: Callable[[Path, dict[str, bytes]], Path]
) -> None:
archive = make(tmp_path / archive_name, {"../../escaped": b"x", "/abs/escaped": b"y"})
dest = tmp_path / "a" / "b" / "bin"
setup_mod.extract(archive, dest, ("escaped",))
assert not (tmp_path / "escaped").exists()
assert not (tmp_path / "a" / "escaped").exists()
assert [p.name for p in dest.iterdir()] == ["escaped"]
def test_extract_moves_a_lone_binary(tmp_path: Path) -> None:
download = tmp_path / "packager-linux-x64"
download.write_bytes(b"elf")
dest = tmp_path / "bin"
assert setup_mod.extract(download, dest, ("packager",)) == [dest / "packager"]
assert not download.exists()
def test_is_clone_follows_pyproject(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
package = tmp_path / "unshackle"
package.mkdir()
monkeypatch.setattr(config.directories, "namespace_dir", package)
assert setup_mod.is_clone() is False
(tmp_path / "pyproject.toml").write_text("")
assert setup_mod.is_clone() is True
def test_write_config_tool_mode_moves_data_dirs(tmp_path: Path) -> None:
path = tmp_path / "config" / "unshackle.yaml"
data_root = tmp_path / "data"
assert setup_mod.write_config(path, tmp_path / "downloads", data_root) is not None
loaded = Config.from_yaml(path)
assert loaded.output_template
assert loaded.directories.downloads == tmp_path / "downloads"
assert loaded.directories.wvds == data_root / "WVDs"
assert loaded.directories.temp == data_root / "temp"
assert loaded.directories.services == [data_root / "services"]
def test_write_config_clone_mode_keeps_package_data_dirs(tmp_path: Path) -> None:
path = tmp_path / "unshackle.yaml"
setup_mod.write_config(path, tmp_path / "downloads", None)
data = yaml.safe_load(path.read_text())
assert data["directories"] == {"downloads": str(tmp_path / "downloads")}
assert Config.from_yaml(path).directories.wvds == Config._Directories.wvds
def test_write_config_never_overwrites(tmp_path: Path) -> None:
path = tmp_path / "unshackle.yaml"
path.write_text("tag: MINE\n")
assert setup_mod.write_config(path, tmp_path / "downloads", tmp_path / "data") is None
assert path.read_text() == "tag: MINE\n"
def make_exe(folder: Path, name: str) -> Path:
folder.mkdir(parents=True, exist_ok=True)
path = folder / f"{name}{EXT}"
path.write_bytes(b"")
path.chmod(0o755)
return path
def test_find_checks_the_user_binaries_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(binaries, "user_binaries_dir", tmp_path / "user")
tool = make_exe(tmp_path / "user", "setup-test-tool")
assert binaries.find("setup-test-tool") == tool
def test_find_prefers_the_package_binaries_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(binaries, "package_binaries_dir", tmp_path / "unshackle" / "binaries")
monkeypatch.setattr(binaries, "user_binaries_dir", tmp_path / "user")
make_exe(tmp_path / "user", "setup-test-tool")
packaged = make_exe(tmp_path / "unshackle" / "binaries", "setup-test-tool")
assert binaries.find("setup-test-tool") == packaged
@pytest.fixture
def asks(monkeypatch: pytest.MonkeyPatch) -> list[str]:
"""Record every Confirm prompt and answer it with no."""
prompts: list[str] = []
def fake_ask(prompt: str, **kwargs: object) -> bool:
prompts.append(prompt)
return False
monkeypatch.setattr(setup_mod.Confirm, "ask", fake_ask)
return prompts
@pytest.mark.parametrize(
("clone", "has_config", "asked", "keep"),
[(False, False, True, False), (True, False, False, True), (False, True, False, True)],
)
def test_only_a_first_tool_mode_run_recommends_a_clone(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
asks: list[str],
clone: bool,
has_config: bool,
asked: bool,
keep: bool,
) -> None:
monkeypatch.setattr(setup_mod, "get_config_path", lambda: tmp_path / "unshackle.yaml" if has_config else None)
assert setup_mod.keep_tool_install(clone=clone) is keep
assert asks == (["Continue with the uv tool install?"] if asked else [])
def test_declining_the_tool_install_stops_setup(monkeypatch: pytest.MonkeyPatch, asks: list[str]) -> None:
def fail() -> None:
raise AssertionError("setup went past the clone prompt")
monkeypatch.setattr(setup_mod, "is_clone", lambda: False)
monkeypatch.setattr(setup_mod, "get_config_path", lambda: None)
monkeypatch.setattr(setup_mod, "get_dependencies", fail)
result = CliRunner().invoke(setup_mod.setup, [])
assert result.exit_code == 0, result.output
assert asks == ["Continue with the uv tool install?"]
REQUIRED_DEPS = {"FFmpeg", "FFprobe", "MKVToolNix", "mkvpropedit", "shaka-packager"}
def fake_deps(missing: Iterable[str]) -> list[dict[str, object]]:
"""Return a `get_dependencies()` result for every TOOLS dep, with the deps of `missing` tools not found."""
return [
{"name": dep, "binary": None if tool in missing else Path(dep), "required": dep in REQUIRED_DEPS}
for tool, spec in setup_mod.TOOLS.items()
for dep in spec["deps"]
]
def test_tools_deps_exist_in_env_check(monkeypatch: pytest.MonkeyPatch) -> None:
# Set in the module dict: a setattr reads the old value first, and that read runs the PATH search.
for attr in set(binaries.__all__) - {"find", "register", "get_registered_dependencies"}:
monkeypatch.setitem(vars(binaries), attr, None)
deps = {dep["name"]: dep["required"] for dep in get_dependencies()}
names = {dep for spec in setup_mod.TOOLS.values() for dep in spec["deps"]}
assert names <= deps.keys()
assert {name for name in names if deps[name]} == REQUIRED_DEPS
@pytest.fixture
def sandbox(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace:
"""Point every path setup touches into tmp_path and record its prompts and installs.
Every tool is missing and downloadable, and each prompt takes its default answer.
"""
box = SimpleNamespace(
package=tmp_path / "clone" / "unshackle",
user_config=tmp_path / "user_config" / "unshackle.yaml",
user_data=tmp_path / "user_data",
prompts=[],
installs=[],
)
box.package.mkdir(parents=True)
(box.package / "unshackle-example.yaml").write_bytes(
(config.directories.namespace_dir / "unshackle-example.yaml").read_bytes()
)
for key, name in setup_mod.DATA_DIRS.items():
monkeypatch.setattr(config.directories, key, box.package / name)
monkeypatch.setattr(config.directories, "services", [box.package / "services"])
monkeypatch.setattr(config.directories, "namespace_dir", box.package)
monkeypatch.setattr(setup_mod, "PACKAGE_CONFIG_PATH", box.package / "unshackle.yaml")
monkeypatch.setattr(setup_mod, "USER_CONFIG_PATH", box.user_config)
monkeypatch.setattr(binaries, "package_binaries_dir", box.package / "binaries")
monkeypatch.setattr(binaries, "user_binaries_dir", box.user_data / "binaries")
monkeypatch.setattr(setup_mod, "is_clone", lambda: True)
monkeypatch.setattr(setup_mod, "get_config_path", lambda: None)
monkeypatch.setattr(setup_mod, "get_dependencies", lambda: fake_deps(setup_mod.TOOLS))
def confirm(prompt: str, default: bool = False, **kwargs: object) -> bool:
box.prompts.append((prompt, default))
return default
monkeypatch.setattr(setup_mod.Confirm, "ask", confirm)
monkeypatch.setattr(setup_mod.Prompt, "ask", lambda prompt, default="", **kwargs: default)
monkeypatch.setattr(setup_mod, "install", lambda tool, url, dest: box.installs.append((tool, dest)) or True)
monkeypatch.setattr(setup_mod, "asset_url", lambda tool: f"https://example.invalid/{tool}")
return box
@pytest.mark.parametrize("clone", [True, False])
def test_setup_writes_into_the_clone_or_the_user_dirs(
sandbox: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, clone: bool
) -> None:
monkeypatch.setattr(setup_mod, "is_clone", lambda: clone)
result = CliRunner().invoke(setup_mod.setup, [])
assert result.exit_code == 0, result.output
if clone:
assert {dest for _, dest in sandbox.installs} == {sandbox.package / "binaries"}
assert (sandbox.package / "unshackle.yaml").is_file()
assert not sandbox.user_config.exists()
assert not sandbox.user_data.exists()
else:
assert {dest for _, dest in sandbox.installs} == {sandbox.user_data / "binaries"}
assert sandbox.user_config.is_file()
assert not (sandbox.package / "unshackle.yaml").exists()
assert (sandbox.user_data / "WVDs").is_dir()
assert [tool for tool, _ in sandbox.installs] == ["FFmpeg", "MKVToolNix", "shaka-packager", "mp4decrypt"]
config_path = sandbox.package / "unshackle.yaml" if clone else sandbox.user_config
assert f"A minimal unshackle.yaml was written to {config_path}." in result.output
assert f"To expand it, see {setup_mod.DOCS_URL}" in result.output
run = "uv run unshackle" if clone else "unshackle"
assert f"Next: run `{run} env check` to confirm the tools." in result.output
@pytest.mark.parametrize(
("missing", "downloadable", "prompt", "default", "installed"),
[
(
{"shaka-packager", "mp4decrypt"},
{"shaka-packager", "mp4decrypt"},
"Install the DRM tools (shaka-packager 2.6.1, mp4decrypt 1.6.0.641) (required)?",
True,
["shaka-packager", "mp4decrypt"],
),
({"mp4decrypt"}, {"mp4decrypt"}, "Install the DRM tools (mp4decrypt 1.6.0.641)?", False, []),
(
{"shaka-packager"},
{"shaka-packager"},
"Install the DRM tools (shaka-packager 2.6.1) (required)?",
True,
["shaka-packager"],
),
(
{"shaka-packager", "mp4decrypt"},
{"shaka-packager"},
"Install the DRM tools (shaka-packager 2.6.1) (required)?",
True,
["shaka-packager"],
),
],
)
def test_drm_tools_share_one_prompt(
sandbox: SimpleNamespace,
monkeypatch: pytest.MonkeyPatch,
missing: set[str],
downloadable: set[str],
prompt: str,
default: bool,
installed: list[str],
) -> None:
monkeypatch.setattr(setup_mod, "get_dependencies", lambda: fake_deps(missing))
monkeypatch.setattr(
setup_mod, "asset_url", lambda tool: f"https://x.invalid/{tool}" if tool in downloadable else None
)
result = CliRunner().invoke(setup_mod.setup, [])
assert result.exit_code == 0, result.output
assert sandbox.prompts == [(prompt, default)]
assert [tool for tool, _ in sandbox.installs] == installed
assert ("mp4decrypt: no download" in result.output) == ("mp4decrypt" in missing - downloadable)
@pytest.fixture
def fake_devices(monkeypatch: pytest.MonkeyPatch) -> None:
"""Accept any device file except one that holds b"bad"."""
def load(path: Path) -> None:
if Path(path).read_bytes() == b"bad":
raise ValueError("bad magic")
monkeypatch.setattr("pywidevine.device.Device.load", load)
monkeypatch.setattr("pyplayready.device.Device.load", load)
def script_cdm_prompts(monkeypatch: pytest.MonkeyPatch, answers: list[str]) -> list[tuple[str, object]]:
"""Answer the CDM prompts in order, then finish; every other prompt takes its default."""
asked: list[tuple[str, object]] = []
def ask(prompt: str, default: str = "", choices: object = None, **kwargs: object) -> str:
asked.append((prompt, choices))
if prompt.startswith("Downloads"):
return default
return answers.pop(0) if answers else default
monkeypatch.setattr(setup_mod.Prompt, "ask", ask)
return asked
def make_devices(folder: Path, files: dict[str, bytes]) -> Path:
folder.mkdir(parents=True, exist_ok=True)
for name, data in files.items():
(folder / name).write_bytes(data)
return folder
def test_device_files_expands_a_folder_without_recursion(tmp_path: Path) -> None:
folder = make_devices(tmp_path / "in", {"a.wvd": b"", "b.wvd": b"", "c.prd": b"", "notes.txt": b""})
make_devices(folder / "sub", {"deep.wvd": b""})
assert setup_mod.device_files(str(folder)) == [folder / "a.wvd", folder / "b.wvd", folder / "c.prd"]
assert setup_mod.device_files(str(make_devices(tmp_path / "empty", {}))) == []
def test_device_files_keeps_quotes_and_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
assert setup_mod.device_files('"~/x.wvd"') == [tmp_path / "x.wvd"]
@pytest.mark.usefixtures("fake_devices")
def test_cdm_prompt_repeats_and_asks_for_the_default(
sandbox: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
folder = make_devices(tmp_path / "in", {"a.wvd": b"", "b.wvd": b"", "c.prd": b"", "notes.txt": b""})
single = make_devices(tmp_path / "other", {"d.wvd": b""}) / "d.wvd"
asked = script_cdm_prompts(monkeypatch, [str(folder), f'"{single}"', "", "c"])
result = CliRunner().invoke(setup_mod.setup, [])
assert result.exit_code == 0, result.output
for name in ("a.wvd", "b.wvd", "c.prd", "d.wvd"):
assert f"Imported: {name}" in result.output
assert [p for p, _ in asked].count(setup_mod.CDM_PROMPT) == 3
assert ("Default CDM", ["a", "b", "c", "d"]) in asked
assert sorted(p.name for p in (sandbox.package / "WVDs").iterdir()) == ["a.wvd", "b.wvd", "d.wvd"]
assert [p.name for p in (sandbox.package / "PRDs").iterdir()] == ["c.prd"]
assert yaml.safe_load((sandbox.package / "unshackle.yaml").read_text())["cdm"] == {"default": "c"}
@pytest.mark.usefixtures("fake_devices")
def test_one_bad_device_does_not_stop_the_rest(
sandbox: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
folder = make_devices(tmp_path / "in", {"bad.wvd": b"bad", "good.wvd": b"", "good2.prd": b""})
asked = script_cdm_prompts(monkeypatch, [str(folder), "", "good"])
result = CliRunner().invoke(setup_mod.setup, [])
assert result.exit_code == 0, result.output
assert "Skipped bad.wvd: not a valid Widevine device (bad magic)" in result.output
assert "Imported: good.wvd" in result.output
assert "Imported: good2.prd" in result.output
assert ("Default CDM", ["good", "good2"]) in asked
assert not (sandbox.package / "WVDs" / "bad.wvd").exists()
@pytest.mark.usefixtures("fake_devices")
def test_folder_without_devices_says_so(
sandbox: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
# Longer than the 80-column console, so a wrapped line would split the path.
folder = make_devices(tmp_path / ("long" * 20) / "in", {"notes.txt": b""})
script_cdm_prompts(monkeypatch, [str(folder)])
result = CliRunner().invoke(setup_mod.setup, [])
assert result.exit_code == 0, result.output
assert f"No .wvd or .prd files in {folder}" in result.output
assert "cdm" not in yaml.safe_load((sandbox.package / "unshackle.yaml").read_text())
@pytest.mark.usefixtures("fake_devices")
def test_destination_folder_is_already_in_place(sandbox: SimpleNamespace, monkeypatch: pytest.MonkeyPatch) -> None:
wvds = make_devices(sandbox.package / "WVDs", {"x.wvd": b"device"})
asked = script_cdm_prompts(monkeypatch, [str(wvds)])
result = CliRunner().invoke(setup_mod.setup, [])
assert result.exit_code == 0, result.output
assert "Already in place: x.wvd" in result.output
assert (wvds / "x.wvd").read_bytes() == b"device"
assert all(prompt != "Default CDM" for prompt, _ in asked)
assert yaml.safe_load((sandbox.package / "unshackle.yaml").read_text())["cdm"] == {"default": "x"}
@pytest.mark.usefixtures("fake_devices")
def test_existing_config_is_never_edited(
sandbox: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
existing = sandbox.package / "unshackle.yaml"
existing.write_text("tag: MINE\n")
monkeypatch.setattr(setup_mod, "get_config_path", lambda: existing)
folder = make_devices(tmp_path / "in", {"a.wvd": b"", "b.wvd": b""})
asked = script_cdm_prompts(monkeypatch, [str(folder)])
result = CliRunner().invoke(setup_mod.setup, [])
assert result.exit_code == 0, result.output
assert "Imported: b.wvd" in result.output
assert existing.read_text() == "tag: MINE\n"
assert "A minimal unshackle.yaml" not in result.output
assert all(prompt != "Default CDM" for prompt, _ in asked)
def test_extract_puts_a_tool_and_its_dlls_in_a_subfolder(tmp_path: Path) -> None:
spec = setup_mod.platform_spec("CCExtractor", "win32")
archive = make_zip(
tmp_path / "ccx.zip",
{
name: b"x"
for name in (
"ccextractorwinfull.exe",
"avcodec-60.dll",
"vcruntime140.dll",
"flutter_windows.dll",
"url_launcher_windows_plugin.dll",
"ccxgui.exe",
"data/app.so",
"tessdata/eng.traineddata",
)
},
)
dest = tmp_path / "bin" / spec["folder"]
placed = setup_mod.extract(archive, dest, ("ccextractorwinfull.exe",), spec["extra"], spec["exclude"])
assert placed == [dest / "ccextractorwinfull.exe"]
assert sorted(p.name for p in dest.iterdir()) == ["avcodec-60.dll", "ccextractorwinfull.exe", "vcruntime140.dll"]
assert [p.name for p in (tmp_path / "bin").iterdir()] == ["ccextractor"]
def test_find_resolves_a_tool_in_its_subfolder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(binaries, "user_binaries_dir", tmp_path / "user")
tool = make_exe(tmp_path / "user" / "ccextractor", "setup-test-ccx")
assert binaries.find("setup-test-ccx") == tool
@pytest.fixture
def fake_appimage(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Add a tool that downloads as an AppImage, and serve its download from tmp_path."""
monkeypatch.setitem(
setup_mod.TOOLS,
"FakeImage",
{"version": "1.0", "bins": ("fakeimage",), "urls": {}, "hint": "see the fake docs"},
)
def fake_requests(url: str, output_dir: Path, filename: str, **kwargs: object) -> Iterator[dict[str, Path]]:
output_dir.mkdir(parents=True, exist_ok=True)
(output_dir / filename).write_bytes(b"appimage")
yield {"file_downloaded": output_dir / filename}
monkeypatch.setattr(setup_mod, "requests", fake_requests)
monkeypatch.setattr(setup_mod, "session", lambda: None)
return tmp_path / "bin"
@pytest.mark.parametrize("returncode", [0, 1])
def test_appimage_is_kept_only_when_it_runs(
fake_appimage: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], returncode: int
) -> None:
calls: list[list[str]] = []
def fake_run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
calls.append(args)
assert kwargs["text"] and kwargs["encoding"] == "utf-8" and kwargs["errors"] == "replace"
return subprocess.CompletedProcess(args, returncode, "", "No suitable fusermount binary found")
monkeypatch.setattr(setup_mod.subprocess, "run", fake_run)
ok = setup_mod.install("FakeImage", "https://x.invalid/FakeImage.AppImage", fake_appimage)
binary = fake_appimage / f"fakeimage{EXT}"
assert calls == [[str(binary), "--version"]]
assert ok is (returncode == 0)
assert binary.exists() is (returncode == 0)
if returncode:
output = capsys.readouterr().out
assert "the AppImage does not run, it needs FUSE" in output
assert "Install it manually: see the fake docs" in output
def test_group_prompt_shows_the_download_size(sandbox: SimpleNamespace, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setitem(setup_mod.TOOLS["CCExtractor"], "size", "88 MB")
monkeypatch.setattr(setup_mod, "get_dependencies", lambda: fake_deps({"SubtitleEdit", "CCExtractor"}))
result = CliRunner().invoke(setup_mod.setup, [])
assert result.exit_code == 0, result.output
assert sandbox.prompts == [("Install the Subtitle tools (SubtitleEdit 5.2.0, CCExtractor 0.96.6, 88 MB)?", False)]