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

576 lines
24 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The shared shapes that a report is built from, with one constructor for each concept.
A node has the same meaning in ``contacts``, ``monitor``, ``courier list``, ``trace``, and
``records``. It must look the same in all five, and a program must parse it the same in
all five. A review cannot guarantee this, but the construction can. Each listing that
mentions a node asks this module for the column. Thus there is only one definition of the
names of the plain lanes of a node and of the content of its machine object.
Each constructor returns one :class:`~meshterm.ui.report.Column`. The column has both
projections of one typed value. A row holds the typed value, for example a
:class:`NodeRef`, a :class:`Position`, a :class:`~datetime.datetime`, or a bare float. A
row never holds a formatted cell. Thus the plain face can print ``+5.1`` and the machine
face can print ``5.1``, and neither face parses the output of the other face.
The most important constructor is :func:`node`, and it is the reason for the design:
**one machine key expands to as many plain columns as the listing has room for.**
``contacts`` uses four (a name, a role, a hash, and a key). The live stream of ``monitor``
uses two. The table of hops of a trace uses one. But all four documents have the same
object with five keys, because the lanes are a choice of rendering and the shape is not.
There are two absences, and they are separate on purpose. ``None`` means "we do not know".
In the plain face it is :data:`~meshterm.ui.script.NONE`, and in JSON it is ``null``. A
word that replaces an absence is a different thing. ``unknown`` in a ``TYPE`` column is a
word that the user understands, so the plain face keeps it. But the document writes
``null``, because a consumer already has one spelling for "there is nothing here". A
second spelling makes a special case for the consumer.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING, Any
from . import script
from .report import Column, Lane, normalise
if TYPE_CHECKING:
from ..core.regions import Scope
# -- the shared value shapes -----------------------------------------------------------
@dataclass(frozen=True, slots=True)
class NodeRef:
"""The identity of a node on the mesh, and nothing else.
This is the concept that both faces use most. It has the identity only. The time when
the node was heard, the signal strength, and the position that the node reports belong
to the **row**. They are different in each listing, and a node object that had them
would have a different meaning in each listing.
Attributes:
name: The name that the node advertises. It is ``None`` if no name was ever
heard, also when a listing puts a bare hash in the place of the name.
key: The full public key, in lowercase hex. It is **never truncated**, because the
caller cannot give a truncated key back to ``--to`` or ``--path``. It is
``None`` if only a hash was ever heard.
hash: The short id that comes from the key. Its width is the width that the
listing uses to address the node. This module never makes it from ``key`` by
its own choice.
type: The advertised role, in the words of the lexicon (``node``, ``repeater``,
``room``, ``sensor``). It is ``None`` if the advert did not give it.
is_self: Whether this is our node. The plain face draws it like any other hop, on
purpose. "You already know who this is" is true for the person at the prompt.
It is not true for the person who reads the file afterwards. The machine face
gives it as ``self``, because a parser has no way to find it.
"""
name: str | None = None
key: str | None = None
hash: str | None = None
type: str | None = None
is_self: bool = False
@dataclass(frozen=True, slots=True)
class Position:
"""A shared location, in decimal degrees.
The document has the values as they are stored, not rounded. The five decimals of the
plain face are a concession to the width of the column. A consumer must not get that
concession.
"""
lat: float
lon: float
@dataclass(frozen=True, slots=True)
class ChannelRef:
"""The identity of one channel slot. The secret is not in it, on purpose.
A channel object goes through listings and send receipts. If a key went with it in all
of them, commands would print a key when nobody asked for one. The key appears only
where the caller asked for it (``channels share``). It is in its own field, beside
this object.
Attributes:
slot: The index, from 0, that the ``INDEX`` of each ``channels`` subcommand takes.
name: The channel name.
public: Whether the key comes from the name (a ``#`` channel or a default of the
firmware) and is not secret.
hash: The channel hash, in lowercase hex.
"""
slot: int
name: str
public: bool
hash: str | None = None
@dataclass(frozen=True, slots=True)
class Rendered:
"""A typed value, with the one plain text that only its own registry can make.
The cell of almost every column can come from its value alone. Thus a
:class:`~meshterm.ui.report.Lane` can be a function of one argument. A *setting* is the
exception. Only the spec of a key can tell what ``config set`` accepts for that key.
An enum needs its number. An empty string needs ``""``. A preference does not have the
unit that its page shows. The spec is for each row and not for each column.
Thus the row has both parts, and the code makes them one time, where the spec is
available. The plain face prints ``text`` and the document emits ``value``. Neither
face must parse the output of the other face. The rest of this module has the same
rule, but it keeps it in other ways.
Attributes:
value: The typed value. It is ``None`` if the source never reported a value.
text: The text that the plain face prints, in the form that the matching ``set``
accepts.
"""
value: Any
text: str
def node_json(ref: NodeRef | None) -> dict[str, Any] | None:
"""One node as its machine object, or ``null`` if there is no node."""
if ref is None:
return None
return {
"name": ref.name or None,
"key": (ref.key or None) and ref.key.lower(),
"hash": (ref.hash or None) and ref.hash.lower(),
"type": ref.type or None,
"self": ref.is_self,
}
def position_json(where: Position | None) -> dict[str, Any] | None:
"""One position as its machine object, or ``null`` if no position was ever shared.
The whole object is ``null``. It is not a pair of ``null`` values, because a latitude
without a longitude is not half of a location. It is no location.
"""
return None if where is None else {"lat": where.lat, "lon": where.lon}
def channel_json(channel: ChannelRef | None) -> dict[str, Any] | None:
"""One channel as its machine object, or ``null`` for an empty slot."""
if channel is None:
return None
return {
"slot": channel.slot,
"name": channel.name,
"public": channel.public,
"hash": (channel.hash or None) and channel.hash.lower(),
}
# -- the constructors ------------------------------------------------------------------
#: How each plain lane of a node makes its cell. A listing selects the lanes that it has
#: room for. The machine object has the same five keys in each case.
_NODE_LANES = {
"name": lambda ref: script.name(ref.name if ref else None),
# The word, not a glyph. A `▲` with no colour needs a legend, and the CLI has no
# legends. `unknown` is a word that the user can act on. The document writes `null`.
"type": lambda ref: ref.type if ref and ref.type else "unknown",
"hash": lambda ref: ((ref.hash or "").lower() if ref else "") or script.NONE,
"key": lambda ref: ((ref.key or "").lower() if ref else "") or script.NONE,
}
def node(key: str = "node", *, lanes: Sequence[tuple[str, str]] = (("name", "NAME"),)) -> Column:
"""One node: the shared object with five keys, and the plain lanes that fit.
Args:
key: The machine key. It is also the key at which the row holds its
:class:`NodeRef`.
lanes: ``(part, header)`` pairs that name the plain columns to draw, in order. The
parts are ``name``, ``type``, ``hash``, and ``key``. A listing that has one
column to use draws the name. A table of hops draws the hash, because the hash
joins the table to the route line above it.
Returns:
The column.
"""
return Column(
key=key,
lanes=tuple(Lane(header=header, render=_NODE_LANES[part]) for part, header in lanes),
json=node_json,
)
def position(key: str = "position", header: str = "LOCATION") -> Column:
"""A shared location: one plain column, one machine object.
It is one column and not two. A pair of coordinates is one fact for the user. The user
puts the pair as it is in the search box of a map. Two columns of ``-`` for each node
that never shared a position are worse than one column.
"""
return Column(
key=key,
lanes=(Lane(header=header, render=_position_cell),),
json=position_json,
)
def channel(
key: str = "channel",
*,
lanes: Sequence[tuple[str, str]] = (("name", "NAME"),),
) -> Column:
"""One channel slot: the shared object with four keys, and the plain lanes that fit.
Args:
key: The machine key.
lanes: ``(part, header)`` pairs. The parts are ``slot``, ``name``, ``type``, and
``hash``. ``type`` is the word of the plain face for the boolean ``public``.
``public`` and ``private`` are clear in a column. With ``true`` and ``false``,
the heading must say which value has which meaning.
"""
parts = {
"slot": lambda c: script.number(c.slot if c else None),
"name": lambda c: script.name(c.name if c else None),
"type": lambda c: ("public" if c.public else "private") if c else script.NONE,
"hash": lambda c: ((c.hash or "").lower() if c else "") or script.NONE,
}
return Column(
key=key,
lanes=tuple(Lane(header=header, render=parts[part]) for part, header in lanes),
json=channel_json,
)
def scope_json(scope: Scope | None) -> dict[str, Any] | None:
"""The scope of a flood as its machine object, or ``null`` if the packet has none.
``state`` is ``scoped`` (``region`` gives the name), ``unknown`` (it is scoped, but no
known region gives ``code``), or ``unscoped``. ``code`` is the first transport code of
the packet, in lowercase hex. It is kept also when it is not resolved, so that you can
see that two packets have the same region. A direct packet is ``null``. No repeater
filters it by region, so it has no scope to report.
"""
if scope is None:
return None
return {"state": scope.state, "region": scope.region, "code": scope.code}
def _scope_cell(scope: Scope | None) -> str:
"""The plain word for a scope: the region, ``unknown`` (not resolved), or ``unscoped``."""
if scope is None:
return script.NONE
if scope.state == "scoped" and scope.region:
return script.name(scope.region)
return "unknown" if scope.scoped else "unscoped"
def scope(key: str = "scope", header: str = "SCOPE") -> Column:
"""The scope of a flood as one lane and one object.
The scope is the region in which a repeater relays the flood. The row holds a
:class:`~meshterm.core.regions.Scope`, or ``None`` for a direct packet. The plain lane
is a word and never the code. A hash of four digits in a column of region names looks
like a region that nobody has. The document has the code for a consumer that needs it.
"""
return Column(key=key, lanes=(Lane(header=header, render=_scope_cell),), json=scope_json)
def regions(key: str = "regions", header: str = "REGIONS") -> Column:
"""A set of region names: joined in the plain face, an array in the document.
The regions are what a repeater relays. The row holds a sequence of bare names and
never the wildcard. "Unscoped" is a separate concept (a :func:`flag` beside this
column), because ``*`` is not the name of a region. If it was in the array, a consumer
that reads the array for names would have to know that it must skip it. An empty
sequence is a real answer: "no regions". It is the word ``none`` in the plain face and
``[]`` in the document. ``None`` means "not known". It is the absent token in the plain
face and ``null`` in the document.
"""
return Column(
key=key,
lanes=(Lane(header=header, render=_regions_cell),),
json=lambda names: None if names is None else [str(n) for n in names],
)
def when(key: str, header: str, *, absent: str = script.NONE) -> Column:
"""A time that the user reads as an age: ``5m``, ``3h``, ``never``.
This is the default form for each time in a listing. The user typed the command to ask
"was it recent?". An ISO instant makes the user do arithmetic to get the answer.
``--absolute`` changes the whole language back for one run. The document is always in
UTC, because it is read in another place and often at a later time.
Args:
key: The machine key. By convention it ends in ``_at``. A JSON key is a type hint
for a parser, and the suffix is the hint.
header: The plain heading. It must name the *fact* (``HEARD``, ``RECORDED``) and
not the form. ``--absolute`` changes the form under the heading, and a column
with the heading ``AGE`` that holds an ISO instant has a heading that is not
true.
absent: The plain text for an unknown time. Use ``never`` if the row exists and the
event did not occur (a node that was not heard yet, or a conversation with no
message in it). Use the default token if the row has no such time.
Returns:
The column.
"""
return Column(
key=key,
lanes=(Lane(header=header, render=lambda v: _time(v, absent=absent), align="right"),),
)
def instant(key: str, header: str) -> Column:
"""A time that is the fact itself. It is always printed as an absolute time.
The setting of ``--absolute`` does not change this. Three listings need this, and they
have the same reason: the instant is the answer, and it is not a way to say how long
ago something occurred. The first is the clock of the device. The second is an
appointment that you set with ``--at``, which is a wall-clock time that the caller
selected. The third is the ``TIME`` column of a live capture, where each row would
otherwise be ``now``.
"""
return Column(key=key, lanes=(Lane(header=header, render=script.stamp),))
def snr(key: str, header: str) -> Column:
"""A signal-to-noise reading: ``+5.1`` in the plain face, ``5.1`` in the document.
The plain sign is a convention of the column. A lane of readings is easy to compare
only when each reading shows its direction. A number in JSON has its own sign, and a
``+`` at the start would make it a string.
"""
return Column(
key=key,
lanes=(Lane(header=header, render=lambda v: script.number(v, "+.1f"), align="right"),),
json=lambda v: None if v is None else round(float(v), 1),
)
def integer(key: str, header: str) -> Column:
"""A whole number, aligned right so that the user can compare a column of them by eye."""
return Column(key=key, lanes=(Lane(header=header, render=script.number, align="right"),))
def decimal(key: str, header: str, spec: str = ".1f") -> Column:
"""A measured number. It is formatted for the column and is not rounded in the document."""
return Column(
key=key,
lanes=(Lane(header=header, render=lambda v: script.number(v, spec), align="right"),),
)
def flag(key: str, header: str) -> Column:
"""A boolean: ``yes`` and ``no`` for the user, ``true`` and ``false`` for a parser.
``None`` is a third answer and stays a third answer. ``acked`` on a channel message is
not ``false``. It means "a channel has no acknowledgement". These are different things
for a person who counts delivery failures.
"""
return Column(
key=key,
lanes=(Lane(header=header, render=_flag_cell),),
)
def word(key: str, header: str) -> Column:
"""A member of a closed set (a state, a role, or an outcome), as its own lowercase word.
Both faces use the same word. Thus the ``no ack`` of ``courier send`` is the same in a
document as on the line where the help lists it.
"""
return Column(key=key, lanes=(Lane(header=header, render=lambda v: v or script.NONE),))
def name(key: str, header: str) -> Column:
"""A bare name that is not the name of a node: a channel label, a conversation, or a device."""
return Column(key=key, lanes=(Lane(header=header, render=script.name),))
def path(key: str, header: str) -> Column:
"""A place in this filesystem: a config directory, a database, or a new file.
This is a separate constructor and not :func:`word`. The typed value is a
:class:`~pathlib.Path`, and no other part of the lane vocabulary changes one into text.
``word`` gives it directly to Rich, which cannot render it. The machine face was
already correct: :func:`~meshterm.ui.report.normalise` always wrote a Path as its
string. Thus this constructor completes the plain half of a shape that had only one
half.
The plain face prints the path as the host writes it, with the separators. The user
usually pastes a path into their own shell.
"""
return Column(key=key, lanes=(Lane(header=header, render=_path_cell),))
def free(key: str, header: str) -> Column:
"""Free text that a stranger wrote. It is escaped so that it can never end its own record.
It is always the last lane in a listing, because it is the only field with no limit on
its width. The document has it **raw and not shortened**. A JSON string can hold a
newline, so the escaping is necessary only in plain text. The code must not do it for
a consumer.
"""
return Column(key=key, lanes=(Lane(header=header, render=script.text),))
def route(key: str = "route", header: str = "ROUTE") -> Column:
"""A sequence of hops that a trace walked: arrows in the plain face, an array in the document.
The document has an array of nodes. The row holds :class:`NodeRef` objects in the
order of propagation. Thus the hops of the document are the same objects that the rows
of a listing have, and a consumer writes its node handler only one time. The value is
``None`` if no answer came back. It is never ``[]``, because that would say that the
walk had no hops.
"""
return Column(
key=key,
lanes=(
Lane(
header=header,
render=lambda hops: (
script.route([(h.name, h.hash) for h in hops]) if hops else script.NONE
),
),
),
json=lambda hops: None if hops is None else [node_json(hop) for hop in hops],
)
def spec(key: str = "path", header: str = "path") -> Column:
"""A forced path exactly as the radio got it: ``a1,d4,a1``, or absent.
This is the one line on either face that can go back into ``--path``, so it keeps its
commas and has nothing added. ``None`` means that the device selected the route of the
walk. The plain face says ``auto``, because the user needs a word there. The document
says ``null``, because ``auto`` is not a path spec and R3 already has a token for
"there is no spec here".
"""
return Column(
key=key,
lanes=(Lane(header=header, render=lambda v: v if v else "auto"),),
)
def rendered(key: str, header: str) -> Column:
"""The value of a setting: text that ``set`` accepts (plain), the typed value (document).
This column takes a :class:`Rendered`. Refer to it for the reason why this one concept
needs both parts in the row. The plain face is designed for a round trip in these
commands: ``config show > f`` and then the input of ``f`` must work. The typed value
in the document is the largest gain of the machine face over the plain face: ``9`` and
not ``"9"``.
"""
return Column(
key=key,
lanes=(Lane(header=header, render=_rendered_cell),),
json=lambda r: None if r is None else r.value,
)
def hexid(key: str, header: str) -> Column:
"""A hash or a key by itself, in lowercase. It is never truncated and never elided."""
return Column(
key=key,
lanes=(Lane(header=header, render=lambda v: (v or "").lower() or script.NONE),),
json=lambda v: (v or None) and str(v).lower(),
)
def note(key: str, header: str) -> Column:
"""Prose for the user, with no machine face.
The DESCRIPTION of a preference is an example. It explains a preference to a person who
decides whether to change it, and a program has no decision to make from it. The
listing declares it as a column and does not add it in a hidden way. Thus the two
faces of the listing are stated in one place.
"""
return Column(key=key, lanes=(Lane(header=header, render=script.text),), json=None)
def plain_only(column: Column) -> Column:
"""The same concept, drawn for the user and not in the document.
Use it for a lane that the machine face says in a better way in another place, and not
that it does not say it. The ``PEER`` of a transcript is an example. It is one column
that holds a channel or a contact, whichever the conversation had, because the user
has the conversation in front of them. The document keeps ``channel`` and ``node``
separate, because a parser must know which of the two it has, and it cannot find that
from the label.
"""
from dataclasses import replace
return replace(column, json=None)
def hidden(key: str) -> Column:
"""A fact that the document has and the plain face has no room for.
It is the opposite of :func:`note`. ``config show`` prints the key and the value of a
setting, because the user wants these. The document also says the type of the setting,
the name of the number of an enum, and whether the value was withheld.
"""
return Column(key=key)
def _regions_cell(names: Sequence[str] | None) -> str:
"""The plain cell of a region set: names joined with commas, ``none`` if empty, or absent."""
if names is None:
return script.NONE
return ", ".join(script.name(n) for n in names) if names else "none"
def _rendered_cell(value: Rendered | None) -> str:
"""The cell of one setting: the text that its own spec made, or the absent token."""
return script.NONE if value is None else value.text
def _path_cell(value: Any) -> str:
"""A path as its own text, or the absent token if there is no path."""
return script.NONE if value is None else str(value)
def _position_cell(where: Position | None) -> str:
"""The cell of one position: the pair of coordinates, or the absent token."""
return script.NONE if where is None else script.location(where.lat, where.lon)
def _flag_cell(value: bool | None) -> str:
"""The cell of one boolean. ``None`` stays an absence and does not become ``no``."""
return script.NONE if value is None else ("yes" if value else "no")
def _time(value: Any, *, absent: str) -> str:
"""Render a time in the language that this run uses (refer to :func:`when`).
``absent`` stays when the run changes to timestamps, because it is not a choice of
rendering. ``never`` says that the event did not occur. The user asked for
``--absolute`` to get a different *form* of time, and not to get one fact less.
"""
if not isinstance(value, datetime) and value is not None:
return normalise(value) # pragma: no cover - a row with another type is a bug
if value is None:
return absent
return script.stamp(value) if _absolute() else script.age(value, absent=absent)
def _absolute() -> bool:
"""Whether this run prints absolute times.
The function reads the value from the preference registry and not from a flag of the
module. By a rule of this project, how MeshTerm behaves is a preference. Thus
``--absolute`` is that preference, overridden for one run (refer to
:func:`~meshterm.cli.main_callback`). It is not a second switch that has the same
meaning in a place where the user cannot find it.
"""
from ..core.preferences import current
return str(current().cli_time_format) == "absolute"