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>
This commit is contained in:
1 parent
2d31810801
commit
6046b4ff0e
12 files changed
+1521
-953
No files matched your search
+102
-939
File diff suppressed because it is too large.
Load diff
+90
-1
@@ -1476,9 +1476,98 @@ bothers to draw a QR of. `text` is the page's plain rendering, not a fold of the
|
||||
into nested JSON — a structured markdown tree would be a second rendering rather than data,
|
||||
and nothing would consume it.
|
||||
|
||||
#### `meshterm diagnostics`
|
||||
|
||||
Everything a bug report opens with, in one block: which MeshTerm, on which host, in which
|
||||
terminal, talking to which radio, over how much stored history. It is the answer to the
|
||||
three or four questions that otherwise get asked one at a time in an issue thread, and the
|
||||
person who hit the bug is usually the one least able to answer them — half of it is
|
||||
resolved at boot and shown on no screen.
|
||||
|
||||
```console
|
||||
$ meshterm diagnostics
|
||||
meshterm 0.3.7
|
||||
install frozen
|
||||
|
||||
os Windows 11
|
||||
os_build 10.0.26100
|
||||
arch AMD64
|
||||
python 3.13.1
|
||||
terminal Windows Terminal
|
||||
term -
|
||||
colorterm truecolor
|
||||
terminal_size 120x30
|
||||
over_ssh no
|
||||
platform regular
|
||||
icons yes
|
||||
icons_source windows-terminal
|
||||
font Cascadia Mono PL (windows-terminal)
|
||||
powerline full (font:windows-terminal)
|
||||
|
||||
connected yes
|
||||
transport ble
|
||||
port -
|
||||
device_role client
|
||||
device_model Heltec V3
|
||||
firmware v1.7.1 1c3f9a2
|
||||
radio_freq_mhz 906.8750
|
||||
radio_bw_khz 250.00
|
||||
radio_sf 10
|
||||
radio_cr 5
|
||||
tx_power_dbm 20
|
||||
error -
|
||||
|
||||
config_dir C:\Users\jp\.meshterm
|
||||
db_size_kb 41280
|
||||
log_level WARNING
|
||||
log_size_kb 96
|
||||
runs_failed 3
|
||||
first_heard 8mo
|
||||
last_heard now
|
||||
|
||||
TABLE ROWS
|
||||
observations 418203
|
||||
runs 912
|
||||
traces 47
|
||||
...
|
||||
|
||||
PREFERENCE VALUE
|
||||
log_level DEBUG
|
||||
map_style terrain
|
||||
```
|
||||
|
||||
**The radio is asked but never required.** A report about a companion that will not connect
|
||||
is exactly the report most worth filing, so a failure to reach it becomes `connected no`
|
||||
with the radio's own words in `error`, and every other fact still arrives.
|
||||
|
||||
**Only overridden preferences are listed.** The defaults are in the source and identical for
|
||||
everyone; the two the reporter changed are the two that can explain anything.
|
||||
|
||||
**The mesh is described in aggregate and only in aggregate** — a row count per table, and
|
||||
the span of time the observations cover. The counts come off the schema rather than a list
|
||||
written down somewhere, so a table added later starts being reported the day it lands.
|
||||
|
||||
**Nothing in the block is private.** No pairing PIN, no admin password, no channel secret,
|
||||
no private key, no position, and no contact's name or key. That is a property of the
|
||||
feature and not a habit: the block is designed to be pasted in public by somebody who has
|
||||
not read it, and `tests/test_diagnostics.py` plants real secrets in every store that holds
|
||||
one and fails if any of them — or a field merely *named* like one — reaches either face.
|
||||
|
||||
Under `--json`, the three fact blocks merge into one flat object and the two listings keep
|
||||
a key of their own:
|
||||
|
||||
```json
|
||||
{"meshterm":"0.3.7","install":"frozen","os":"Windows 11","…":"…","tables":[{"table":"observations","rows":418203}],"preferences":[{"preference":"log_level","value":"DEBUG"}]}
|
||||
```
|
||||
|
||||
In the menu this is the **Diagnostics** page under *This app*, drawn on a bare frame — no
|
||||
border, no header, no footer, no key hints — because a terminal is selected by dragging and
|
||||
every one of those is a character the clipboard would carry into the issue.
|
||||
|
||||
#### `meshterm platform`
|
||||
|
||||
A diagnostic for which UI flavour a given invocation resolves to, and why.
|
||||
The narrow sibling of `diagnostics`: which UI flavour a given invocation resolves to, and
|
||||
why, without asking the radio anything.
|
||||
|
||||
```console
|
||||
$ meshterm platform
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""What MeshTerm is running on, read once so a bug report does not have to be interviewed.
|
||||
|
||||
Every value here answers a question that otherwise costs a round trip with whoever
|
||||
reported the problem: which build of MeshTerm, on which OS, in which terminal, how wide,
|
||||
with which colour depth. None of it is about the mesh and none of it is private — this
|
||||
module deliberately knows nothing about contacts, keys, positions or messages, so the
|
||||
block it feeds can be pasted in public without anyone having to read it first.
|
||||
|
||||
Two probes are **ladders over environment variables** rather than protocol queries, which
|
||||
is a concession worth naming: a terminal has no portable way to be asked who it is. The
|
||||
one sequence that comes close (``CSI > q``) needs a live tty, a reply that may never
|
||||
arrive, and a timeout budget on a path that must never hang. So each terminal is
|
||||
identified by the variable it sets about *itself* — which is the same ladder
|
||||
:func:`~meshterm.ui.termfont.detect_terminal_font` already climbs for the font — and
|
||||
anything unrecognised falls back to ``TERM`` rather than to a guess.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
#: The first Windows 11 build. Windows 11 still reports its release as ``"10"`` through
|
||||
#: every documented API, so the build number is the only thing that separates the two.
|
||||
_WINDOWS_11_BUILD = 22000
|
||||
|
||||
#: Terminals that announce themselves, by the variable each one sets. Ordered most to
|
||||
#: least specific: an emulator hosting another (VS Code's terminal, a multiplexer) sets
|
||||
#: its own marker *and* inherits the outer one, so the inner identity has to win.
|
||||
#:
|
||||
#: ``TERM_PROGRAM`` is checked separately, between these and the ``TERM`` fallback,
|
||||
#: because its *value* is the name — it identifies terminals this table never has to list.
|
||||
_TERMINAL_VARS: tuple[tuple[str, str], ...] = (
|
||||
("WT_SESSION", "Windows Terminal"),
|
||||
("ConEmuPID", "ConEmu"),
|
||||
("TERMINAL_EMULATOR", ""), # JetBrains IDEs put the name in the value
|
||||
("KITTY_WINDOW_ID", "kitty"),
|
||||
("ALACRITTY_WINDOW_ID", "Alacritty"),
|
||||
("KONSOLE_VERSION", "Konsole"),
|
||||
("GNOME_TERMINAL_SCREEN", "GNOME Terminal"),
|
||||
("VTE_VERSION", "VTE-based"),
|
||||
)
|
||||
|
||||
#: What ``TERM_PROGRAM`` calls terminals whose own name is not what a person calls them.
|
||||
#: Only the ones that genuinely differ; every other value passes through as it is, which
|
||||
#: is what keeps this from growing into a catalogue of every terminal there is.
|
||||
_TERM_PROGRAM_NAMES: dict[str, str] = {
|
||||
"Apple_Terminal": "Terminal.app",
|
||||
"vscode": "VS Code",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Host:
|
||||
"""The machine and the Python running MeshTerm.
|
||||
|
||||
Attributes:
|
||||
install: How this copy was installed — ``"frozen"`` (a one-file build, which has
|
||||
its own bootloader, its own relaunch rules and no source tree to read),
|
||||
``"source"`` (a checkout, so a commit can be asked for), or ``"package"``
|
||||
(a wheel from an index). The three fail in different ways and the reporter
|
||||
usually does not know which one they have.
|
||||
os: The flavour and its version as a person writes it — ``Windows 11``,
|
||||
``macOS 15.6``, ``Debian GNU/Linux 12 (bookworm)``.
|
||||
os_build: The exact build under that name: a Windows build number, or the kernel
|
||||
release everywhere else. What separates two hosts that call themselves the
|
||||
same thing.
|
||||
arch: The processor architecture, which is what tells an arm64 build from an
|
||||
x86_64 one when a binary misbehaves.
|
||||
python: The interpreter version, three parts.
|
||||
"""
|
||||
|
||||
install: str
|
||||
os: str
|
||||
os_build: str
|
||||
arch: str
|
||||
python: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Terminal:
|
||||
"""The terminal MeshTerm is drawing into, and what it can draw.
|
||||
|
||||
Attributes:
|
||||
program: The emulator's name, or ``TERM``'s value where nothing identified
|
||||
itself, or ``None`` when even that is unset.
|
||||
term: The ``TERM`` value, verbatim. Kept beside ``program`` rather than folded
|
||||
into it: a terminal lying about ``TERM`` is itself a common cause of a
|
||||
rendering report.
|
||||
colorterm: The ``COLORTERM`` value, which is how a terminal claims truecolor and
|
||||
the first thing to check when a screen arrives in the wrong colours.
|
||||
cols: The terminal's width in cells.
|
||||
rows: The terminal's height in cells.
|
||||
over_ssh: Whether this is an ssh session. It changes what is knowable — the font
|
||||
lives on the far client — and it is why several other verdicts here read
|
||||
``unknown`` rather than wrong.
|
||||
"""
|
||||
|
||||
program: str | None
|
||||
term: str | None
|
||||
colorterm: str | None
|
||||
cols: int
|
||||
rows: int
|
||||
over_ssh: bool
|
||||
|
||||
|
||||
def install_kind() -> str:
|
||||
"""How this copy of MeshTerm got here: ``frozen``, ``source`` or ``package``.
|
||||
|
||||
A frozen build is unambiguous — the bootloader sets the flag. Past that, the question
|
||||
is whether there is a repository above the package: a checkout can be asked for its
|
||||
commit and can carry uncommitted work, and a wheel can do neither.
|
||||
|
||||
Returns:
|
||||
One of ``"frozen"``, ``"source"`` or ``"package"``.
|
||||
"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return "frozen"
|
||||
# meshterm/core/hostinfo.py -> meshterm/core -> meshterm -> the tree that holds it
|
||||
if (Path(__file__).resolve().parents[2] / ".git").exists():
|
||||
return "source"
|
||||
return "package"
|
||||
|
||||
|
||||
def host() -> Host:
|
||||
"""Read the machine's own facts.
|
||||
|
||||
Returns:
|
||||
The :class:`Host` block.
|
||||
"""
|
||||
name, build = _os_name_and_build()
|
||||
return Host(
|
||||
install=install_kind(),
|
||||
os=name,
|
||||
os_build=build,
|
||||
arch=platform.machine() or "unknown",
|
||||
python=platform.python_version(),
|
||||
)
|
||||
|
||||
|
||||
def terminal(environ: Mapping[str, str] | None = None) -> Terminal:
|
||||
"""Read the terminal's own facts.
|
||||
|
||||
Args:
|
||||
environ: The environment to inspect (defaults to ``os.environ``), injectable so
|
||||
the ladder can be tested without a terminal of the right kind to hand.
|
||||
|
||||
Returns:
|
||||
The :class:`Terminal` block.
|
||||
"""
|
||||
env = os.environ if environ is None else environ
|
||||
size = shutil.get_terminal_size()
|
||||
return Terminal(
|
||||
program=terminal_program(env),
|
||||
term=env.get("TERM") or None,
|
||||
colorterm=env.get("COLORTERM") or None,
|
||||
cols=size.columns,
|
||||
rows=size.lines,
|
||||
over_ssh=bool(env.get("SSH_TTY") or env.get("SSH_CONNECTION")),
|
||||
)
|
||||
|
||||
|
||||
def terminal_program(environ: Mapping[str, str] | None = None) -> str | None:
|
||||
"""Name the terminal emulator from what it says about itself.
|
||||
|
||||
The ladder: the terminals in :data:`_TERMINAL_VARS` that set a marker variable, then
|
||||
``TERM_PROGRAM`` (whose *value* is the name, so it covers terminals this module has
|
||||
never heard of), then ``TERM`` as the last honest thing there is to say.
|
||||
|
||||
Args:
|
||||
environ: The environment to inspect (defaults to ``os.environ``).
|
||||
|
||||
Returns:
|
||||
The terminal's name, or ``None`` when nothing in the environment names one.
|
||||
"""
|
||||
env = os.environ if environ is None else environ
|
||||
for var, name in _TERMINAL_VARS:
|
||||
value = env.get(var)
|
||||
if value:
|
||||
return name or value
|
||||
program = env.get("TERM_PROGRAM")
|
||||
if program:
|
||||
return _TERM_PROGRAM_NAMES.get(program, program)
|
||||
return env.get("TERM") or None
|
||||
|
||||
|
||||
def _os_name_and_build() -> tuple[str, str]:
|
||||
"""The OS as a person writes it, and the exact build under that name."""
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
return _windows()
|
||||
if system == "Darwin":
|
||||
version = platform.mac_ver()[0]
|
||||
return (f"macOS {version}" if version else "macOS"), platform.release()
|
||||
if system == "Linux":
|
||||
return (_pretty_name() or "Linux"), platform.release()
|
||||
return (system or "unknown"), platform.release()
|
||||
|
||||
|
||||
def _windows() -> tuple[str, str]:
|
||||
"""Windows by the name it is sold under, and its build number.
|
||||
|
||||
``platform.win32_ver`` reports release ``"10"`` on Windows 11 as well — the rename
|
||||
never reached the version APIs — so the build number is what decides which one this
|
||||
is. The build is also the useful half of the pair: it is what a fix is shipped
|
||||
against.
|
||||
"""
|
||||
release, version, _service_pack, _kind = platform.win32_ver()
|
||||
try:
|
||||
build = int(version.rsplit(".", 1)[-1])
|
||||
except ValueError:
|
||||
build = 0
|
||||
name = "Windows 11" if build >= _WINDOWS_11_BUILD else f"Windows {release or '?'}"
|
||||
return name, version or platform.version()
|
||||
|
||||
|
||||
def _pretty_name() -> str | None:
|
||||
"""The distribution's own name for itself, from ``/etc/os-release``.
|
||||
|
||||
The one place a Linux flavour is stated by the system rather than inferred, and the
|
||||
reason a report can say *Calculinux* or *Raspberry Pi OS* instead of ``Linux``.
|
||||
A host without the file (or without permission to read it) simply has no answer.
|
||||
"""
|
||||
try:
|
||||
text = Path("/etc/os-release").read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
for line in text.splitlines():
|
||||
key, _, value = line.partition("=")
|
||||
if key == "PRETTY_NAME":
|
||||
return value.strip().strip('"') or None
|
||||
return None
|
||||
@@ -1068,6 +1068,69 @@ class Repository:
|
||||
row = self._conn.execute("SELECT COUNT(*) AS n FROM observations").fetchone()
|
||||
return int(row["n"]) if row else 0
|
||||
|
||||
def table_counts(self) -> dict[str, int]:
|
||||
"""Return a row count for every table in the database, by name.
|
||||
|
||||
Read off ``sqlite_master`` rather than a list written out here, which is the whole
|
||||
point: a table added to the schema starts being reported the day it lands, and a
|
||||
table removed stops, with nothing to keep in step. The one thing this must never
|
||||
become is a curated set of the counts somebody thought were interesting — the
|
||||
shape of a database is the aggregate, and the interesting one is always the table
|
||||
nobody expected to be full.
|
||||
|
||||
Every value is a *count*. Nothing here reads a row, so the result says how much
|
||||
history a report's author has without saying a word about who they talk to.
|
||||
|
||||
Returns:
|
||||
Table name to row count, alphabetically, SQLite's own internal tables
|
||||
excluded (they describe the file format, not this app's data).
|
||||
"""
|
||||
names = [
|
||||
str(row["name"])
|
||||
for row in self._conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' "
|
||||
"AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||
).fetchall()
|
||||
]
|
||||
counts: dict[str, int] = {}
|
||||
for name in names:
|
||||
# The names come from sqlite_master, so they are this database's own tables
|
||||
# and not caller input; quoted anyway, because an identifier cannot be bound.
|
||||
row = self._conn.execute(f'SELECT COUNT(*) AS n FROM "{name}"').fetchone()
|
||||
counts[name] = int(row["n"]) if row else 0
|
||||
return counts
|
||||
|
||||
def failed_run_count(self) -> int:
|
||||
"""Return how many recorded tool runs ended in an error.
|
||||
|
||||
The single most useful number a reporter can hand over: it says whether this
|
||||
install has been failing quietly for weeks or broke for the first time today,
|
||||
and it costs one query rather than an interview.
|
||||
|
||||
Returns:
|
||||
The number of ``runs`` rows whose status is ``error``.
|
||||
"""
|
||||
row = self._conn.execute("SELECT COUNT(*) AS n FROM runs WHERE status = 'error'").fetchone()
|
||||
return int(row["n"]) if row else 0
|
||||
|
||||
def observation_span(self) -> tuple[datetime | None, datetime | None]:
|
||||
"""Return the first and last times anything was overheard.
|
||||
|
||||
How much mesh this database has actually seen. A report whose span is an hour and
|
||||
a report whose span is a year describe different installs, and the difference
|
||||
explains a class of "it works here" before anyone else has to ask for it.
|
||||
|
||||
Returns:
|
||||
``(first, last)`` as aware UTC times, or ``(None, None)`` when nothing has
|
||||
ever been heard.
|
||||
"""
|
||||
row = self._conn.execute(
|
||||
"SELECT MIN(observed_at) AS first, MAX(observed_at) AS last FROM observations"
|
||||
).fetchone()
|
||||
if row is None or row["first"] is None:
|
||||
return None, None
|
||||
return _as_when(row["first"]), _as_when(row["last"])
|
||||
|
||||
def recent_observations(self, *, since: datetime, limit: int = 4000) -> list[Observation]:
|
||||
"""Return raw stored observations in a recent window, oldest first.
|
||||
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The ``diagnostics`` tool: everything a bug report needs, in one block worth pasting.
|
||||
|
||||
A report that opens with "it crashed" costs two or three round trips before anyone knows
|
||||
which MeshTerm, which OS, which terminal, which radio — and the person who hit the bug is
|
||||
usually the one least able to answer, because half of it is resolved at boot and never
|
||||
shown. This states all of it at once.
|
||||
|
||||
**What it deliberately does not say.** The block is designed to be pasted in public by
|
||||
somebody who has not read it, so nothing that identifies a person or unlocks anything may
|
||||
be in it: no pairing PIN, no admin password, no channel secret, no private key, no
|
||||
position, and no contact's name or key. The mesh is described in **aggregate** — how many
|
||||
rows of each kind, and the span of time they cover — which is the half that explains a bug
|
||||
(a database with four observations and one with four hundred thousand fail differently)
|
||||
without naming anyone the reporter talks to. :func:`~meshterm.core.hostinfo.host` and
|
||||
:meth:`~meshterm.persistence.repository.Repository.table_counts` hold that line on their
|
||||
own side, so it cannot be crossed here by accident.
|
||||
|
||||
**One report, three faces**, which is the whole reason this is a report and not a
|
||||
``print``: the menu draws it on a bare frame with nothing around it to select by mistake
|
||||
(see :mod:`meshterm.ui.diagnostics`), ``meshterm diagnostics`` prints the same rows for
|
||||
anyone who would rather redirect than copy, and ``--json`` hands the same facts to
|
||||
whatever files the issue.
|
||||
|
||||
The device is asked but never required. A report about a radio that will not connect is
|
||||
exactly the report that most needs to be filed, so a failure to reach it becomes a fact in
|
||||
the block (``connected``/``error``) rather than an error that replaces it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..context import AppContext
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..ui.report import Report
|
||||
|
||||
#: MeshCore advert-type byte -> the role a person calls it. Duplicated from ``info``
|
||||
#: rather than shared: that module's copy is part of the device-status answer, and a
|
||||
#: cross-import between two tools to save five entries buys a dependency and no clarity.
|
||||
_ROLES = {1: "client", 2: "repeater", 3: "room server", 4: "sensor"}
|
||||
|
||||
|
||||
@register
|
||||
class DiagnosticsTool(Tool):
|
||||
"""What MeshTerm, this machine, this terminal and this radio actually are."""
|
||||
|
||||
name = "diagnostics"
|
||||
title = "Diagnostics"
|
||||
icon = "🩺"
|
||||
help = "Version, host, terminal and device facts for a bug report"
|
||||
category = "This app"
|
||||
order = 7 # after Preferences (the row that changes MeshTerm), before the pages
|
||||
|
||||
async def prompt_params(self, ctx: AppContext) -> dict[str, Any] | None:
|
||||
"""Open the page in the menu; returning ``None`` completes with no run row.
|
||||
|
||||
The same shape the About pages use: the menu's whole interaction *is* the screen,
|
||||
and a run row recording that somebody looked at their own version number would be
|
||||
a line in the log for every glance.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
|
||||
Returns:
|
||||
Always ``None`` in the menu; the CLI never reaches this.
|
||||
"""
|
||||
from ..ui.diagnostics import open_diagnostics_page
|
||||
|
||||
await open_diagnostics_page(ctx, await build_report(ctx))
|
||||
return None
|
||||
|
||||
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
||||
"""State the diagnostics — only reachable from the CLI (the menu opens the screen).
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
params: Unused beyond the injected ``_run_id``.
|
||||
|
||||
Returns:
|
||||
A :class:`ToolResult` carrying the whole block as its report.
|
||||
"""
|
||||
return ToolResult(summary={"tool": self.name}, report=await build_report(ctx))
|
||||
|
||||
|
||||
async def build_report(ctx: AppContext) -> Report:
|
||||
"""Gather every diagnostic fact, as the report both faces render.
|
||||
|
||||
Five blocks in the order a reader needs them: what this program is, what it is running
|
||||
on, what it is drawing into, what radio it found, and how much history it has. The
|
||||
three :class:`~meshterm.ui.report.Facts` blocks merge into one flat JSON object and the
|
||||
two :class:`~meshterm.ui.report.Listing` blocks keep a key of their own, which is also
|
||||
why the table counts are a listing — a table named after a fact would otherwise be able
|
||||
to collide with it.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
|
||||
Returns:
|
||||
The five-block report.
|
||||
"""
|
||||
return (
|
||||
_app_facts(),
|
||||
_host_facts(),
|
||||
await _device_facts(ctx),
|
||||
_mesh_facts(ctx),
|
||||
_table_listing(ctx),
|
||||
_preference_listing(ctx),
|
||||
)
|
||||
|
||||
|
||||
def _app_facts() -> Any:
|
||||
"""What this program is: version, how it was installed, and where it keeps things."""
|
||||
from .. import __version__
|
||||
from ..core import hostinfo
|
||||
from ..ui import fields
|
||||
from ..ui.report import Facts
|
||||
|
||||
return Facts(
|
||||
key="meshterm",
|
||||
fields=(
|
||||
fields.word("meshterm", "meshterm"),
|
||||
fields.word("install", "install"),
|
||||
),
|
||||
values={"meshterm": __version__, "install": hostinfo.install_kind()},
|
||||
)
|
||||
|
||||
|
||||
def _host_facts() -> Any:
|
||||
"""The machine and the terminal, including every verdict resolved once at boot.
|
||||
|
||||
The boot-time verdicts are the point of this block. Whether icons draw, whether the
|
||||
path widget's separators draw, which platform flavour resolved and *why* — each is
|
||||
decided from the environment before the first frame, none is visible on any screen,
|
||||
and each is the answer to a whole family of "it looks wrong" reports.
|
||||
"""
|
||||
from ..core import hostinfo
|
||||
from ..platforms import get_platform
|
||||
from ..ui import fields
|
||||
from ..ui.report import Facts
|
||||
from ..ui.termfont import detect_terminal_font, emoji_support, powerline_support
|
||||
|
||||
machine, term = hostinfo.host(), hostinfo.terminal()
|
||||
emoji = emoji_support()
|
||||
powerline = powerline_support()
|
||||
font = detect_terminal_font()
|
||||
return Facts(
|
||||
key="host",
|
||||
fields=(
|
||||
fields.word("os", "os"),
|
||||
fields.word("os_build", "os_build"),
|
||||
fields.word("arch", "arch"),
|
||||
fields.word("python", "python"),
|
||||
fields.word("terminal", "terminal"),
|
||||
fields.word("term", "term"),
|
||||
fields.word("colorterm", "colorterm"),
|
||||
fields.word("terminal_size", "terminal_size"),
|
||||
fields.flag("over_ssh", "over_ssh"),
|
||||
fields.word("platform", "platform"),
|
||||
fields.flag("icons", "icons"),
|
||||
fields.word("icons_source", "icons_source"),
|
||||
fields.word("font", "font"),
|
||||
fields.word("powerline", "powerline"),
|
||||
),
|
||||
values={
|
||||
"os": machine.os,
|
||||
"os_build": machine.os_build,
|
||||
"arch": machine.arch,
|
||||
"python": machine.python,
|
||||
"terminal": term.program,
|
||||
"term": term.term,
|
||||
"colorterm": term.colorterm,
|
||||
"terminal_size": f"{term.cols}x{term.rows}",
|
||||
"over_ssh": term.over_ssh,
|
||||
"platform": get_platform().name,
|
||||
"icons": emoji.supported,
|
||||
"icons_source": emoji.source,
|
||||
# The face, and which terminal it was read from — an unidentified terminal
|
||||
# (or an ssh session, where the font lives on the far client) has no answer,
|
||||
# and saying so is worth more than naming a font nobody here can see.
|
||||
"font": f"{font.face} ({font.source})" if font else None,
|
||||
# Level and source together: `none (font:windows-terminal)` is a measurement
|
||||
# and `unknown (ssh)` is an admission, and they lead somewhere different.
|
||||
"powerline": f"{powerline.level} ({powerline.source})",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _device_facts(ctx: AppContext) -> Any:
|
||||
"""The radio: what it is, how it is attached, and what its link is set to.
|
||||
|
||||
Best-effort by design. Every value here is ``None`` on a machine whose radio is
|
||||
unplugged, asleep, or refusing to pair — and ``connected: false`` with the failure's
|
||||
own words in ``error`` is a better first line for that bug report than a command that
|
||||
refused to produce one.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import Facts
|
||||
|
||||
values: dict[str, Any] = dict.fromkeys(
|
||||
(
|
||||
"connected",
|
||||
"transport",
|
||||
"port",
|
||||
"device_role",
|
||||
"device_model",
|
||||
"firmware",
|
||||
"radio_freq_mhz",
|
||||
"radio_bw_khz",
|
||||
"radio_sf",
|
||||
"radio_cr",
|
||||
"tx_power_dbm",
|
||||
"error",
|
||||
)
|
||||
)
|
||||
values["connected"] = False
|
||||
try:
|
||||
device = await ctx.device()
|
||||
snapshot = await _snapshot(ctx, device)
|
||||
info = await _device_info(device)
|
||||
adv_type = snapshot.get("adv_type")
|
||||
values.update(
|
||||
connected=True,
|
||||
transport="mock" if ctx.mock else (ctx.active_transport or "serial"),
|
||||
port=ctx.active_port,
|
||||
device_role=(
|
||||
_ROLES.get(int(adv_type), f"type {adv_type}") if adv_type is not None else None
|
||||
),
|
||||
device_model=info.get("model") or None,
|
||||
firmware=" ".join(str(info[k]) for k in ("ver", "fw_build") if info.get(k)) or None,
|
||||
radio_freq_mhz=snapshot.get("radio_freq"),
|
||||
radio_bw_khz=snapshot.get("radio_bw"),
|
||||
radio_sf=snapshot.get("radio_sf"),
|
||||
radio_cr=snapshot.get("radio_cr"),
|
||||
tx_power_dbm=snapshot.get("tx_power"),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - any failure to reach the radio is the fact
|
||||
values["error"] = str(exc) or exc.__class__.__name__
|
||||
|
||||
return Facts(
|
||||
key="device",
|
||||
fields=(
|
||||
fields.flag("connected", "connected"),
|
||||
fields.word("transport", "transport"),
|
||||
fields.word("port", "port"),
|
||||
fields.word("device_role", "device_role"),
|
||||
fields.word("device_model", "device_model"),
|
||||
fields.word("firmware", "firmware"),
|
||||
fields.decimal("radio_freq_mhz", "radio_freq_mhz", ".4f"),
|
||||
fields.decimal("radio_bw_khz", "radio_bw_khz", ".2f"),
|
||||
fields.integer("radio_sf", "radio_sf"),
|
||||
fields.integer("radio_cr", "radio_cr"),
|
||||
fields.integer("tx_power_dbm", "tx_power_dbm"),
|
||||
fields.free("error", "error"),
|
||||
),
|
||||
values=values,
|
||||
)
|
||||
|
||||
|
||||
def _mesh_facts(ctx: AppContext) -> Any:
|
||||
"""How much history this install holds, and where it holds it.
|
||||
|
||||
Counts and a span, never a row. The span is the fact that sizes every other number
|
||||
here: a thousand observations over two years and a thousand in an afternoon describe
|
||||
two different installs, and only one of them has a radio worth suspecting.
|
||||
"""
|
||||
from ..core.preferences import current
|
||||
from ..persistence.logging import log_path
|
||||
from ..ui import fields
|
||||
from ..ui.report import Facts
|
||||
|
||||
preferences = ctx.preferences or current()
|
||||
db_path = ctx.settings.db_path
|
||||
first, last = ctx.repo.observation_span()
|
||||
return Facts(
|
||||
key="mesh",
|
||||
fields=(
|
||||
fields.path("config_dir", "config_dir"),
|
||||
fields.integer("db_size_kb", "db_size_kb"),
|
||||
fields.word("log_level", "log_level"),
|
||||
fields.integer("log_size_kb", "log_size_kb"),
|
||||
fields.integer("runs_failed", "runs_failed"),
|
||||
fields.instant("first_heard", "first_heard"),
|
||||
fields.instant("last_heard", "last_heard"),
|
||||
),
|
||||
values={
|
||||
"config_dir": ctx.settings.config_dir,
|
||||
"db_size_kb": _size_kb(db_path),
|
||||
"log_level": preferences.log_level,
|
||||
# How much the log holds, so a maintainer asking for it knows what they are
|
||||
# asking for — and so a file still at zero says the level never let it write.
|
||||
"log_size_kb": _size_kb(log_path(ctx.settings.config_dir)),
|
||||
"runs_failed": ctx.repo.failed_run_count(),
|
||||
"first_heard": first,
|
||||
"last_heard": last,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _table_listing(ctx: AppContext) -> Any:
|
||||
"""Every table in the database and how many rows it holds.
|
||||
|
||||
A listing rather than more facts, for two reasons. The keys are discovered from the
|
||||
schema rather than declared, so a table named after a fact could otherwise collide
|
||||
with it in the merged JSON object; and this genuinely is a set of records — one per
|
||||
table — which is the shape a reader and a parser both already know.
|
||||
"""
|
||||
from ..ui import fields
|
||||
from ..ui.report import Column, Listing
|
||||
|
||||
counts = ctx.repo.table_counts()
|
||||
return Listing(
|
||||
key="tables",
|
||||
columns=(fields.word("table", "TABLE"), Column(key="rows", lanes=(_rows_lane(),))),
|
||||
rows=[{"table": name, "rows": n} for name, n in counts.items()],
|
||||
)
|
||||
|
||||
|
||||
def _preference_listing(ctx: AppContext) -> Any:
|
||||
"""The preferences that differ from their defaults — the whole list, and only those.
|
||||
|
||||
An override is the only part of the preference set that can explain anything: the
|
||||
defaults are in the source, the same for everyone, and dumping all forty would bury
|
||||
the two the reporter actually changed.
|
||||
"""
|
||||
from ..core.preferences import current
|
||||
from ..ui import fields
|
||||
from ..ui.report import Listing
|
||||
|
||||
preferences = ctx.preferences or current()
|
||||
return Listing(
|
||||
key="preferences",
|
||||
columns=(fields.word("preference", "PREFERENCE"), fields.free("value", "VALUE")),
|
||||
rows=[
|
||||
{"preference": key, "value": str(value)}
|
||||
for key, value in preferences.overrides().items()
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _rows_lane() -> Any:
|
||||
"""The row-count lane: right-aligned, because a column of magnitudes only compares aligned."""
|
||||
from ..ui.report import Lane
|
||||
|
||||
return Lane(header="ROWS", render=lambda v: str(v if v is not None else 0), align="right")
|
||||
|
||||
|
||||
def _size_kb(path: Any) -> int | None:
|
||||
"""A file's size in whole kilobytes, or ``None`` when it is not there to measure."""
|
||||
if path is None:
|
||||
return None
|
||||
try:
|
||||
return round(path.stat().st_size / 1024)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
async def _snapshot(ctx: AppContext, device: Any) -> dict:
|
||||
"""The device's settings snapshot, or an empty one when the read fails."""
|
||||
from ..ui.config_editor import cached_snapshot
|
||||
|
||||
try:
|
||||
return await cached_snapshot(ctx, device)
|
||||
except Exception: # noqa: BLE001 - a settings read that fails leaves the radio facts blank
|
||||
return {}
|
||||
|
||||
|
||||
async def _device_info(device: Any) -> dict:
|
||||
"""The device's self-description, or an empty one when the firmware does not answer."""
|
||||
try:
|
||||
return await device.get_device_info() or {}
|
||||
except Exception: # noqa: BLE001 - firmware predating the query contributes nothing
|
||||
return {}
|
||||
@@ -0,0 +1,153 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The Diagnostics page: the bug-report block, on a frame with nothing else on it.
|
||||
|
||||
**A bare frame, and the reason is mechanical rather than aesthetic.** The one thing this
|
||||
page is for is getting its text out of the terminal and into an issue, and a terminal is
|
||||
selected by dragging: a panel border is characters sitting 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. Same for a header, a footer hint and an F-key lane, which arrive as text that
|
||||
looks like part of the report to whoever reads the paste. So this page draws its rows and
|
||||
nothing at all besides (:attr:`~meshterm.ui.tui.screen.Screen.bare`), the same frame the
|
||||
share screen uses for the same kind of reason — there, a camera wants no contrast arguing
|
||||
with the code; here, a clipboard wants no furniture pretending to be data. Esc leaves, as
|
||||
everywhere.
|
||||
|
||||
The rows come from the tool's :mod:`~meshterm.ui.report`, projected through the very lanes
|
||||
the command line prints (:func:`~meshterm.ui.renderers.facts_pairs`), so what is copied off
|
||||
this screen and what ``meshterm diagnostics`` writes to a pipe are the same facts in the
|
||||
same words. Only the *layout* differs, and 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 entire job is to be complete — so here a long value hangs under its
|
||||
own block instead (the app-wide rule for a wrapped labelled row).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from .tui.screen import ScrollScreen
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..context import AppContext
|
||||
from .report import Facts, Listing, Report
|
||||
|
||||
|
||||
class DiagnosticsPage(ScrollScreen):
|
||||
"""The bug-report block, full-screen and unadorned.
|
||||
|
||||
Bare, so a mouse drag takes the facts and only the facts; scrollable, because the
|
||||
block outgrows a 26-row console and a truncated bug report is worse than a scrolled
|
||||
one. Read-only: there is nothing here to change, and the page is deliberately not a
|
||||
place to change it from.
|
||||
"""
|
||||
|
||||
bare = True
|
||||
|
||||
def __init__(self, report: Report) -> None:
|
||||
"""Lay the report out as the page's body.
|
||||
|
||||
Args:
|
||||
report: The tool's report — the same blocks the CLI renders.
|
||||
"""
|
||||
super().__init__(render_report(report), title="Diagnostics", floating=False)
|
||||
|
||||
|
||||
async def open_diagnostics_page(ctx: AppContext, report: Report) -> None:
|
||||
"""Open the Diagnostics page full-screen and hold it until the reader backs out.
|
||||
|
||||
Args:
|
||||
ctx: The shared application context (must be running the interactive TUI).
|
||||
report: The block to show.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If called outside the interactive menu (no full-screen session).
|
||||
"""
|
||||
from .surface import TuiUi
|
||||
|
||||
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
|
||||
raise RuntimeError("the Diagnostics page is only available in the menu")
|
||||
await ctx.ui.session.run_screen(DiagnosticsPage(report))
|
||||
|
||||
|
||||
def render_report(report: Report) -> RenderableType:
|
||||
"""Lay a report out as the page's body: each block in order, a blank row between.
|
||||
|
||||
A block that would draw nothing — a listing with no rows — is left out along with its
|
||||
separator, exactly as the scripted face leaves it out, so the two never disagree about
|
||||
whether a section exists.
|
||||
|
||||
Args:
|
||||
report: The blocks to draw.
|
||||
|
||||
Returns:
|
||||
The page body.
|
||||
"""
|
||||
from .report import Facts
|
||||
|
||||
parts: list[RenderableType] = []
|
||||
for block in report:
|
||||
drawn = _facts(block) if isinstance(block, Facts) else _listing(block)
|
||||
if drawn is None:
|
||||
continue
|
||||
if parts:
|
||||
parts.append(Text(""))
|
||||
parts.append(drawn)
|
||||
return Group(*parts)
|
||||
|
||||
|
||||
def _facts(block: Facts) -> RenderableType | None:
|
||||
"""One facts block: key on the left, value on the right, wrapping under itself."""
|
||||
from .renderers import facts_pairs
|
||||
|
||||
rows = facts_pairs(block)
|
||||
if not rows:
|
||||
return None
|
||||
table = _grid()
|
||||
table.add_column(no_wrap=True)
|
||||
# The one column allowed to wrap, and the reason this page is not just the scripted
|
||||
# renderer at a different width: a config directory or a connection error cropped to
|
||||
# the frame is a bug report missing the half that mattered.
|
||||
table.add_column(overflow="fold")
|
||||
for key, value in rows:
|
||||
table.add_row(Text(key, style="muted"), Text(value))
|
||||
return table
|
||||
|
||||
|
||||
def _listing(block: Listing) -> RenderableType | None:
|
||||
"""One listing: its header row, then its records. Nothing where there are no records."""
|
||||
if not block.rows:
|
||||
return None
|
||||
lanes = block.lanes()
|
||||
table = _grid()
|
||||
for _column, lane in lanes:
|
||||
table.add_column(justify=lane.align, no_wrap=True)
|
||||
table.add_row(*(Text(lane.header, style="muted") for _column, lane in lanes))
|
||||
for row in block.rows:
|
||||
table.add_row(*(Text(lane.render(row.get(column.key))) for column, lane in lanes))
|
||||
return table
|
||||
|
||||
|
||||
def _grid() -> Table:
|
||||
"""A frameless table that starts in column zero.
|
||||
|
||||
No box, no edge padding and no leading pad: every cell of chrome on a row is a
|
||||
character the clipboard carries, and an indent before the first key is one the reader
|
||||
of the issue has to strip off every line.
|
||||
"""
|
||||
from .script import GUTTER
|
||||
|
||||
return Table(
|
||||
box=None,
|
||||
show_header=False,
|
||||
show_edge=False,
|
||||
pad_edge=False,
|
||||
expand=False,
|
||||
padding=(0, GUTTER, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["DiagnosticsPage", "open_diagnostics_page", "render_report"]
|
||||
@@ -332,6 +332,21 @@ def name(key: str, header: str) -> Column:
|
||||
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.
|
||||
|
||||
@@ -443,6 +458,11 @@ def _rendered_cell(value: Rendered | None) -> str:
|
||||
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)
|
||||
|
||||
@@ -61,6 +61,34 @@ class OutputFormat(str, Enum):
|
||||
JSON = "json"
|
||||
|
||||
|
||||
def facts_pairs(block: Facts) -> list[tuple[str, str]]:
|
||||
"""Project a :class:`Facts` block into its ``(key, value)`` text rows.
|
||||
|
||||
The plain face's half of a facts block, lifted out of :class:`PlainRenderer` because
|
||||
one surface in the menu wants the same rows laid out differently: the Diagnostics page
|
||||
hangs a long value under its key rather than cropping it, since a cropped path in a
|
||||
bug report is worse than a wrapped one. Sharing the projection is what keeps the two
|
||||
from drifting — a field added to a report appears on both without anyone remembering.
|
||||
|
||||
Laying the rows out is the caller's business; turning a typed value into text is not,
|
||||
and that stays here, in the declared :class:`~meshterm.ui.report.Lane`.
|
||||
|
||||
Args:
|
||||
block: The facts block to project.
|
||||
|
||||
Returns:
|
||||
One ``(header, cell)`` pair per lane, in the order the block declares them.
|
||||
"""
|
||||
rows: list[tuple[str, str]] = []
|
||||
for column in block.fields:
|
||||
value = block.values.get(column.key)
|
||||
if block.omit_absent and (value is None or value == ""):
|
||||
continue
|
||||
for lane in column.lanes:
|
||||
rows.append((lane.header, lane.render(value)))
|
||||
return rows
|
||||
|
||||
|
||||
class Renderer:
|
||||
"""Turns a report into bytes. One subclass per format."""
|
||||
|
||||
@@ -157,14 +185,7 @@ class PlainRenderer(Renderer):
|
||||
# As a Text, not a markup string: this is a private key, a share URL or a
|
||||
# remote node's own reply, and Rich would read a bracket in one as a style tag.
|
||||
return Text(column.lanes[0].render(value))
|
||||
rows: list[tuple[str, str]] = []
|
||||
for column in block.fields:
|
||||
value = block.values.get(column.key)
|
||||
if block.omit_absent and (value is None or value == ""):
|
||||
continue
|
||||
for lane in column.lanes:
|
||||
rows.append((lane.header, lane.render(value)))
|
||||
return script.pairs(rows)
|
||||
return script.pairs(facts_pairs(block))
|
||||
|
||||
@contextmanager
|
||||
def stream(self, listing: Listing) -> Iterator[Any]:
|
||||
|
||||
@@ -695,6 +695,7 @@ _GLYPH_MAP: dict[str, str] = {
|
||||
"📶": "≥", # TX power sweep — the power ramp
|
||||
"🔧": "⚙", # config (parameter concept)
|
||||
"📋": "i", # info
|
||||
"🩺": "?", # diagnostics — the question a diagnosis answers (🎭 shares the mark)
|
||||
"📰": "…", # live feed — a stream of items
|
||||
"🎧": "≈", # monitor — listening to the waveform
|
||||
"🗼": "▲", # repeater admin — the repeater mark itself
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The Diagnostics block: what it says, what it refuses to say, and how it says it.
|
||||
|
||||
The privacy tests here are the load-bearing ones. This block exists to be pasted in public
|
||||
by somebody who has not read it, so "no secret is in it" has to be a property of the
|
||||
feature rather than a habit of whoever last edited it. Two tests hold that line from
|
||||
different sides: one plants real secrets in the stores that hold them and asserts none
|
||||
came out, and one refuses the *names* such a field would go under — so a value the suite
|
||||
has no way to plant is still caught by the key it would arrive as.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from meshterm.context import AppContext
|
||||
from meshterm.core import hostinfo
|
||||
from meshterm.core.admin_store import AdminStore
|
||||
from meshterm.core.channel_store import ChannelStore
|
||||
from meshterm.core.config import Settings
|
||||
from meshterm.core.device_store import DeviceStore
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.core.preferences import Preferences
|
||||
from meshterm.persistence.repository import Repository
|
||||
from meshterm.tools.diagnostics import build_report
|
||||
from meshterm.ui.renderers import JsonRenderer, PlainRenderer, facts_pairs
|
||||
from meshterm.ui.report import Facts, Listing
|
||||
|
||||
#: Planted into the stores that really hold this kind of value. Each is the kind of thing
|
||||
#: the block must never carry: one administers a repeater, one decrypts a channel.
|
||||
_ADMIN_PASSWORD = "correct-horse-battery"
|
||||
_CHANNEL_SECRET = "8f" * 16
|
||||
|
||||
#: Key *segments* no field in this block may be built from — matched on the underscore
|
||||
#: parts of a key rather than as substrings, so ``platform`` is not read as a latitude.
|
||||
#: The first row names a secret and the second names a person or a place; ``key`` and
|
||||
#: ``name`` close the set, because the block identifies no node at all, not even ours.
|
||||
_FORBIDDEN_SEGMENTS = frozenset(
|
||||
{
|
||||
"password",
|
||||
"passphrase",
|
||||
"secret",
|
||||
"private",
|
||||
"privkey",
|
||||
"pin",
|
||||
"token",
|
||||
"credential",
|
||||
"contact",
|
||||
"contacts",
|
||||
"lat",
|
||||
"latitude",
|
||||
"lon",
|
||||
"longitude",
|
||||
"position",
|
||||
"message",
|
||||
"messages",
|
||||
"key",
|
||||
"name",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(tmp_path: Path) -> AppContext:
|
||||
"""A context on a throwaway home, with a secret planted in the stores that hold them."""
|
||||
settings = Settings(config_dir=tmp_path)
|
||||
admin_store = AdminStore(tmp_path / "admin.json")
|
||||
admin_store.remember(
|
||||
Contact(name="Hilltop-Repeater", public_key="3d" * 32, key_prefix="3d63c642"),
|
||||
_ADMIN_PASSWORD,
|
||||
)
|
||||
channel_store = ChannelStore(tmp_path / "channels.json")
|
||||
channel_store.remember("ab" * 32, 1, "Lakeside", bytes.fromhex(_CHANNEL_SECRET))
|
||||
return AppContext(
|
||||
console=Console(),
|
||||
settings=settings,
|
||||
repo=Repository(settings.db_path),
|
||||
device_store=DeviceStore(tmp_path / "device.json"),
|
||||
admin_store=admin_store,
|
||||
channel_store=channel_store,
|
||||
preferences=Preferences(tmp_path / "preferences.toml", {"log_level": "DEBUG"}),
|
||||
mock=True,
|
||||
)
|
||||
|
||||
|
||||
# --- what it must never say -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_planted_secret_reaches_either_face(ctx: AppContext) -> None:
|
||||
"""A remembered admin password and a channel secret appear in neither face.
|
||||
|
||||
The point of the feature: a reporter pastes this into a public issue without reading
|
||||
it first.
|
||||
"""
|
||||
report = _report(ctx)
|
||||
for secret in (_ADMIN_PASSWORD, _CHANNEL_SECRET):
|
||||
assert secret not in _plain(report)
|
||||
assert secret not in _machine(report)
|
||||
|
||||
|
||||
def test_no_field_is_named_after_a_secret_or_a_person(ctx: AppContext) -> None:
|
||||
"""No key in the block reads like a credential, a position or somebody's identity.
|
||||
|
||||
The durable half of the guarantee. A value this suite cannot plant — a PIN read live
|
||||
off a radio, a private key exported on demand — still has to arrive under a name, and
|
||||
every name a secret would plausibly take is refused here.
|
||||
"""
|
||||
for key in _keys(_report(ctx)):
|
||||
offending = _FORBIDDEN_SEGMENTS & set(key.lower().split("_"))
|
||||
assert not offending, f"{key!r} is named after {', '.join(sorted(offending))}"
|
||||
|
||||
|
||||
def test_the_mesh_is_described_only_in_aggregate(ctx: AppContext) -> None:
|
||||
"""The mesh reaches the block as counts, never as a row anybody could be named in.
|
||||
|
||||
Every table is *counted*, and the count is an integer: the moment a listing starts
|
||||
carrying a row out of one of those tables, a contact's name or a message body is one
|
||||
edit away from the clipboard.
|
||||
"""
|
||||
tables = _flat(_report(ctx))["tables"]
|
||||
assert tables, "the schema should always have tables to count"
|
||||
assert all(set(row) == {"table", "rows"} for row in tables)
|
||||
assert all(isinstance(row["rows"], int) for row in tables)
|
||||
|
||||
|
||||
# --- what it must say -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_states_the_facts_a_bug_report_opens_with(ctx: AppContext) -> None:
|
||||
"""Version, install shape, host, terminal and device all reach the block."""
|
||||
from meshterm import __version__
|
||||
|
||||
values = _flat(_report(ctx))
|
||||
assert values["meshterm"] == __version__
|
||||
assert values["install"] in {"frozen", "source", "package"}
|
||||
assert values["os"] and values["python"] and values["arch"]
|
||||
assert "x" in values["terminal_size"]
|
||||
assert values["platform"] in {"regular", "picocalc"}
|
||||
# The simulator answers like a radio, so the device half is populated rather than
|
||||
# skipped: this is the shape a real connected run produces.
|
||||
assert values["connected"] is True
|
||||
assert values["transport"] == "mock"
|
||||
assert values["firmware"]
|
||||
|
||||
|
||||
def test_an_unreachable_radio_becomes_a_fact_not_a_failure(
|
||||
ctx: AppContext, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A radio that will not connect leaves the block intact and says why.
|
||||
|
||||
The report about a radio that never connects is exactly the one most worth filing, so
|
||||
the device read is best-effort: the failure lands in ``error`` and every other fact
|
||||
still arrives.
|
||||
"""
|
||||
|
||||
async def refuse(self):
|
||||
raise RuntimeError("no companion answered on COM7")
|
||||
|
||||
monkeypatch.setattr(AppContext, "device", refuse)
|
||||
values = _flat(_report(ctx))
|
||||
assert values["connected"] is False
|
||||
assert "COM7" in values["error"]
|
||||
assert values["firmware"] is None
|
||||
assert values["meshterm"] # the rest of the block is untouched
|
||||
|
||||
|
||||
def test_only_overridden_preferences_are_listed(ctx: AppContext) -> None:
|
||||
"""The preference listing carries the disagreements with the defaults, and only those.
|
||||
|
||||
A dump of all forty would bury the one the reporter actually changed; the defaults are
|
||||
in the source and identical for everyone.
|
||||
"""
|
||||
assert _flat(_report(ctx))["preferences"] == [{"preference": "log_level", "value": "DEBUG"}]
|
||||
|
||||
|
||||
# --- how it says it -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_page_and_the_command_line_state_the_same_facts(ctx: AppContext) -> None:
|
||||
"""Every key/value the page draws is one the scripted face prints, in the same words.
|
||||
|
||||
The two faces share :func:`~meshterm.ui.renderers.facts_pairs` precisely so a field
|
||||
added to the report reaches both without anyone remembering to, and this is what would
|
||||
catch the page growing a projection of its own.
|
||||
"""
|
||||
report = _report(ctx)
|
||||
printed = _plain(report)
|
||||
for block in report:
|
||||
if isinstance(block, Facts):
|
||||
for key, value in facts_pairs(block):
|
||||
assert key in printed
|
||||
assert value in printed
|
||||
|
||||
|
||||
def test_the_page_draws_no_chrome(ctx: AppContext) -> None:
|
||||
"""The page is bare, and its rows start in column zero.
|
||||
|
||||
Both are the same requirement seen twice: a terminal is selected by dragging, so a
|
||||
border, a header or a leading indent is a character the clipboard carries into the
|
||||
issue.
|
||||
"""
|
||||
from meshterm.ui.diagnostics import DiagnosticsPage
|
||||
|
||||
page = DiagnosticsPage(_report(ctx))
|
||||
assert page.bare is True
|
||||
lines = [_strip(line) for line in page.render_body(72)]
|
||||
content = [line for line in lines if line.strip()]
|
||||
assert content, "the page should have drawn something"
|
||||
assert not content[0].startswith(" ")
|
||||
|
||||
|
||||
def test_an_empty_listing_is_drawn_by_neither_face() -> None:
|
||||
"""A listing with no rows is absent from the page exactly as it is from the output.
|
||||
|
||||
The two faces must not disagree about whether a section exists: a reader comparing a
|
||||
pasted block against their own would otherwise read the difference as meaningful.
|
||||
"""
|
||||
from meshterm.ui.diagnostics import render_report
|
||||
|
||||
empty = (Listing(key="preferences", columns=(), rows=[]),)
|
||||
assert _plain(empty).strip() == ""
|
||||
assert _render(render_report(empty)).strip() == ""
|
||||
|
||||
|
||||
# --- the host probe -------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"environ,expected",
|
||||
[
|
||||
({"WT_SESSION": "abc", "TERM": "xterm"}, "Windows Terminal"),
|
||||
({"TERM_PROGRAM": "Apple_Terminal", "TERM": "xterm-256color"}, "Terminal.app"),
|
||||
({"TERM_PROGRAM": "vscode"}, "VS Code"),
|
||||
# A terminal this module has never heard of still names itself, because
|
||||
# TERM_PROGRAM's *value* is the name — which is why that rung exists at all.
|
||||
({"TERM_PROGRAM": "WezTerm"}, "WezTerm"),
|
||||
({"TERM": "linux"}, "linux"),
|
||||
({}, None),
|
||||
],
|
||||
)
|
||||
def test_the_terminal_is_identified_from_what_it_says_about_itself(
|
||||
environ: dict, expected: str | None
|
||||
) -> None:
|
||||
"""Each rung of the ladder, down to the honest ``None`` when nothing names one."""
|
||||
assert hostinfo.terminal_program(environ) == expected
|
||||
|
||||
|
||||
def test_an_inner_terminal_wins_over_the_one_hosting_it() -> None:
|
||||
"""A marker variable beats ``TERM_PROGRAM``, because the inner emulator is the answer.
|
||||
|
||||
VS Code's terminal inherits the outer terminal's environment and adds its own marker;
|
||||
reporting the outer one would name a terminal that is not drawing anything.
|
||||
"""
|
||||
env = {"WT_SESSION": "abc", "TERM_PROGRAM": "vscode"}
|
||||
assert hostinfo.terminal_program(env) == "Windows Terminal"
|
||||
|
||||
|
||||
def test_ssh_is_read_from_either_variable() -> None:
|
||||
"""Being over ssh is a fact the block states, because it changes what is knowable."""
|
||||
assert hostinfo.terminal({"SSH_TTY": "/dev/pts/0"}).over_ssh is True
|
||||
assert hostinfo.terminal({"SSH_CONNECTION": "10.0.0.2 51000 10.0.0.1 22"}).over_ssh is True
|
||||
assert hostinfo.terminal({}).over_ssh is False
|
||||
|
||||
|
||||
def test_the_host_block_is_populated_on_whatever_runs_the_suite() -> None:
|
||||
"""Every host field has an answer, on any OS CI runs this on."""
|
||||
machine = hostinfo.host()
|
||||
assert machine.install in {"frozen", "source", "package"}
|
||||
assert machine.os and machine.arch and machine.python
|
||||
assert machine.python.count(".") == 2
|
||||
|
||||
|
||||
# --- the repository aggregates --------------------------------------------------------
|
||||
|
||||
|
||||
def test_table_counts_are_discovered_from_the_schema(tmp_path: Path) -> None:
|
||||
"""Every table is counted, and SQLite's own internal tables are not.
|
||||
|
||||
Read off ``sqlite_master`` so a table added to the schema starts being reported the day
|
||||
it lands. A curated list would go stale silently, and the interesting count is always
|
||||
the table nobody expected to be full.
|
||||
"""
|
||||
counts = Repository(tmp_path / "meshterm.db").table_counts()
|
||||
assert {"runs", "observations", "messages", "traces"} <= set(counts)
|
||||
assert not any(name.startswith("sqlite_") for name in counts)
|
||||
assert all(isinstance(n, int) for n in counts.values())
|
||||
|
||||
|
||||
def test_the_observation_span_is_absent_until_something_is_heard(tmp_path: Path) -> None:
|
||||
"""An empty database has no span, rather than a span of nothing."""
|
||||
repo = Repository(tmp_path / "meshterm.db")
|
||||
assert repo.observation_span() == (None, None)
|
||||
assert repo.failed_run_count() == 0
|
||||
|
||||
|
||||
# --- helpers ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _report(ctx: AppContext):
|
||||
"""Build the report, as both faces do."""
|
||||
return asyncio.run(build_report(ctx))
|
||||
|
||||
|
||||
def _flat(report) -> dict:
|
||||
"""Every fact in the report, flattened the way the JSON face flattens it."""
|
||||
flat: dict = {}
|
||||
for block in report:
|
||||
if isinstance(block, Facts):
|
||||
flat.update(block.values)
|
||||
else:
|
||||
flat[block.key] = list(block.rows)
|
||||
return flat
|
||||
|
||||
|
||||
def _keys(report) -> list[str]:
|
||||
"""Every key the block declares, facts and listing columns alike."""
|
||||
keys: list[str] = []
|
||||
for block in report:
|
||||
if isinstance(block, Facts):
|
||||
keys.extend(column.key for column in block.fields)
|
||||
else:
|
||||
keys.extend(column.key for column in block.columns)
|
||||
return keys
|
||||
|
||||
|
||||
def _plain(report) -> str:
|
||||
"""The whole block as the plain face writes it."""
|
||||
console = _console(200)
|
||||
with console.capture() as captured:
|
||||
PlainRenderer(console).render(report)
|
||||
return captured.get()
|
||||
|
||||
|
||||
def _machine(report) -> str:
|
||||
"""The machine face's document, as text."""
|
||||
console = _console(10_000)
|
||||
with console.capture() as captured:
|
||||
JsonRenderer(console).render(report)
|
||||
return captured.get()
|
||||
|
||||
|
||||
def _render(renderable) -> str:
|
||||
"""A renderable as plain text at the standard width."""
|
||||
console = _console(72)
|
||||
with console.capture() as captured:
|
||||
console.print(renderable)
|
||||
return captured.get()
|
||||
|
||||
|
||||
def _console(width: int) -> Console:
|
||||
"""A capture console with nothing between the content and the string."""
|
||||
return Console(width=width, color_system=None, highlight=False, markup=False)
|
||||
|
||||
|
||||
def _strip(line: str) -> str:
|
||||
"""One rendered line with its SGR runs removed."""
|
||||
return re.sub(r"\x1b\[[0-9;]*m", "", line)
|
||||
@@ -984,6 +984,77 @@ def _join_discord(cols: int, rows: int) -> Screen:
|
||||
return AboutPage("Join Discord", join_discord())
|
||||
|
||||
|
||||
def _diagnostics(cols: int, rows: int) -> Screen:
|
||||
"""The Diagnostics page, carrying the widest values a real one can.
|
||||
|
||||
A deep Windows config directory and a connection error in the radio's own words are
|
||||
the two fields with no natural bound, and both are here on purpose: this page folds
|
||||
where the scripted face crops, so the fold is what has to survive 53 columns.
|
||||
"""
|
||||
from meshterm.ui.diagnostics import DiagnosticsPage
|
||||
|
||||
return DiagnosticsPage(_diagnostics_report())
|
||||
|
||||
|
||||
def _diagnostics_report():
|
||||
"""A representative diagnostics report, shaped exactly as the tool states one."""
|
||||
from meshterm.ui import fields
|
||||
from meshterm.ui.report import Column, Facts, Lane, Listing
|
||||
|
||||
return (
|
||||
Facts(
|
||||
key="meshterm",
|
||||
fields=(fields.word("meshterm", "meshterm"), fields.word("install", "install")),
|
||||
values={"meshterm": "0.3.7", "install": "frozen"},
|
||||
),
|
||||
Facts(
|
||||
key="host",
|
||||
fields=(
|
||||
fields.word("os", "os"),
|
||||
fields.word("python", "python"),
|
||||
fields.word("terminal", "terminal"),
|
||||
fields.word("terminal_size", "terminal_size"),
|
||||
fields.flag("over_ssh", "over_ssh"),
|
||||
fields.word("powerline", "powerline"),
|
||||
),
|
||||
values={
|
||||
"os": "Debian GNU/Linux 12 (bookworm)",
|
||||
"python": "3.11.2",
|
||||
"terminal": "Windows Terminal",
|
||||
"terminal_size": "53x26",
|
||||
"over_ssh": True,
|
||||
"powerline": "none (font:windows-terminal)",
|
||||
},
|
||||
),
|
||||
Facts(
|
||||
key="device",
|
||||
fields=(
|
||||
fields.flag("connected", "connected"),
|
||||
fields.path("config_dir", "config_dir"),
|
||||
fields.free("error", "error"),
|
||||
),
|
||||
values={
|
||||
"connected": False,
|
||||
"config_dir": Path("C:/Users/somebody/AppData/Roaming/meshterm/profiles/handheld"),
|
||||
"error": "no companion answered on COM7 within 10s; the port is open elsewhere",
|
||||
},
|
||||
),
|
||||
Listing(
|
||||
key="tables",
|
||||
columns=(
|
||||
fields.word("table", "TABLE"),
|
||||
Column(key="rows", lanes=(Lane(header="ROWS", render=str, align="right"),)),
|
||||
),
|
||||
rows=[{"table": "observations", "rows": 418_203}, {"table": "runs", "rows": 91}],
|
||||
),
|
||||
Listing(
|
||||
key="preferences",
|
||||
columns=(fields.word("preference", "PREFERENCE"), fields.free("value", "VALUE")),
|
||||
rows=[{"preference": "log_level", "value": "DEBUG"}],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _share_qr(cols: int, rows: int) -> Screen:
|
||||
"""The share screen: a contact card's code and link on a bare frame (the widest QR)."""
|
||||
from meshterm.ui.qr import QrScreen
|
||||
@@ -1032,6 +1103,7 @@ _ENTRIES: list[_Entry] = [
|
||||
_Entry("join_discord", _join_discord),
|
||||
_Entry("support_project", _support_project),
|
||||
_Entry("share_qr", _share_qr),
|
||||
_Entry("diagnostics", _diagnostics),
|
||||
]
|
||||
|
||||
#: (platform, cols, rows) combos every entry above renders under. PicoCalc gets both its
|
||||
|
||||
+16
-5
@@ -184,8 +184,8 @@ def test_main_menu_sections_answer_the_menus_own_question() -> None:
|
||||
someone else's over the mesh, or MeshTerm itself.
|
||||
|
||||
This app is the last of the three scopes and the reason it sorts last: it holds the
|
||||
preferences that change the program and the pages that describe it, neither of which
|
||||
is a thing to do *on the mesh*.
|
||||
preferences that change the program, the diagnostics that state what it currently is,
|
||||
and the pages that describe it — none of which is a thing to do *on the mesh*.
|
||||
"""
|
||||
from meshterm.tools import load_all_tools
|
||||
from meshterm.tools.base import _CATEGORY_ORDER, all_tools
|
||||
@@ -203,11 +203,22 @@ def test_main_menu_sections_answer_the_menus_own_question() -> None:
|
||||
"Explore": ["map", "walk", "trace", "trace-path", "records"],
|
||||
"This node": ["info", "config", "advert"],
|
||||
"Other nodes": ["repeater-admin", "tx-optimize"],
|
||||
"This app": ["preferences", "about", "about-author", "discord", "support"],
|
||||
"This app": [
|
||||
"preferences",
|
||||
"diagnostics",
|
||||
"about",
|
||||
"about-author",
|
||||
"discord",
|
||||
"support",
|
||||
],
|
||||
}
|
||||
# No section is so big it stops being a grouping (the old Mesh bucket held seven of
|
||||
# nineteen rows), and none is a bucket of one.
|
||||
assert all(2 <= len(names) <= 5 for names in sections.values())
|
||||
# nineteen rows), and none is a bucket of one. This app is the one at six: it runs
|
||||
# mutable, then live, then fixed — the row that *changes* MeshTerm, the row that states
|
||||
# what MeshTerm is *right now* on this machine, then the four pages that say what
|
||||
# MeshTerm is in general. Six of twenty-four rows is still a grouping; seven of
|
||||
# nineteen was not.
|
||||
assert all(2 <= len(names) <= 6 for names in sections.values())
|
||||
|
||||
|
||||
#: Two lexicon icons the terminal genuinely draws in different widths — the whole reason
|
||||
|
||||
Reference in new issue
Block a user