refactor!: port shell scripts to Python package
Bash scripts were untestable and duplicated device/LUKS/mount logic; the lim/ package centralizes it behind one subprocess wrapper and a YAML image catalog (single point of truth). BREAKING CHANGE: scripts/*.sh removed. Use `lim --type <cmd>`; new types mount/umount/single-boot/raid1-boot/lock/unlock/import/export replace direct script calls. --extra is deprecated and ignored. - distributions.yml + lim/catalog.py hold the image catalog (PyYAML) - pytest suite: 102 tests with mocked subprocess (tests/unit) and a 250-line max file-length guard (tests/lint) - ruff strict (select ALL), GitHub Actions CI, Dependabot; Travis gone - Makefile: install (symlink ~/.local/bin/lim) and test targets - fixes over bash: SUDO_USER-aware chown, mmcblk/nvme partition paths, sha512 checksum support, whole-pipeline failure detection, blkid UUID fallback for pre-mounted images, conditional fstab seeding for PARTUUID/LABEL images, clean errors for missing binaries
This commit is contained in:
134
lim/runner.py
Normal file
134
lim/runner.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Thin wrapper around subprocess for all external commands.
|
||||
|
||||
Every module calls external tools through this module so tests can
|
||||
replace ``run``/``output``/``succeeds``/``pipeline`` with fakes.
|
||||
|
||||
Missing binaries never surface as tracebacks: actions (``check=True``,
|
||||
``pipeline``) abort with a clear LimError, tolerated calls
|
||||
(``check=False``, ``succeeds``) degrade to a warning plus shell-style
|
||||
exit code 127.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
from lim import ui
|
||||
from lim.errors import LimError
|
||||
|
||||
COMMAND_NOT_FOUND = 127
|
||||
|
||||
|
||||
def _with_sudo(cmd: list[str], *, sudo: bool) -> list[str]:
|
||||
parts = [str(part) for part in cmd]
|
||||
if sudo and os.geteuid() != 0:
|
||||
return ["sudo", *parts]
|
||||
return parts
|
||||
|
||||
|
||||
def _not_found_message(cmd: list[str]) -> str:
|
||||
return f"Command not found: {cmd[0]} — please install it."
|
||||
|
||||
|
||||
def run(
|
||||
cmd: list[str],
|
||||
*,
|
||||
sudo: bool = False,
|
||||
input_text: str | None = None,
|
||||
check: bool = True,
|
||||
error_msg: str | None = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a command inheriting stdio so progress output stays visible."""
|
||||
cmd = _with_sudo(cmd, sudo=sudo)
|
||||
ui.info(f"Running: {shlex.join(cmd)}")
|
||||
try:
|
||||
result = subprocess.run(cmd, input=input_text, text=True, check=False)
|
||||
except FileNotFoundError:
|
||||
if check:
|
||||
raise LimError(error_msg or _not_found_message(cmd)) from None
|
||||
ui.warning(_not_found_message(cmd))
|
||||
return subprocess.CompletedProcess(cmd, COMMAND_NOT_FOUND)
|
||||
if check and result.returncode != 0:
|
||||
raise LimError(
|
||||
error_msg
|
||||
or f"Command failed with code {result.returncode}: {shlex.join(cmd)}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def output(
|
||||
cmd: list[str],
|
||||
*,
|
||||
sudo: bool = False,
|
||||
check: bool = True,
|
||||
error_msg: str | None = None,
|
||||
) -> str:
|
||||
"""Run a command and return its stripped stdout."""
|
||||
cmd = _with_sudo(cmd, sudo=sudo)
|
||||
try:
|
||||
result = subprocess.run(cmd, text=True, capture_output=True, check=False)
|
||||
except FileNotFoundError:
|
||||
if check:
|
||||
raise LimError(error_msg or _not_found_message(cmd)) from None
|
||||
ui.warning(_not_found_message(cmd))
|
||||
return ""
|
||||
if check and result.returncode != 0:
|
||||
raise LimError(
|
||||
error_msg
|
||||
or f"Command failed with code {result.returncode}: {shlex.join(cmd)}"
|
||||
+ (f"\n{result.stderr.strip()}" if result.stderr else "")
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def succeeds(cmd: list[str], *, sudo: bool = False) -> bool:
|
||||
"""Run a command silently, report whether it exited with 0."""
|
||||
cmd = _with_sudo(cmd, sudo=sudo)
|
||||
try:
|
||||
return subprocess.run(cmd, capture_output=True, check=False).returncode == 0
|
||||
except FileNotFoundError:
|
||||
ui.warning(_not_found_message(cmd))
|
||||
return False
|
||||
|
||||
|
||||
def pipeline(
|
||||
*cmds: list[str],
|
||||
sudo_last: bool = False,
|
||||
error_msg: str | None = None,
|
||||
) -> None:
|
||||
"""Run commands as a shell-style pipeline (cmd1 | cmd2 | ...)."""
|
||||
prepared = [
|
||||
_with_sudo(cmd, sudo=sudo_last and index == len(cmds) - 1)
|
||||
for index, cmd in enumerate(cmds)
|
||||
]
|
||||
pretty = " | ".join(shlex.join(cmd) for cmd in prepared)
|
||||
ui.info(f"Running: {pretty}")
|
||||
processes: list[subprocess.Popen] = []
|
||||
previous_stdout = None
|
||||
try:
|
||||
for index, cmd in enumerate(prepared):
|
||||
last = index == len(prepared) - 1
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=previous_stdout,
|
||||
stdout=None if last else subprocess.PIPE,
|
||||
)
|
||||
if previous_stdout is not None:
|
||||
previous_stdout.close()
|
||||
previous_stdout = process.stdout
|
||||
processes.append(process)
|
||||
except FileNotFoundError as exc:
|
||||
if previous_stdout is not None:
|
||||
previous_stdout.close()
|
||||
for process in processes:
|
||||
process.kill()
|
||||
process.wait()
|
||||
raise LimError(f"Command not found: {exc.filename} — please install it.") from None
|
||||
for process in processes:
|
||||
process.wait()
|
||||
if any(process.returncode != 0 for process in processes):
|
||||
raise LimError(error_msg or f"Pipeline failed: {pretty}")
|
||||
|
||||
|
||||
def sync_disks() -> None:
|
||||
run(["sync"])
|
||||
Reference in New Issue
Block a user