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>
380 lines
16 KiB
Python
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("")
|