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.
- meshcorectl/__init__.py +3 -0
- meshcorectl/__main__.py +6 -0
- meshcorectl/cli.py +243 -0
- meshcorectl/commands/__init__.py +1 -0
- meshcorectl/commands/advert.py +33 -0
- meshcorectl/commands/completion.py +40 -0
- meshcorectl/commands/config_cmd.py +186 -0
- meshcorectl/commands/create.py +65 -0
- meshcorectl/commands/delete.py +100 -0
- meshcorectl/commands/describe.py +86 -0
- meshcorectl/commands/exec_.py +107 -0
- meshcorectl/commands/get.py +183 -0
- meshcorectl/commands/login.py +115 -0
- meshcorectl/commands/logs.py +130 -0
- meshcorectl/commands/reboot.py +40 -0
- meshcorectl/commands/scan.py +76 -0
- meshcorectl/commands/send.py +124 -0
- meshcorectl/commands/set_.py +73 -0
- meshcorectl/commands/top.py +77 -0
- meshcorectl/commands/trace.py +39 -0
- meshcorectl/commands/version.py +58 -0
- meshcorectl/connect.py +74 -0
- meshcorectl/context_store.py +256 -0
- meshcorectl/durations.py +32 -0
- meshcorectl/logging_utils.py +44 -0
- meshcorectl/mesh_data.py +460 -0
- meshcorectl/output/__init__.py +109 -0
- meshcorectl/output/resources.py +73 -0
- meshcorectl/output/table.py +69 -0
- meshcorectl/py.typed +0 -0
- meshcorectl/resource_specs.py +89 -0
- meshcorectl/selectors.py +185 -0
- meshcorectl-0.1.0.dist-info/METADATA +104 -0
- meshcorectl-0.1.0.dist-info/RECORD +37 -0
- meshcorectl-0.1.0.dist-info/WHEEL +4 -0
- meshcorectl-0.1.0.dist-info/entry_points.txt +2 -0
- meshcorectl-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Named connection contexts, kubeconfig-style.
|
|
2
|
+
|
|
3
|
+
A YAML store of named connection profiles at
|
|
4
|
+
``~/.config/meshcorectl/config.yaml`` (or ``$XDG_CONFIG_HOME``), plus a
|
|
5
|
+
``current-context`` pointer -- the same shape as ``~/.kube/config``.
|
|
6
|
+
|
|
7
|
+
Every public method here is pure I/O and validation, with no dependency on
|
|
8
|
+
Click, asyncio, or the `meshcore` transport library.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
_VALID_KINDS = ("ble", "serial", "tcp")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ContextStoreError(Exception):
|
|
24
|
+
"""Base class for context-store problems."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfigFileCorruptError(ContextStoreError):
|
|
28
|
+
"""The config file exists but could not be parsed or validated."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ContextNotFoundError(ContextStoreError):
|
|
32
|
+
"""Raised when a named context does not exist."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, name: str):
|
|
35
|
+
super().__init__(f'no context exists with the name "{name}"')
|
|
36
|
+
self.name = name
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def default_config_path() -> Path:
|
|
40
|
+
"""Return the default config file location, honoring $XDG_CONFIG_HOME."""
|
|
41
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
42
|
+
base = Path(xdg) if xdg else Path.home() / ".config"
|
|
43
|
+
return base / "meshcorectl" / "config.yaml"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class ConnectionSpec:
|
|
48
|
+
"""How to reach a device: exactly one of ble/serial/tcp.
|
|
49
|
+
|
|
50
|
+
The boundary type ``connect.py`` consumes to open a live connection --
|
|
51
|
+
it carries no live state itself.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
kind: str
|
|
55
|
+
# ble
|
|
56
|
+
address: str | None = None
|
|
57
|
+
name_filter: str | None = None
|
|
58
|
+
# serial
|
|
59
|
+
port: str | None = None
|
|
60
|
+
baudrate: int = 115200
|
|
61
|
+
# tcp
|
|
62
|
+
host: str | None = None
|
|
63
|
+
tcp_port: int = 5000
|
|
64
|
+
|
|
65
|
+
def __post_init__(self) -> None:
|
|
66
|
+
if self.kind not in _VALID_KINDS:
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"unknown connection kind {self.kind!r} (must be one of {_VALID_KINDS})"
|
|
69
|
+
)
|
|
70
|
+
if self.kind == "serial" and not self.port:
|
|
71
|
+
raise ValueError("a serial connection requires 'port'")
|
|
72
|
+
if self.kind == "tcp" and not self.host:
|
|
73
|
+
raise ValueError("a tcp connection requires 'host'")
|
|
74
|
+
|
|
75
|
+
def summary(self) -> str:
|
|
76
|
+
"""A one-line human summary, used in `config get-contexts` and error messages."""
|
|
77
|
+
if self.kind == "ble":
|
|
78
|
+
if self.address:
|
|
79
|
+
return f"ble:{self.address}"
|
|
80
|
+
if self.name_filter:
|
|
81
|
+
return f"ble:name~{self.name_filter}"
|
|
82
|
+
return "ble:<first found>"
|
|
83
|
+
if self.kind == "serial":
|
|
84
|
+
return f"serial:{self.port}@{self.baudrate}"
|
|
85
|
+
return f"tcp:{self.host}:{self.tcp_port}"
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict[str, Any]:
|
|
88
|
+
d: dict[str, Any] = {"kind": self.kind}
|
|
89
|
+
if self.kind == "ble":
|
|
90
|
+
if self.address:
|
|
91
|
+
d["address"] = self.address
|
|
92
|
+
if self.name_filter:
|
|
93
|
+
d["name-filter"] = self.name_filter
|
|
94
|
+
elif self.kind == "serial":
|
|
95
|
+
d["port"] = self.port
|
|
96
|
+
d["baudrate"] = self.baudrate
|
|
97
|
+
else: # tcp
|
|
98
|
+
d["host"] = self.host
|
|
99
|
+
d["port"] = self.tcp_port
|
|
100
|
+
return d
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def from_dict(cls, d: dict[str, Any]) -> ConnectionSpec:
|
|
104
|
+
kind = d.get("kind")
|
|
105
|
+
if kind == "ble":
|
|
106
|
+
return cls(kind="ble", address=d.get("address"), name_filter=d.get("name-filter"))
|
|
107
|
+
if kind == "serial":
|
|
108
|
+
if "port" not in d:
|
|
109
|
+
raise ValueError("serial connection is missing 'port'")
|
|
110
|
+
return cls(kind="serial", port=d["port"], baudrate=int(d.get("baudrate", 115200)))
|
|
111
|
+
if kind == "tcp":
|
|
112
|
+
if "host" not in d:
|
|
113
|
+
raise ValueError("tcp connection is missing 'host'")
|
|
114
|
+
return cls(kind="tcp", host=d["host"], tcp_port=int(d.get("port", 5000)))
|
|
115
|
+
raise ValueError(f"unknown connection kind {kind!r} (must be one of {_VALID_KINDS})")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class Context:
|
|
120
|
+
"""One named, addressable device profile."""
|
|
121
|
+
|
|
122
|
+
name: str
|
|
123
|
+
connection: ConnectionSpec
|
|
124
|
+
timeout: float | None = None
|
|
125
|
+
|
|
126
|
+
def to_dict(self) -> dict[str, Any]:
|
|
127
|
+
d: dict[str, Any] = {"connection": self.connection.to_dict()}
|
|
128
|
+
if self.timeout is not None:
|
|
129
|
+
d["timeout"] = self.timeout
|
|
130
|
+
return d
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_dict(cls, name: str, d: dict[str, Any]) -> Context:
|
|
134
|
+
if "connection" not in d:
|
|
135
|
+
raise ValueError(f"context {name!r} is missing 'connection'")
|
|
136
|
+
timeout = d.get("timeout")
|
|
137
|
+
return cls(
|
|
138
|
+
name=name,
|
|
139
|
+
connection=ConnectionSpec.from_dict(d["connection"]),
|
|
140
|
+
timeout=float(timeout) if timeout is not None else None,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class Config:
|
|
146
|
+
"""The whole config file contents."""
|
|
147
|
+
|
|
148
|
+
current_context: str | None = None
|
|
149
|
+
contexts: dict[str, Context] = field(default_factory=dict)
|
|
150
|
+
defaults: dict[str, Any] = field(default_factory=dict)
|
|
151
|
+
|
|
152
|
+
def to_dict(self) -> dict[str, Any]:
|
|
153
|
+
d: dict[str, Any] = {}
|
|
154
|
+
if self.current_context is not None:
|
|
155
|
+
d["current-context"] = self.current_context
|
|
156
|
+
d["contexts"] = {name: ctx.to_dict() for name, ctx in self.contexts.items()}
|
|
157
|
+
if self.defaults:
|
|
158
|
+
d["defaults"] = self.defaults
|
|
159
|
+
return d
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def from_dict(cls, d: dict[str, Any]) -> Config:
|
|
163
|
+
contexts_raw = d.get("contexts") or {}
|
|
164
|
+
if not isinstance(contexts_raw, dict):
|
|
165
|
+
raise ValueError("'contexts' must be a mapping of name -> context")
|
|
166
|
+
contexts = {name: Context.from_dict(name, c) for name, c in contexts_raw.items()}
|
|
167
|
+
defaults = d.get("defaults") or {}
|
|
168
|
+
if not isinstance(defaults, dict):
|
|
169
|
+
raise ValueError("'defaults' must be a mapping")
|
|
170
|
+
return cls(current_context=d.get("current-context"), contexts=contexts, defaults=defaults)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ContextStore:
|
|
174
|
+
"""Loads/saves `Config` and provides the `config` subcommand's operations."""
|
|
175
|
+
|
|
176
|
+
def __init__(self, path: Path | str | None = None):
|
|
177
|
+
self.path = Path(path) if path is not None else default_config_path()
|
|
178
|
+
|
|
179
|
+
def load(self) -> Config:
|
|
180
|
+
if not self.path.exists():
|
|
181
|
+
return Config()
|
|
182
|
+
try:
|
|
183
|
+
raw = yaml.safe_load(self.path.read_text())
|
|
184
|
+
except yaml.YAMLError as exc:
|
|
185
|
+
raise ConfigFileCorruptError(f"could not parse {self.path}: {exc}") from exc
|
|
186
|
+
if raw is None:
|
|
187
|
+
return Config()
|
|
188
|
+
if not isinstance(raw, dict):
|
|
189
|
+
raise ConfigFileCorruptError(
|
|
190
|
+
f"{self.path} must contain a YAML mapping at the top level"
|
|
191
|
+
)
|
|
192
|
+
try:
|
|
193
|
+
return Config.from_dict(raw)
|
|
194
|
+
except ValueError as exc:
|
|
195
|
+
raise ConfigFileCorruptError(f"invalid config in {self.path}: {exc}") from exc
|
|
196
|
+
|
|
197
|
+
def save(self, config: Config) -> None:
|
|
198
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
self.path.write_text(yaml.safe_dump(config.to_dict(), sort_keys=False))
|
|
200
|
+
|
|
201
|
+
def list_contexts(self) -> list[Context]:
|
|
202
|
+
return sorted(self.load().contexts.values(), key=lambda c: c.name)
|
|
203
|
+
|
|
204
|
+
def get_context(self, name: str) -> Context:
|
|
205
|
+
cfg = self.load()
|
|
206
|
+
try:
|
|
207
|
+
return cfg.contexts[name]
|
|
208
|
+
except KeyError:
|
|
209
|
+
raise ContextNotFoundError(name) from None
|
|
210
|
+
|
|
211
|
+
def current_context(self) -> Context:
|
|
212
|
+
"""Return the `Context` the `current-context` pointer names.
|
|
213
|
+
|
|
214
|
+
Raises `ContextStoreError` if unset, `ContextNotFoundError` if it
|
|
215
|
+
points at a context that no longer exists.
|
|
216
|
+
"""
|
|
217
|
+
cfg = self.load()
|
|
218
|
+
if not cfg.current_context:
|
|
219
|
+
raise ContextStoreError("current-context is not set")
|
|
220
|
+
return self.get_context(cfg.current_context)
|
|
221
|
+
|
|
222
|
+
def use_context(self, name: str) -> None:
|
|
223
|
+
cfg = self.load()
|
|
224
|
+
if name not in cfg.contexts:
|
|
225
|
+
raise ContextNotFoundError(name)
|
|
226
|
+
cfg.current_context = name
|
|
227
|
+
self.save(cfg)
|
|
228
|
+
|
|
229
|
+
def set_context(
|
|
230
|
+
self,
|
|
231
|
+
name: str,
|
|
232
|
+
*,
|
|
233
|
+
connection: ConnectionSpec,
|
|
234
|
+
timeout: float | None = None,
|
|
235
|
+
set_current: bool = False,
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Create or overwrite a context. Becomes current if it's the first
|
|
238
|
+
context ever defined, or if `set_current` is explicitly requested."""
|
|
239
|
+
cfg = self.load()
|
|
240
|
+
cfg.contexts[name] = Context(name=name, connection=connection, timeout=timeout)
|
|
241
|
+
if set_current or cfg.current_context is None:
|
|
242
|
+
cfg.current_context = name
|
|
243
|
+
self.save(cfg)
|
|
244
|
+
|
|
245
|
+
def delete_context(self, name: str) -> bool:
|
|
246
|
+
"""Delete a context. Returns True if it was the current context
|
|
247
|
+
(in which case current-context is cleared)."""
|
|
248
|
+
cfg = self.load()
|
|
249
|
+
if name not in cfg.contexts:
|
|
250
|
+
raise ContextNotFoundError(name)
|
|
251
|
+
del cfg.contexts[name]
|
|
252
|
+
was_current = cfg.current_context == name
|
|
253
|
+
if was_current:
|
|
254
|
+
cfg.current_context = None
|
|
255
|
+
self.save(cfg)
|
|
256
|
+
return was_current
|
meshcorectl/durations.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Parses kubectl-`--since`-style relative duration strings ("30m", "2h", "1d").
|
|
2
|
+
|
|
3
|
+
Shared by `logs --since`, `top --history --since`, and the `-l` selector
|
|
4
|
+
grammar's `u<2d`/`u>1h` clauses.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
_UNIT_SECONDS = {
|
|
12
|
+
"s": 1,
|
|
13
|
+
"m": 60,
|
|
14
|
+
"h": 3600,
|
|
15
|
+
"d": 86400,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
_PATTERN = re.compile(r"^(?P<value>\d+(?:\.\d+)?)(?P<unit>[smhd])$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_duration(text: str) -> float:
|
|
22
|
+
"""Parse "30m", "2h", "1.5d", "45s" into a number of seconds.
|
|
23
|
+
|
|
24
|
+
Raises `ValueError` (with a message naming the offending text) on
|
|
25
|
+
anything else, so callers can surface it via `click.ClickException`.
|
|
26
|
+
"""
|
|
27
|
+
match = _PATTERN.match(text.strip())
|
|
28
|
+
if not match:
|
|
29
|
+
raise ValueError(
|
|
30
|
+
f"invalid duration {text!r}: expected a number followed by s/m/h/d, e.g. '30m'"
|
|
31
|
+
)
|
|
32
|
+
return float(match.group("value")) * _UNIT_SECONDS[match.group("unit")]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Keeps plaintext secrets out of `--verbose` debug logs.
|
|
2
|
+
|
|
3
|
+
The `meshcore` library logs everything on its own `"meshcore"` logger --
|
|
4
|
+
including a hex dump of every outbound frame (`commands/base.py`'s
|
|
5
|
+
"Sending raw data: <hex>") and, for a handful of commands, the plaintext
|
|
6
|
+
secret itself: a login password (`commands/messaging.py`'s login sender),
|
|
7
|
+
a device PIN (`commands/device.py`'s PIN setter), and message/channel
|
|
8
|
+
text (`commands/messaging.py`'s message senders) -- all at DEBUG.
|
|
9
|
+
|
|
10
|
+
`meshcorectl -vv` sets the root logger to DEBUG so operators can debug
|
|
11
|
+
connection issues, which would otherwise print those secrets straight to
|
|
12
|
+
the terminal -- and into journald/CI logs when run unattended (see
|
|
13
|
+
CWE-532). `redact_secrets()` briefly raises the upstream logger's own
|
|
14
|
+
level above DEBUG around a single call that hands it a secret, without
|
|
15
|
+
touching `-vv` debug output for anything else (connection lifecycle,
|
|
16
|
+
frame types, timeouts, and so on stay visible).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
from contextlib import contextmanager
|
|
24
|
+
|
|
25
|
+
UPSTREAM_LOGGER_NAME = "meshcore"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def redact_secrets() -> Iterator[None]:
|
|
30
|
+
"""Suppress the upstream `meshcore` logger's DEBUG output for the
|
|
31
|
+
duration of the `with` block, regardless of `-vv`.
|
|
32
|
+
|
|
33
|
+
Wrap calls that hand a plaintext secret to
|
|
34
|
+
`MeshCoreConnection.commands` -- the library logs the outbound frame,
|
|
35
|
+
secret included, at DEBUG. Safe to use from async code: entering and
|
|
36
|
+
exiting only flips a logger's level, nothing is awaited.
|
|
37
|
+
"""
|
|
38
|
+
logger = logging.getLogger(UPSTREAM_LOGGER_NAME)
|
|
39
|
+
previous = logger.level
|
|
40
|
+
logger.setLevel(logging.INFO)
|
|
41
|
+
try:
|
|
42
|
+
yield
|
|
43
|
+
finally:
|
|
44
|
+
logger.setLevel(previous)
|