The same rewrite as for the core package, for the screens, the dialogs, the map, the charts, and the CLI renderers. The glossary words are used for the screen: the user (not the reader), dialog (not popup), viewport, list window, highlight, pointer, graph lane, marker, raster, result screen, and key prefix (never hash for a truncated key). The rewrite also corrects approximately 70 docstrings and comments that no longer agreed with the code: rows that no longer exist (Back rows), counts that changed, renamed constants, wrong cross-references, and behaviour that changed. Each correction follows the code; no code changed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
670 lines
30 KiB
Python
670 lines
30 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""The output language of the plain CLI: aligned text without decoration, for a prompt.
|
|
|
|
MeshTerm has two front ends and a third face. A person who sits in front of the menu
|
|
reads the menu. All the other code in ``meshterm/ui/`` is made for that user: colour that
|
|
has a meaning, glyphs for concepts, and frames that make groups. A program reads
|
|
``--json``, often on a different machine and often later. This module is the third face:
|
|
**a person at a prompt who typed a command to find a fact, and who wants to read the
|
|
answer in one look.**
|
|
|
|
This is a change of premise. Before, the plain face was a serialization format that a
|
|
person could read only with effort. Several of its rules were there only to make it safe
|
|
to *split* the output into fields: quoted names, absolute timestamps, and a comma between
|
|
the hops of a path. Now a program that splits the output reads the machine face instead
|
|
(refer to :mod:`meshterm.ui.report` and :mod:`meshterm.ui.renderers`). Thus those rules
|
|
had a cost and no longer had a purpose. The rules that stay are the rules that make the
|
|
output the output of a Unix utility, and one new rule that replaces the quotes.
|
|
|
|
* **Alignment is the delimiter.** The eye already sees an aligned column as one field.
|
|
Thus a name has no quotes (:func:`name`). The *escapes* under the quotes stay, and they
|
|
must always stay (:func:`_escaped`). A node broadcasts its own name, and a stranger
|
|
writes the body of a message. Neither value can end the record that contains it.
|
|
* **No colour.** ``stdout`` is more often a pipe than a terminal. A colour that goes into
|
|
a file is noise in that file. The console that this module builds has
|
|
``color_system=None``, thus Rich writes no escape sequence at all: no dim and no bold.
|
|
This is stricter than ``no_color``, which keeps the attributes and removes only the
|
|
colours.
|
|
* **No wrapping.** A record is a line. If the console wraps a record, the record becomes
|
|
two lines, and the fields of the second line are under the wrong headings. Thus the
|
|
console is :data:`WIDTH` cells wide, much wider than any real line, and each column is
|
|
``no_wrap``. The only intentional exception is a *written page* (``about``,
|
|
``support``). It is drawn on a console that is :data:`PAGE_WIDTH` cells wide instead.
|
|
A paragraph is not a record, and it has no headings to align under. If it does not
|
|
wrap, it is one line of 600 cells, and a person cannot read that line in any terminal.
|
|
* **No frames.** No panel borders, no table boxes, no rules, and no titles. The command
|
|
that the person typed is the title. A box around the answer is a second title.
|
|
* **Aligned columns, one header line.** The shape of ``ps`` and ``df``: a header row in
|
|
upper case, then the records. :data:`GUTTER` spaces separate the padded fields. Refer to
|
|
:func:`columns`.
|
|
* **A time is an age.** ``5m``, ``3h``, ``never``: the same scale that the columns of the
|
|
menu use (:func:`age`). The reason is that "how long ago" is the question that a person
|
|
asks. An absolute instant stays where the instant is the fact: the device clock, a
|
|
scheduled appointment, and the clock of a live capture (:func:`stamp`). ``--absolute``
|
|
changes all the output back to absolute times for a person who wants them. The machine
|
|
face always uses absolute UTC times.
|
|
* **A route is drawn, a path is typed.** The lexicon already makes these two concepts
|
|
different. A *path* is an ordered list of hops that you write or force. A *route* is
|
|
the sequence of nodes that a walk went through. Thus a path keeps its commas
|
|
(:func:`spec`), because you can type it back into ``--path`` (a round trip). A route
|
|
gets arrows (:func:`route`), because it is a picture and clearly not a spec. Before,
|
|
both had commas, and thus both seemed to make a round trip. Only one of them does.
|
|
* **One token for "nothing".** :data:`NONE` (a ``-`` alone) in each column. Thus a person
|
|
never has to tell an absent value from an empty value, an ``—``, an ``n/a``, or a blank.
|
|
``never`` is a *value*, not an absence: a node that was never heard is a fact.
|
|
* **No trailing whitespace.** If the last column is padded to its width, each line ends
|
|
with invisible spaces. The console removes them when it writes the line
|
|
(:class:`_Trimmed`).
|
|
|
|
The menu cannot get to this module, and this module cannot get to the menu.
|
|
:class:`~meshterm.ui.surface.PlainUi` is the seam. :func:`~meshterm.cli.main_callback`
|
|
chooses it when the command line names a subcommand.
|
|
"""
|
|
|
|
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 width of the scripted console. It is wide enough that Rich never wraps or cuts any
|
|
#: text that MeshTerm can make (a key of 64 hex digits, a full route, the description of a
|
|
#: preference). If a line is longer than the terminal of the person, that terminal deals
|
|
#: with it. The value is not ``sys.maxsize``, because Rich sizes some structures from the
|
|
#: width. A very large number costs nothing, but an astronomically large number has a cost.
|
|
WIDTH = 1 << 14
|
|
|
|
#: The number of spaces between columns. It is two, so that a padded column never touches
|
|
#: the next column. Also, a person never reads a single space in a field without quotes as
|
|
#: a separator.
|
|
GUTTER = 2
|
|
|
|
#: The only token for a value that is absent, unknown, or not applicable. A person (and a
|
|
#: parser) learns it one time. It is never ``—``, ``n/a``, ``never``, or an empty cell.
|
|
NONE = "-"
|
|
|
|
#: The separator between the hops of a route, with a space on each side. It is the same
|
|
#: ``→`` that the path lines of the menu use (:data:`meshterm.ui.pathline._ARROW`). The
|
|
#: PicoCalc console font has this glyph (we checked it on the handheld). The arrow shows
|
|
#: the direction in which the packet went. Also, it makes the quotes unnecessary: a person
|
|
#: can no longer read a comma in the name of a node as a boundary between hops.
|
|
ARROW = " → "
|
|
|
|
#: The width at which a *written page* wraps. It is not the width of the terminal, because
|
|
#: the same paragraph must look the same in a pipe, in a redirected file, or in the
|
|
#: ``text`` field of the machine face. Also, 72 is already the width limit of each screen
|
|
#: in this app.
|
|
PAGE_WIDTH = 72
|
|
|
|
|
|
#: The characters that have a readable escape of their own. All other characters that
|
|
#: cannot go in a record get the ``\xNN``/``\uNNNN`` escape below.
|
|
_BREAKS = {"\\": "\\\\", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
|
|
|
|
#: The code points that must never go to stdout without an escape. This is a *category*,
|
|
#: not a list: each C0 and C1 control character, and the two Unicode separators at which a
|
|
#: terminal also breaks a line. Before, a table of four entries caught the newline and the
|
|
#: tab, but let ESC through. An ESC in a node name starts a live colour run in the file of
|
|
#: the caller. The rule "no colour, not a single escape sequence" is there to prevent
|
|
#: exactly this. A BEL was worse: it disappeared without a warning, so the printed name was
|
|
#: not the name in the advert.
|
|
_UNPRINTABLE = frozenset([*range(0x00, 0x20), 0x7F, *range(0x80, 0xA0), 0x2028, 0x2029])
|
|
|
|
|
|
def _escaped(text: str) -> str:
|
|
r"""``text``, with an escape for each character that cannot go in one record.
|
|
|
|
A record is a line, and a field is a sequence of printable cells. This function makes
|
|
both statements true for a value that came over the radio. It uses a named escape
|
|
where one is readable (``\n``), and a numeric escape for all other characters. It
|
|
doubles the backslash, so that the result has only one meaning when a person reads it
|
|
back.
|
|
|
|
This function is the part of the quoting that stayed when we removed the quotes. The
|
|
quotes made a name *one field* for a program that splits the line, and now the
|
|
alignment does that. But nothing else can stop a newline in a name: without the
|
|
escape, the newline ends the record of the name too early.
|
|
"""
|
|
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 name(value: str | None) -> str:
|
|
r"""The name of a node as a field: without quotes and with escapes, or :data:`NONE`.
|
|
|
|
The quotes are gone. They were there so that a name with a space or a comma stayed one
|
|
field for the program that split the line. Now that program reads JSON, and in plain
|
|
text the alignment of the columns already shows where a field ends. The escapes stay.
|
|
A node broadcasts its own name, so each name here is remote data, and a newline in a
|
|
name must still not end the record that contains it.
|
|
|
|
An empty name is still :data:`NONE`, not an empty cell. ``""`` means *called
|
|
nothing*, and the token means *never said*. These are different facts. The machine
|
|
face also needs this difference (an absent name is ``null``, an empty name is
|
|
``""``), and a field that shows both in the same way cannot give the difference back.
|
|
|
|
Args:
|
|
value: The name, or ``None`` or empty when the node did not give a name.
|
|
|
|
Returns:
|
|
The escaped name, or :data:`NONE`.
|
|
"""
|
|
return _escaped(value) if value else NONE
|
|
|
|
|
|
def text(value: str | None) -> str:
|
|
r"""Free text as a field that can end a record without a break in the record.
|
|
|
|
Use it for the field that a listing puts last, because that field is the rest of the
|
|
line: the body of a message, or the description of a preference. "The rest of the
|
|
line" and "any text at all" are not compatible. Before, a body that contained a
|
|
newline ended its record too early. The rest of the text was indented under the other
|
|
columns, and it looked like a second record with an empty ``TIME``. Also, a stranger
|
|
writes the body, so such a message is a real possibility.
|
|
|
|
Args:
|
|
value: The free text. ``None`` gives :data:`NONE`.
|
|
|
|
Returns:
|
|
The text, with escapes for its line breaks and tabs.
|
|
"""
|
|
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 as :data:`NONE`.
|
|
|
|
Use it where the instant is the fact, and not a way to say how long ago something
|
|
occurred: the clock of the device, an appointment set with ``--at``, the time of each
|
|
row in a live capture (without it, each row shows ``now``), and each column under
|
|
``--absolute``.
|
|
|
|
Stored times are UTC and have a timezone. A naive time has an offset that we do not
|
|
know. Thus it is not a fact, and the function shows it as absent.
|
|
:func:`~meshterm.ui.widgets.age_seconds` uses the same rule for ages.
|
|
|
|
Args:
|
|
when: The instant to format.
|
|
|
|
Returns:
|
|
For example ``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``.
|
|
|
|
This is the default form of a time, because of the change of premise. A person at a
|
|
prompt who reads ``2026-09-07T19:58:53-04:00`` must do arithmetic to answer
|
|
"recently?", and that is the question for which the person typed the command. The
|
|
function calls :func:`~meshterm.ui.widgets.format_age`, so that the two faces always
|
|
agree.
|
|
|
|
Args:
|
|
when: The instant to show as an age. A naive datetime has no offset that we know,
|
|
thus it shows as absent, the same as in :func:`stamp`.
|
|
absent: The text for an unknown time. A heard age gives ``"never"``, because a
|
|
node that was never heard is a fact, not an absent field. All other callers
|
|
use the default token, which says that this row has no such time at all.
|
|
:func:`name` makes the same difference between ``-`` and an empty name, for a
|
|
different value.
|
|
|
|
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`` shows as absent.
|
|
spec: An optional :func:`format` spec, for example ``"+.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 that :func:`duration` uses, the 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 in a readable form: ``93784`` → ``1d 2h``, ``360`` → ``6m``.
|
|
|
|
It has a maximum of two units, and the two units are always next to each other. Thus
|
|
a person sees the magnitude immediately, and the precision is never more than a person
|
|
uses to make a decision. Use it only as a *gloss* next to the raw number
|
|
(``uptime_s 93784 (1d 2h)``), never instead of the number. The key tells what the
|
|
number counts, and a caller that reads the key must still find a number under it.
|
|
|
|
Args:
|
|
seconds: The span. ``None`` shows as absent. The function removes the sign,
|
|
because a caller that adds a gloss to a signed value gives the direction 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, not two. For a person, a pair of coordinates is one fact, and it goes
|
|
unchanged into the search box of a map. Also, two columns of ``-`` for each contact
|
|
that never shared a position are worse than one column.
|
|
|
|
Args:
|
|
lat: Latitude in decimal degrees.
|
|
lon: Longitude in decimal degrees.
|
|
|
|
Returns:
|
|
The pair, to five decimal places, or :data:`NONE` when one of the two values is
|
|
absent.
|
|
"""
|
|
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:
|
|
"""The route of a walk, drawn: ``Yagi-Repeater (a1) → Alice (d4) → Yagi (a1)``.
|
|
|
|
A *route* is what a walk did. Thus it is a picture and not a spec, and for this reason
|
|
it uses :data:`ARROW` and not the comma that ``--path`` accepts. When a route had
|
|
commas, it looked as if a person could paste it, but a person cannot. The route
|
|
contains the names, and nothing can read it back (no round trip).
|
|
|
|
Each hop always shows its hash. The hash is the join key to the table of hops under a
|
|
trace, and it is the token that ``--path`` accepts. It also makes two contacts with
|
|
the same name different. If the hash is removed "where the name is unique", the
|
|
grammar of one line depends on the content of a different line.
|
|
|
|
Our node is a hop like all other hops, with a name and a hash. The menu draws it as
|
|
``★``, because the user never has to be told which node is theirs. But here, the
|
|
person who reads the line often reads it from a file, and was not at the prompt when
|
|
the command ran.
|
|
|
|
Args:
|
|
hops: ``(label, hash)`` pairs, in the order of propagation. A hop with both values
|
|
shows as ``Name (hash)``. A hop with one value shows only that value, never an
|
|
empty ``()``. The menu's :class:`~meshterm.ui.pathline.PathLine` also labels an
|
|
unresolved hop in this way.
|
|
|
|
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 in the form that the device got it: ``a1,d4,a1``.
|
|
|
|
This is the other half of the split that :func:`route` names. It is the *spec*: hashes
|
|
with commas between them, exactly what ``--path`` accepts back. Thus it is the only
|
|
line on the two faces that makes a round trip, and it must never get a name, an
|
|
arrow, or a space.
|
|
|
|
Args:
|
|
hops: The hashes of the hops, in the order of propagation.
|
|
|
|
Returns:
|
|
The spec, joined with commas, or :data:`NONE` when there are no hops.
|
|
"""
|
|
parts = [hop for hop in hops if hop]
|
|
return ",".join(parts) if parts else NONE
|
|
|
|
|
|
def columns(*headers: str, right: Sequence[str] = ()) -> Table:
|
|
"""Build the scripted table: a header line in upper case, then padded records.
|
|
|
|
The shape that ``ps`` and ``df`` print: no box, no title, and no padding at the edges.
|
|
Each column has the width of its widest value, and :data:`GUTTER` spaces separate the
|
|
columns. Nothing is wrapped, and nothing is cut with an ellipsis. Numeric lanes align
|
|
to the right. Thus the eye can compare the magnitudes in a column, and the result of a
|
|
split of the column does not change. An *age* lane aligns in the same way, so that the
|
|
scale from ``now`` to ``4w`` reads down the column.
|
|
|
|
Args:
|
|
*headers: The column headings, already in upper case.
|
|
right: The headings (from ``headers``) whose values align to the right.
|
|
|
|
Returns:
|
|
A Rich :class:`Table` that is ready for ``ctx.ui.show``.
|
|
"""
|
|
table = Table(
|
|
box=None,
|
|
show_edge=False,
|
|
pad_edge=False,
|
|
expand=False,
|
|
# (top, right, bottom, left): all of the gutter is on the right of each cell. Thus
|
|
# no line starts with an indent, and the last column ends where its value ends.
|
|
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". Neither one cuts text with an ellipsis: neither one
|
|
# ever writes the "…" that makes 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. Thus "ignore" discards ``justify="right"`` without a
|
|
# warning. You can think that the two settings are different only past the
|
|
# 16384 cells of the console. But there, "crop" cuts and "ignore" also cuts.
|
|
# They are different on each line before that point, where only "crop"
|
|
# aligns.
|
|
overflow="crop",
|
|
)
|
|
return table
|
|
|
|
|
|
def pairs(rows: Iterable[tuple[str, str]]) -> Table:
|
|
"""Build the scripted key/value listing: the key, then the value, aligned, no header.
|
|
|
|
A set of facts about one thing prints in this form (``meshterm info``, ``config
|
|
show``, ``trace``), the shape of ``sysctl -a``. The keys are the keys that the related
|
|
``get``/``set`` subcommand accepts. Thus a person can type a line from ``show`` back
|
|
in.
|
|
|
|
Args:
|
|
rows: ``(key, value)`` pairs, in the order of display.
|
|
|
|
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 column layout with fixed widths, for output whose widths come with the data.
|
|
|
|
:func:`columns` sizes each lane to its widest value. It can do this only after it has
|
|
all the records. A live capture never has all the records: ``monitor`` prints a row
|
|
when a packet arrives, and the next row can be two times as wide. Before, those two
|
|
streams joined their fields with the gutter and had no alignment at all. For this
|
|
reason, a name in one of them still had its quotes: nothing else showed where the
|
|
field ended.
|
|
|
|
Lanes that are fixed at the start correct this problem fully. A value that is wider
|
|
than its lane **goes past its lane and pushes the rest of that row to the right**. It
|
|
is not cut with an ellipsis. One row of many is wider, and nothing gives false
|
|
information about what the row contains. Put the one field that has no width limit
|
|
(the name of a node, the body of a message) last. Then it pushes nothing at all.
|
|
|
|
Attributes:
|
|
widths: ``(header, width)`` for each lane, in order.
|
|
right: The headers whose cells align to the right.
|
|
"""
|
|
|
|
widths: tuple[tuple[str, int], ...]
|
|
right: frozenset[str] = frozenset()
|
|
|
|
@property
|
|
def header(self) -> str:
|
|
"""The 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 with 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 layout with fixed lanes for a live stream (refer to :class:`Lanes`).
|
|
|
|
Args:
|
|
*widths: ``(header, width)`` for each lane, in order.
|
|
right: The headings whose values align to the right.
|
|
|
|
Returns:
|
|
The layout, which makes its own header line and formats each record.
|
|
"""
|
|
return Lanes(widths=widths, right=frozenset(right))
|
|
|
|
|
|
# -- the console ---------------------------------------------------------------------
|
|
|
|
|
|
class _Trimmed:
|
|
"""A text stream that removes the trailing whitespace from each line that goes through.
|
|
|
|
Rich pads the cells of a table to the width of their column, also the cells of the
|
|
last column. Without this stream, each record ends with invisible spaces. The stream
|
|
is the only place that catches all of them (table padding, a justified :class:`Text`,
|
|
a blank line that Rich padded), and no renderable has to know about it.
|
|
|
|
The stream holds back only the *trailing run of whitespace*, never a full line. Rich
|
|
writes a rendered row one segment at a time, and on Windows it flushes after each
|
|
segment. Thus a wrapper that waits for the newline before it decides must decide
|
|
dozens of times in each row. And a wrapper that trims at each flush removes the
|
|
padding that separates two columns.
|
|
|
|
All the text up to the last character that is not a space goes through immediately.
|
|
The spaces after it wait until the stream knows if a newline or another column
|
|
follows. At the end of the stream, nothing follows, and the held spaces are truly
|
|
trailing whitespace. Thus the stream removes them.
|
|
"""
|
|
|
|
def __init__(self, stream: Any) -> None:
|
|
"""Wrap ``stream``, and hold back the trailing whitespace that arrives."""
|
|
self._stream = stream
|
|
self._held = ""
|
|
|
|
def write(self, text: str) -> int:
|
|
"""Write ``text``, but write whitespace only after 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, but keep the held whitespace.
|
|
|
|
This method intentionally writes nothing. A flush comes between two columns as
|
|
often as at the end of a line, and it does not tell which of the two it is.
|
|
"""
|
|
self._stream.flush()
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
"""Delegate all other attributes (``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. The default is ``sys.stdout``.
|
|
|
|
Returns:
|
|
A :class:`~rich.console.Console` that writes 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 that is not in cp1252 must not raise on an old Windows console
|
|
reconfigure(encoding="utf-8")
|
|
except (ValueError, OSError): # pragma: no cover - the stream cannot be reconfigured
|
|
pass
|
|
return Console(
|
|
file=_Trimmed(target),
|
|
# Not `no_color`, which keeps bold, dim, reverse, and the other attributes, and
|
|
# removes only the colours. `color_system=None` is the only setting with which
|
|
# Rich writes no escape sequence at all.
|
|
color_system=None,
|
|
width=WIDTH,
|
|
# Without this, Rich finds a real terminal and soft-wraps to its width.
|
|
soft_wrap=True,
|
|
highlight=False,
|
|
emoji=False,
|
|
# A node broadcasts its own name, so each name on this console is remote data.
|
|
# Rich reads `[...]` as a style tag. Before, `[bold]Loud` printed as `Loud`: the
|
|
# name changed without a warning, and it was no longer the string that identifies
|
|
# the node. Also, `[/]Bob` raised MarkupError, and the full command stopped. The
|
|
# scripted CLI never prints markup, so here the parser can only cause errors.
|
|
markup=False,
|
|
)
|
|
|
|
|
|
def stderr_console() -> Console:
|
|
"""The console for all the output that is *about* the run, and not part of its answer.
|
|
|
|
stdout contains only the answer to the command. Thus a progress bar, a log line, an
|
|
acknowledgement, and an error message all go to this console instead. They are
|
|
visible in a terminal, but they are not in ``meshterm contacts > contacts.txt``. This
|
|
console keeps its colour, because usually only a terminal reads it.
|
|
|
|
**When it does not write to a terminal, it does not wrap.** That is the purpose of the
|
|
width below. When Rich cannot measure the destination, it uses 80 cells. Thus
|
|
``2> errors.log`` wrapped each message at 80 cells, and then
|
|
``grep 'not already connected'`` found nothing, because the logger broke the sentence
|
|
across three lines. In a real terminal, the console does not change the width: a
|
|
progress bar that is 16384 cells wide is useless as a progress bar.
|
|
|
|
Returns:
|
|
A :class:`~rich.console.Console` with the theme, that writes to ``sys.stderr``.
|
|
"""
|
|
import sys
|
|
|
|
from .theme import active_theme
|
|
|
|
reconfigure = getattr(sys.stderr, "reconfigure", None)
|
|
if reconfigure is not None:
|
|
try: # the ✓ of an acknowledgement must not depend on the code page of the machine
|
|
reconfigure(encoding="utf-8")
|
|
except (ValueError, OSError): # pragma: no cover - the stream cannot be reconfigured
|
|
pass
|
|
try:
|
|
wraps = bool(sys.stderr.isatty())
|
|
except (AttributeError, ValueError): # pragma: no cover - a closed or unusual stream
|
|
wraps = False
|
|
return Console(
|
|
file=sys.stderr,
|
|
theme=active_theme(),
|
|
markup=False,
|
|
emoji=False,
|
|
width=None if wraps else WIDTH,
|
|
)
|
|
|
|
|
|
# -- the fallback --------------------------------------------------------------------
|
|
|
|
|
|
def flatten(renderable: RenderableType) -> list[RenderableType]:
|
|
"""Remove the frames from a renderable that comes to the scripted console with them.
|
|
|
|
Each CLI surface is *written* plain: a tool gives its answer as a
|
|
:mod:`~meshterm.ui.report`, and the plain renderer builds it through :func:`columns`
|
|
and :func:`pairs`. This function is the fallback under that rule. It is for a
|
|
renderable that the menu also uses and that still comes in a box. A :class:`Panel`
|
|
loses its border and title, and the function returns its body. A :class:`Table`
|
|
loses its box, its title, and its expansion. The function flattens a :class:`Group`
|
|
one member at a time.
|
|
|
|
It is intentionally not a *design*. The table of a screen has the columns of the
|
|
menu, not the columns of the CLI. Thus this function makes such a table printable,
|
|
not correct.
|
|
|
|
Args:
|
|
renderable: The renderable that a tool gave to
|
|
:meth:`~meshterm.ui.surface.PlainUi.show`.
|
|
|
|
Returns:
|
|
The renderables without frames, 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, to separate a listing from the block after it."""
|
|
return Text("")
|