"""Flatpak updates, read through `--columns`. `--columns` is flatpak's documented machine-readable interface, so this is a real integration rather than output scraping. The one trap is that flatpak will print `error: ...` on stderr and still exit 0 in some failure modes, so the return code alone is not enough to decide whether the answer is trustworthy. """ from __future__ import annotations import shutil import subprocess from dataclasses import dataclass, field COLUMNS = "application,branch,origin,version" TIMEOUT = 60 @dataclass(frozen=True) class FlatpakRef: application: str version: str = "" branch: str = "" origin: str = "" kind: str = "app" @property def label(self) -> str: bits = [self.application] if self.version: bits.append(self.version) elif self.branch: bits.append(self.branch) return " ".join(bits) @dataclass class FlatpakState: available: bool = False reason: str = "" remotes: list[str] = field(default_factory=list) installed: list[FlatpakRef] = field(default_factory=list) updates: list[FlatpakRef] = field(default_factory=list) @property def has_updates(self) -> bool: return bool(self.updates) @property def short_reason(self) -> str: """A reason short enough for a one-line check row. flatpak's own messages embed absolute paths and quoted filenames, which are noise in a summary; the actionable part is which remote failed. """ text = self.reason.strip() if not text: return "" for prefix in ("error: ", "Error: "): text = text.removeprefix(prefix) lowered = text.lower() if "unable to load summary from remote" in lowered: remote = text.split("remote", 1)[1].strip().split()[0].rstrip(":") return f"remote {remote} unreachable" if "read-only file system" in lowered: return "cache is read-only" return text.split(":")[0][:48] if ":" in text else text[:48] @property def app_updates(self) -> list[FlatpakRef]: return [u for u in self.updates if u.kind == "app"] def flatpak_binary() -> str | None: return shutil.which("flatpak") def _run(args: list[str], *, timeout: int = TIMEOUT) -> tuple[int, str, str]: binary = flatpak_binary() if binary is None: return 127, "", "flatpak is not installed" try: proc = subprocess.run( # noqa: S603 - fixed argv [binary, *args], capture_output=True, text=True, errors="replace", timeout=timeout, check=False, ) except subprocess.TimeoutExpired: return 124, "", f"flatpak {' '.join(args)} timed out after {timeout}s" except OSError as exc: return 126, "", f"could not run flatpak: {exc}" return proc.returncode, proc.stdout, proc.stderr def _looks_failed(returncode: int, stderr: str) -> str: """An empty reason means the result can be trusted.""" if returncode != 0: return (stderr.strip().splitlines() or [f"exit status {returncode}"])[0] for line in stderr.splitlines(): stripped = line.strip() if stripped.lower().startswith("error:"): return stripped return "" def _parse(text: str, *, kind: str) -> list[FlatpakRef]: out: list[FlatpakRef] = [] for line in text.splitlines(): if not line.strip(): continue parts = line.split("\t") while len(parts) < 4: parts.append("") application, branch, origin, version = (p.strip() for p in parts[:4]) if not application: continue out.append( FlatpakRef( application=application, version=version, branch=branch, origin=origin, kind=kind ) ) return out def _dedupe(refs: list[FlatpakRef]) -> list[FlatpakRef]: seen: dict[tuple[str, str, str], FlatpakRef] = {} for ref in refs: seen.setdefault((ref.kind, ref.application, ref.branch), ref) return sorted(seen.values(), key=lambda r: (r.kind, r.application)) def remotes() -> tuple[list[str], str]: code, out, err = _run(["remotes", "--columns=name"]) reason = _looks_failed(code, err) if reason: return [], reason return [line.strip() for line in out.splitlines() if line.strip()], "" def collect(*, include_runtimes: bool = False, with_installed: bool = True) -> FlatpakState: """One call answers everything the plan needs about flatpak. Never raises: flatpak being absent, broken or offline is a normal state, and the caller decides what to say about it. """ state = FlatpakState() if flatpak_binary() is None: state.reason = "flatpak is not installed" return state state.remotes, reason = remotes() if reason and not state.remotes: state.reason = reason return state if with_installed: code, out, err = _run(["list", "--columns=" + COLUMNS]) bad = _looks_failed(code, err) if bad: state.reason = bad return state state.installed = _parse(out, kind="app") kinds = ["--app"] + (["--runtime"] if include_runtimes else []) updates: list[FlatpakRef] = [] for flag in kinds: code, out, err = _run(["remote-ls", "--updates", flag, "--columns=" + COLUMNS]) bad = _looks_failed(code, err) if bad: # an unreachable remote is a reportable condition, not a crash state.reason = bad return state updates.extend(_parse(out, kind="app" if flag == "--app" else "runtime")) state.available = True state.updates = _dedupe(updates) return state def update_command(*, include_runtimes: bool = True) -> list[str]: """The argv to apply updates non-interactively.""" args = ["flatpak", "update", "--noninteractive", "-y"] if not include_runtimes: args.append("--no-related") return args