"""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