Files
MeshTerm/meshterm/ui/fields.py
T
Jean-Pierre MartineauandClaude Opus 5 8d2a7b8281 --absolute asks for another form of time, not for one fewer fact
A never-heard node has no instant to print, so the timestamp language dropped
"never" back to the plain absent token and a contacts listing under --absolute
could no longer tell "we have not heard from it" from "this row has no heard
time". The absent word survives the switch; only the form of a real time changes.

Also drops a dead guard in the preferences writer.

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

479 lines
20 KiB
Python

"""The shared shapes a report is built out of — one constructor per concept.
A node means the same thing in ``contacts``, ``monitor``, ``courier list``, ``trace`` and
``records``, and it should *look* the same in all five and parse the same in all five. The
way to guarantee that is not review, it is construction: every listing that mentions a
node asks this module for the column, so there is exactly one definition of what a node's
plain lanes are called and what its machine object holds.
Each constructor returns one :class:`~meshterm.ui.report.Column`, which carries both
projections of one typed value. The typed value is what a row holds — a :class:`NodeRef`,
a :class:`Position`, a :class:`~datetime.datetime`, a bare float — never a formatted cell,
so the plain face can print ``+5.1`` and the machine face ``5.1`` without either one
parsing the other's output back.
The interesting one is :func:`node`, and it is what earns the design: **one machine key
expands to as many plain columns as the surface has room for.** ``contacts`` spends four
(a name, a role, a hash, a key), ``monitor``'s live stream spends two, a trace's per-hop
table spends one — and all four documents carry the same five-key object, because the
lanes are a rendering choice and the shape is not.
Two absences, kept apart on purpose. ``None`` is *we do not know*, and it reads as
:data:`~meshterm.ui.script.NONE` plain and ``null`` in JSON. A word standing in for an
absence is not the same thing: ``unknown`` in a ``TYPE`` column is a word a reader
understands, so the plain face keeps it, while the document writes ``null`` — because a
consumer already has one spelling for "there is nothing here" and a second one costs it a
special case.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from . import script
from .report import Column, Lane, normalise
# -- the shared value shapes -----------------------------------------------------------
@dataclass(frozen=True, slots=True)
class NodeRef:
"""A mesh participant's identity, and nothing else.
The most repeated concept in either face. *Identity only*: when a node was heard, how
strong it came in, where it says it is — those belong to the **row**, because they
differ per surface and a node object that carried them would mean something different
in every one.
Attributes:
name: The name the node advertises, or ``None`` where none was ever heard —
including where a surface substitutes a bare hash for one.
key: The full public key, lowercase hex, **never truncated**: a key cut short is
not something a caller can hand back to ``--to`` or ``--path``. ``None`` where
only a hash was ever heard.
hash: The short derived id, at the width the surrounding surface addressed the
node by — never ``key`` shortened by this module's own choosing.
type: The advertised role in the lexicon's words (``node``, ``repeater``,
``room``, ``sensor``), or ``None`` where the advert never said.
is_self: Whether this is our own node. The plain face draws it like any other hop,
which is the point: "you already know who this is" is true of the person at
the prompt and false of whoever reads the file afterwards. The machine face
carries it as ``self``, because a parser has no way to work it out at all.
"""
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.
Unrounded in the document, as stored: the plain face's five decimals are a column-width
concession and a consumer should not inherit one.
"""
lat: float
lon: float
@dataclass(frozen=True, slots=True)
class ChannelRef:
"""One channel slot's identity. The secret is deliberately not in it.
A channel object travels through listings and send receipts, and a key that rode along
in all of them would be a key printed by commands nobody asked to print one. It
appears only where the caller explicitly asked (``channels share``), under its own
field, beside this.
Attributes:
slot: The 0-based index every ``channels`` subcommand's ``INDEX`` takes.
name: The channel name.
public: Whether the key is derived from the name (a ``#`` channel or a firmware
default) rather than secret.
hash: The channel hash, lowercase hex.
"""
slot: int
name: str
public: bool
hash: str | None = None
@dataclass(frozen=True, slots=True)
class Rendered:
"""A typed value beside the one plain rendering only its own registry can produce.
Almost every cell is derivable from its value alone, which is what lets a
:class:`~meshterm.ui.report.Lane` be a function of one argument. A *setting* is the
exception: what ``config set`` takes back for a given key is a question only that key's
spec can answer — an enum wants its number, an empty string wants ``""``, a preference
drops the unit its page shows — and the spec is per row, not per column.
So the row carries both halves, computed once where the spec is in hand. The plain
face prints ``text`` and the document emits ``value``, and neither has to parse the
other's output to get at what it needs, which is the same rule the rest of this module
keeps by other means.
Attributes:
value: The typed value, or ``None`` where the source never reported one.
text: What the plain face prints, in the form the matching ``set`` accepts back.
"""
value: Any
text: str
def node_json(ref: NodeRef | None) -> dict[str, Any] | None:
"""One node as its machine object, or ``null`` where there is no node at all."""
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`` where none was ever shared.
The whole object is ``null`` rather than a pair of ``null``s: a latitude without a
longitude is not half 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 of a node's plain lanes reads its cell. A surface picks the ones it has room
#: for; the machine object is the same five keys either way.
_NODE_LANES = {
"name": lambda ref: script.name(ref.name if ref else None),
# The word, not a glyph: `▲` without its colour would need a legend and the CLI has no
# legends. `unknown` is a word a reader acts 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 five-key object, and whichever plain lanes fit.
Args:
key: The machine key, and the key the row holds its :class:`NodeRef` at.
lanes: ``(part, header)`` pairs naming the plain columns to draw, in order. The
parts are ``name``, ``type``, ``hash`` and ``key``. A surface with one column
to spend draws the name; a per-hop table draws the hash, because that is what
joins it 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.
One column and not 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 node that has
never shared a position is worse than one.
"""
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 four-key object, and whichever plain lanes fit.
Args:
key: The machine key.
lanes: ``(part, header)`` pairs; the parts are ``slot``, ``name``, ``type`` and
``hash``. ``type`` is the plain face's word for the ``public`` boolean —
``public``/``private`` reads where ``true``/``false`` would need the heading
to say which way round it runs.
"""
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 when(key: str, header: str, *, absent: str = script.NONE) -> Column:
"""A time the reader reads as an age: ``5m``, ``3h``, ``never``.
The default form for every time in a listing, because "recently?" is the question the
reader typed the command to ask and an ISO instant makes them do arithmetic to answer
it. ``--absolute`` swaps the whole language back for a run; the document is UTC
regardless, since it is read somewhere else and often later.
Args:
key: The machine key. It ends in ``_at`` by convention — a JSON key is a type hint
for a parser, and the suffix is the hint.
header: The plain heading.
absent: What an unknown time reads as plain. ``never`` where the row exists and
the event has not happened (a node not yet heard, a conversation with nothing
said in it); the default token where the row has no such time at all.
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, printed absolutely whatever ``--absolute`` says.
Three surfaces want this and each for the same reason — the instant is the answer, not
a way of saying how long ago something was. The device's own clock. An appointment set
with ``--at``, which is a wall-clock time the caller chose. And a live capture's own
``TIME`` column, every row of which would otherwise read ``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`` plain, ``5.1`` in the document.
The plain sign is a column convention — a lane of readings only reads as a ladder when
every one of them declares its direction. A number in JSON carries its own sign and a
leading ``+`` 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, right-aligned so a column of them is comparable 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, formatted for the column and left unrounded 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``/``no`` for a reader, ``true``/``false`` for a parser.
``None`` is a third answer and stays one — ``acked`` on a channel message is not
``false``, it is *there is no acknowledgement on a channel*, and the two are different
things to anyone counting 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, an outcome — as its own lowercase word.
Both faces say the same word, so ``courier send``'s ``no ack`` reads the same in a
document as it does on the line the help lists it on.
"""
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 a node's — a channel label, a conversation, a device."""
return Column(key=key, lanes=(Lane(header=header, render=script.name),))
def free(key: str, header: str) -> Column:
"""Free text a stranger filled in: escaped so it can never end its own record.
Always the last lane in a listing, because it is the one field with no width. The
document carries it **raw and unshortened** — a JSON string holds a newline natively,
so the escaping is a plain-text necessity rather than something to inflict on a
consumer.
"""
return Column(key=key, lanes=(Lane(header=header, render=script.text),))
def route(key: str = "route", header: str = "ROUTE") -> Column:
"""A walked hop sequence: drawn with arrows plain, an array of nodes in the document.
The row holds :class:`NodeRef` objects in propagation order, so the document's hops are
the same objects a listing's rows carry and a consumer's node handler is written once.
``None`` where nothing came home — never ``[]``, which would claim a walk of 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 was given it: ``a1,d4,a1``, or absent.
The one line on either face that round-trips into ``--path``, so it keeps its commas
and grows nothing. ``None`` means the device routed the walk itself: the plain face
says ``auto`` because a reader needs a word there, and 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:
"""A setting's value: what ``set`` takes back plain, and the typed value in the document.
Takes a :class:`Rendered` (see there for why this one concept needs both halves in the
row). The round-trip claim is the plain face's whole design for these commands —
``config show > f`` and feeding ``f`` back in must work — and the document's typed
value is the machine face's largest single gain over it: ``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 on its own, lowercase, 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 a reader, with no machine face at all.
A preference's DESCRIPTION is the case: it explains a preference to somebody deciding
whether to change it, and there is nothing in it a program would branch on. Declaring
it as a column rather than smuggling it in keeps the listing's two faces 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 a reader and left out of the document.
For a lane the machine face says *better* somewhere else rather than not at all. A
transcript's ``PEER`` is the case: one column holding whichever of a channel or a
contact the conversation had, because a reader has the conversation in front of them —
where the document keeps ``channel`` and ``node`` apart, since a parser has to know
which of the two it is holding and cannot tell from the label.
"""
from dataclasses import replace
return replace(column, json=None)
def hidden(key: str) -> Column:
"""A fact the document carries and the plain face has no room for.
The mirror of :func:`note`. ``config show`` prints a setting's key and its value —
that is what a reader came for — while the document also says what type it is, what an
enum's number is called, and whether the value was withheld.
"""
return Column(key=key)
def _rendered_cell(value: Rendered | None) -> str:
"""One setting's cell: the text its own spec produced, or the absent token."""
return script.NONE if value is None else value.text
def _position_cell(where: Position | None) -> str:
"""One position's cell: the pair, 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:
"""One boolean's cell, keeping ``None`` as an absence rather than a ``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 whichever language this run speaks (see :func:`when`).
``absent`` survives the switch to timestamps, because it is not a rendering choice:
``never`` says the event has not happened, and ``--absolute`` was asked for a different
*form* of time, not for one fewer fact.
"""
if not isinstance(value, datetime) and value is not None:
return normalise(value) # pragma: no cover - a row holding something else 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.
Read from the preference registry rather than a module flag, because *how MeshTerm
behaves* is a preference by this project's own rule — and ``--absolute`` is then
simply that preference overridden for one run (see
:func:`~meshterm.cli.main_callback`), rather than a second switch saying the same
thing in a place a reader cannot find.
"""
from ..core.preferences import current
return str(current().cli_time_format) == "absolute"