Files
Jean-Pierre MartineauandClaude Opus 5.5 abfd32c491 Docstrings and comments of meshterm/ui are in ASD-STE100
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>
2026-10-06 20:37:26 -04:00

259 lines
13 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""What a tool answers with, stated as data before anything decides how it looks.
A scripted run once said its answer when it *printed* it. ``Tool.run`` called
``ctx.ui.show(<a table built by ui/script.py>)`` and returned a :class:`ToolResult` that
had only bookkeeping. When the CLI boundary got that result, the contacts had already
gone down the pipe as a table that spaces aligned. Thus no data was left to give to a
second format. This is the reason that ``--json`` reached two commands out of twenty and
stopped. Each command that wanted it got an ``if ctx.json_output:`` branch *above* the
rendering. That branch stated the whole answer again in a second dialect, which no other
command could reuse.
``exit_code`` already had the shape that works. The tool states it, the boundary changes
it to a process status, and no tool has ever called :func:`sys.exit`. This module lets
the answer travel the same way:
**A tool states its answer as data. A renderer turns data into bytes. The CLI
boundary picks the renderer** (refer to :mod:`meshterm.ui.renderers`).
A report is a short sequence of blocks. There are only two types of block, because the
plain face needed only two: a :class:`Listing` (records under a header line) and
:class:`Facts` (one thing, described key by key). A :class:`Facts` block also covers the
answer of one scalar, through :data:`BARE`. ``config get`` prints its value alone
*because the caller named the key*, but the machine face still gets the object with the
key, the type, and the label in it. A third type of block for this answer would have
given a second name to the same data.
**A row holds the typed value.** Examples are ``6.0``, ``None``, and a
:class:`~datetime.datetime`. A row never holds a formatted cell. Each :class:`Column`
has both projections instead: the plain :class:`Lane` that changes the value to text, and
the JSON function that normalises it. A row is typed one time and has two projections.
If a third format is necessary later, it is one more function, not one more builder of
rows. :mod:`meshterm.ui.fields` builds the columns for the concepts that the
whole app shares (a node, a time, an SNR, a route). Thus each listing has the same shapes
because of the construction, not because of a review.
Nothing here renders. A block does not know what a gutter is, and a column does not know
if anyone will ask it for JSON.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Any
#: How a :class:`Facts` block reaches the *plain* face. The machine face is the same
#: object in each case. This only chooses what a person at a prompt sees.
PAIRS = "pairs"
#: The value of one named field, alone, with no key before it. This is what ``config get``
#: prints. The caller named the key, so a print of the key again is one more thing that
#: the caller must remove.
BARE = "bare"
#: Nothing at all. This is for an acknowledgement (``config advert``, ``channels join``)
#: whose plain report is its exit status. The machine face still gets a document, because
#: "prints nothing" is an answer that a person can use and a program cannot.
SILENT = "silent"
def _text(value: Any) -> str:
"""The plain projection of last resort: the value as text, or the absent token."""
from . import script
return script.NONE if value is None else str(value)
def normalise(value: Any) -> Any:
"""A typed value in its machine form: ready for JSON, and the same on each host.
This is the only place where the value rules of the contract that apply to all
commands are applied. Thus a command cannot forget one:
* A **time** is UTC, RFC 3339, with the ``Z`` suffix, to the second. It always has
twenty characters, so a comparison of strings is a comparison of times. A local
offset is a fact about the machine that ran the command, not about the event. Two
hosts that ask the same radio the same question must produce the same document. A
*naive* time is a time with an offset that we do not know. This is not a fact, so
it is absent, the same as on the plain face.
* An **enum** is its value, not its Python name.
* A **path** is its text.
* Everything else travels as it is, typed: ``9`` and not ``"9"``, ``false`` and not
``"false"``, ``None`` and not the ``-`` of the plain face.
Args:
value: The typed value from a report row.
Returns:
The value in a form that :mod:`json` can write.
"""
if isinstance(value, datetime):
if value.tzinfo is None:
return None
return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
if isinstance(value, Enum):
return normalise(value.value)
if isinstance(value, Path):
return str(value)
if isinstance(value, (list, tuple)):
return [normalise(item) for item in value]
return value
@dataclass(frozen=True, slots=True, kw_only=True)
class Lane:
"""One plain-text column, drawn from the typed value of its :class:`Column`.
A lane is the half of a column that a person reads. Several lanes can share one column
when the plain face uses more room for a concept than the machine face does. A node is
one JSON object and up to four plain columns (its name, its role, its hash, and its
key). This is the reason that a column owns a list of lanes and not one header string.
Attributes:
header: The heading in upper case, and the field that a caller searches for with
grep. If a header is renamed, a script breaks with no error. Thus a header is
a contract, the same as the exit status.
render: Changes the typed value of the column to the cell of this lane.
align: ``"right"`` for a lane of magnitudes. The eye can compare a column of
numbers or ages only when the digits line up.
width: The number of cells to which this lane is pinned in a *live* listing. In
a live listing, MeshTerm cannot measure the widths because the records have
not arrived. An ordinary listing ignores this value and sizes each lane to
its widest value.
"""
header: str
render: Callable[[Any], str] = _text
align: str = "left"
width: int = 0
@dataclass(frozen=True, slots=True, kw_only=True)
class Column:
"""One concept in a report, with a projection for each face.
Attributes:
key: The name of this concept: the JSON key, and the key at which the mapping of a
row holds its typed value.
lanes: The plain columns that this concept draws, usually one. **Empty means that
the concept has no plain face.** This is how ``config show`` puts the type
and the enum label of a setting in the document, but prints only the two
fields that a person wants.
json: The machine projection of the typed value. It is ``None`` when the concept
has no machine face. This is the opposite of an empty ``lanes``. For example,
the DESCRIPTION of a preference is prose for the person who chooses a value.
It has nothing that a program uses to make a decision. The default is
:func:`normalise`, which handles the scalars. A shared shape (a node, a
position) passes its own function.
"""
key: str
lanes: tuple[Lane, ...] = ()
json: Callable[[Any], Any] | None = normalise
@dataclass(frozen=True, slots=True, kw_only=True)
class Block:
"""One part of an answer.
Attributes:
key: The name this block goes under in a multi-block document.
caption: The heading that a format with headings draws above this block. The plain
face has no heading, because a heading over a block of keys and values is
only decoration on a face that is based on lines. This is also the reason
that ``config show`` removes even its column headers. The machine face has
``key`` instead. The markdown projection makes the caption a ``##`` section.
This gives a written page the landmarks that its section jumps and its sticky
headings step by. Empty means that the block is drawn without a heading.
plain_only: Whether this block is a rendering for a person that the machine face
does not use. The only case is the closing summary of a stream. ``monitor``
prints an aggregate for each node under its live capture. A consumer that has
each record of the capture can compute the same aggregate. A second *shape*
of line in the middle of an NDJSON stream with one shape forces each
consumer to have a discriminator that it does not otherwise need.
"""
key: str
caption: str = ""
plain_only: bool = False
@dataclass(frozen=True, slots=True, kw_only=True)
class Listing(Block):
"""Records under a header line: a listing on the plain face, an array of objects in JSON.
Attributes:
columns: The concepts that each record has, in the order of the machine face.
rows: One mapping for each record. Its keys are ``Column.key`` and it holds
**typed** values.
order: The lane headers in the draw order of the plain face. Empty means "as
declared", which is what most listings want. A listing states an order when
the two orders are really different. ``contacts`` puts HEARD and PKTS between
the TYPE and the HASH of a node. ``monitor`` puts the one field with no limit
(the name of a node) last, so that it can never push a column.
headed: Whether the plain face draws the header line. ``config show`` is the only
listing that does not. To a parser it is an array of settings, and to a person
it is a ``sysctl -a`` block. A header line over two columns, when their keys
are the answer, would be only decoration.
"""
columns: tuple[Column, ...]
rows: Sequence[Mapping[str, Any]] = ()
order: tuple[str, ...] = ()
headed: bool = True
def __post_init__(self) -> None:
"""Reject an ``order`` that does not name the lanes of this listing.
A typo here draws a listing with a column missing and gives no error. A header
line cannot show this failure. It costs less to raise an error at construction.
"""
if not self.order:
return
known = {lane.header for column in self.columns for lane in column.lanes}
if set(self.order) != known or len(self.order) != len(known):
raise ValueError(f"{self.key}: order must name every lane exactly once")
def lanes(self) -> list[tuple[Column, Lane]]:
"""The plain columns in draw order, each with the column that it reads from."""
pairs = [(column, lane) for column in self.columns for lane in column.lanes]
if not self.order:
return pairs
by_header = {lane.header: (column, lane) for column, lane in pairs}
return [by_header[header] for header in self.order]
@dataclass(frozen=True, slots=True, kw_only=True)
class Facts(Block):
"""One thing, described key by key: a ``sysctl -a`` block on the plain face, an object in JSON.
Attributes:
fields: The concepts, in the order in which both faces show them.
values: The typed value for each ``Column.key``.
shape: :data:`PAIRS`, :data:`BARE`, or :data:`SILENT`. This is what the *plain*
face prints. The machine face is the object in each case.
bare: With :data:`BARE`, the one field whose value is printed alone.
omit_absent: Whether the plain block leaves out a field that has no value. Only
``info`` needs this, and it is deliberate. Its readings are optional device
queries, so a missing key says "this radio does not report it". A dash would
say that the radio reported nothing. The machine face keeps each declared key
and writes ``null``. To a caller, "does not report" and "reported nothing" are
the same fact. A set of keys that changes would force each consumer to test
if a key is present, for each field.
"""
fields: tuple[Column, ...]
values: Mapping[str, Any] = field(default_factory=dict)
shape: str = PAIRS
bare: str = ""
omit_absent: bool = False
#: The whole answer of a tool. It is a plain sequence and not a class, on purpose. A
#: report does nothing. The two rules for the way in which several blocks combine belong
#: to the renderer that combines them (refer to :mod:`meshterm.ui.renderers`).
Report = tuple[Block, ...]