Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cli_audit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@
is_pinned,
is_never,
should_skip,
classify_pin,
apply_pin_to_status,
pin_label,
)
from .install_plan import InstallPlan, InstallStep, generate_install_plan, dry_run_install # noqa: E402

Expand Down Expand Up @@ -184,7 +186,9 @@
"is_pinned",
"is_never",
"should_skip",
"classify_pin",
"apply_pin_to_status",
"pin_label",
"InstallPlan",
"InstallStep",
"generate_install_plan",
Expand Down
104 changes: 93 additions & 11 deletions cli_audit/pins.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,36 @@ def should_skip(tool_name: str, latest_version: str, pins: dict[str, Any] | None
return pin == latest_version


def apply_pin_to_status(status: str, installed: str, pin: str) -> str:
def classify_pin(pin: str, cycle: str | None) -> str:
"""Classify a pin value against the row's cycle (if any).

The ``pins.json`` schema stores pins as strings but the same slot
can mean three different things:

- ``"never"`` → deliberately-not-installed sentinel
- pin equal to cycle → "hold this cycle, accept any patch"
- anything else → specific version pin (patch-level intent)

For single-version tools ``cycle`` is ``None`` and every non-never
pin is treated as a specific version.

Returns one of: ``"none"``, ``"never"``, ``"cycle"``, ``"version"``.
"""
if not pin:
return "none"
if pin == "never":
return "never"
if cycle is not None and pin == cycle:
return "cycle"
return "version"


def apply_pin_to_status(
status: str,
installed: str,
pin: str,
cycle: str | None = None,
) -> str:
"""Adjust a snapshot status value using the user's pin as the target.

The snapshot's ``status`` is computed against ``latest_upstream`` and
Expand All @@ -159,24 +188,77 @@ def apply_pin_to_status(status: str, installed: str, pin: str) -> str:
respect it — ``UP-TO-DATE`` must not be reported on a row whose
installed version diverges from the pin.

Cycle-awareness (see :func:`classify_pin`): a pin value equal to the
row's ``cycle`` (e.g. ``"3.12"`` on ``python@3.12``) means "hold this
cycle, any patch", so an installed ``3.12.7`` is up-to-date with
respect to the pin. For multi-version tools, a stale patch-level pin
(installed differs from pin and pin is not the cycle) is left at its
upstream status rather than escalated to ``CONFLICT``: the schema
can't distinguish a deliberate patch-hold from a skip-marker that
guide.sh left behind after the world moved on.

Rules:

- ``pin`` empty → pass through (no pin applies).
- ``pin == "never"``
- nothing installed → ``UP-TO-DATE`` (the pin is honored).
- pin kind ``none`` → pass through.
- pin kind ``never``
- nothing installed → ``UP-TO-DATE`` (pin honored).
- something installed→ ``CONFLICT`` (user said never).
- specific version pin
- nothing installed → ``NOT INSTALLED`` (unchanged).
- installed == pin → ``UP-TO-DATE`` (regardless of latest).
- installed != pin → ``CONFLICT`` (pin is being violated).
- pin kind ``cycle``
- nothing installed → ``NOT INSTALLED``.
- installed in cycle → ``UP-TO-DATE``.
- installed elsewhere→ ``CONFLICT``.
- pin kind ``version``
- nothing installed → ``NOT INSTALLED``.
- installed == pin → ``UP-TO-DATE``.
- installed != pin, multi-version row → pass through (stale).
- installed != pin, single-version row → ``CONFLICT``.
"""
if not pin:
kind = classify_pin(pin, cycle)
if kind == "none":
return status
if pin == "never":
if kind == "never":
return "UP-TO-DATE" if not installed else "CONFLICT"
# Specific-version pin.
if kind == "cycle":
if not installed:
return "NOT INSTALLED"
# installed is within the pinned cycle if it equals the cycle
# (e.g. bare "3.12") or looks like "3.12.x".
assert cycle is not None # guaranteed by classify_pin
if installed == cycle or installed.startswith(cycle + "."):
return "UP-TO-DATE"
return "CONFLICT"
# kind == "version" — specific patch/exact pin.
if not installed:
return "NOT INSTALLED"
if installed == pin:
return "UP-TO-DATE"
if cycle is not None:
# Multi-version: the patch-pin is either stale or the world
# moved past it (see module docstring). Don't elevate.
return status
return "CONFLICT"


def pin_label(pin: str, cycle: str | None, installed: str) -> str:
"""Human-readable label for the pin suffix in the ``installed`` column.

Returns an empty string when there's no pin. Otherwise one of:

- ``PIN:never``
- ``CYCLE:3.12`` (cycle-hold — pin matches the row's cycle)
- ``PIN:8.5.3`` (explicit patch-hold, currently honored)
- ``PIN:8.5.3 stale`` (patch-hold on a multi-version row where
installed no longer matches the pin — treated
as fossil data, kept visible for awareness)
"""
kind = classify_pin(pin, cycle)
if kind == "none":
return ""
if kind == "never":
return "PIN:never"
if kind == "cycle":
return f"CYCLE:{pin}"
# version
if installed and installed != pin and cycle is not None:
return f"PIN:{pin} stale"
return f"PIN:{pin}"
106 changes: 74 additions & 32 deletions cli_audit/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
import sys
from typing import Any

from .pins import apply_pin_to_status, load_pins, lookup_pin
from .config import load_config
from .pins import apply_pin_to_status, load_pins, lookup_pin, pin_label


# Environment options
Expand Down Expand Up @@ -122,10 +123,8 @@ def osc8(url: str, text: str) -> str:

def render_table(tools: list[dict[str, Any]]) -> None:
"""Render tools as pipe-delimited table, optionally grouped by category."""
from .config import load_config

# Header — 5 columns. Pin info lives next to the ``installed`` value
# it constrains; ``notes`` carries install method and auto-update flag.
# Header — 5 columns. Auto-update and pin markers live next to the
# ``installed`` value they constrain; ``notes`` carries install method only.
headers = ("state", "tool", "installed", "latest_upstream", "notes")
print("|".join(headers))

Expand Down Expand Up @@ -160,33 +159,71 @@ def render_table(tools: list[dict[str, Any]]) -> None:
_render_tool_row(tool, pins, config)


def _pin_suffix(pin: str) -> str:
"""Format a pin value as an appendable suffix (empty if no pin)."""
if not pin:
return ""
if pin == "never":
return " [PIN:never]"
return f" [PIN:{pin}]"
def _row_cycle(tool: dict[str, Any]) -> str | None:
"""Extract the cycle string for a multi-version row, else None.

Prefer the snapshot field ``version_cycle``; fall back to splitting
the tool name on ``@`` so callers don't need to synthesize the field.
"""
if tool.get("is_multi_version"):
cycle = tool.get("version_cycle")
if cycle:
return str(cycle)
name = tool.get("tool", "")
if "@" in name:
return name.split("@", 1)[1] or None
return None


def _build_notes(tool: dict[str, Any], config: Any) -> str:
"""Compose the ``notes`` cell: ``method · auto``.
def _auto_update_explicit(tool: dict[str, Any], config: Any) -> bool | None:
"""Return the explicit auto_update state for a tool, or ``None``.

Pin info is rendered in the ``installed`` column, not here.
Only reports ``True`` / ``False`` when the user wrote an explicit
``auto_update`` for this tool (cycle-qualified or base) in config.yml.
The global ``preferences.auto_upgrade`` default is intentionally
*not* reflected — it would put a marker on every row and drown the
signal.
"""
parts: list[str] = []
method = tool.get("installed_method") or ""
if method:
parts.append(method)
if config is None:
return None
name = tool.get("tool", "")
base = name.split("@", 1)[0] if "@" in name else name
tool_cfg = config.tools.get(name) or config.tools.get(base)
if tool_cfg is None:
return None
return tool_cfg.auto_update # may be None if the key isn't set


if config is not None:
name = tool.get("tool", "")
base = name.split("@", 1)[0] if "@" in name else name
tool_cfg = config.tools.get(name) or config.tools.get(base)
if tool_cfg is not None and tool_cfg.auto_update is True:
parts.append("auto")
def _installed_markers(pin: str, cycle: str | None, installed: str, auto: bool | None) -> str:
"""Build the bracketed suffix that appends to the ``installed`` column.

return " · ".join(parts)
Collects, in order: the pin label (if any) and the AUTO marker (if
explicitly enabled). Markers are space-separated and wrapped in a
single pair of square brackets. Empty when no marker applies.

``AUTO`` is suppressed when the pin says ``never`` — the two states
contradict each other and showing both confuses the row.
"""
markers: list[str] = []
label = pin_label(pin, cycle, installed)
if label:
markers.append(label)
if auto is True and pin != "never":
markers.append("AUTO")
if not markers:
return ""
return " [" + " ".join(markers) + "]"


def _build_notes(tool: dict[str, Any]) -> str:
"""Compose the ``notes`` cell — now just the install method.

Pin info and auto-update flag render in the ``installed`` column
(see :func:`_installed_markers`) so the ``notes`` column is
reserved for provenance (``apt`` / ``cargo`` / ``manual``).
"""
method = tool.get("installed_method") or ""
return method


def _render_tool_row(
Expand All @@ -205,8 +242,11 @@ def _render_tool_row(
# A pin overrides the "upgrade target" for display purposes. The
# snapshot's ``status`` is computed against latest_upstream and does
# not know about pins, so fix it up here before choosing icon/colors.
# ``cycle`` is passed so cycle-holds (``pin == "3.12"`` on
# ``python@3.12``) are interpreted as "any patch of 3.12 is fine".
pin_value = lookup_pin(name, pins)
status = apply_pin_to_status(raw_status, installed, pin_value)
cycle = _row_cycle(tool)
status = apply_pin_to_status(raw_status, installed, pin_value, cycle)

# Icon
icon = status_icon(status, installed)
Expand Down Expand Up @@ -244,12 +284,13 @@ def _render_tool_row(
if latest_url:
latest_display = osc8(latest_url, latest_display)

# Attach pin marker to the version it constrains (installed column).
# The suffix renders outside the hyperlink so it stays readable when
# nothing is installed.
installed_display = f"{installed_display}{_pin_suffix(pin_value)}"
# Attach pin + AUTO markers to the version they describe (installed
# column). The suffix renders outside the hyperlink so it stays
# readable when nothing is installed.
auto = _auto_update_explicit(tool, config)
installed_display = f"{installed_display}{_installed_markers(pin_value, cycle, installed, auto)}"

notes = _build_notes(tool, config)
notes = _build_notes(tool)

print("|".join((icon, name_display, installed_display, latest_display, notes)))

Expand All @@ -271,6 +312,7 @@ def _effective(t: dict[str, Any]) -> str:
t.get("status", "UNKNOWN"),
t.get("installed", ""),
lookup_pin(t.get("tool", ""), pins),
_row_cycle(t),
)

effective = [_effective(t) for t in tools]
Expand Down
Loading
Loading