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:
Kevin Veen-Birkenbach
2026-07-14 11:27:49 +02:00
parent c420dd164d
commit ccdef065df
77 changed files with 3402 additions and 1614 deletions

108
tests/conftest.py Normal file
View File

@@ -0,0 +1,108 @@
import subprocess
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from lim import runner, ui
from lim.errors import LimError
class FakeRunner:
"""Records every command instead of executing it.
``outputs``/``success_by_fragment``/``failures`` map a substring of the
joined command to the canned behavior.
"""
def __init__(self):
self.calls: list[tuple[str, list, str | None]] = []
self.outputs: dict[str, str] = {}
self.failures: set[str] = set()
self.success_by_fragment: dict[str, bool] = {}
self.sudo_log: list[tuple[list[str], bool]] = []
def _fails(self, cmd: list[str]) -> bool:
joined = " ".join(cmd)
return any(fragment in joined for fragment in self.failures)
def run(self, cmd, *, sudo=False, input_text=None, check=True, error_msg=None):
cmd = [str(part) for part in cmd]
self.calls.append(("run", cmd, input_text))
self.sudo_log.append((cmd, sudo))
returncode = 1 if self._fails(cmd) else 0
if check and returncode != 0:
raise LimError(error_msg or f"Command failed: {' '.join(cmd)}")
return subprocess.CompletedProcess(cmd, returncode)
def output(self, cmd, *, sudo=False, check=True, error_msg=None):
cmd = [str(part) for part in cmd]
self.calls.append(("output", cmd, None))
joined = " ".join(cmd)
for fragment, value in self.outputs.items():
if fragment in joined:
return value
return ""
def succeeds(self, cmd, *, sudo=False):
cmd = [str(part) for part in cmd]
self.calls.append(("succeeds", cmd, None))
joined = " ".join(cmd)
for fragment, value in self.success_by_fragment.items():
if fragment in joined:
return value
return False
def pipeline(self, *cmds, sudo_last=False, error_msg=None):
cmds = [[str(part) for part in cmd] for cmd in cmds]
self.calls.append(("pipeline", cmds, None))
for cmd in cmds:
if self._fails(cmd):
raise LimError(error_msg or "Pipeline failed")
def sync_disks(self):
self.calls.append(("run", ["sync"], None))
def commands(self) -> list[list[str]]:
return [cmd for _, cmd, _ in self.calls]
def find(self, *fragments: str) -> list[list[str]]:
"""All recorded commands whose joined form contains every fragment."""
result = []
for command in self.commands():
joined = " ".join(
" ".join(part) if isinstance(part, list) else part for part in command
)
if all(fragment in joined for fragment in fragments):
result.append(command)
return result
@pytest.fixture
def fake_runner(monkeypatch):
fake = FakeRunner()
monkeypatch.setattr(runner, "run", fake.run)
monkeypatch.setattr(runner, "output", fake.output)
monkeypatch.setattr(runner, "succeeds", fake.succeeds)
monkeypatch.setattr(runner, "pipeline", fake.pipeline)
monkeypatch.setattr(runner, "sync_disks", fake.sync_disks)
return fake
@pytest.fixture
def answers(monkeypatch):
"""Feed scripted answers to ui.ask (and thereby ui.confirm)."""
queue: list[str] = []
def fake_ask(prompt: str) -> str:
assert queue, f"No scripted answer left for prompt: {prompt}"
return queue.pop(0)
monkeypatch.setattr(ui, "ask", fake_ask)
def feed(*items: str) -> None:
queue.extend(items)
return feed