feat(env): show tool versions in env check, paths with --verbose

binary_version moves to core/binaries and reads the version from the tool's own banner line; only mp4decrypt is run with no arguments.
This commit is contained in:
imSp4rky
2026-11-02 13:37:50 -07:00
parent 31328dc109
commit dd3862be8a
6 changed files with 135 additions and 29 deletions
+2 -1
View File
@@ -313,7 +313,8 @@ reports which tools it found:
It prints a table grouped by category (Core, DRM, HDR, Subtitle, Player,
Network) with a status column: a green check for tools that resolved and a red
cross for those it could not find, plus a summary line such as
cross for those it could not find. A version column shows the version that each
tool reports, or `-` when it reports none. It ends with a summary line such as
`All required tools installed ✓` or `Missing required: <names>`.
If a required tool shows as missing, `env check` tells you to run
+6 -2
View File
@@ -364,10 +364,14 @@ unshackle env COMMAND [ARGS]...
### `env check`
```
unshackle env check
unshackle env check [--verbose]
```
Prints a dependency table (Category / Tool / Status / Required / Purpose) that shows which external tools unshackle finds on your `PATH`. It also gives a summary of how many required tools are present. If a required tool is missing, it tells you to run [`unshackle setup`](#setup).
| Option | Description |
|---|---|
| `--verbose` | Adds a Path column with the file that unshackle loads for each tool. Use it to find a tool that you want to replace. |
Prints a dependency table (Category / Tool / Status / Version / Required / Purpose) that shows which external tools unshackle finds on your `PATH` and the version that each one reports. The version is `-` when a tool does not report one. It also gives a summary of how many required tools are present. If a required tool is missing, it tells you to run [`unshackle setup`](#setup).
| Category | Tools |
|---|---|
+61
View File
@@ -0,0 +1,61 @@
import subprocess
from pathlib import Path
from typing import Any
import pytest
from unshackle.core import binaries
from unshackle.core.binaries import binary_version, parse_version
@pytest.mark.parametrize(
("text", "expected"),
[
("ffmpeg version n9.0.1-11-ge47273f4d9 Copyright (c) 2000-2026\nbuilt with gcc 15.2.0", "9.0.1"),
("ffmpeg version 2024-03-07-git-97beb63a66-full_build\n built with gcc 12.2.0", None),
("ffmpeg version N-113672-g1e2a6dbb46-20240307\n built with gcc 13.2.0", None),
("packager-linux-x64 version v2.6.1-634af65-release", "2.6.1"),
("MP4 Decrypter - Version 1.4\n(Bento4 Version 1.6.0.0)", "1.4"),
("mkvmerge v88.0 ('All I Know') 64-bit", "88.0"),
("mpv 0.34.1 Copyright 2000-2021\nFFmpeg library versions:\n libavutil 56.70.100", "0.34.1"),
("mpv 0.34.1 Copyright 2000-2021\nFFmpeg version: 4.4.2", "0.34.1"),
("\nffmpeg version 7.0.2-static Copyright (c) 2000-2024", "7.0.2"),
("unknown option: --version\nusage: tool [--listen 127.0.0.1:8080]", None),
("dovi_tool e617c48", None),
("CCExtractor 0.93, Carlos Fernandez Sanz\nCCExtractor detailed version info\n\tVersion: 0.93", "0.93"),
],
)
def test_parse_version(text: str, expected: str | None) -> None:
assert parse_version(text) == expected
def test_bare_call_only_for_listed_tools(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[list[str]] = []
def run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess:
calls.append(cmd)
out = "MP4 Decrypter - Version 1.4\nusage: tool [options]" if len(cmd) == 1 else "ERROR: missing output"
return subprocess.CompletedProcess(cmd, 1, "", out)
monkeypatch.setattr(binaries.subprocess, "run", run)
listed, other = Path("/opt/bin/mp4decrypt"), Path("/opt/bin/othertool")
assert binary_version(listed) == "1.4"
assert calls == [[str(listed)]]
calls.clear()
assert binary_version(other) is None
assert calls == [[str(other), "--version"], [str(other), "-version"]]
def test_timeout_stops_the_probe(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[list[str]] = []
def run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess:
calls.append(cmd)
raise subprocess.TimeoutExpired(cmd, kwargs["timeout"])
monkeypatch.setattr(binaries.subprocess, "run", run)
assert binary_version(Path("/opt/bin/othertool")) is None
assert len(calls) == 1
+13 -5
View File
@@ -189,7 +189,8 @@ def env() -> None:
@env.command()
def check() -> None:
@click.option("--verbose", is_flag=True, default=False, help="Also show the path each tool is loaded from.")
def check(verbose: bool) -> None:
"""Checks environment for the required dependencies."""
all_deps = get_dependencies()
@@ -199,11 +200,14 @@ def check() -> None:
missing_required = []
table = listing_table("Environment Dependencies", expand=True, show_lines=True)
table.add_column("Category", style="cyan", width=10)
table.add_column("Tool", style="text", width=16)
table.add_column("Status", justify="center", width=10)
table.add_column("Category", style="cyan", width=8)
table.add_column("Tool", style="text", width=14)
table.add_column("Status", justify="center", width=6)
table.add_column("Version", style="text2", width=8, overflow="ellipsis")
table.add_column("Req", justify="center", width=4)
table.add_column("Purpose", style="text2", overflow="fold")
if verbose:
table.add_column("Path", style="text2", overflow="fold")
last_cat = None
for dep in all_deps:
@@ -226,7 +230,11 @@ def check() -> None:
req = "[red]Y[/red]" if dep["required"] else "[bright_black]-[/bright_black]"
table.add_row(category, dep["name"], status, req, dep["desc"])
version = binaries.binary_version(path) or "[bright_black]-[/bright_black]"
row = [category, dep["name"], status, version, req, dep["desc"]]
if verbose:
row.append(str(path) if path else "[bright_black]-[/bright_black]")
table.add_row(*row)
print_wide(table)
+2 -21
View File
@@ -28,6 +28,7 @@ from unshackle.core.api.input_bridge import AuthStatus, InputBridge, NoUserBridg
from unshackle.core.api.sanitize import MAX_SESSION_CACHE_KEYS, safe_cache_key, safe_cache_payload, sanitize_log
from unshackle.core.api.session_log import SessionLogBuffer, SessionLogMirror, capture_service_logs
from unshackle.core.api.session_store import SessionStore
from unshackle.core.binaries import binary_version
from unshackle.core.cacher import Cacher
from unshackle.core.cdm.detect import cdm_type_stub
from unshackle.core.config import config, load_yaml
@@ -3596,26 +3597,6 @@ async def refresh_services_handler(request: Optional[web.Request] = None) -> web
return handle_api_exception(e, context={"operation": "refresh_services"}, debug_mode=debug_mode)
VERSION_RE = re.compile(r"\d+\.\d+(?:\.\d+)*")
def binary_version(path: Any) -> Optional[str]:
"""Best-effort version probe of a binary. Returns None when nothing is parseable."""
import subprocess
for flag in ("--version", "-version"):
try:
proc = subprocess.run(
[str(path), flag], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5
)
except (OSError, subprocess.SubprocessError, ValueError):
return None
match = VERSION_RE.search(proc.stdout or "") or VERSION_RE.search(proc.stderr or "")
if match:
return match.group(0)
return None
async def env_check_handler(request: Optional[web.Request] = None) -> web.Response:
"""Answer the environment dependency check request."""
from unshackle.commands.env import get_dependencies
@@ -3628,7 +3609,7 @@ async def env_check_handler(request: Optional[web.Request] = None) -> web.Respon
{
"name": dep["name"],
"installed": binary is not None,
"version": binary_version(binary) if binary else None,
"version": binary_version(binary),
"required": dep["required"],
}
)
+51
View File
@@ -1,4 +1,6 @@
import re
import shutil
import subprocess # nosec B404
import sys
from pathlib import Path
from typing import Optional
@@ -101,6 +103,55 @@ def get_registered_dependencies() -> list[dict]:
return list(_registered_binaries.values())
DOTTED_NUMBER = r"\d+\.\d+(?:\.\d+)*"
VERSION_RE = re.compile(DOTTED_NUMBER)
LABELLED_VERSION_RE = re.compile(rf"(?i)\bversion[:\s]+[a-z]?({DOTTED_NUMBER})")
VERSION_WORD_RE = re.compile(r"(?i)\bversion\b")
BARE_VERSION_TOOLS = {"mp4decrypt"}
def parse_version(text: str) -> Optional[str]:
"""Return the version from the first line that says "version" or holds a dotted number.
That line is the tool's own banner; later lines carry library and compiler versions.
"""
for line in text.splitlines():
if match := LABELLED_VERSION_RE.search(line):
return match.group(1)
if VERSION_WORD_RE.search(line):
return None
if match := VERSION_RE.search(line):
return match.group(0)
return None
def binary_version(path: Optional[Path]) -> Optional[str]:
"""Best-effort version probe of a binary. Returns None when nothing is parseable.
BARE_VERSION_TOOLS run with no arguments because they print their version only in the usage text.
"""
if not path:
return None
bare = path.stem.lower() in BARE_VERSION_TOOLS
for args in [[]] if bare else [["--version"], ["-version"]]:
try:
proc = subprocess.run( # nosec B603
[str(path), *args],
capture_output=True,
stdin=subprocess.DEVNULL,
text=True,
encoding="utf-8",
errors="replace",
timeout=5,
)
except (OSError, subprocess.SubprocessError, ValueError):
return None
version = parse_version(f"{proc.stdout}\n{proc.stderr}")
if version:
return version
return None
_services_loaded = False