Files
MeshTerm/meshterm/core/preferences.py
T
Jean-Pierre MartineauandClaude Opus 5 1c24f9149d Let a tool state its answer instead of printing it
The CLI said what it found by printing it: run() called ctx.ui.show(<a table>)
and handed back a ToolResult carrying only bookkeeping. By the time the boundary
saw that result the contacts had gone down the pipe already rendered, and there
was nothing left to offer a second format — which is exactly why --json reached
two commands out of twenty and stopped, each one growing an `if ctx.json_output:`
branch above the rendering that restated the whole answer in a dialect nobody
else could reuse.

exit_code has always worked the other way round: the tool states it, the boundary
turns it into a process status, and no tool calls sys.exit. The answer now travels
the same way. A tool states a Report; a renderer turns it into bytes; --json picks
the renderer.

ui/report.py holds the model — a Listing of records or a set of Facts, whose rows
carry the typed value and whose columns carry both projections of it. ui/fields.py
is the shared shapes, one constructor per concept, so every listing that mentions
a node speaks the same five-key object by construction rather than by review.
ui/renderers.py is the two faces: aligned text for a person, and the bare document
for a program — an array for a listing, an object for a set of facts, no envelope,
because the report a caller needs is still $?.

Nothing is migrated yet: report defaults to None and every command renders exactly
as it did. The script vocabulary the plain face will need lands alongside — ages,
durations, locations, the arrow-drawn route, the comma-joined spec, pinned lanes
for a live stream, and a wrap for the written pages — with the quoting still in
place until its callers move off it.

Also here, because the field library needs it: cli_time_format, the preference that
says whether the command line prints ages or timestamps, with --absolute as its
per-run override rather than a second switch beside it.

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

711 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Declarative registry of MeshTerm's own preferences, and the YAML file they live in.
The distinction this module draws is between the three kinds of "setting" the app already
had and never named apart:
* :mod:`meshterm.core.device_config` describes the **radio's** settings — they live in
the companion's firmware, are read live each session, and are edited through the Device
config screen.
* :mod:`meshterm.core.config` (``config.toml``) describes **where things are and which
device to talk to** — the config directory, the database, the named device profiles.
It is machine setup, edited in a text editor, and has no place in a full-screen page.
* This module describes **how MeshTerm itself behaves** — whether it opens the link at
launch, how long it waits between transmissions, how much history it keeps, how a map
frames itself. Those values were scattered through the code as module constants and a
handful of undocumented ``config.toml`` keys with no screen behind them; they are
gathered here, given one place to be changed (the Preferences page), and one file to be
remembered in.
Each :class:`PrefSpec` states a preference's group, type, bounds, and — the point of the
whole exercise — its **default**. A preference is only written to disk once it is changed
away from that default, so the file is a short list of your disagreements with the
built-in behaviour rather than a snapshot that silently pins every value forever. Delete a
key (or use *Reset to defaults*) and the code's default takes over again.
The file is ``<config_dir>/preferences.yaml``, written grouped and commented so it reads
the way the page does. Reads are tolerant: an unknown key, a malformed value, or an
unparseable file costs the override, never the session.
Two access paths, deliberately:
* :attr:`~meshterm.context.AppContext.preferences` is the explicit one, and what every
tool, service, and screen should use.
* :func:`current` is for the render and session layer, which has no context to reach
through — the same reason :func:`meshterm.platforms.get_platform` exists. The context
installs itself there as it is built.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .atomicwrite import write_atomically
from .watch_store import DEFAULT_SILENCE_HOURS, OFF, SILENCE_CHOICES_H
#: Display groups, in the order the page and the file present them. The order is the
#: order a session happens in — what MeshTerm puts on the air, how loud, what it watches
#: for, how it draws the world, what it keeps, and how it paints. Nothing sorts
#: alphabetically: a reader looking for "how long before I give up on a message" should
#: not have to know it starts with a D.
GROUPS: tuple[str, ...] = (
"Sending",
"TX optimize",
"Watchtower",
"Map",
"History",
"Display",
"Diagnostics",
)
class PreferenceError(ValueError):
"""Raised when a preference value cannot be parsed or fails validation.
The message is user-facing — the CLI prints it, and the editor's typed prompt shows
it under the input as the reason the value was refused.
"""
@dataclass(frozen=True, slots=True)
class PrefSpec:
"""Specification for one preference.
Attributes:
key: Canonical key — the attribute name on :class:`Preferences`, and the key in
the YAML file.
label: Human-friendly name, as the page's SETTING lane shows it.
help: One-line description, as the page's DESCRIPTION lane shows it.
group: One of :data:`GROUPS`.
value_type: ``"str" | "int" | "float" | "bool" | "enum"``.
default: The built-in value, used whenever the file names no override.
choices: For ``enum``, a mapping of allowed value to label.
minimum: Inclusive lower bound for numeric types, if any.
maximum: Inclusive upper bound for numeric types, if any.
unit: Short suffix appended when the value is formatted (``"s"``, ``"dBm"``,
``"days"``) so a bare number in the VALUE lane still says what it counts.
relaunch: Whether the new value only takes hold next launch. Woven into the
description rather than shown as its own mark — it is a caveat about one
preference, not a status the reader scans a column for.
"""
key: str
label: str
help: str
group: str
value_type: str
default: Any
choices: dict[Any, str] | None = None
minimum: float | None = None
maximum: float | None = None
unit: str = ""
relaunch: bool = False
@property
def description(self) -> str:
"""The DESCRIPTION lane's text: the help, plus the relaunch caveat where it applies."""
return f"{self.help} (next launch)" if self.relaunch else self.help
#: The two languages the plain CLI can print a time in. Named for what the reader sees
#: rather than for the mechanism: an *age* is how long ago, a *timestamp* is when.
_CLI_TIME_CHOICES: dict[str, str] = {"relative": "ages", "absolute": "timestamps"}
#: The silence-rule choices, drawn from the Watchtower's own ring so the two never drift.
_SILENCE_CHOICES: dict[int, str] = {
**{h: f"{h} h" for h in SILENCE_CHOICES_H},
OFF: "off",
}
#: How the terminal-width reclaim can be asked for: follow the platform's own verdict, or
#: overrule it in either direction. The row's description asks a yes/no question ("use the
#: column ... ?"), so the values answer it in those words rather than naming the mechanism
#: twice. See :func:`meshterm.ui.tui.session._reclaim_last_column` for why a terminal may
#: need to disagree with its platform.
_WIDTH_CHOICES: dict[str, str] = {"auto": "auto", "yes": "yes", "no": "no"}
#: Every preference MeshTerm has, in page order. Adding one here gives it a row on the
#: Preferences page, a key in the YAML file, a ``preferences get``/``set`` CLI face, and a
#: default — nothing else has to follow.
#: What the log file keeps. The plain level names, which are what every other tool calls
#: these — someone being talked through a problem is being told "set it to debug", not
#: "set it to everything".
#: What MeshTerm may offer when it lands in a console that cannot draw it. Only ever
#: consulted on the classic Windows console, the one host with no font fallback of its own.
_CONSOLE_SETUP_CHOICES: dict[str, str] = {
"auto": "Automatic",
"off": "Leave it alone",
}
_LOG_LEVEL_CHOICES: dict[str, str] = {
"ERROR": "Error",
"WARNING": "Warning",
"INFO": "Info",
"DEBUG": "Debug",
}
PREFERENCES: tuple[PrefSpec, ...] = (
# --- Sending -----------------------------------------------------------------
PrefSpec(
key="trace_cooldown_s",
label="Transmit cooldown",
help="Wait this long before transmitting again",
group="Sending",
value_type="float",
default=5.0,
minimum=0.1, # a floor, not a switch: there is no "off" for duty-cycle courtesy
maximum=60.0,
unit="s",
),
PrefSpec(
key="flood_advert_cooldown_s",
label="Flood cooldown",
help="Extra wait before another mesh-wide advert",
group="Sending",
value_type="float",
default=60.0,
minimum=5.0, # every repeater in range rebroadcasts one; five seconds is the floor
maximum=3600.0,
unit="s",
),
PrefSpec(
key="direct_message_soft_retries",
label="Message retries",
help="Times to resend a message that gets no reply",
group="Sending",
value_type="int",
default=0, # one shot: a re-send is a second transmission on a shared mesh
minimum=0,
maximum=2,
),
# --- TX optimize -------------------------------------------------------------
PrefSpec(
key="tx_opt_min",
label="Range min",
help="Weakest power it will try",
group="TX optimize",
value_type="int",
default=18,
minimum=1,
maximum=30,
unit="dBm",
),
PrefSpec(
key="tx_opt_max",
label="Range max",
help="Strongest power it will try",
group="TX optimize",
value_type="int",
default=28,
minimum=1,
maximum=30,
unit="dBm",
),
PrefSpec(
key="tx_snr_tolerance_db",
label="Tie margin",
help="Signals this close are a tie, so less power wins",
group="TX optimize",
value_type="float",
default=1.0,
minimum=0.0,
maximum=10.0,
unit="dB",
),
# --- Watchtower --------------------------------------------------------------
PrefSpec(
key="watch_silence_hours",
label="Alert after",
help="Warn when a watched node goes quiet this long",
group="Watchtower",
value_type="enum",
default=DEFAULT_SILENCE_HOURS,
choices=_SILENCE_CHOICES,
),
PrefSpec(
key="watch_alerts_kept",
label="Alerts kept",
help="How many past alerts to keep",
group="Watchtower",
value_type="int",
default=200,
minimum=10,
maximum=5000,
),
# --- Map ---------------------------------------------------------------------
PrefSpec(
key="map_view_fraction",
label="Map fit",
help="Share of nodes to fit on screen; 1 shows them all",
group="Map",
value_type="float",
default=0.5,
minimum=0.1,
maximum=1.0,
),
PrefSpec(
key="basemap_tilejson_url",
label="Map tiles",
help="Where map images come from",
group="Map",
value_type="str",
default="https://tiles.openfreemap.org/planet",
relaunch=True,
),
# --- History kept ------------------------------------------------------------
PrefSpec(
key="history_days",
label="Keep packets for",
help="Older ones are deleted; 0 keeps everything",
group="History",
value_type="int",
default=365,
minimum=0,
maximum=36500,
unit="days",
),
PrefSpec(
key="chat_history_limit",
label="Chat history",
help="Old messages to load when you open a chat",
group="History",
value_type="int",
default=200,
minimum=20,
maximum=5000,
),
PrefSpec(
key="courier_history_kept",
label="Courier history",
help="Finished outbox messages to keep",
group="History",
value_type="int",
default=100,
minimum=10,
maximum=5000,
),
# --- Display -----------------------------------------------------------------
PrefSpec(
key="cli_time_format",
label="Command-line times",
help="Whether the command line prints ages or timestamps",
group="Display",
value_type="enum",
# Relative, because a person at a prompt asking "heard recently?" should not have
# to subtract an ISO instant from `date` to find out. `--absolute` overrides this
# for one run, and the JSON face is UTC either way — it is read somewhere else and
# often later, where a relative age has nothing to be relative to.
default="relative",
choices=_CLI_TIME_CHOICES,
),
PrefSpec(
key="fast_render",
label="Fast redraw",
help="Faster screen updates; turn off if it looks wrong",
group="Display",
value_type="bool",
default=True,
relaunch=True,
),
PrefSpec(
key="full_width",
label="Last column",
help="Use the extra column some terminals hide",
group="Display",
value_type="enum",
default="auto",
choices=_WIDTH_CHOICES,
),
PrefSpec(
key="console_setup",
label="Console setup",
help="Move to a better terminal when this console can't draw MeshTerm",
group="Display",
value_type="enum",
# Reopening in Windows Terminal is done, not asked: the classic console cannot
# draw a single icon whatever font it is given, so there is only one sensible
# answer and the reader has not seen the app yet to judge it. Installing a *font*
# still asks, because that writes to their machine. Declining that twice writes
# "off" here, which turns both off for good.
default="auto",
choices=_CONSOLE_SETUP_CHOICES,
),
# --- Diagnostics ---------------------------------------------------------------
PrefSpec(
key="log_level",
label="Log detail",
help="How much MeshTerm writes to its log file",
group="Diagnostics",
value_type="enum",
# Problems, not a narration of a working session. The file used to take
# everything, which buried the dozen interesting lines under twenty thousand
# dull ones and made it awkward to attach to a bug report.
default="WARNING",
choices=_LOG_LEVEL_CHOICES,
relaunch=True,
),
)
_BY_KEY: dict[str, PrefSpec] = {spec.key: spec for spec in PREFERENCES}
def get_spec(key: str) -> PrefSpec:
"""Look up one preference's spec.
Args:
key: The preference key.
Returns:
Its :class:`PrefSpec`.
Raises:
PreferenceError: If no preference is registered under ``key``.
"""
try:
return _BY_KEY[key]
except KeyError:
raise PreferenceError(f"unknown preference: {key!r}") from None
def by_group() -> list[tuple[str, list[PrefSpec]]]:
"""Every preference grouped for display, in :data:`GROUPS` order.
Returns:
``(group, specs)`` pairs; a group holding no preferences is omitted.
"""
grouped = [(g, [s for s in PREFERENCES if s.group == g]) for g in GROUPS]
return [(g, specs) for g, specs in grouped if specs]
# --- value parsing / formatting -----------------------------------------------
_TRUE = {"1", "true", "yes", "on", "y"}
_FALSE = {"0", "false", "no", "off", "n"}
def parse_value(spec: PrefSpec, raw: Any) -> Any:
"""Parse and validate a raw value (a typed scalar, or text from the CLI/file/prompt).
Args:
spec: The target preference.
raw: The value to coerce.
Returns:
The typed, range-checked value.
Raises:
PreferenceError: If the value is the wrong type, out of range, or not a choice.
"""
text = str(raw).strip()
if spec.value_type == "bool":
low = text.lower()
if low in _TRUE:
return True
if low in _FALSE:
return False
raise PreferenceError(f"{spec.key}: expected on or off, got {raw!r}")
if spec.value_type == "str":
return text
if spec.value_type == "enum":
choices = spec.choices or {}
if raw in choices and not isinstance(raw, bool):
return raw
if isinstance(raw, bool):
# YAML 1.1 reads a bare ``yes``/``no`` (and ``on``/``off``) as a *boolean*, so a
# hand-edited file hands us ``True`` where the choice is spelled "yes". We quote
# ours on the way out, but someone typing the obvious thing should still be
# understood: map the boolean back to whichever spelling this spec offers.
for choice in choices:
if str(choice).lower() in (_TRUE if raw else _FALSE):
return choice
# Text arriving from the CLI or a hand-edited file: match a choice by its own
# spelling, so `preferences set watch_silence_hours 6` and a YAML `6` land alike.
for choice in choices:
if text.lower() == str(choice).lower():
return choice
allowed = ", ".join(str(c) for c in choices)
raise PreferenceError(f"{spec.key}: must be one of {allowed}, got {raw!r}")
try:
value: Any = float(text) if spec.value_type == "float" else int(text, 0)
except (ValueError, TypeError) as exc:
raise PreferenceError(f"{spec.key}: invalid {spec.value_type} value {raw!r}") from exc
if spec.minimum is not None and value < spec.minimum:
raise PreferenceError(f"{spec.key}: must be >= {spec.minimum:g}, got {value:g}")
if spec.maximum is not None and value > spec.maximum:
raise PreferenceError(f"{spec.key}: must be <= {spec.maximum:g}, got {value:g}")
return value
def format_value(spec: PrefSpec, value: Any) -> str:
"""Render one preference's value for display.
Booleans read ``on``/``off`` (the words the editor's button pair offers), an enum
reads its own label, and a number carries its unit so a bare figure in the VALUE lane
still says what it counts.
Args:
spec: The preference the value belongs to.
value: The value to render.
Returns:
The display string.
"""
if spec.value_type == "bool":
return "on" if value else "off"
if spec.value_type == "enum" and spec.choices is not None:
return str(spec.choices.get(value, value))
if spec.value_type in ("int", "float"):
text = f"{value:g}"
return f"{text} {spec.unit}" if spec.unit else text
return str(value)
def range_hint(spec: PrefSpec) -> str:
"""A muted "allowed values" hint for a typed prompt, or ``""`` where nothing bounds it."""
unit = f" {spec.unit}" if spec.unit else ""
if spec.minimum is not None and spec.maximum is not None:
return f"Allowed: {spec.minimum:g} – {spec.maximum:g}{unit}"
if spec.minimum is not None:
return f"Allowed: >= {spec.minimum:g}{unit}"
if spec.maximum is not None:
return f"Allowed: <= {spec.maximum:g}{unit}"
return ""
# --- the store ----------------------------------------------------------------
#: The file's opening comment. It says the one thing a reader hand-editing the file needs
#: to know — that absence means "use the default", so deleting a line is how a preference
#: is undone — and points at the two neighbouring kinds of setting so nobody looks for a
#: device's radio parameters in here.
_HEADER = """\
# MeshTerm preferences -- how the app itself behaves.
#
# Every preference has a built-in default. A key is listed here only while you have
# changed it away from that default, so deleting a line (or "Reset to defaults" on the
# Preferences page) hands the value back to the code. `meshterm preferences show` prints
# every preference, what it is set to, and what it defaults to.
#
# Device profiles, the database location, and the rest of the machine setup stay in
# config.toml; the radio's own settings live on the radio, under Device config.
"""
class Preferences:
"""The effective preferences: the built-in defaults, with the file's overrides on top.
Read a preference as an attribute — ``preferences.trace_cooldown_s`` — which resolves
through the registry, so a typo raises :class:`AttributeError` at the call site
instead of quietly reading ``None``. :meth:`set` takes a raw value, validates it
through the key's spec, and records it only while it differs from the default;
:meth:`save` writes the result.
Nothing here writes on its own: the Preferences page stages changes and saves them on
*Apply*, which — with the CLI — is the only path that reaches :meth:`save`.
"""
def __init__(self, path: Path | None = None, values: Mapping[str, Any] | None = None) -> None:
"""Build a preferences view over an optional file and an optional set of overrides.
Args:
path: Where :meth:`save` writes. ``None`` for an in-memory set (tests, and
the fallback :func:`current` hands out before a context is built).
values: Overrides to start from; invalid ones are dropped, and a value equal
to its default is not recorded as an override at all.
"""
self._path = path
self._values: dict[str, Any] = {}
if values:
self.update(values)
@classmethod
def load(cls, path: Path) -> Preferences:
"""Read ``path``, returning all-defaults for a missing, empty, or corrupt file.
Args:
path: The YAML file to read.
Returns:
The loaded preferences, bound to ``path`` for a later :meth:`save`.
"""
import yaml
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, ValueError, yaml.YAMLError):
return cls(path)
return cls(path, data if isinstance(data, Mapping) else None)
@property
def path(self) -> Path | None:
"""Where :meth:`save` writes, or ``None`` for an in-memory set."""
return self._path
def __getattr__(self, name: str) -> Any:
"""Resolve a registered preference key as an attribute.
Raises:
AttributeError: For any name that is not a preference key, so a mistyped
``preferences.trace_cooldwn_s`` fails where it is written rather than
quietly reading nothing.
"""
if name.startswith("_") or name not in _BY_KEY:
raise AttributeError(name)
return self.get(name)
def get(self, key: str) -> Any:
"""The effective value for ``key``: the override where there is one, else the default.
Raises:
PreferenceError: If ``key`` is not a registered preference.
"""
spec = get_spec(key)
return self._values.get(key, spec.default)
def is_overridden(self, key: str) -> bool:
"""Whether ``key`` currently differs from its built-in default."""
return key in self._values
def overrides(self) -> dict[str, Any]:
"""The values that differ from their defaults, in registry order (a copy)."""
return {s.key: self._values[s.key] for s in PREFERENCES if s.key in self._values}
def set(self, key: str, raw: Any) -> Any:
"""Validate ``raw`` for ``key`` and record it, dropping it when it *is* the default.
An override is only ever a disagreement with the default, so setting a value back
to its default clears the entry rather than pinning it. That is what lets a later
change to the code's default reach an install that never asked to be exempt.
Args:
key: The preference key.
raw: The new value, typed or as text.
Returns:
The parsed value now in force.
Raises:
PreferenceError: If ``key`` is unknown or ``raw`` fails its spec.
"""
spec = get_spec(key)
value = parse_value(spec, raw)
if value == spec.default:
self._values.pop(key, None)
else:
self._values[key] = value
return value
def update(self, values: Mapping[str, Any]) -> int:
"""Apply several changes at once, skipping any the registry or a spec refuses.
Tolerant by design: this is the path a hand-edited file arrives through, and one
bad line should cost that line, not the file.
Args:
values: ``key -> value`` pairs.
Returns:
How many were accepted.
"""
applied = 0
for key, value in values.items():
try:
self.set(str(key), value)
except PreferenceError:
continue
applied += 1
return applied
def reset(self) -> int:
"""Drop every override, returning how many there were."""
count = len(self._values)
self._values.clear()
return count
def as_yaml(self) -> str:
"""Render the current overrides as the file's text: header, then grouped entries.
Each group holding an override gets its own comment heading, and each entry its
help line and its default above it, so the file reads the way the page does
rather than as a bare map someone has to look up elsewhere.
"""
import yaml
lines = [_HEADER]
for group, specs in by_group():
present = [s for s in specs if s.key in self._values]
if not present:
continue
lines.append(f"# --- {group} ---")
for spec in present:
entry = yaml.safe_dump(
{spec.key: self._values[spec.key]},
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
).rstrip("\n")
lines.append(f"# {spec.help}")
lines.append(f"# default: {format_value(spec, spec.default)}")
lines.append(entry)
lines.append("")
if len(lines) == 1:
lines.append("# (nothing overridden -- every preference is at its default)")
lines.append("")
return "\n".join(lines)
def save(self) -> None:
"""Write the overrides to :attr:`path` atomically (a crash mid-write keeps the old file).
Raises:
RuntimeError: If this set was built without a path (an in-memory set).
"""
if self._path is None:
raise RuntimeError("these preferences have no file to save to")
write_atomically(self._path, self.as_yaml())
#: The set installed for this process. Starts as a defaults-only, file-less instance so
#: the render layer works before (and without) an :class:`~meshterm.context.AppContext` —
#: a specimen render, a unit test, the CLI's early boot.
_current: Preferences = Preferences()
def current() -> Preferences:
"""The preferences in force for this process.
For the layers that have no context to reach through — the session's renderer, the
stores constructed without one — mirroring
:func:`meshterm.platforms.get_platform`. Anything holding an
:class:`~meshterm.context.AppContext` reads
:attr:`~meshterm.context.AppContext.preferences` instead.
"""
return _current
def install(preferences: Preferences) -> None:
"""Make ``preferences`` the set :func:`current` returns (the context does this at boot)."""
global _current
_current = preferences
__all__ = [
"GROUPS",
"PREFERENCES",
"PrefSpec",
"PreferenceError",
"Preferences",
"by_group",
"current",
"format_value",
"get_spec",
"install",
"parse_value",
"range_hint",
]