meshcorectl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """meshcorectl: a kubectl-style CLI for MeshCore companion radios."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Allows `python -m meshcorectl` to behave like the `meshcorectl` console script."""
2
+
3
+ from meshcorectl.cli import main
4
+
5
+ if __name__ == "__main__": # pragma: no cover - identical to cli.py's own entry-point guard
6
+ main()
meshcorectl/cli.py ADDED
@@ -0,0 +1,243 @@
1
+ """The root Click group: global flags, logging setup, and `CliState`.
2
+
3
+ Every command module receives `CliState` via `@click.pass_obj` and uses
4
+ `state.resolve_context()` / `state.connect()` to reach a device — command
5
+ code never touches `ContextStore` or `connect.connect()` directly, which is
6
+ what keeps `tests/commands/*` fast: inject a fake `MeshCoreConnection`
7
+ instead of calling `state.connect()`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ import sys
15
+ from collections.abc import AsyncIterator, Callable, Coroutine
16
+ from contextlib import asynccontextmanager
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Any, TypeVar
20
+
21
+ import click
22
+
23
+ from . import __version__, resource_specs # noqa: F401 - import registers resource specs
24
+ from .commands.advert import advert_command
25
+ from .commands.completion import completion_command
26
+ from .commands.config_cmd import config_group
27
+ from .commands.create import create_group
28
+ from .commands.delete import delete_group
29
+ from .commands.describe import describe_group
30
+ from .commands.exec_ import exec_command
31
+ from .commands.get import get_group
32
+ from .commands.login import login_command, logout_command
33
+ from .commands.logs import logs_command
34
+ from .commands.reboot import reboot_command
35
+ from .commands.scan import scan_command
36
+ from .commands.send import send_group
37
+ from .commands.set_ import set_group
38
+ from .commands.top import top_group
39
+ from .commands.trace import trace_command
40
+ from .commands.version import version_command
41
+ from .connect import ConnectError, MeshCoreConnection, connect
42
+ from .context_store import Context, ContextStore
43
+ from .mesh_data import MeshDataError
44
+ from .output import OutputFormat
45
+
46
+ DEFAULT_TIMEOUT = 10.0
47
+
48
+ _T = TypeVar("_T")
49
+
50
+
51
+ @dataclass
52
+ class CliState:
53
+ """Resolved global options + the context store, threaded through every command."""
54
+
55
+ store: ContextStore
56
+ context_override: str | None
57
+ output: OutputFormat
58
+ timeout_override: float | None
59
+ verbosity: int
60
+
61
+ def resolve_context(self) -> Context:
62
+ """The `Context` a command should use: `--context` override, else
63
+ the store's `current-context`.
64
+
65
+ Raises `click.ClickException` (not a raw store exception) since
66
+ this is always called from inside a command and the message is
67
+ meant to reach the user as-is.
68
+ """
69
+ name = self.context_override
70
+ if name is None:
71
+ cfg = self.store.load()
72
+ name = cfg.current_context
73
+ if name is None:
74
+ raise click.ClickException(
75
+ "no context specified: pass --context NAME or run "
76
+ "'meshcorectl config use-context NAME' "
77
+ "(see 'meshcorectl config set-context --help' to create one)"
78
+ )
79
+ try:
80
+ return self.store.get_context(name)
81
+ except Exception as exc:
82
+ raise click.ClickException(str(exc)) from exc
83
+
84
+ def effective_timeout(self, context: Context) -> float:
85
+ """Precedence: `--timeout` flag > the context's own timeout > the
86
+ config file's `defaults.timeout` > `DEFAULT_TIMEOUT`."""
87
+ if self.timeout_override is not None:
88
+ return self.timeout_override
89
+ if context.timeout is not None:
90
+ return context.timeout
91
+ configured = self.store.load().defaults.get("timeout")
92
+ return float(configured) if configured is not None else DEFAULT_TIMEOUT
93
+
94
+ async def connect(self) -> MeshCoreConnection:
95
+ """Resolve the current context and open a live connection to it."""
96
+ context = self.resolve_context()
97
+ return await connect(
98
+ context.connection,
99
+ timeout=self.effective_timeout(context),
100
+ debug=self.verbosity >= 2,
101
+ )
102
+
103
+ def run_async(self, coro: Coroutine[Any, Any, _T]) -> _T:
104
+ """Convenience for commands: `state.run_async(state.connect())`."""
105
+ return asyncio.run(coro)
106
+
107
+ @asynccontextmanager
108
+ async def connected(self) -> AsyncIterator[MeshCoreConnection]:
109
+ """`async with state.connected() as conn:` -- connect, yield, always
110
+ disconnect, even if the command body raises."""
111
+ connection = await self.connect()
112
+ try:
113
+ yield connection
114
+ finally:
115
+ await connection.disconnect()
116
+
117
+ def run_command(self, coro: Coroutine[Any, Any, _T]) -> _T:
118
+ """Run `coro` (typically built around `async with state.connected()`),
119
+ translating connection/device failures into the one
120
+ `click.ClickException` every command surfaces -- never a raw
121
+ traceback from `connect.ConnectError`/`mesh_data.MeshDataError`."""
122
+ try:
123
+ return self.run_async(coro)
124
+ except (ConnectError, MeshDataError) as exc:
125
+ raise click.ClickException(str(exc)) from exc
126
+
127
+ def call(self, fn: Callable[[MeshCoreConnection], Coroutine[Any, Any, _T]]) -> _T:
128
+ """The one-liner most read/write commands use: connect, call
129
+ `fn(connection)`, disconnect, translate errors -- e.g.
130
+ `state.call(fetch_contacts)`, or `state.call(lambda conn:
131
+ fetch_telemetry(conn, contact))` for a call needing extra args."""
132
+
133
+ async def run() -> _T:
134
+ async with self.connected() as connection:
135
+ return await fn(connection)
136
+
137
+ return self.run_command(run())
138
+
139
+
140
+ def _configure_logging(verbosity: int) -> None:
141
+ level = logging.WARNING
142
+ if verbosity == 1:
143
+ level = logging.INFO
144
+ elif verbosity >= 2:
145
+ level = logging.DEBUG
146
+ logging.basicConfig(level=level, format="%(levelname)s:%(name)s:%(message)s", force=True)
147
+
148
+
149
+ @click.group(name="meshcorectl")
150
+ @click.option(
151
+ "--context",
152
+ "context_override",
153
+ default=None,
154
+ metavar="NAME",
155
+ help="Connection context to use (overrides the current context).",
156
+ )
157
+ @click.option(
158
+ "-o",
159
+ "--output",
160
+ "output_str",
161
+ type=click.Choice([f.value for f in OutputFormat]),
162
+ default=OutputFormat.TABLE.value,
163
+ show_default=True,
164
+ help="Output format.",
165
+ )
166
+ @click.option(
167
+ "--timeout",
168
+ "timeout_override",
169
+ type=float,
170
+ default=None,
171
+ metavar="SECONDS",
172
+ help="Per-command timeout (overrides the context/config default).",
173
+ )
174
+ @click.option(
175
+ "-v",
176
+ "--verbose",
177
+ "verbosity",
178
+ count=True,
179
+ help=(
180
+ "Increase logging verbosity (-v for info, -vv for debug). Login "
181
+ "passwords, device PINs, and message text are kept out of the "
182
+ "debug log."
183
+ ),
184
+ )
185
+ @click.option(
186
+ "--config",
187
+ "config_path",
188
+ type=click.Path(dir_okay=False, path_type=Path),
189
+ default=None,
190
+ metavar="PATH",
191
+ help="Path to the meshcorectl config file (default: ~/.config/meshcorectl/config.yaml).",
192
+ )
193
+ @click.version_option(version=__version__, prog_name="meshcorectl")
194
+ @click.pass_context
195
+ def cli(
196
+ ctx: click.Context,
197
+ context_override: str | None,
198
+ output_str: str,
199
+ timeout_override: float | None,
200
+ verbosity: int,
201
+ config_path: Path | None,
202
+ ) -> None:
203
+ """meshcorectl: a kubectl-style CLI for MeshCore companion radios."""
204
+ _configure_logging(verbosity)
205
+ ctx.obj = CliState(
206
+ store=ContextStore(config_path),
207
+ context_override=context_override,
208
+ output=OutputFormat(output_str),
209
+ timeout_override=timeout_override,
210
+ verbosity=verbosity,
211
+ )
212
+
213
+
214
+ cli.add_command(config_group)
215
+ cli.add_command(get_group)
216
+ cli.add_command(describe_group)
217
+ cli.add_command(create_group)
218
+ cli.add_command(delete_group)
219
+ cli.add_command(send_group)
220
+ cli.add_command(exec_command)
221
+ cli.add_command(login_command)
222
+ cli.add_command(logout_command)
223
+ cli.add_command(top_group)
224
+ cli.add_command(logs_command)
225
+ cli.add_command(trace_command)
226
+ cli.add_command(advert_command)
227
+ cli.add_command(reboot_command)
228
+ cli.add_command(set_group)
229
+ cli.add_command(scan_command)
230
+ cli.add_command(version_command)
231
+ cli.add_command(completion_command)
232
+
233
+
234
+ def main() -> None:
235
+ try:
236
+ cli(prog_name="meshcorectl")
237
+ except KeyboardInterrupt:
238
+ click.echo("aborted", err=True)
239
+ sys.exit(130)
240
+
241
+
242
+ if __name__ == "__main__": # pragma: no cover - exercised via the console-script entry point
243
+ main()
@@ -0,0 +1 @@
1
+ """Click command groups, one module per verb."""
@@ -0,0 +1,33 @@
1
+ """`meshcorectl advert` -- send an advertisement packet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+ from ..connect import MeshCoreConnection
10
+ from ..mesh_data import send_advert
11
+ from ..output import output_option, render_result, resolve_output
12
+
13
+
14
+ @click.command(name="advert")
15
+ @click.option(
16
+ "--flood", is_flag=True, help="Flood the advert instead of sending a normal (zero-hop) one."
17
+ )
18
+ @click.option("--dry-run", is_flag=True, help="Show what would be sent without sending it.")
19
+ @output_option
20
+ @click.pass_obj
21
+ def advert_command(state: Any, flood: bool, dry_run: bool, output_override: str | None) -> None:
22
+ """Send an advertisement packet announcing this device."""
23
+ if dry_run:
24
+ click.echo(f"would send{' a flood' if flood else ''} advert (dry run)")
25
+ return
26
+
27
+ async def run(connection: MeshCoreConnection) -> None:
28
+ await send_advert(connection, flood=flood)
29
+
30
+ state.call(run)
31
+ text = "advert sent" + (" (flood)" if flood else "")
32
+ fmt = resolve_output(state.output, output_override)
33
+ click.echo(render_result({"sent": True, "flood": flood}, fmt, text))
@@ -0,0 +1,40 @@
1
+ """`meshcorectl completion` -- print a shell completion script.
2
+
3
+ Exposes Click's built-in completion support as a discoverable subcommand
4
+ (like `kubectl completion`/`gh completion`) instead of requiring the
5
+ `_MESHCORECTL_COMPLETE=bash_source` env-var convention.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import click
11
+ from click.shell_completion import get_completion_class
12
+
13
+ SHELLS = ("bash", "zsh", "fish")
14
+
15
+
16
+ @click.command(name="completion")
17
+ @click.argument("shell", type=click.Choice(SHELLS))
18
+ @click.pass_context
19
+ def completion_command(ctx: click.Context, shell: str) -> None:
20
+ """Print a shell completion script for SHELL.
21
+
22
+ \b
23
+ Load it for the current session:
24
+ bash: source <(meshcorectl completion bash)
25
+ zsh: source <(meshcorectl completion zsh)
26
+ fish: meshcorectl completion fish | source
27
+
28
+ \b
29
+ Or install it permanently, e.g. for bash:
30
+ meshcorectl completion bash > ~/.local/share/bash-completion/completions/meshcorectl
31
+ """
32
+ completion_class = get_completion_class(shell)
33
+ if completion_class is None: # pragma: no cover - click.Choice already restricts `shell`
34
+ raise click.ClickException(f"unsupported shell {shell!r}")
35
+
36
+ root = ctx.find_root()
37
+ prog_name = root.info_name or "meshcorectl"
38
+ complete_var = f"_{prog_name.upper().replace('-', '_')}_COMPLETE"
39
+ complete = completion_class(root.command, {}, prog_name, complete_var)
40
+ click.echo(complete.source())
@@ -0,0 +1,186 @@
1
+ """`meshcorectl config` — manage named connection contexts.
2
+
3
+ Deliberately mirrors `kubectl config`: `get-contexts`, `current-context`,
4
+ `use-context`, `set-context`, `delete-context`, `view`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+ import click
12
+ import yaml
13
+
14
+ from ..context_store import ConnectionSpec, ContextNotFoundError
15
+ from ..output import OutputFormat, output_option, resolve_output
16
+ from ..output.table import format_table
17
+
18
+
19
+ @click.group(name="config")
20
+ def config_group() -> None:
21
+ """Manage connection contexts (BLE/serial/TCP device profiles)."""
22
+
23
+
24
+ @config_group.command("get-contexts")
25
+ @click.pass_obj
26
+ def get_contexts(state) -> None:
27
+ """List all configured contexts."""
28
+ cfg = state.store.load()
29
+ if not cfg.contexts:
30
+ click.echo(
31
+ "No contexts defined. Create one with 'meshcorectl config set-context'.", err=True
32
+ )
33
+ return
34
+ rows = [
35
+ {
36
+ "current": "*" if name == cfg.current_context else "",
37
+ "name": name,
38
+ "connection": context.connection.summary(),
39
+ "timeout": context.timeout if context.timeout is not None else "",
40
+ }
41
+ for name, context in sorted(cfg.contexts.items())
42
+ ]
43
+ click.echo(
44
+ format_table(
45
+ rows,
46
+ [
47
+ ("CURRENT", "current"),
48
+ ("NAME", "name"),
49
+ ("CONNECTION", "connection"),
50
+ ("TIMEOUT", "timeout"),
51
+ ],
52
+ )
53
+ )
54
+
55
+
56
+ @config_group.command("current-context")
57
+ @click.pass_obj
58
+ def current_context(state) -> None:
59
+ """Print the name of the current context."""
60
+ cfg = state.store.load()
61
+ if not cfg.current_context:
62
+ raise click.ClickException("current-context is not set")
63
+ click.echo(cfg.current_context)
64
+
65
+
66
+ @config_group.command("use-context")
67
+ @click.argument("name")
68
+ @click.pass_obj
69
+ def use_context(state, name: str) -> None:
70
+ """Set the current context to NAME."""
71
+ try:
72
+ state.store.use_context(name)
73
+ except ContextNotFoundError as exc:
74
+ raise click.ClickException(str(exc)) from exc
75
+ click.echo(f'switched to context "{name}"')
76
+
77
+
78
+ @config_group.command("set-context")
79
+ @click.argument("name")
80
+ @click.option(
81
+ "--ble-address", default=None, metavar="ADDR", help="BLE MAC address (or UUID on macOS)."
82
+ )
83
+ @click.option(
84
+ "--ble-name",
85
+ "ble_name_filter",
86
+ default=None,
87
+ metavar="SUBSTRING",
88
+ help="Match a substring of the device's advertised BLE name, when the address isn't known.",
89
+ )
90
+ @click.option(
91
+ "--serial-port", default=None, metavar="PATH", help="Serial device, e.g. /dev/ttyUSB0."
92
+ )
93
+ @click.option("--serial-baudrate", default=115200, show_default=True, type=int)
94
+ @click.option(
95
+ "--tcp-host", default=None, metavar="HOST", help="Hostname/IP of a TCP-exposed radio."
96
+ )
97
+ @click.option("--tcp-port", default=5000, show_default=True, type=int)
98
+ @click.option(
99
+ "--timeout",
100
+ "context_timeout",
101
+ type=float,
102
+ default=None,
103
+ metavar="SECONDS",
104
+ help="Default per-command timeout for this context.",
105
+ )
106
+ @click.option(
107
+ "--current/--no-current",
108
+ "set_current",
109
+ default=False,
110
+ help="Also make this the current context (the first context you create always becomes "
111
+ "current).",
112
+ )
113
+ @click.pass_obj
114
+ def set_context(
115
+ state,
116
+ name: str,
117
+ ble_address: str | None,
118
+ ble_name_filter: str | None,
119
+ serial_port: str | None,
120
+ serial_baudrate: int,
121
+ tcp_host: str | None,
122
+ tcp_port: int,
123
+ context_timeout: float | None,
124
+ set_current: bool,
125
+ ) -> None:
126
+ """Create or update a connection context named NAME."""
127
+ kinds_given = {
128
+ "ble": ble_address is not None or ble_name_filter is not None,
129
+ "serial": serial_port is not None,
130
+ "tcp": tcp_host is not None,
131
+ }
132
+ selected = [kind for kind, given in kinds_given.items() if given]
133
+ if len(selected) > 1:
134
+ raise click.ClickException(
135
+ f"conflicting connection flags for: {', '.join(selected)} "
136
+ "(a context can only use one of --ble-*, --serial-*, or --tcp-*)"
137
+ )
138
+ if not selected:
139
+ raise click.ClickException(
140
+ "no connection specified: pass --ble-address/--ble-name, --serial-port, or --tcp-host"
141
+ )
142
+
143
+ kind = selected[0]
144
+ if kind == "ble":
145
+ connection = ConnectionSpec(kind="ble", address=ble_address, name_filter=ble_name_filter)
146
+ elif kind == "serial":
147
+ connection = ConnectionSpec(kind="serial", port=serial_port, baudrate=serial_baudrate)
148
+ else:
149
+ connection = ConnectionSpec(kind="tcp", host=tcp_host, tcp_port=tcp_port)
150
+
151
+ state.store.set_context(
152
+ name, connection=connection, timeout=context_timeout, set_current=set_current
153
+ )
154
+ now_current = set_current or state.store.load().current_context == name
155
+ click.echo(f'context "{name}" set.' + (" (current)" if now_current else ""))
156
+
157
+
158
+ @config_group.command("delete-context")
159
+ @click.argument("name")
160
+ @click.pass_obj
161
+ def delete_context(state, name: str) -> None:
162
+ """Delete context NAME."""
163
+ try:
164
+ was_current = state.store.delete_context(name)
165
+ except ContextNotFoundError as exc:
166
+ raise click.ClickException(str(exc)) from exc
167
+ click.echo(f'context "{name}" deleted.')
168
+ if was_current:
169
+ click.echo(
170
+ "note: that was the current context; set a new one with "
171
+ "'meshcorectl config use-context'.",
172
+ err=True,
173
+ )
174
+
175
+
176
+ @config_group.command("view")
177
+ @output_option
178
+ @click.pass_obj
179
+ def view(state, output_override: str | None) -> None:
180
+ """Print the full contents of the config file."""
181
+ cfg = state.store.load()
182
+ fmt = resolve_output(state.output, output_override)
183
+ if fmt is OutputFormat.JSON:
184
+ click.echo(json.dumps(cfg.to_dict(), indent=2))
185
+ else:
186
+ click.echo(yaml.safe_dump(cfg.to_dict(), sort_keys=False), nl=False)
@@ -0,0 +1,65 @@
1
+ """`meshcorectl create` -- import a contact card, or define a channel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+ from ..connect import MeshCoreConnection
10
+ from ..mesh_data import create_channel, import_contact
11
+ from ..output import output_option, render, render_result, resolve_output
12
+
13
+
14
+ @click.group(name="create")
15
+ def create_group() -> None:
16
+ """Create a resource: import a contact, or define a channel."""
17
+
18
+
19
+ @create_group.command("contact")
20
+ @click.option("--uri", required=True, metavar="URI", help="A meshcore:// contact card URI.")
21
+ @click.option("--dry-run", is_flag=True, help="Show what would be imported without doing it.")
22
+ @output_option
23
+ @click.pass_obj
24
+ def create_contact(state: Any, uri: str, dry_run: bool, output_override: str | None) -> None:
25
+ """Import a contact from its meshcore:// card URI."""
26
+ if dry_run:
27
+ click.echo(f"would import contact from {uri} (dry run)")
28
+ return
29
+ state.call(lambda conn: import_contact(conn, uri))
30
+ result = {"imported": True, "uri": uri}
31
+ fmt = resolve_output(state.output, output_override)
32
+ click.echo(render_result(result, fmt, f"contact imported from {uri}"))
33
+
34
+
35
+ @create_group.command("channel")
36
+ @click.argument("index", type=int)
37
+ @click.argument("name")
38
+ @click.argument("key", required=False, metavar="[KEY]")
39
+ @click.option("--dry-run", is_flag=True, help="Show what would be created without doing it.")
40
+ @output_option
41
+ @click.pass_obj
42
+ def create_channel_command(
43
+ state: Any, index: int, name: str, key: str | None, dry_run: bool, output_override: str | None
44
+ ) -> None:
45
+ """Define channel INDEX with NAME and optional 32-hex-char KEY.
46
+
47
+ When KEY is omitted: a '#'-prefixed NAME is the public-channel convention,
48
+ so the device derives the same secret from NAME itself -- anyone who knows
49
+ the name can compute it and join. For any other NAME, meshcorectl generates
50
+ a random secret for you (a name-derived secret would make a "private"
51
+ channel just as guessable as a public one).
52
+ """
53
+ generating = key is None and not name.startswith("#")
54
+ if dry_run:
55
+ suffix = " (auto-generating a random secret)" if generating else ""
56
+ click.echo(f"would create channel {index} named {name!r}{suffix} (dry run)")
57
+ return
58
+
59
+ async def run(connection: MeshCoreConnection) -> dict[str, Any]:
60
+ return await create_channel(connection, index, name, key)
61
+
62
+ channel = state.call(run)
63
+ if generating:
64
+ click.echo(f"Generated channel secret: {channel['secret']}", err=True)
65
+ click.echo(render(channel, resolve_output(state.output, output_override), kind="channel"))
@@ -0,0 +1,100 @@
1
+ """`meshcorectl delete` -- remove a contact (by name or -l selector), or
2
+ clear a channel slot.
3
+
4
+ No `pending-contacts` subcommand: "pending" contacts are client-side
5
+ bookkeeping accumulated over a session (see `get.py`'s `pending-contacts`,
6
+ which watches for a window instead of reading a cache, for the same
7
+ reason). A one-shot connection never accumulates anything across
8
+ invocations, so there's nothing for `delete pending-contacts` to clear.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ import click
16
+
17
+ from ..connect import MeshCoreConnection
18
+ from ..mesh_data import (
19
+ AmbiguousMatchError,
20
+ delete_channel,
21
+ fetch_channels,
22
+ fetch_contacts,
23
+ find_channel,
24
+ find_contact,
25
+ )
26
+ from ..mesh_data import remove_contact as remove_contact_data
27
+ from ..selectors import SelectorError, filter_contacts
28
+
29
+
30
+ @click.group(name="delete")
31
+ def delete_group() -> None:
32
+ """Delete a contact, or clear a channel slot."""
33
+
34
+
35
+ @delete_group.command("contact")
36
+ @click.argument("name", required=False, metavar="NAME")
37
+ @click.option(
38
+ "-l",
39
+ "--selector",
40
+ "selector_text",
41
+ default=None,
42
+ metavar="SELECTOR",
43
+ help="Delete every contact matching this filter instead of one by name, e.g. 't=client,u>30d'.",
44
+ )
45
+ @click.option("--dry-run", is_flag=True, help="Show what would be deleted without deleting it.")
46
+ @click.pass_obj
47
+ def delete_contact(state: Any, name: str | None, selector_text: str | None, dry_run: bool) -> None:
48
+ """Delete one contact by NAME, or every contact matching -l/--selector."""
49
+ if (name is None) == (selector_text is None):
50
+ raise click.ClickException("pass exactly one of NAME or -l/--selector")
51
+
52
+ async def run(connection: MeshCoreConnection) -> None:
53
+ contacts = await fetch_contacts(connection)
54
+ if name is not None:
55
+ try:
56
+ contact = find_contact(contacts, name)
57
+ except AmbiguousMatchError as exc:
58
+ raise click.ClickException(str(exc)) from exc
59
+ if contact is None:
60
+ raise click.ClickException(f"no contact matching {name!r}")
61
+ targets = [contact]
62
+ else:
63
+ try:
64
+ targets = filter_contacts(contacts, selector_text)
65
+ except SelectorError as exc:
66
+ raise click.ClickException(str(exc)) from exc
67
+ if not targets:
68
+ click.echo("No contacts matched.", err=True)
69
+ return
70
+
71
+ for target in targets:
72
+ if dry_run:
73
+ click.echo(f"contact {target['name']!r} would be deleted (dry run)")
74
+ continue
75
+ await remove_contact_data(connection, target)
76
+ click.echo(f"contact {target['name']!r} deleted")
77
+
78
+ state.call(run)
79
+
80
+
81
+ @delete_group.command("channel")
82
+ @click.argument("index_or_name", metavar="INDEX_OR_NAME")
83
+ @click.option("--dry-run", is_flag=True, help="Show what would be deleted without deleting it.")
84
+ @click.pass_obj
85
+ def delete_channel_command(state: Any, index_or_name: str, dry_run: bool) -> None:
86
+ """Clear channel INDEX_OR_NAME (there's no true delete: the name and
87
+ secret are cleared instead)."""
88
+
89
+ async def run(connection: MeshCoreConnection) -> None:
90
+ channels = await fetch_channels(connection)
91
+ channel = find_channel(channels, index_or_name)
92
+ if channel is None:
93
+ raise click.ClickException(f"no channel matching {index_or_name!r}")
94
+ if dry_run:
95
+ click.echo(f"channel {channel['name']!r} would be cleared (dry run)")
96
+ return
97
+ await delete_channel(connection, channel["index"])
98
+ click.echo(f"channel {channel['name']!r} cleared")
99
+
100
+ state.call(run)