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