Files
MeshTerm/meshterm/ui/script.py
T
Jean-Pierre MartineauandClaude Opus 5 1c24f9149d Let a tool state its answer instead of printing it
The CLI said what it found by printing it: run() called ctx.ui.show(<a table>)
and handed back a ToolResult carrying only bookkeeping. By the time the boundary
saw that result the contacts had gone down the pipe already rendered, and there
was nothing left to offer a second format — which is exactly why --json reached
two commands out of twenty and stopped, each one growing an `if ctx.json_output:`
branch above the rendering that restated the whole answer in a dialect nobody
else could reuse.

exit_code has always worked the other way round: the tool states it, the boundary
turns it into a process status, and no tool calls sys.exit. The answer now travels
the same way. A tool states a Report; a renderer turns it into bytes; --json picks
the renderer.

ui/report.py holds the model — a Listing of records or a set of Facts, whose rows
carry the typed value and whose columns carry both projections of it. ui/fields.py
is the shared shapes, one constructor per concept, so every listing that mentions
a node speaks the same five-key object by construction rather than by review.
ui/renderers.py is the two faces: aligned text for a person, and the bare document
for a program — an array for a listing, an object for a set of facts, no envelope,
because the report a caller needs is still $?.

Nothing is migrated yet: report defaults to None and every command renders exactly
as it did. The script vocabulary the plain face will need lands alongside — ages,
durations, locations, the arrow-drawn route, the comma-joined spec, pinned lanes
for a live stream, and a wrap for the written pages — with the quoting still in
place until its callers move off it.

Also here, because the field library needs it: cli_time_format, the preference that
says whether the command line prints ages or timestamps, with --absolute as its
per-run override rather than a second switch beside it.

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

727 lines
31 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 dataclasses import dataclass
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 = "-"
#: The separator between a route's hops, spaced — the same ``→`` the menu's path lines
#: draw with (:data:`meshterm.ui.pathline._ARROW`), and device-verified present in the
#: PicoCalc console font. It carries the direction the packet travelled, and it is what
#: makes the quoting unnecessary: a comma inside a node's name can no longer be read as a
#: hop boundary.
ARROW = " → "
#: How wide a *written page* wraps. Not the terminal's width: the same paragraph must read
#: the same when it is piped, redirected, or shipped as the machine face's ``text`` field,
#: and 72 is the width every screen in this app is already held to.
PAGE_WIDTH = 72
#: The characters with a readable escape of their own. Everything else that cannot travel
#: in a record falls through to ``\xNN``/``\uNNNN`` below.
_BREAKS = {"\\": "\\\\", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
#: Codepoints that must never reach stdout unescaped, as a *category* rather than a list:
#: every C0 and C1 control, plus the two Unicode separators a terminal also breaks a line
#: on. A four-entry table caught the newline and the tab and let ESC through — and an ESC
#: inside a node name is a live colour run written into the caller's file, which is the
#: one thing "no colour, not a single escape sequence" exists to prevent. A BEL was worse:
#: silently dropped, so the printed name was not the advertised one.
_UNPRINTABLE = frozenset([*range(0x00, 0x20), 0x7F, *range(0x80, 0xA0), 0x2028, 0x2029])
def _escaped(text: str) -> str:
r"""``text`` with everything that cannot travel inside one record folded into an escape.
A record is a line and a field is a run of printable cells; this is what makes both
true of a value that arrived over the air. Named escapes where one reads (``\n``),
numeric escapes everywhere else, and the backslash doubled so the whole thing reads
back unambiguously.
"""
out: list[str] = []
for ch in text:
if ch in _BREAKS:
out.append(_BREAKS[ch])
elif ord(ch) in _UNPRINTABLE:
out.append(f"\\x{ord(ch):02x}" if ord(ch) < 0x100 else f"\\u{ord(ch):04x}")
else:
out.append(ch)
return "".join(out)
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 and the line breaks too, so the
value reads back unambiguously and cannot end the record it sits in.
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\""``.
"""
return '"' + _escaped(value or "").replace('"', '\\"') + '"'
def name(value: str | None) -> str:
r"""A node's name as a field: quoted when there is one, :data:`NONE` when there isn't.
:func:`quote` renders a missing name as ``""``, which is a different claim — *this node
is called nothing* rather than *this node never told us what it is called*. The CLI has
one token for the second, and seven of the ten places that printed a name were making
the first claim by accident, so the same unnamed node read as ``""`` in one listing and
``-`` in the next.
It is also the distinction the JSON rendering needs: an absent name is ``null`` and an
empty one is ``""``, and it cannot be recovered later from a field that flattened both.
Args:
value: The name, or ``None``/empty where the node never supplied one.
Returns:
The quoted name, or :data:`NONE`.
"""
return quote(value) if value else NONE
def oneline(value: str | None) -> str:
r"""``value`` as a field that can end a record without breaking it.
For the one field a listing leaves unquoted because it *is* the rest of the line — a
message body. "The rest of the line" and "anything at all" are not compatible: a body
holding a newline ended its record early and left the remainder indented under the
other columns, reading as a second record with an empty ``TIME``. A body is also the
field a stranger fills in, so that is not a hypothetical shape of message.
Args:
value: The free text. ``None`` gives :data:`NONE`.
Returns:
The text with its line breaks and tabs escaped, and no quotes around it.
"""
return NONE if value is None else _escaped(value)
def text(value: str | None) -> str:
r"""Free text as a field that can end a record without breaking it.
For the field a listing puts last because it *is* the rest of the line — a message
body, a preference's description. "The rest of the line" and "anything at all" are not
compatible: a body holding a newline ended its record early and left the remainder
indented under the other columns, reading as a second record with an empty ``TIME``.
A body is also the field a stranger fills in, so that is not a hypothetical shape of
message.
Args:
value: The free text. ``None`` gives :data:`NONE`.
Returns:
The text with its line breaks and tabs escaped.
"""
return NONE if value is None else _escaped(value)
def stamp(when: datetime | None) -> str:
"""Format a timestamp as local ISO-8601 to the second, or :data:`NONE`.
Kept for the times where the instant *is* the fact rather than a way of saying how
long ago something was: the device's own clock, an appointment set with ``--at``, a
live capture's per-row time (every row of which would otherwise read ``now``), and
every column under ``--absolute``.
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 age(when: datetime | None, *, absent: str = NONE) -> str:
"""How long ago ``when`` was: ``now``, ``5m``, ``3h``, ``2d``, ``4w``.
The default time form, and the reason is the premise change: a person at a prompt
reading ``2026-09-07T19:58:53-04:00`` is doing arithmetic to answer "recently?", which
is the question they typed the command to ask. Delegates to
:func:`~meshterm.ui.widgets._format_age` so the two faces cannot drift apart.
Args:
when: The instant to age. A naive datetime has no offset we know, so it reads as
absent, exactly as :func:`stamp` treats it.
absent: What an unknown time reads as. A heard-age passes ``"never"`` — a node
that has never been heard is a fact, not a missing field — and everything else
takes the default token, which says this row has no such time at all. The same
distinction :func:`name` draws between ``-`` and an empty name, one axis over.
Returns:
The age, or ``absent``.
"""
from .widgets import _age_seconds, _format_age
seconds = _age_seconds(when)
return absent if seconds is None else _format_age(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)
#: The units :func:`duration` counts in, largest first.
_UNITS: tuple[tuple[int, str], ...] = ((86400, "d"), (3600, "h"), (60, "m"), (1, "s"))
def duration(seconds: float | None) -> str:
"""A span of seconds as something readable: ``93784`` → ``1d 2h``, ``360`` → ``6m``.
Two units at most and always adjacent, so the magnitude arrives at a glance and the
precision never outruns what anyone would act on. Used only as a *gloss* beside the
raw figure (``uptime_s 93784 (1d 2h)``), never in place of it — the key says what
the number counts, and a caller reading the key must still find a number under it.
Args:
seconds: The span. ``None`` reads as absent; the sign is dropped, since a caller
glossing a signed reading says which way in its own words.
Returns:
The span in one or two units, or :data:`NONE`.
"""
if seconds is None:
return NONE
whole = int(abs(seconds))
amounts: list[tuple[int, str]] = []
for size, unit in _UNITS:
amounts.append((whole // size, unit))
whole %= size
lead = next((i for i, (amount, _) in enumerate(amounts) if amount), len(amounts) - 1)
parts = [f"{amounts[lead][0]}{amounts[lead][1]}"]
if lead + 1 < len(amounts) and amounts[lead + 1][0]:
parts.append(f"{amounts[lead + 1][0]}{amounts[lead + 1][1]}")
return " ".join(parts)
def location(lat: float | None, lon: float | None) -> str:
"""A shared position as one field: ``45.50190,-73.56740``, or :data:`NONE`.
One column rather than two. A coordinate pair is one fact to a reader, it is what goes
into a map's search box verbatim, and two columns of ``-`` for every contact that has
never shared a position is worse than one.
Args:
lat: Latitude in decimal degrees.
lon: Longitude in decimal degrees.
Returns:
The pair to five decimals, or :data:`NONE` when either half is missing.
"""
if lat is None or lon is None:
return NONE
return f"{lat:.5f},{lon:.5f}"
def route(hops: Iterable[tuple[str | None, str | None]]) -> str:
"""A walked hop sequence, drawn: ``Yagi-Repeater (a1) → Alice (d4) → Yagi (a1)``.
A *route* is what a walk actually did, so it is a picture and not a spec — which is
why it takes :data:`ARROW` and not the comma ``--path`` accepts. Rendering it with
commas made it look pasteable, which it is not: the names are in it, and it is a
round trip nothing has.
The hash rides on every hop and always. It is the join key to the per-hop table below
a trace, the token ``--path`` takes, and the disambiguator for two contacts sharing a
name. Dropping it "where the name is unique" would make one line's grammar depend on
another line's content.
Our own node is a hop like any other — named and hashed. The menu draws it as ``★``
because a reader never has to be told which node is theirs; here the line is as often
read out of a file by someone who was not at the prompt when it ran.
Args:
hops: ``(label, hash)`` pairs in propagation order. A hop with both halves reads
``Name (hash)``; a hop with one half is that half alone, never an empty
``()`` — which is how the menu's :class:`~meshterm.ui.pathline.PathLine`
labels an unresolved hop too.
Returns:
The joined route, or :data:`NONE` when there are no hops.
"""
parts: list[str] = []
for label, value in hops:
if label and value:
parts.append(f"{_escaped(label)} ({value})")
else:
parts.append(_escaped(label) if label else (value or NONE))
return ARROW.join(parts) if parts else NONE
def spec(hops: Iterable[str]) -> str:
"""A forced path as the radio was given it: ``a1,d4,a1``.
The other half of the split :func:`route` names. This is the *spec* — comma-separated
hashes, exactly what ``--path`` takes back — so it is the one line on either face that
round-trips, and it must never grow a name, an arrow or a space.
Args:
hops: The hop hashes in propagation order.
Returns:
The comma-joined spec, or :data:`NONE` when there are no hops.
"""
parts = [hop for hop in hops if hop]
return ",".join(parts) if parts else NONE
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
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; an *age* lane aligns the
same way, so the ladder from ``now`` to ``4w`` reads down the column.
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``,
``trace``) — 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
@dataclass(frozen=True, slots=True)
class Lanes:
"""A fixed-width column layout for output whose widths arrive with the data.
:func:`columns` sizes each lane to its widest value, which it can only do once it has
seen every record. A live capture has no "once": ``monitor`` prints a row the instant
a packet lands, and the row after it may be twice as wide. Those two streams used to
be gutter-joined with no alignment at all, which is why a name in one still carried
its quotes — nothing else said where the field ended.
Pinning the lanes up front fixes it properly. A value wider than its lane **overruns
and pushes the rest of that row right** rather than being elided: one row out of many
is simply wider, and nothing lies about what it holds. Put the one unbounded field
(a node's name, a message body) last and it cannot push anything at all.
Attributes:
widths: ``(header, width)`` per lane, in order.
right: The headers whose cells right-align.
"""
widths: tuple[tuple[str, int], ...]
right: frozenset[str] = frozenset()
@property
def header(self) -> str:
"""The one header line, printed before the first record."""
return self.record(*(header for header, _ in self.widths))
def record(self, *cells: str) -> str:
"""One record, padded into the lanes and joined by the gutter."""
out: list[str] = []
for (header, width), cell in zip(self.widths, cells, strict=True):
out.append(cell.rjust(width) if header in self.right else cell.ljust(width))
return (" " * GUTTER).join(out).rstrip()
def stream(*widths: tuple[str, int], right: Sequence[str] = ()) -> Lanes:
"""Build a fixed-lane layout for a live stream (see :class:`Lanes`).
Args:
*widths: ``(header, width)`` per lane, in order.
right: The headings whose values right-align.
Returns:
The layout, which prints its own header and formats each record.
"""
return Lanes(widths=widths, right=frozenset(right))
def wrap(body: str, width: int = PAGE_WIDTH) -> str:
"""Fold a written page's paragraphs to ``width``, leaving its structure alone.
The deliberate exception to "no wrapping", and the reason the rule exists says why:
wrapping splits a *record* and lands half its fields under the wrong headings. A
written page has no records and no headings — and left unwrapped, ``meshterm about``
prints paragraphs six hundred cells long that no terminal can read.
A line's own indent is kept and carried onto its continuations, so a bullet stays a
bullet and an indented block stays indented; a blank line stays blank; a line that is
already short is untouched.
Args:
body: The page, as its plain renderer laid it out.
width: The cell width to fold to.
Returns:
The page with every over-long line folded under its own indent.
"""
import textwrap
out: list[str] = []
for line in body.splitlines():
stripped = line.lstrip()
if not stripped:
out.append("")
continue
indent = line[: len(line) - len(stripped)]
# A bullet's continuations hang under its text, not under its marker.
hanging = indent + (" " if stripped[:1] in "-*•" else "")
out.extend(
textwrap.wrap(
stripped,
width=max(width - len(indent), 20),
initial_indent=indent,
subsequent_indent=hanging,
break_long_words=False,
break_on_hyphens=False,
)
or [indent + stripped]
)
return "\n".join(out)
# -- 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, an acknowledgement and an error message all go here instead — visible in a
terminal, absent from ``meshterm contacts > contacts.txt``. This one keeps its colour,
because a terminal is usually the only thing that reads it.
**Off a terminal it stops wrapping**, and that is the point of the width below. Rich
falls back to 80 cells when it cannot measure the destination, so ``2> errors.log``
used to hard-wrap every message at 80 — and ``grep 'not already connected'`` then
found nothing, because the sentence it was looking for had been broken across three
lines by the logger. In a real terminal the width is left alone: a progress bar sized
to 16384 cells is not a progress bar.
Returns:
A themed :class:`~rich.console.Console` writing to ``sys.stderr``.
"""
import sys
from .theme import active_theme
try:
wraps = bool(sys.stderr.isatty())
except (AttributeError, ValueError): # pragma: no cover - a closed or exotic stream
wraps = False
return Console(
file=sys.stderr,
theme=active_theme(),
markup=False,
emoji=False,
width=None if wraps else WIDTH,
)
# -- 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 — a tool states its answer as a
:mod:`~meshterm.ui.report` and the plain renderer builds it through :func:`columns`
and :func:`pairs`. 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.
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("")