devicectl-core 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.
Files changed (47) hide show
  1. devicectl/__init__.py +18 -0
  2. devicectl/cli/__init__.py +1 -0
  3. devicectl/cli/command.py +95 -0
  4. devicectl/cli/exits.py +32 -0
  5. devicectl/cli/fanout.py +142 -0
  6. devicectl/cli/main.py +69 -0
  7. devicectl/cli/output.py +299 -0
  8. devicectl/cli/parser.py +80 -0
  9. devicectl/cli/report.py +86 -0
  10. devicectl/cli/target.py +26 -0
  11. devicectl/clock.py +57 -0
  12. devicectl/devtools/__init__.py +6 -0
  13. devicectl/devtools/frontlint.py +935 -0
  14. devicectl/devtools/htmcheck.py +396 -0
  15. devicectl/devtools/rendercheck.py +384 -0
  16. devicectl/doctor.py +112 -0
  17. devicectl/errors.py +68 -0
  18. devicectl/fields.py +564 -0
  19. devicectl/meta.py +64 -0
  20. devicectl/paths.py +40 -0
  21. devicectl/progress.py +77 -0
  22. devicectl/report.py +67 -0
  23. devicectl/testing.py +199 -0
  24. devicectl/trace.py +333 -0
  25. devicectl/web/__init__.py +1 -0
  26. devicectl/web/agents.py +94 -0
  27. devicectl/web/events.py +171 -0
  28. devicectl/web/http.py +243 -0
  29. devicectl/web/progress.py +101 -0
  30. devicectl/web/server.py +1013 -0
  31. devicectl/web/static/core.css +3034 -0
  32. devicectl/web/static/js/api.js +198 -0
  33. devicectl/web/static/js/band.js +640 -0
  34. devicectl/web/static/js/chart.js +400 -0
  35. devicectl/web/static/js/drafts.js +312 -0
  36. devicectl/web/static/js/notify.js +272 -0
  37. devicectl/web/static/js/panels.js +432 -0
  38. devicectl/web/static/js/shell.js +672 -0
  39. devicectl/web/static/js/trace.js +133 -0
  40. devicectl/web/static/js/ui.js +1139 -0
  41. devicectl/web/static/vendor/preact-htm.module.js +27 -0
  42. devicectl/web/worker.py +697 -0
  43. devicectl_core-0.1.0.dist-info/METADATA +131 -0
  44. devicectl_core-0.1.0.dist-info/RECORD +47 -0
  45. devicectl_core-0.1.0.dist-info/WHEEL +4 -0
  46. devicectl_core-0.1.0.dist-info/licenses/LICENSE +287 -0
  47. devicectl_core-0.1.0.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,384 @@
1
+ #!/usr/bin/env python3
2
+ """The checks that need the page actually drawn.
3
+
4
+ ``pytest`` proves the server sends the right JSON, Biome parses the browser
5
+ half, :mod:`~devicectl.devtools.frontlint` resolves it across files and
6
+ :mod:`~devicectl.devtools.htmcheck` proves every template parses. All four
7
+ passed while a page was showing a drop-down with no usable entries, a switch
8
+ word rendered as the number 16, a unit sitting on its own line under its
9
+ input, a value column wide enough to push four columns off-screen, and a
10
+ model number split across two lines. None of those is a crash, so nothing
11
+ without a layout engine could see them.
12
+
13
+ This drives a real browser over a program's own UI and fails on the ones
14
+ that can be stated exactly:
15
+
16
+ * **A page error**, or an error on the console, on any tab.
17
+ * **A value broken mid-token.** A model number or a serial is one token and
18
+ splitting it makes it unreadable and unsearchable.
19
+ * **A table wider than the box it scrolls in**, which is a column pushed
20
+ off-screen rather than a table that scrolls.
21
+ * **A tab that rendered nothing**, which is how a wiring mistake looks from
22
+ outside.
23
+ * **A page that scrolls sideways**, which is a column, an input or a table
24
+ that has run off the side rather than wrapped or scrolled in its own box.
25
+ * **A setpoint that will not answer the arrow keys**, which is a control
26
+ that looks live, drags, draws correctly -- and cannot be set to the value
27
+ somebody actually wants, because a pixel of track is worth more than a
28
+ step.
29
+ * **A drag that does not hand the setpoint the keyboard**, which is the
30
+ same fault one step earlier: the arrow keys work, and nothing the pointer
31
+ does ever points them at anything.
32
+
33
+ Every tab, at four widths, because a layout is only correct at the width it
34
+ was checked at -- and the narrowest is a phone, where a two-column row has
35
+ to become one column or the value's editor ends up off the screen.
36
+
37
+ A program supplies two things and gets the rest: a context manager that
38
+ serves its UI on a given port, and the map of tab name to hash route. See
39
+ :func:`main`.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import argparse
45
+ import socket
46
+ import sys
47
+ from typing import Any, Callable, ContextManager, Mapping, Sequence
48
+
49
+ # The widths a layout has to survive: a desktop, a small laptop, a tablet in
50
+ # landscape, and a phone -- which is what is actually in your pocket when the
51
+ # alarm goes off.
52
+ WIDTHS = (1440, 1280, 1024, 420)
53
+
54
+ # Below this, a table is meant to be scrolled and a hyphenated label is meant
55
+ # to break: the two checks that ask whether something fits are asking a
56
+ # question the phone width has already answered no to, on purpose. What
57
+ # still holds there is that the page itself must not run off the side.
58
+ FITS_ABOVE = 700
59
+
60
+ # How long a tab gets to finish its own reads before it is judged.
61
+ SETTLE_MS = 2200
62
+
63
+ # Below this many characters a tab has not drawn anything: the chrome around
64
+ # it -- the header, the tab strip, the link pill -- is already more than this.
65
+ DREW_NOTHING = 100
66
+
67
+ # How tall the window is. Only the width is varied; the height changes
68
+ # nothing these checks ask about.
69
+ VIEWPORT_HEIGHT = 1000
70
+
71
+ # One client rect per line box is the only reliable line count: a table cell's
72
+ # height includes its padding, so height over line-height calls every cell a
73
+ # wrap. Elements holding several words are skipped -- prose is meant to wrap.
74
+ FIND_WRAPS = """() => {
75
+ const bad = [];
76
+ for (const el of document.querySelectorAll('.row > .v, .row > .k, td, th, .badge, h2, .dim')) {
77
+ const text = el.textContent.trim();
78
+ if (!text || /\\s/.test(text)) continue;
79
+ if (el.children.length || el.querySelector('input,select,button')) continue;
80
+ const range = document.createRange();
81
+ range.selectNodeContents(el);
82
+ if (range.getClientRects().length > 1) bad.push(text);
83
+ }
84
+ return bad;
85
+ }"""
86
+
87
+ # A page that scrolls sideways. Not a box that scrolls -- a table in its own
88
+ # scroller is fine and deliberate -- but the document itself, which is
89
+ # something that could not fit and was not told what to do about it. The
90
+ # element is named as well, or the answer is "somewhere on this page".
91
+ FIND_SIDEWAYS = """() => {
92
+ const room = document.documentElement.clientWidth;
93
+ if (document.documentElement.scrollWidth <= room + 1) return [];
94
+ const bad = [];
95
+ for (const el of document.querySelectorAll('body *')) {
96
+ const box = el.getBoundingClientRect();
97
+ if (box.width === 0 || (box.right <= room + 1 && box.left >= -1)) continue;
98
+ let p = el.parentElement, held = false;
99
+ while (p) {
100
+ const flow = getComputedStyle(p).overflowX;
101
+ if (flow === 'auto' || flow === 'scroll' || flow === 'hidden') { held = true; break; }
102
+ p = p.parentElement;
103
+ }
104
+ if (!held) {
105
+ const name = el.className ? `.${String(el.className).split(' ')[0]}` : '';
106
+ bad.push(`${el.tagName.toLowerCase()}${name} reaches ${Math.round(box.right)}px`);
107
+ }
108
+ }
109
+ return [...new Set(bad)].slice(0, 3);
110
+ }"""
111
+
112
+ # Every setpoint on every band, asked whether the keyboard moves it.
113
+ #
114
+ # A grip is a `role="slider"`, and the one thing every slider anywhere has
115
+ # to do is answer the arrow keys. They are also the only way to set the
116
+ # last decimal of one: a band three hundred pixels wide spans a volt, so a
117
+ # pixel is two millivolts and the pointer cannot ask for 3.451 at all. A
118
+ # grip that does not answer them is a control that is half dead in the one
119
+ # direction nothing else checks -- it draws, it fits, it drags -- and that
120
+ # is exactly how it got shipped: a band drawn to one register's decimals,
121
+ # holding another register whose value was written back rounded, so the
122
+ # keypress moved the setpoint and the round put it straight back.
123
+ #
124
+ # Both directions are tried before anything is reported, because a setpoint
125
+ # sitting less than a step from the bound its neighbour imposes really
126
+ # cannot move that way, and that is not a fault.
127
+ #
128
+ # This leaves the page holding an unsent draft, which is why it is asked
129
+ # last. Nothing is written: the card's own Apply is what writes.
130
+ NUDGE_GRIPS = """async () => {
131
+ const bad = [];
132
+ const settle = () => new Promise((done) => setTimeout(done, 25));
133
+ for (const grip of document.querySelectorAll('.band button.grip')) {
134
+ // A tab that keeps another tab's cards in the document has its bands
135
+ // in there too, and nothing hidden can be focused or dragged.
136
+ if (grip.checkVisibility && !grip.checkVisibility()) continue;
137
+ const name = grip.getAttribute('aria-label') || '(unnamed)';
138
+ const low = Number(grip.getAttribute('aria-valuemin'));
139
+ const high = Number(grip.getAttribute('aria-valuemax'));
140
+ const at = () => Number(grip.getAttribute('aria-valuenow'));
141
+ const before = at();
142
+ grip.focus();
143
+ if (document.activeElement !== grip) {
144
+ bad.push(`${name} will not take the keyboard`);
145
+ continue;
146
+ }
147
+ const ways = [];
148
+ if (before < high) ways.push('ArrowRight');
149
+ if (before > low) ways.push('ArrowLeft');
150
+ let moved = ways.length === 0;
151
+ for (const key of ways) {
152
+ grip.dispatchEvent(
153
+ new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
154
+ );
155
+ await settle();
156
+ if (at() !== before) { moved = true; break; }
157
+ }
158
+ if (!moved) {
159
+ bad.push(`${name} did not move for an arrow key (${before}, between ${low} and ${high})`);
160
+ }
161
+ }
162
+ return bad;
163
+ }"""
164
+
165
+ # Whether a setpoint is the thing the next keypress would go to.
166
+ HAS_THE_KEYBOARD = """() => {
167
+ const on = document.activeElement;
168
+ return Boolean(on && on.classList && on.classList.contains('grip'));
169
+ }"""
170
+
171
+ # Where on the screen to drag, for the first band on this tab that has
172
+ # something to drag. Asked in the page rather than through the engine's
173
+ # own element handling, which waits for a hidden element to become visible
174
+ # and a tab with a band behind a collapsed card has one that never will.
175
+ # `scrollIntoView` first, because a drag is in viewport coordinates and half
176
+ # a page is below the fold.
177
+ FIND_A_BAND = """(least) => {
178
+ for (const plot of document.querySelectorAll('.band .plot.settable')) {
179
+ if (!plot.querySelector('button.grip')) continue;
180
+ if (plot.checkVisibility && !plot.checkVisibility()) continue;
181
+ if (plot.getBoundingClientRect().width < least) continue;
182
+ plot.scrollIntoView({ block: 'center' });
183
+ const box = plot.getBoundingClientRect();
184
+ if (box.width < least || box.height <= 0) continue;
185
+ return { x: box.x, y: box.y, width: box.width, height: box.height };
186
+ }
187
+ return null;
188
+ }"""
189
+
190
+ # Narrower than this a band is not a control anybody drags, and a drag
191
+ # across it says nothing. It is also what a band collapsed to nothing by a
192
+ # layout fault measures, which is a question the other checks ask.
193
+ DRAGGABLE_PX = 60
194
+
195
+ FIND_WIDE_TABLES = """() => {
196
+ const bad = [];
197
+ for (const table of document.querySelectorAll('table')) {
198
+ const box = table.closest('.scroll') || table.parentElement;
199
+ if (!box) continue;
200
+ const over = table.getBoundingClientRect().width - box.clientWidth;
201
+ if (over > 1) {
202
+ const head = [...table.querySelectorAll('th')].map((th) => th.textContent.trim());
203
+ bad.push(`${Math.round(over)}px over in [${head.join(', ')}]`);
204
+ }
205
+ }
206
+ return bad;
207
+ }"""
208
+
209
+
210
+ def free_port() -> int:
211
+ """Ask the kernel for a port nothing else is on."""
212
+ with socket.socket() as sock:
213
+ sock.bind(("127.0.0.1", 0))
214
+ return sock.getsockname()[1]
215
+
216
+
217
+ def require_playwright(how: str) -> Any:
218
+ """Return ``sync_playwright``, or say how to get it and stop.
219
+
220
+ ``how`` is the command that was being run, so the message ends with the
221
+ line the reader was about to retype.
222
+ """
223
+ try:
224
+ from playwright.sync_api import sync_playwright
225
+ except ImportError:
226
+ raise SystemExit(
227
+ "playwright is not installed; it lives in the browser group:\n"
228
+ " uv sync --group browser\n"
229
+ " uv run python -m playwright install chromium # once\n"
230
+ f" {how}"
231
+ ) from None
232
+ return sync_playwright
233
+
234
+
235
+ def _dragged_keeps_the_keyboard(page: Any, where: str, problems: list[str]) -> None:
236
+ """Drag a setpoint and check the keyboard followed it.
237
+
238
+ The arrow keys moving a focused grip is worth nothing if no ordinary
239
+ use of the page ever focuses one. Somebody who has just dragged a
240
+ setpoint into place with the pointer is exactly the person who wants
241
+ the last three decimals, and they will reach for the keyboard where
242
+ their hand already is -- so the press has to aim it.
243
+
244
+ Which grip it lands on is not asked: a press on the track takes hold of
245
+ the nearest setpoint, and which one is nearest to the middle of a band
246
+ is a fact about that band rather than about this.
247
+ """
248
+ box = page.evaluate(FIND_A_BAND, DRAGGABLE_PX)
249
+ if box is None:
250
+ return
251
+ middle = box["y"] + box["height"] / 2
252
+ page.mouse.move(box["x"] + box["width"] * 0.5, middle)
253
+ page.mouse.down()
254
+ page.mouse.move(box["x"] + box["width"] * 0.55, middle, steps=5)
255
+ page.mouse.up()
256
+ if not page.evaluate(HAS_THE_KEYBOARD):
257
+ problems.append(f"{where}: dragging a setpoint did not give it the keyboard")
258
+
259
+
260
+ def _judge(
261
+ page: Any, where: str, width: int, problems: list[str], *, keys: bool = False
262
+ ) -> None:
263
+ """Ask one drawn tab every question that can be asked of it.
264
+
265
+ ``keys`` also works the setpoints, which is asked at one width only:
266
+ whether a slider answers the keyboard is not a question about how wide
267
+ the window is, and asking it four times over costs four times as long
268
+ and says the same thing.
269
+ """
270
+ if len(page.inner_text("body")) < DREW_NOTHING:
271
+ problems.append(f"{where}: rendered almost nothing")
272
+ return
273
+ if width >= FITS_ABOVE:
274
+ for text in page.evaluate(FIND_WRAPS):
275
+ problems.append(f"{where}: {text!r} is broken across lines")
276
+ for detail in page.evaluate(FIND_WIDE_TABLES):
277
+ problems.append(f"{where}: a table overflows -- {detail}")
278
+ for detail in page.evaluate(FIND_SIDEWAYS):
279
+ problems.append(f"{where}: the page scrolls sideways -- {detail}")
280
+ if keys:
281
+ _dragged_keeps_the_keyboard(page, where, problems)
282
+ for detail in page.evaluate(NUDGE_GRIPS):
283
+ problems.append(f"{where}: {detail}")
284
+
285
+
286
+ def check(
287
+ url: str,
288
+ tabs: Mapping[str, str],
289
+ wanted: Sequence[str],
290
+ widths: Sequence[int],
291
+ *,
292
+ how: str = "rendercheck",
293
+ settle_ms: int = SETTLE_MS,
294
+ ) -> list[str]:
295
+ """Drive an already-serving page and return everything wrong with it."""
296
+ sync_playwright = require_playwright(how)
297
+ problems: list[str] = []
298
+ with sync_playwright() as pw:
299
+ browser = pw.chromium.launch()
300
+ for width in widths:
301
+ page = browser.new_page(
302
+ viewport={"width": width, "height": VIEWPORT_HEIGHT}
303
+ )
304
+ page.on(
305
+ "pageerror",
306
+ lambda exc, w=width: problems.append(f"{w}px: page error: {exc}"),
307
+ )
308
+ page.on(
309
+ "console",
310
+ lambda msg, w=width: (
311
+ problems.append(f"{w}px: console: {msg.text}")
312
+ if msg.type == "error"
313
+ else None
314
+ ),
315
+ )
316
+ for name in wanted:
317
+ page.goto(url.rstrip("/") + "/#" + tabs[name])
318
+ page.wait_for_timeout(settle_ms)
319
+ _judge(
320
+ page,
321
+ f"{width}px #{name}",
322
+ width,
323
+ problems,
324
+ keys=width == widths[0],
325
+ )
326
+ page.close()
327
+ browser.close()
328
+ return list(dict.fromkeys(problems))
329
+
330
+
331
+ def main(
332
+ serve: Callable[[int], ContextManager[Any]],
333
+ tabs: Mapping[str, str],
334
+ *,
335
+ program: str,
336
+ argv: Sequence[str] | None = None,
337
+ ) -> int:
338
+ """Parse the arguments, serve, run the checks, report.
339
+
340
+ ``serve`` is handed a port and must yield once the UI answers on it.
341
+ """
342
+ how = f"uv run tools/rendercheck.py # in {program}"
343
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
344
+ ap.add_argument(
345
+ "tabs", nargs="*", choices=[*tabs, []], help="which tabs (default: all)"
346
+ )
347
+ ap.add_argument(
348
+ "--width",
349
+ type=int,
350
+ action="append",
351
+ metavar="PX",
352
+ help=f"a viewport width to check (repeatable; default {', '.join(map(str, WIDTHS))})",
353
+ )
354
+ args = ap.parse_args(argv)
355
+ widths = tuple(args.width or WIDTHS)
356
+ wanted = list(args.tabs) or list(tabs)
357
+ # Fail on a missing engine before starting a server for it.
358
+ require_playwright(how)
359
+ port = free_port()
360
+ with serve(port):
361
+ problems = check(f"http://127.0.0.1:{port}/", tabs, wanted, widths, how=how)
362
+ if not problems:
363
+ print(f"rendercheck: clean ({len(wanted)} tabs at {len(widths)} widths)")
364
+ return 0
365
+ for line in problems:
366
+ print(f" {line}", file=sys.stderr)
367
+ print(f"rendercheck: {len(problems)} problem(s)", file=sys.stderr)
368
+ return 1
369
+
370
+
371
+ __all__ = [
372
+ "DRAGGABLE_PX",
373
+ "DREW_NOTHING",
374
+ "FIND_A_BAND",
375
+ "FITS_ABOVE",
376
+ "HAS_THE_KEYBOARD",
377
+ "NUDGE_GRIPS",
378
+ "SETTLE_MS",
379
+ "WIDTHS",
380
+ "check",
381
+ "free_port",
382
+ "main",
383
+ "require_playwright",
384
+ ]
devicectl/doctor.py ADDED
@@ -0,0 +1,112 @@
1
+ """The shape of a health report: findings, weights, and what could not be read.
2
+
3
+ Every program here grows a ``doctor`` command that reads the device over
4
+ once and says what looks wrong. What it *checks* is entirely the program's
5
+ own -- cell voltages mean nothing to a wallbox -- but the shape of the answer
6
+ is not, and both the terminal renderer and the browser one are written
7
+ against that shape rather than against the checks.
8
+
9
+ A finding is one of three weights. ``error`` is the device telling us
10
+ something is wrong, or two of its own readings disagreeing; ``warning`` is a
11
+ reading outside the band the program believes is healthy; ``note`` is a
12
+ setting somebody chose that is worth being reminded of -- a disabled switch
13
+ is not a fault, and a doctor that called it one would be ignored.
14
+
15
+ :attr:`Report.unavailable` is deliberately not a finding. A register the
16
+ device does not carry produced no answer, which is a different sentence from
17
+ "healthy", and a report that blurred the two would be worth less than one
18
+ that admitted the gap.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+
25
+ ERROR = "error"
26
+ WARNING = "warning"
27
+ NOTE = "note"
28
+
29
+ # Worst first, so a report reads top-down.
30
+ SEVERITY_ORDER = {ERROR: 0, WARNING: 1, NOTE: 2}
31
+
32
+ # Where an unrecognised weight sorts: after all the real ones, rather than
33
+ # raising in the middle of rendering a report.
34
+ UNKNOWN_SEVERITY_RANK = 9
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Finding:
39
+ """One thing worth telling somebody about this device."""
40
+
41
+ severity: str
42
+ area: str
43
+ detail: str
44
+ fix: str | None = None
45
+ """The command that would deal with it, when there is one."""
46
+
47
+
48
+ @dataclass
49
+ class Report:
50
+ """Everything one pass found, and everything it could not look at.
51
+
52
+ Programs subclass this to carry the readings the checks were made from;
53
+ every field here has a default, so a subclass may add its own.
54
+ """
55
+
56
+ findings: list[Finding] = field(default_factory=list)
57
+ unavailable: list[str] = field(default_factory=list)
58
+ """Areas whose check could not run, with why."""
59
+
60
+ @property
61
+ def worst(self) -> str | None:
62
+ """The highest severity present, or None on a clean report."""
63
+ if not self.findings:
64
+ return None
65
+ return min((f.severity for f in self.findings), key=_rank)
66
+
67
+ @property
68
+ def ok(self) -> bool:
69
+ """Whether nothing of ``error`` weight was found."""
70
+ return not any(f.severity == ERROR for f in self.findings)
71
+
72
+ def sorted(self) -> list[Finding]:
73
+ """Return the findings worst first, then by area.
74
+
75
+ Ordered by weight and area rather than by the order the checks ran,
76
+ so the same device produces the same report however the checks are
77
+ arranged.
78
+ """
79
+ return sorted(self.findings, key=lambda f: (_rank(f.severity), f.area))
80
+
81
+
82
+ def _rank(severity: str) -> int:
83
+ """Where one weight sorts among the others."""
84
+ return SEVERITY_ORDER.get(severity, UNKNOWN_SEVERITY_RANK)
85
+
86
+
87
+ def finding_json(finding: Finding) -> dict[str, str | None]:
88
+ """One finding, in the shape the shared ``HealthCard`` reads.
89
+
90
+ The card wants exactly these four keys and does the rest itself -- it
91
+ counts the weights and picks the worst client-side -- so this is the
92
+ finding and nothing more. Both programs' doctor endpoints serialise
93
+ through it, so the one component is handed one shape rather than two
94
+ that have drifted apart.
95
+ """
96
+ return {
97
+ "severity": finding.severity,
98
+ "area": finding.area,
99
+ "detail": finding.detail,
100
+ "fix": finding.fix,
101
+ }
102
+
103
+
104
+ __all__ = [
105
+ "ERROR",
106
+ "NOTE",
107
+ "SEVERITY_ORDER",
108
+ "WARNING",
109
+ "Finding",
110
+ "Report",
111
+ "finding_json",
112
+ ]
devicectl/errors.py ADDED
@@ -0,0 +1,68 @@
1
+ """The base class every error a program raises on purpose derives from.
2
+
3
+ A command fails for one of two reasons. Either the device, the link or the
4
+ input was not what it needed -- a setting that does not exist, a firmware
5
+ image for another model, something that will not answer -- which is ordinary
6
+ and gets one clear line on stderr. Or the program has a bug, which deserves
7
+ a traceback.
8
+
9
+ :class:`DeviceError` is the first kind. Each program derives one class of
10
+ its own from it (``AlfenError``, ``JkError``) and every module raises a
11
+ subclass of *that*, so a caller who cares can still tell a refused register
12
+ apart from a bad firmware file. What the shared code needs is only the
13
+ root: :func:`devicectl.cli.main` catches it, prints the message and exits,
14
+ and the web server turns it into a 400 -- neither has to know the list.
15
+
16
+ Errors that are specifically about a *value* also derive from
17
+ :class:`ValueError`, because that is what they are and callers already catch
18
+ them that way.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import traceback
24
+
25
+ # How much of a long message is worth showing in a UI ticker or a log line.
26
+ MESSAGE_LIMIT = 400
27
+
28
+ # Exception classes whose name adds nothing to their message: nobody needs
29
+ # to be told that "no such property" was a ValueError.
30
+ PLAIN = ("RuntimeError", "ValueError")
31
+
32
+
33
+ class DeviceError(Exception):
34
+ """An expected failure, reportable to the user as a single line."""
35
+
36
+ traceable: bool = False
37
+ """Whether the device, or the link to it, is what failed.
38
+
39
+ A subclass for "the device did not answer" or "the device refused" sets
40
+ this, and the web layer then says so in the error reply -- which is what
41
+ lets the page start recording the wire on the one kind of failure a
42
+ recording of the wire can explain. A value out of range, a port nobody
43
+ chose, a busy worker: those leave it False.
44
+ """
45
+
46
+
47
+ def describe(exc: BaseException) -> str:
48
+ """Return one readable line for an exception.
49
+
50
+ For an activity ticker, a job's ``error`` field, or anywhere else a
51
+ failure has to fit on a line beside the thing that failed.
52
+ """
53
+ text = str(exc).strip()
54
+ if not text:
55
+ text = exc.__class__.__name__
56
+ elif exc.__class__.__name__ not in PLAIN:
57
+ text = f"{exc.__class__.__name__}: {text}"
58
+ return text.splitlines()[0][:MESSAGE_LIMIT]
59
+
60
+
61
+ def format_traceback(exc: BaseException) -> str:
62
+ """Return the full traceback text, for ``--debug`` logging of a failure."""
63
+ return "".join(
64
+ traceback.format_exception(type(exc), exc, exc.__traceback__)
65
+ ).strip()
66
+
67
+
68
+ __all__ = ["DeviceError", "describe", "format_traceback"]