mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|---|---|
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user