Files
MeshTerm/meshterm/cli.py
T
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

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

284 lines
11 KiB
Python

"""Typer entry point.
Global options are parsed once in the callback, which builds the shared
:class:`~meshterm.context.AppContext`. Running with no subcommand launches the
interactive menu; otherwise the selected tool's subcommand runs. Both paths funnel
through :func:`run_tool_command` / :func:`run_menu`, so behavior stays identical.
"""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
import typer
from .context import AppContext
from .core.admin_store import AdminStore
from .core.config import Settings
from .core.connection import DeviceCommandError, is_connection_lost
from .core.device_config import DeviceConfigError
from .core.device_store import DeviceStore
from .core.preferences import PreferenceError
from .core.selection import DeviceSelectionError
from .persistence.logging import configure_logging
from .persistence.repository import Repository
from .platforms import Resolution, resolve, set_platform
from .tools import all_tools
from .tools.base import Tool, ToolResult
from .ui.theme import make_console
def _unframe_help_panels() -> None:
"""Render Typer's help/error sections as coloured headings instead of boxed panels.
Typer's rich help gives us the colour we want — yellow usage, cyan options, green
switches — but wraps each Options/Commands/Error section in a rounded :class:`Panel`,
the boxed framing scripted output is meant to be free of. There's no constant to drop
that box, so we swap the ``Panel`` the help formatter calls for a thin stand-in that
keeps the colour and the section title (as a bold heading, indigo like the app accent,
red for errors) but no border — the same heading-over-content shape tool results use
(see :func:`~meshterm.ui.surface._deframe`). The inner tables are already box-less.
"""
import typer.rich_utils as rich_utils
from rich.console import Group
from rich.text import Text
def _bare_panel(
renderable: object,
*,
title: object = None,
border_style: str = "",
**_: object,
) -> Group:
style = "bold red" if border_style == "red" else "bold #818cf8"
heading = Text(str(title), style=style) if title else Text("")
return Group(Text(""), heading, renderable) # type: ignore[list-item]
rich_utils.Panel = _bare_panel # type: ignore[assignment,misc]
_unframe_help_panels()
app = typer.Typer(
add_completion=False,
no_args_is_help=False,
# Rich help for its colour (usage, option flags, metavars), but with the boxed
# section panels flattened to plain headings by _unframe_help_panels() above — a
# splash of colour, none of the framing.
rich_markup_mode="rich",
help="MeshTerm — a modern toolkit for tuning and exploring your MeshCore mesh",
)
# The context built by the callback and consumed by subcommands within one process.
_state: AppContext | None = None
# The platform resolution the callback made, for the ``platform`` diagnostic subcommand
# to report back (see :func:`platform_command`) without re-deriving it independently.
_platform_resolution: Resolution | None = None
@app.callback(invoke_without_command=True)
def main_callback(
ctx: typer.Context,
profile: str | None = typer.Option(None, "--profile", "-p", help="Device profile"),
port: str | None = typer.Option(None, "--port", help="Serial port override"),
ble: str | None = typer.Option(
None, "--ble", help="Bluetooth address of a companion device (selects the BLE transport)"
),
ble_pin: str | None = typer.Option(
None, "--ble-pin", help="BLE pairing PIN, if the Bluetooth companion requires one"
),
tcp: str | None = typer.Option(
None,
"--tcp",
help="Network address host[:port] of a TCP companion (selects the TCP transport)",
),
mock: bool = typer.Option(False, "--mock", help="Use the built-in simulator"),
db_path: Path | None = typer.Option(None, "--db", help="SQLite database path"),
json_output: bool = typer.Option(False, "--json", help="Machine-readable output"),
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress console logging"),
platform: str | None = typer.Option(
None,
"--platform",
help="Force the UI platform (regular|picocalc) instead of auto-detecting it",
),
) -> None:
"""Build the application context and dispatch to the menu or a subcommand.
Args:
ctx: The Click/Typer context.
profile: Named device profile to use.
port: Explicit serial port, overriding the profile.
ble: Explicit Bluetooth address, selecting the BLE transport.
ble_pin: Optional BLE pairing PIN for the Bluetooth companion.
tcp: Explicit network address ``host[:port]``, selecting the TCP transport.
mock: Whether to use the simulator instead of real hardware.
db_path: Override the database location.
json_output: Request machine-readable output from tools.
quiet: Suppress console logging (file logging continues).
platform: Explicit ``--platform`` override; see :func:`meshterm.platforms.resolve`.
"""
global _state, _platform_resolution
# Resolved and installed first, before anything below it (make_console, the theme,
# the eventual TuiSession) reads the active platform.
try:
_platform_resolution = resolve(platform)
except ValueError as exc:
raise typer.BadParameter(str(exc), param_hint="--platform") from exc
set_platform(_platform_resolution.platform)
settings = Settings.load()
if db_path is not None:
settings.db_path = db_path
console = make_console()
configure_logging(console, settings.config_dir, level=logging.INFO, quiet=quiet or json_output)
app_ctx = AppContext(
console=console,
settings=settings,
repo=Repository(settings.db_path),
device_store=DeviceStore(settings.config_dir / "devices.json"),
admin_store=AdminStore(settings.config_dir / "admin.json"),
profile=settings.resolve_profile(profile),
mock=mock,
port_override=port,
ble_override=ble,
tcp_override=tcp,
ble_pin=ble_pin,
json_output=json_output,
explicit_selection=(
profile is not None or port is not None or ble is not None or tcp is not None
),
)
_state = app_ctx
ctx.call_on_close(app_ctx.repo.close)
if ctx.invoked_subcommand is None:
from .ui.menu import run_menu
asyncio.run(_drive(run_menu(app_ctx), app_ctx))
@app.command(name="platform")
def platform_command() -> None:
"""Print the resolved platform and every input the resolution looked at.
A diagnostic for the platform seam (see ``meshterm/platforms.py``): confirms which
flavour a given invocation would run as, and why — the ``--platform``/
``MESHTERM_PLATFORM`` inputs, what ``/proc/device-tree/model`` reports (``None`` off
the actual hardware), and which of those decided it.
"""
assert _platform_resolution is not None # set by the callback that always runs first
r = _platform_resolution
console = make_console()
console.print(f"platform: [accent]{r.platform.name}[/accent] (source: {r.source})")
console.print(f" --platform: {r.flag or '(not passed)'}")
console.print(f" MESHTERM_PLATFORM: {r.env or '(not set)'}")
console.print(f" /proc/device-tree/model: {r.detected_model or '(unavailable)'}")
@app.command(name="specimen")
def specimen_command() -> None:
"""Print the visual-language specimen for the active platform.
Every mark, icon funnel, colour scale and fold on one card, drawn through the same
theme and glyph machinery the TUI uses — so on the PicoCalc console it is the
font/palette acceptance screen, and with ``--platform picocalc`` on a desktop it
previews that flavour. See :mod:`meshterm.ui.specimen`.
"""
from .ui.specimen import specimen_lines
console = make_console()
for line in specimen_lines():
console.print(line)
def run_tool_command(tool: Tool, params: dict) -> None:
"""Execute a tool from a CLI subcommand and render its result.
Builds and tears down the device connection within a single event loop so the
``meshcore`` client is never used across loops.
Args:
tool: The tool to run.
params: Parameters parsed from the subcommand's options.
"""
assert _state is not None # set by the callback before any subcommand runs
try:
asyncio.run(_drive(_execute_and_render(tool, params, _state), _state))
except (DeviceSelectionError, DeviceConfigError, DeviceCommandError, PreferenceError) as exc:
# Expected user-facing error (ambiguous/absent device, a bad config or preference
# value, or a transient command failure): show the message, not a traceback.
_state.console.print(f"[err]✗[/err] {exc}")
raise typer.Exit(1) from exc
except Exception as exc:
# A dropped serial link (device unplugged/powered off mid-command) can't be recovered
# from in a one-shot scripted run the way the interactive menu offers — but it should
# still read as a clean message, not a traceback. Anything else propagates as before.
if not is_connection_lost(exc):
raise
_state.console.print(
"[err]✗[/err] the connection to your device was lost "
"(it may have been unplugged or powered off)."
)
raise typer.Exit(1) from exc
async def _drive(coro, ctx: AppContext) -> None:
"""Await a coroutine then disconnect the device (but keep the repo open).
Args:
coro: The coroutine to run (a tool execution or the menu loop).
ctx: The application context whose device should be closed afterward.
"""
try:
await coro
finally:
if ctx._device is not None:
try:
await ctx._device.disconnect()
except Exception: # noqa: BLE001 - a dead/lost link must not crash teardown
pass
ctx._device = None
async def _execute_and_render(tool: Tool, params: dict, ctx: AppContext) -> ToolResult:
"""Run a tool and print its closing message and artifact list.
Args:
tool: The tool to execute.
params: Parameters for the tool.
ctx: The application context.
Returns:
The tool's :class:`ToolResult`.
"""
result = await tool.execute(ctx, params)
if result.message:
ctx.console.print(result.message)
for artifact in result.artifacts:
ctx.console.print(f"[ok]●[/ok] wrote [accent]{artifact}[/accent]")
return result
def _register_all() -> None:
"""Register every tool's CLI subcommand with the Typer app."""
for tool in all_tools():
tool.register_cli(app)
_register_all()
def main() -> None:
"""Console-script entry point."""
app()
if __name__ == "__main__":
main()