Files

341 lines
10 KiB
Python
Raw Permalink Normal View History

"""NCMR songs-videos helper toolchain — Python, stdlib only.
Shared plumbing for the repo's helper scripts. Every script in this package is
runnable as a plain file (``python3 scripts/bom_gen.py``) and as a module
(``python3 -m scripts.bom_gen``); both work because this package is imported
either way without needing its parent directory on ``sys.path``.
Nothing outside the standard library is imported anywhere in this package.
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from typing import Callable, Iterable, Sequence
__all__ = [
"Die",
"VERSION",
"Report",
"find_ffmpeg",
"glob_files",
"human_size",
"report",
"run",
"subparsers_metavar",
"text_table",
]
VERSION = "0.2.0"
class Die(Exception):
"""Fatal, user-facing error. Caught in ``main()`` and printed to stderr."""
# --------------------------------------------------------------------------- #
# Console output
# --------------------------------------------------------------------------- #
def _colour_enabled(stream=None) -> bool:
stream = stream or sys.stdout
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("FORCE_COLOR"):
return True
return hasattr(stream, "isatty") and stream.isatty()
def _style(text: str, code: str) -> str:
if not _colour_enabled():
return text
return f"\033[{code}m{text}\033[0m"
@dataclass
class Report:
"""Prints user-facing output, with optional ANSI colour when on a TTY."""
quiet: bool = False
def _emit(self, message: str, *, err: bool = False) -> None:
stream = sys.stderr if err else sys.stdout
print(message, file=stream)
def info(self, message: str = "") -> None:
"""Ordinary progress output."""
self._emit(message)
def ok(self, message: str) -> None:
self._emit(_style(f"✓ {message}", "32"))
def warn(self, message: str) -> None:
self._emit(_style(f"warning: {message}", "33"), err=True)
def error(self, message: str) -> None:
self._emit(_style(f"error: {message}", "31"), err=True)
def verbose(self, message: str) -> None:
if not self.quiet:
self._emit(_style(f" {message}", "2"))
#: Default reporter used when a caller doesn't supply one.
report = Report()
# --------------------------------------------------------------------------- #
# Formatting helpers
# --------------------------------------------------------------------------- #
_SIZE_UNITS = ("B", "KiB", "MiB", "GiB", "TiB")
# Characters that make a key unquoted-safe in YAML and legible in a table.
_SAFE_KEY_RE = re.compile(r"^[A-Za-z0-9_.\-/]+$")
def human_size(num_bytes: int) -> str:
"""Format a byte count as ``1.5 MiB`` / ``12 B``."""
value = float(num_bytes)
for unit in _SIZE_UNITS[:-1]:
if value < 1024:
break
value /= 1024
else:
unit = _SIZE_UNITS[-1]
if unit == "B":
return f"{int(value)} B"
return f"{value:.1f} {unit}"
def text_table(headers: Sequence[str], rows: Iterable[Sequence[str]]) -> str:
"""Render a simple whitespace-aligned table (no boxes, pipe-friendly)."""
body = [[str(cell) for cell in row] for row in rows]
widths = [len(str(h)) for h in headers]
for row in body:
for i, cell in enumerate(row):
if i < len(widths):
widths[i] = max(widths[i], len(cell))
def line(cells: Sequence[str]) -> str:
return " ".join(cell.ljust(widths[i]) for i, cell in enumerate(cells)).rstrip()
out = [line([str(h) for h in headers])]
out.append(" ".join("-" * w for w in widths).rstrip())
for row in body:
out.append(line(row))
return "\n".join(out)
def glob_files(directory: str, suffix: str) -> list[str]:
"""Sorted list of files directly inside *directory* whose name ends in *suffix*.
Returns an empty list when the directory does not exist, so callers can treat
"no such folder" and "folder is empty" identically.
"""
try:
entries = os.listdir(directory)
except (FileNotFoundError, NotADirectoryError):
return []
suffix = suffix.lower()
found = [
os.path.join(directory, name)
for name in entries
if name.lower().endswith(suffix) and os.path.isfile(os.path.join(directory, name))
]
return sorted(found)
# --------------------------------------------------------------------------- #
# Subprocess plumbing
# --------------------------------------------------------------------------- #
def run(
cmd: Sequence[str],
*,
report: Report | None = None,
check: bool = True,
cwd: str | None = None,
dry_run: bool = False,
) -> subprocess.CompletedProcess:
"""Run *cmd* with a shell-free argument list, echoing it first.
``report`` is the reporter used for the echo; the helper deliberately takes a
reporter argument rather than closing over the module-level default so that a
``--quiet`` CLI cannot accidentally echo commands.
"""
log = report or globals()["report"]
printable = " ".join(shlex_quote(part) for part in cmd)
if dry_run:
log.info(_style(f"[dry-run] {printable}", "36"))
return subprocess.CompletedProcess(list(cmd), 0)
log.info(_style(f"$ {printable}", "2"))
# ffmpeg prints its whole build configuration as a banner unless told not to.
# Keep it quiet by default, and keep stderr so a failure can be explained.
full_cmd = list(cmd)
if os.path.basename(full_cmd[0]) in ("ffmpeg", "ffprobe"):
full_cmd[1:1] = ["-hide_banner"]
try:
result = subprocess.run(full_cmd, cwd=cwd, capture_output=True, text=True)
except FileNotFoundError as exc:
raise Die(f"command not found: {cmd[0]}") from exc
if result.stdout:
sys.stdout.write(result.stdout)
if result.stderr:
# ffmpeg writes progress to stderr; pass it through so the user still sees
# the encode happen, but drop the wall of banner text.
sys.stderr.write(result.stderr)
if check and result.returncode != 0:
detail = _last_meaningful_line(result.stderr)
suffix = f": {detail}" if detail else ""
raise Die(f"{os.path.basename(cmd[0])} failed (exit {result.returncode}){suffix}")
return result
def _last_meaningful_line(text: str | None) -> str:
"""Pick the most useful line out of ffmpeg's stderr to show in an error."""
if not text:
return ""
for line in reversed(text.strip().splitlines()):
line = line.strip()
if not line:
continue
# Progress lines start with 'frame=' and overwrite each other.
if line.startswith(("frame=", "size=")) or "speed=" in line:
continue
return line
return ""
def shlex_quote(part: str) -> str:
"""Quote one argv element for display. Local copy keeps the module stdlib-only."""
if not part:
return "''"
if re.fullmatch(r"[A-Za-z0-9_@%+=:,./\-]+", part):
return part
return "'" + part.replace("'", "'\"'\"'") + "'"
def find_ffmpeg(override: str | None = None) -> str:
"""Locate the ``ffmpeg`` binary, or raise :class:`Die` with a useful message."""
if override:
if os.path.sep in override:
if not os.access(override, os.X_OK):
raise Die(f"--ffmpeg {override} is not an executable file")
return override
return override # a bare name; let the OS resolve it on $PATH
found = shutil.which("ffmpeg")
if not found:
raise Die("ffmpeg not found on $PATH — install it or pass --ffmpeg /path/to/ffmpeg")
return found
# --------------------------------------------------------------------------- #
# argparse ergonomics
# --------------------------------------------------------------------------- #
def subparsers_metavar(parser: argparse.ArgumentParser, name: str = "mode") -> None:
"""Stop argparse from printing an unreadable ``{a,b,c}`` in help output.
Call *after* every subparser has been registered.
"""
for action in parser._actions:
if isinstance(action, argparse._SubParsersAction):
action.metavar = name
action.choices = None # type: ignore[assignment] # keep choices working
def add_common_arguments(parser: argparse.ArgumentParser, *, suppress_defaults: bool = False) -> None:
"""Switches every tool in the toolbox understands.
``suppress_defaults`` is used when the same switches are also registered on
subparsers: it stops the subparser copy from stamping its default over a value
the user gave before the mode word.
"""
default_suppress = argparse.SUPPRESS if suppress_defaults else False
parser.add_argument(
"-q",
"--quiet",
action="store_true",
default=default_suppress,
help="suppress progress output (errors still go to stderr)",
)
parser.add_argument(
"-n",
"--dry-run",
action="store_true",
default=default_suppress,
help="print the work that would happen without doing it",
)
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {VERSION}",
)
def add_ffmpeg_argument(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--ffmpeg",
metavar="PATH",
default=None,
help="ffmpeg binary to use (default: whichever is on $PATH)",
)
@dataclass
class ReportMixin:
"""Mixin for subcommand classes that need a reporter and a dry-run flag."""
report: Report = field(default_factory=Report)
dry_run: bool = False
def main_wrapper(fn: Callable[[], int | None]) -> Callable[[], int]:
"""Decorator turning ``Die`` exceptions into a clean exit status."""
def wrapper() -> int:
try:
return fn() or 0
except Die as exc:
report.error(str(exc))
return 1
except KeyboardInterrupt:
report.error("interrupted")
return 130
return wrapper