Files
MeshTerm/meshterm/ui/script.py
T
Jean-Pierre MartineauandClaude Opus 5 369e29e44a Five ways the scripted output lied, and the name that crashed it
The review pass found the CLI breaking its own written rules.

A node broadcasts its own name, so every name the CLI prints is remote
data — and Rich read `[...]` in it as a style tag. A node called
`[bold]Loud` printed as `Loud`, silently no longer the string that
identifies it; a node called `[/]Bob` raised MarkupError and took the
command down. The log handler then failed the same way while reporting
it, replacing the diagnostic with a traceback about the diagnostic. No
log line and no scripted line is ever marked up, so all three consoles
now say so.

`right=` never right-aligned anything. Rich's `Text.wrap` returns early
on `overflow="ignore"`, above the justify step, so asking for one threw
the other away — every numeric lane in the CLI was left-aligned while
CLAUDE.md, script.py's own docstring and the manual all said otherwise.
"crop" refuses to elide just as firmly and still aligns.

`config show` printed an empty string as `""` and `config set` read
those two characters literally, so feeding a dump back in replaced every
empty setting with a pair of quote marks — and the next dump looked
identical, so nothing ever said so. It also masked an *unreported* PIN
behind six bullets, telling the reader the radio was PIN-locked when it
was not: `conceal` already declined to mask the menu's absence token and
had simply never been shown the CLI's.

`meshterm about` drew the `---` above its colophon as a rule sized to
the console, which on a 16384-cell console is one 48KB line in a 4KB
page. A rule is a frame; the scripted copy drops it with the QR.

The contract fixture set only `--db`, which moves the history and
nothing else — so the suite read whatever was in the developer's own
~/.meshterm. That is how `[/]Bob`, planted by hand on one machine, could
crash the suite there and pass everywhere else. $MESHTERM_HOME moves the
whole directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 02:17:41 -04:00

380 lines
16 KiB
Python

"""The scripted CLI's output language — plain text, aligned, unadorned.
MeshTerm has two front ends and they are read by different things. The menu is read by a
person sitting in front of it, and everything else in ``meshterm/ui/`` is built for that
reader: colour that carries meaning, glyphs that stand for concepts, frames that group.
The CLI is read by ``awk``. So it gets its own vocabulary, and this module is all of it.
The rules, and why each one:
* **No colour.** ``stdout`` is a pipe more often than a terminal, and a colour that
survives into a file is noise in it. The console this module builds has
``color_system=None``, so Rich emits no escape sequence at all — not a dim, not a bold.
That is stricter than ``no_color``, which keeps the attributes and drops only the hues.
A *highlight* colour is reserved for the one thing that would be unreadable without it
(a route graph's emphasised path); nothing in the CLI draws one, and nothing claims the
colour speculatively.
* **No wrapping.** A record is a line. Wrapping turns one record into two and puts the
second one's fields under the wrong headings, so the console is made :data:`WIDTH`
cells wide — far past any real line — and every column is ``no_wrap``. Long output runs
off the right, and the terminal (or ``less -S``, or the reader's pipe) decides what to
do about it.
* **No frames.** No panel borders, no table boxes, no rules, no titles. The command the
reader typed is the title; a box around the answer is a second one.
* **Aligned columns, one header line.** ``ps`` and ``df``'s shape: an uppercase header
row, then records, fields padded apart by :data:`GUTTER` spaces. See :func:`columns`.
* **Names are quoted where they share a line.** A node name can hold a space, a comma,
even a quote, so in a listing a bare name is not a field — it is however many fields the
name happens to split into. Every name in a :func:`columns` row or a :func:`path` line
goes through :func:`quote`. A :func:`pairs` line is the exception and needs no quoting:
it has exactly two fields, so the value is the rest of the line whatever it holds, and
quotes there would only be something to strip back off.
* **Times are absolute.** ``3h`` is for a person watching a screen; a script wants
something it can sort and subtract, so every timestamp is local ISO-8601 to the second
(:func:`stamp`). The relative ages and their recency heat stay in the menu, where the
colour they are half made of still exists.
* **One token for "nothing".** :data:`NONE` — a lone ``-`` — in every column, so an
absent value never has to be told apart from an empty one, an ``—``, an ``n/a`` or a
blank.
* **No trailing whitespace.** Padding the last column to its width would put invisible
spaces at the end of every line; the console trims them on the way out
(:class:`_Trimmed`).
Nothing here is reachable from the menu, and nothing in the menu is reachable from here:
:class:`~meshterm.ui.surface.PlainUi` is the seam, chosen in
:func:`~meshterm.cli.main_callback` by whether a subcommand was named.
"""
from __future__ import annotations
from collections.abc import Iterable, Sequence
from datetime import datetime
from typing import Any
from rich.console import Console, Group, RenderableType
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
#: The scripted console's width. Wide enough that Rich never wraps or truncates anything
#: MeshTerm can produce (a 64-hex key, a full route, a preference description), leaving
#: the reader's own terminal to deal with a line longer than itself. Not ``sys.maxsize``:
#: Rich sizes a few structures against the width, and a merely enormous number costs
#: nothing where an astronomical one does.
WIDTH = 1 << 14
#: Spaces between columns. Two, so a padded column and its neighbour never touch and a
#: lone space is never mistaken for a separator inside an unquoted field.
GUTTER = 2
#: The one token for a value that is absent, unknown, or does not apply. A reader (and a
#: parser) learns it once. Never ``—``, ``n/a``, ``never``, or an empty cell.
NONE = "-"
def quote(value: str | None) -> str:
r"""Wrap a name in doublequotes, escaping the ones inside it.
A node name is user-supplied text that can hold a space, a comma, or a quote, so it
is never a field on its own — quoting is what makes it one. The escaping is the
ordinary backslash convention and covers the backslash too, so the value reads back
unambiguously.
Args:
value: The name to quote. ``None`` and the empty string both give ``""``.
Returns:
The quoted name, e.g. ``"Yagi-Repeater"`` or ``"He said \"hi\""``.
"""
text = value or ""
return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
def stamp(when: datetime | None) -> str:
"""Format a timestamp as local ISO-8601 to the second, or :data:`NONE`.
Stored times are timezone-aware UTC; a naive one is a time whose offset we do not
actually know, which is not a fact and so reads as absent — the same rule
:func:`~meshterm.ui.widgets._age_seconds` applies to ages.
Args:
when: The instant to format.
Returns:
e.g. ``2026-09-07T18:22:41-04:00``, or :data:`NONE`.
"""
if when is None or when.tzinfo is None:
return NONE
return when.astimezone().isoformat(timespec="seconds")
def number(value: Any, spec: str = "") -> str:
"""Format a number, or :data:`NONE` when it is absent.
Args:
value: The number to format; ``None`` reads as absent.
spec: An optional :func:`format` spec, e.g. ``"+.1f"``.
Returns:
The formatted number, or :data:`NONE`.
"""
if value is None:
return NONE
return format(value, spec) if spec else str(value)
def columns(*headers: str, right: Sequence[str] = ()) -> Table:
"""Build the scripted table: an uppercase header line, then padded records.
The shape ``ps`` and ``df`` print — no box, no title, no edge padding, every column
sized to its widest value and separated by :data:`GUTTER` spaces, nothing wrapped and
nothing elided. Numeric lanes right-align, which is what makes a column of magnitudes
comparable by eye without changing what splitting it yields.
Args:
*headers: The column headings, already uppercase.
right: The headings (drawn from ``headers``) whose values right-align.
Returns:
A Rich :class:`Table` ready for ``ctx.ui.show``.
"""
table = Table(
box=None,
show_edge=False,
pad_edge=False,
expand=False,
# (top, right, bottom, left): the whole gutter hangs to the right of each cell, so
# no line opens with an indent and the last column ends where its value does.
padding=(0, GUTTER, 0, 0),
)
wanted = set(right)
for header in headers:
table.add_column(
header,
justify="right" if header in wanted else "left",
no_wrap=True,
# "crop", not "ignore". Both refuse to elide — neither ever writes the "…"
# that would make a key or a route unusable to the caller — but Rich's
# ``Text.wrap`` returns early on "ignore" (text.py: ``if overflow ==
# "ignore": lines.append(line); continue``) and that early return is *above*
# the justify step, so "ignore" silently discards ``justify="right"``. The
# two settings differ only past the console's 16384 cells, where "crop" cuts
# and "ignore" also cuts; they differ on every line before that, where only
# "crop" aligns.
overflow="crop",
)
return table
def pairs(rows: Iterable[tuple[str, str]]) -> Table:
"""Build the scripted key/value listing: key then value, aligned, no header line.
What a set of facts about one thing prints as (``meshterm info``, ``config show``,
``preferences show``) — the ``sysctl -a`` shape. Keys are the ones the matching
``get``/``set`` subcommand takes, so a line read out of ``show`` can be typed back in.
Args:
rows: ``(key, value)`` pairs in display order.
Returns:
A Rich :class:`Table` with no header line.
"""
table = Table(
box=None,
show_edge=False,
show_header=False,
pad_edge=False,
expand=False,
padding=(0, GUTTER, 0, 0),
)
table.add_column(no_wrap=True, overflow="crop")
table.add_column(no_wrap=True, overflow="crop")
for key, value in rows:
table.add_row(key, value)
return table
def path(hops: Iterable[tuple[str, str | None]]) -> str:
"""Render a hop sequence as the CLI's path line.
``"Origin" (3d),"Relay" (f2),"Us" (a1)`` — each node's name quoted (:func:`quote`),
its hash in parentheses *outside* the quotes, hops separated by a bare comma.
The hash is there because the CLI has no colour. On a screen a route's hops are told
apart by their key-derived hues, and matched to a route graph's one-byte labels the
same way; with the colour gone, the hash is what carries that identity.
Our own node is a hop like any other — named and hashed, never the menu's ``★``. The
star says "you already know who this is", which is true of a reader and false of a
parser.
The comma is the separator ``--path`` already takes, and the quoting is what keeps the
line splittable when a name contains one.
Args:
hops: ``(label, hash)`` pairs in propagation order; a ``None`` hash prints the
name alone, for a node whose identity is genuinely unknown.
Returns:
The joined path line, or :data:`NONE` when there are no hops.
"""
parts = [f"{quote(label)} ({value})" if value else quote(label) for label, value in hops]
return ",".join(parts) if parts else NONE
# -- the console ---------------------------------------------------------------------
class _Trimmed:
"""A text stream that drops trailing whitespace from every line passing through it.
Rich pads a table's cells to their column width, the last column's included, which
would leave invisible spaces at the end of every record. Trimming at the stream is the
one place that catches all of it — table padding, a justified :class:`Text`, a blank
line Rich decided to pad — without any renderable having to know.
What it holds back is only the *trailing whitespace run*, never a whole line. Rich
writes a rendered row segment by segment and, on Windows, flushes after each one, so a
wrapper that waited for the newline before deciding would be asked to decide dozens of
times a row — and one that trimmed at flush would throw away the very padding that
separates two columns. Everything up to the last non-space character goes straight
through; the spaces after it wait to find out whether a newline or another column
follows. At end of stream nothing follows, and trailing whitespace is exactly what it
is then, so the held run is simply dropped.
"""
def __init__(self, stream: Any) -> None:
"""Wrap ``stream``, holding back whatever trailing whitespace has arrived."""
self._stream = stream
self._held = ""
def write(self, text: str) -> int:
"""Write ``text``, emitting whitespace only once something follows it on the line."""
pending = self._held + text
if "\n" in pending:
*lines, pending = pending.split("\n")
self._stream.write("".join(line.rstrip() + "\n" for line in lines))
body = pending.rstrip()
if body:
self._stream.write(body)
self._held = pending[len(body) :]
return len(text)
def flush(self) -> None:
"""Flush the wrapped stream, keeping the held whitespace held.
Deliberately *not* a place that emits: a flush lands between two columns as often
as at the end of a line, and it carries no information about which.
"""
self._stream.flush()
def __getattr__(self, name: str) -> Any:
"""Delegate everything else (``isatty``, ``encoding``, ...) to the real stream."""
return getattr(self._stream, name)
def console(stream: Any = None) -> Console:
"""Build the scripted console: no colour, no wrapping, no trailing whitespace.
Args:
stream: The file to write to; defaults to ``sys.stdout``.
Returns:
A :class:`~rich.console.Console` that emits plain text and nothing else.
"""
import sys
target = sys.stdout if stream is None else stream
reconfigure = getattr(target, "reconfigure", None)
if reconfigure is not None:
try: # a name or mark outside cp1252 must not raise on a legacy Windows console
reconfigure(encoding="utf-8")
except (ValueError, OSError): # pragma: no cover - stream not reconfigurable
pass
return Console(
file=_Trimmed(target),
# Not `no_color`, which keeps bold, dim, reverse and the rest and drops only the
# hues. `color_system=None` is the one setting under which Rich emits no escape
# sequence at all.
color_system=None,
width=WIDTH,
# Rich would otherwise notice a real terminal and soft-wrap to its width.
soft_wrap=True,
highlight=False,
emoji=False,
# A node broadcasts its own name, so every name on this console is remote data.
# Rich reads `[...]` as a style tag: `[bold]Loud` printed as `Loud` — silently
# corrupted, and no longer the string that identifies the node — while `[/]Bob`
# raised MarkupError and took the whole command down with it. Nothing the scripted
# CLI prints is ever marked up, so the parser has nothing to do here but misfire.
markup=False,
)
def stderr_console() -> Console:
"""The console for everything that is *about* the run rather than part of its answer.
stdout carries what the command was asked for and nothing else, so a progress bar, a
log line and an error message all go here instead — visible in a terminal, absent from
``meshterm contacts > contacts.txt``. This one keeps its colour and its wrapping: a
terminal is the only thing that ever reads it.
Returns:
A themed :class:`~rich.console.Console` writing to ``sys.stderr``.
"""
import sys
from .theme import active_theme
return Console(file=sys.stderr, theme=active_theme(), markup=False, emoji=False)
# -- the safety net ------------------------------------------------------------------
def flatten(renderable: RenderableType) -> list[RenderableType]:
"""Strip framing from anything reaching the scripted console still wearing it.
Every CLI surface is *written* plain — the tools build their output through
:func:`columns`, :func:`pairs` and :func:`path`. This is the net under that, for a
renderable shared with the menu that still arrives boxed: a :class:`Panel` gives up
its border and title and yields its body, a :class:`Table` gives up its box, title and
expansion, and a :class:`Group` is flattened member by member.
It is deliberately not a *design*: a screen's table has the menu's columns, not the
CLI's, so passing it through here makes it printable, not right. Anything a script is
meant to read gets a plain renderer of its own.
Args:
renderable: What a tool handed to :meth:`~meshterm.ui.surface.PlainUi.show`.
Returns:
The unframed renderables to print, in order.
"""
if isinstance(renderable, Panel):
return flatten(renderable.renderable)
if isinstance(renderable, Table):
renderable.title = None
renderable.caption = None
renderable.box = None
renderable.show_edge = False
renderable.pad_edge = False
renderable.expand = False
renderable.padding = (0, GUTTER, 0, 0)
for column in renderable.columns:
column.no_wrap = True
column.overflow = "crop"
column.ratio = None
return [renderable]
if isinstance(renderable, Group):
out: list[RenderableType] = []
for child in renderable.renderables:
out.extend(flatten(child))
return out
return [renderable]
def blank() -> Text:
"""One empty line, for separating a listing from the block after it."""
return Text("")