Files
MeshTerm/meshterm/ui/fields.py
T
Jean-Pierre MartineauandClaude Opus 5 6046b4ff0e A bug report should be one page, and a page nobody has to read first
A report that opens with "it crashed" costs three round trips before
anyone knows which MeshTerm, on which OS, in which terminal, talking to
which radio. The person who hit the bug is the one least able to answer:
half of it is resolved at boot and shown on no screen -- whether icons
draw, whether powerline separators do, which platform flavour resolved and
why. Diagnostics states all of it at once, as a page under This app and as
meshterm diagnostics.

The page is a bare frame, and that is mechanical rather than aesthetic.
The one thing it is for is getting its text out of the terminal and into
an issue, and a terminal is selected by dragging: a border is characters
on the same lines as the content, so every line of a line-wise selection
arrives with a box-drawing glyph welded to each end. A header and a footer
hint arrive as text that looks like part of the report to whoever reads
the paste. So the page draws its rows and nothing besides, the frame the
share screen already uses for the same kind of reason.

Nothing in the block is private, and that has to be a property rather than
a habit, because the whole premise is that it gets pasted in public by
somebody who did not read it. No pairing PIN, no admin password, no
channel secret, no private key, no position, no contact's name or key. The
mesh arrives as aggregate -- a row count per table and the span the
observations cover -- which is the half that explains a bug without naming
anyone the reporter talks to. Two tests hold the line from opposite sides:
one plants real secrets in the stores that hold them and asserts none came
out, and one refuses the key *names* such a field would take, so a value
the suite cannot plant is still caught by what it would arrive as.

The radio is asked and never required. A report about a companion that
will not connect is exactly the report most worth filing, so the failure
becomes connected=no with the radio's own words in error, and every other
fact still arrives.

Table counts come off sqlite_master rather than a list written down here,
so a table added later starts being reported the day it lands; the
interesting count is always the table nobody expected to be full. Only
overridden preferences are listed -- the defaults are in the source and
identical for everyone.

Both faces share one projection of a facts block, so the page cannot drift
from what the command prints: a field added to the report reaches both
without anyone remembering to. Only the layout differs, in one respect --
the scripted face crops a value too wide for its lane, which is right for
a record somebody will split on whitespace and wrong for a page whose
whole job is to be complete, so a long value hangs under its own block
instead. A path also gains the lane constructor it never had; the machine
face had always spelled one correctly and the plain face could not render
one at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 15:58:12 -04:00

502 lines
21 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""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. It must name the *fact* — ``HEARD``, ``RECORDED`` —
rather than the form, because ``--absolute`` changes the form underneath it
and a column headed ``AGE`` holding an ISO instant is a heading that lies.
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 path(key: str, header: str) -> Column:
"""A place on this filesystem: a config directory, a database, a file just written.
Its own constructor rather than :func:`word` because the *typed* value is a
:class:`~pathlib.Path` and nothing else in the lane vocabulary turns one into text —
``word`` hands it straight to Rich, which cannot render it. The machine face was
already right: :func:`~meshterm.ui.report.normalise` has always spelled a Path as its
string, so this closes the plain half of a shape that was only ever half-declared.
Printed as the host writes it, separators and all, since the one thing a reader does
with a path is paste it back into their own shell.
"""
return Column(key=key, lanes=(Lane(header=header, render=_path_cell),))
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 _path_cell(value: Any) -> str:
"""A path as its own text, or the absent token where there is none."""
return script.NONE if value is None else str(value)
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"