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,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
@@ -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)