"""The review screen: what is about to happen, before anything happens. Terse by default. Every row is short, every section states its own totals, and anything that needs a second line gets one only when it is genuinely a warning rather than a routine version bump. """ from __future__ import annotations from dataclasses import dataclass, field from rich.text import Text from ..audit import Basis, Report, Severity from ..icons import Glyphs from ..theme import Theme from .base import Columns, Screen, human_bytes, truncate @dataclass class RepoRow: tag: str # U upgrade | N new | R removed | D downgrade name: str old: str new: str size: str = "" @property def role(self) -> str: return {"U": "accent", "N": "ok", "R": "err", "D": "warn"}.get(self.tag, "muted") @dataclass class AurRow: tag: str # U upgrade | ! needs a look | !! blocked name: str old: str new: str note: str = "" loud: bool = False details: list[str] = field(default_factory=list) #: whether the package could be examined at all. An empty report for a #: package with no PKGBUILD is not a clean result, and must not read as one. audited: bool = True #: read, but with nothing to compare against: a weaker answer than a diff, #: and one that must not be presented as the same thing uncompared: bool = False @property def role(self) -> str: return "alert" if self.tag == "!!" else ("warn" if self.tag == "!" else "accent") @dataclass class VcsRow: """A devel package paru would actually rebuild. Carries the version transition, because "r634 -> r893" is the whole story: a -git package's recorded version moves when upstream moves. """ name: str old: str = "" new: str = "" @dataclass class CheckRow: ok: bool label: str detail: str #: a fuller explanation, shown when the row is opened. A check that has one #: is worth opening; one that does not is a single fact and should not #: pretend to be more. details: list[str] = field(default_factory=list) #: the command that resolves this check, if there is one. The front end asks #: before running anything, and shows this exact string when it does. action: list[str] = field(default_factory=list) #: said out loud before an action runs warning: str = "" @dataclass class Step: index: str command: str detail: str #: the full breakdown, shown when the step is opened: the exact argv, what #: it will act on, and what it does underneath details: list[str] = field(default_factory=list) #: the ordered commands the step will actually run, as dicts. This is the #: thing worth reading: `pacman -Syu` is a summary, not a queue. queue: list[dict] = field(default_factory=list) @dataclass class Review: """Everything the review screen renders. Assembled by the CLI.""" meta: str = "" repo: list[RepoRow] = field(default_factory=list) repo_total: int = 0 repo_download: int = 0 repo_delta: int = 0 aur: list[AurRow] = field(default_factory=list) aur_total: int = 0 aur_build: int = 0 vcs: list[VcsRow] = field(default_factory=list) #: (id, severity, fixed, packages, issues) for each advisory advisories: list[tuple[str, str, str, str, int]] = field(default_factory=list) checks: list[CheckRow] = field(default_factory=list) steps: list[Step] = field(default_factory=list) row_limit: int = 6 vcs_limit: int = 8 @property def blocked(self) -> list[AurRow]: return [r for r in self.aur if r.tag == "!!"] @property def loud(self) -> list[AurRow]: return [r for r in self.aur if r.loud] #: Advisory severity, as arch-audit spells it, onto a role. Distinct from #: SEVERITY_ROLE, which maps the *audit's* own INFO/LOOK/BLOCK vocabulary. ADVISORY_ROLE = { "critical": "alert", "high": "alert", "medium": "warn", "low": "accent", } def advisory_role(severity: str) -> str: return ADVISORY_ROLE.get((severity or "").strip().lower(), "muted") def aur_row_from(name: str, old: str, new: str, report: Report | None) -> AurRow: """Derive the row's tone from the audit verdict, not from a separate flag.""" row = AurRow(tag="U", name=name, old=old, new=new) if report is None: return row row.audited = report.audited if not report.audited: # an empty report for a package with no PKGBUILD is not a clean result row.note = "not audited" row.details = [report.basis.label] return row # Read, but with no earlier revision to compare against. Saying nothing # would present a weaker check as a full one. if report.basis is Basis.NEW_ONLY: row.uncompared = True row.details = [report.basis.label] loud = report.loud if not loud: # No baseline is the normal state until an update has been recorded # once, so it is not a row note -- that would mark every row on a first # run. It stays in the detail, where it explains what was checked. return row worst = report.verdict row.tag = "!!" if worst is Severity.BLOCK else "!" row.loud = True row.note = "will be skipped" if worst is Severity.BLOCK else "needs review" row.details = [f"{f.title}" + (f" ({f.evidence})" if f.evidence else "") for f in loud[:3]] return row # -------------------------------------------------------------------------- def _package_row( screen: Screen, cols: Columns, *, tag: str, role: str, name: str, old: str, new: str, note: str = "", note_role: str = "muted", size: str = "", ) -> None: theme, g = screen.theme, screen.glyphs row = screen.row() row.span(tag, getattr(theme, role)) row.span(" ") name_limit = max(10, cols.ver - row.at - 4) row.span(truncate(name, name_limit), theme.text or None) if cols.narrow: row.span(" ") row.span(old, theme.old) row.span(f" {g.arrow} ", theme.muted) row.span(new, theme.new) else: row.right(old, theme.old, col=cols.ver - 5) row.span(f" {g.arrow} ") row.span(truncate(new, 16), theme.new) if note: row.to(cols.note) room = cols.right - cols.note - (len(size) + 2 if size else 0) row.span(truncate(note, max(4, room)), getattr(theme, note_role)) if size: row.right(size, theme.muted) screen.finish(row) def build( review: Review, *, theme: Theme, glyphs: Glyphs, width: int = 100, into: Screen | None = None, dry_run: bool = False, ) -> list[Text]: screen = into or Screen(theme=theme, glyphs=glyphs, columns=Columns.for_width(width)) cols = screen.columns g = glyphs if review.meta: screen.header(review.meta) # -- repository -------------------------------------------------------- if review.repo: meta = f"{review.repo_total or len(review.repo)} packages" if review.repo_download: meta += f" · {human_bytes(review.repo_download)} to download" if review.repo_delta: meta += f" · {human_bytes(review.repo_delta, signed=True)} on disk" screen.section("Repository", meta) for row in review.repo[: review.row_limit]: _package_row( screen, cols, tag=row.tag, role=row.role, name=row.name, old=row.old, new=row.new, size=row.size, ) hidden = ( review.repo_total - min(len(review.repo), review.row_limit) if review.repo_total else len(review.repo) - review.row_limit ) if hidden > 0: screen.add(screen.row().span(f"+ {hidden} more", theme.muted).done()) # -- AUR, VCS packages included ---------------------------------------- # VCS packages are AUR packages: same section, same rules, same ability to # be skipped. A separate section implied they were a different kind of thing # and could not be acted on. if review.aur or review.vcs: meta = f"{len(review.aur)} packages" if review.vcs: meta += f" · {len(review.vcs)} from git" if review.aur_build: meta += f" · {review.aur_build} to build" screen.section("AUR", meta) for row in review.aur[: review.row_limit]: _package_row( screen, cols, tag=row.tag, role=row.role, name=row.name, old=row.old, new=row.new, note=row.note, note_role=row.role, ) for detail in row.details: line = screen.row(indent=cols.indent + 4) line.span(f"{g.warn} ", getattr(theme, row.role)) line.span(truncate(detail, cols.right - line.at), theme.muted) screen.finish(line) hidden = ( review.aur_total - min(len(review.aur), review.row_limit) if review.aur_total else len(review.aur) - review.row_limit ) if hidden > 0: screen.add(screen.row().span(f"+ {hidden} more", theme.muted).done()) if review.vcs: # only devel packages paru would actually rebuild, so these are real # updates and read like the rest of the section for vcs in review.vcs[: review.vcs_limit]: row = screen.row() row.span("V ", theme.warn) row.span(truncate(vcs.name, cols.ver - cols.indent - 4), theme.text) row.right(vcs.old, theme.old, col=cols.ver - 2) row.span(" ") row.span(f"{g.arrow} ", theme.muted) row.span(vcs.new, theme.new) screen.finish(row) if len(review.vcs) > review.vcs_limit: screen.add( screen.row() .span(f"+ {len(review.vcs) - review.vcs_limit} more devel updates", theme.muted) .done() ) # -- security ---------------------------------------------------------- if review.advisories: fixable = sum(1 for a in review.advisories if a[2]) meta = f"{len(review.advisories)} advisories" if fixable: meta += f" · {fixable} fixable" else: meta += " · none fixable by updating" screen.section("Security", meta) # fixable first: an update can only resolve those for adv_id, severity, fixed, packages, issues in review.advisories: row = screen.row() # Severity decides the colour. Fixability is already said in # words ("fixed in ..." / "none fixable"), and using the colour for # it made Critical and Low the same shade -- the one distinction a # security list exists to make. role = advisory_role(severity) # pad, do not just truncate: an 8-character id followed straight by # a package name reads as one word row.span(truncate(severity, 12).ljust(12) + " ", getattr(theme, role)) row.span(truncate(adv_id, 10).ljust(10) + " ", theme.text) tail = f" {issues} CVE(s)" if issues else "" row.span(truncate(packages, max(0, cols.right - row.at - len(tail))), theme.ver) if tail: row.right(tail.strip(), theme.muted) screen.finish(row) # -- checks ------------------------------------------------------------ if review.checks: screen.section("Checks") for check in review.checks: row = screen.row() row.span(g.done if check.ok else g.warn, theme.ok if check.ok else theme.warn) row.span(" ") row.span(f"{check.label:<14}", theme.text or None) row.span(truncate(check.detail, cols.right - row.at), theme.muted) screen.finish(row) # -- plan -------------------------------------------------------------- if review.steps: screen.section("Plan") # From the content, not a constant: `paru -S --needed` is longer than a # fixed column would be, so it runs straight into its own explanation. command_width = min(26, max(14, max(len(s.command) for s in review.steps) + 2)) for step in review.steps: row = screen.row() row.span(step.index, theme.rule) row.span(" ") row.span(f"{step.command:<{command_width}}", theme.accent) row.span(truncate(step.detail, cols.right - row.at), theme.muted) screen.finish(row) screen.blank() blocked = len(review.blocked) if blocked: phrase = ( "1 package needs attention" if blocked == 1 else f"{blocked} packages need attention" ) screen.add(screen.row().span(phrase, theme.alert).done()) if dry_run: # never render a prompt that will not be waited on: it reads as the tool # quitting unexpectedly rather than as a deliberate dry run screen.add( screen.row() .span("dry run", theme.accent) .span(" nothing has been applied", theme.muted) .done() ) screen.add( screen.row() .span("run ", theme.muted) .span("updater", theme.accent) .span(" without --dry-run to apply this plan", theme.muted) .done() ) return screen.render() # deliberately no prompt here: `confirm()` owns it. Rendering it in the # screen as well produced two prompts, the second appearing on its own line # after the first keystroke. return screen.render()