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,396 @@
1
+ #!/usr/bin/env python3
2
+ """Render every ``html`` template in the web UI, and report what mis-parses.
3
+
4
+ The templates are tagged-template literals driven through the vendored
5
+ preact-htm bundle -- the same code the browser runs -- so this check needs
6
+ no browser and no build step, only a small JavaScript engine. It exists
7
+ because of a bug class no static check can see. An attribute expression
8
+ that has lost its ``$`` -- ``onWrite={(payload) =>`` -- is read by htm as
9
+ a quoted value whose text then swallows the markup up to the next ``}``;
10
+ the page does not crash, the tab is just quietly wrong: elements without
11
+ tag names, a handler's source rendered as prose. Something like this
12
+ shipped once, and neither Biome nor the wiring checks saw anything.
13
+
14
+ So that is the check: evaluate each template through the real parser with
15
+ every free identifier stubbed, then walk the resulting virtual DOM and
16
+ report the one shape no valid output contains -- an element whose type is
17
+ ``undefined``, its tag name consumed as text (``H001``). The stubs cannot
18
+ hide a mis-parse: parsing happens in the template's statics, before any
19
+ value is ever looked up, so anything that renders cleanly under stubs
20
+ parses cleanly in the browser.
21
+
22
+ The parser is also what says where the markup ends, which catches a
23
+ second mistake that renders rather than crashing. A template is markup,
24
+ not code, and markup has no comments: a ``/* ... */`` written inside one
25
+ is not stripped by anything -- htm keeps it as text and the page shows it
26
+ to the user, in the middle of a card (``H004``). The tag is wrapped on
27
+ the way in so each template's statics -- its markup, everything that is
28
+ not a ``${...}`` -- can be read as it parses, which is what keeps a real
29
+ comment inside an interpolation from being mistaken for one.
30
+
31
+ The engine is PyMiniRacer: an embedded V8 in one wheel, so no Node, no
32
+ npm and nothing to compile. It is neither a dependency of any program here
33
+ nor one of the default dev set -- it is ~80 MB and publishes no armv7
34
+ wheel, and these tools have to stay installable on a Raspberry Pi. It
35
+ lives in the ``browser`` dependency group instead::
36
+
37
+ uv sync --group browser
38
+
39
+ and the check skips itself, saying so, when the engine is absent. Run it
40
+ over a program's own UI::
41
+
42
+ python -m devicectl.devtools.htmcheck src/<program>/web/static
43
+
44
+ or let that program's pytest do it; either way a problem is one line of
45
+ ``path:line: CODE message``.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import re
51
+ import sys
52
+ from dataclasses import dataclass
53
+ from pathlib import Path
54
+
55
+ try:
56
+ # ty: ignore[unresolved-import] -- the engine lives in the `browser`
57
+ # dependency group, so it is absent from a default checkout by design and
58
+ # the type checker never sees it.
59
+ from py_mini_racer import MiniRacer
60
+ except ImportError: # the engine is a tool, not a dependency (see Biome)
61
+ MiniRacer = None # type: ignore[assignment]
62
+
63
+ # The vendor bundle's single export statement names the minified locals it
64
+ # binds; htm's ``html`` is the tag this check drives. Replacing the export
65
+ # with plain ``var`` bindings makes the bundle evaluable as a script, which
66
+ # is the only way to load it in an engine without ES modules.
67
+ VENDOR_EXPORT = re.compile(r"export\{[^}]*\};?\s*$")
68
+ BINDINGS = "var h = a, html = fe, render = M;"
69
+
70
+ # What every template may reference without naming it: the language's own
71
+ # words and globals. Everything else is a free identifier -- a prop, an
72
+ # import, a helper -- and gets a stub, because the parse happens before any
73
+ # of them is ever looked up.
74
+ RESERVED = frozenset(
75
+ """
76
+ break case catch class const continue debugger default delete do else
77
+ export extends finally for function if import in instanceof new return
78
+ super switch this throw try typeof var void while with yield let static
79
+ enum await implements package protected interface private public
80
+ arguments eval true false null undefined
81
+ """.split()
82
+ )
83
+ ENGINE_GLOBALS = frozenset(
84
+ """
85
+ h html render Math JSON String Number Array Object Boolean Symbol Proxy
86
+ Map Set Date RegExp Error isNaN parseInt parseFloat console window
87
+ document globalThis
88
+ """.split()
89
+ )
90
+ IDENTIFIER = re.compile(r"[A-Za-z_$][\w$]*")
91
+
92
+ # What a template is not: ``html`` in prose (a comment) or in a string.
93
+ # Their interiors are blanked before the search, same length so the line
94
+ # numbers survive; template literals are left alone, because one template
95
+ # inside another's interpolation is code, not prose.
96
+ _COMMENT = re.compile(r"//[^\n]*|/\*.*?\*/", re.S)
97
+ _QUOTED = re.compile(r"'(?:[^'\\\n]|\\.)*'|\"(?:[^\"\\\n]|\\.)*\"")
98
+
99
+
100
+ def _blanked(text: str) -> str:
101
+ """Replace comment and string interiors with spaces, keeping the shape."""
102
+
103
+ def blanks(match: re.Match[str]) -> str:
104
+ whole = match.group(0)
105
+ return whole[0] + " " * (len(whole) - 2) + whole[-1]
106
+
107
+ out = _COMMENT.sub(lambda m: " " * len(m.group(0)), text)
108
+ return _QUOTED.sub(blanks, out)
109
+
110
+
111
+ def templates(text: str) -> list[tuple[int, str]]:
112
+ """Find every ``html`...``` template and the line it starts on.
113
+
114
+ The scan for openers runs over the blanked text, so a mention in
115
+ prose is not a template; the span then comes from the real text,
116
+ brace-aware -- a backtick inside ``${...}`` belongs to a nested
117
+ template -- because half the UI nests one template inside another's
118
+ interpolations.
119
+ """
120
+ where = _blanked(text)
121
+ found: list[tuple[int, str]] = []
122
+ start = 0
123
+ while True:
124
+ at = where.find("html`", start)
125
+ if at < 0:
126
+ return found
127
+ line = text.count("\n", 0, at) + 1
128
+ i = at + len("html`")
129
+ depth = 0
130
+ while i < len(text):
131
+ ch = text[i]
132
+ if ch == "{":
133
+ depth += 1
134
+ elif ch == "}":
135
+ depth -= 1
136
+ elif ch == "`" and depth == 0:
137
+ break
138
+ i += 1
139
+ if i >= len(text): # an unterminated template: biome's to say, not ours
140
+ start = at + 5
141
+ continue
142
+ found.append((line, text[at : i + 1]))
143
+ start = i + 1
144
+
145
+
146
+ # A stub stands in for whatever the template is tooled against: call it,
147
+ # read any property, iterate it, spread it -- none of that is this check's
148
+ # business, and none of it can hide a mis-parse, which happens in the
149
+ # statics before any value is touched.
150
+ #
151
+ # With one thing it does have to do: call the functions it is handed. Most
152
+ # of this UI's markup is written inside a callback -- ``${rows.map((row) =>
153
+ # html`...`)}`` -- and a callback nobody calls is a template nobody parses.
154
+ # A stub that only returned itself stopped the check at the first ``.map``,
155
+ # so a card whose rows were built that way was reported clean whatever was
156
+ # wrong with them. What the call itself does is still not this check's
157
+ # business, so what it throws is dropped: the parse the check came for has
158
+ # already happened by then. Nor does the markup a callback builds have to
159
+ # come back out of it -- a stub still hands its caller a stub rather than
160
+ # the mapped array, and every template reports itself to the tap instead.
161
+ STUBS = """
162
+ var depth = 0;
163
+ function stubFor() {
164
+ const stub = new Proxy(function () {}, {
165
+ get(target, key) {
166
+ if (key === Symbol.iterator) return function* () {};
167
+ if (key === 'length') return 0;
168
+ if (key === Symbol.toPrimitive) return () => 'stub';
169
+ return stub;
170
+ },
171
+ apply(target, self, args) {
172
+ for (const arg of args) {
173
+ if (typeof arg !== 'function' || depth > 8) continue;
174
+ depth += 1;
175
+ try { arg(stubFor(), 0, stubFor()); } catch (err) {} finally { depth -= 1; }
176
+ }
177
+ return stub;
178
+ },
179
+ has() { return true; },
180
+ });
181
+ return stub;
182
+ }
183
+ """
184
+
185
+
186
+ # The vendored tag, wrapped so the check can read both what it was handed
187
+ # and what it made of it. A tagged template's ``strings.raw`` is its
188
+ # statics: the markup, split at the interpolations, exactly as written.
189
+ #
190
+ # Every template reports itself here, nested ones included, which is what
191
+ # makes a template built inside a callback checkable at all -- its result
192
+ # is walked from this list rather than from the tree, because the stub
193
+ # that called the callback threw the return value away.
194
+ TAP = """
195
+ function tapped(seen, built) {
196
+ return function (strings) {
197
+ const raw = strings.raw || strings;
198
+ for (const part of raw) seen.push(part);
199
+ const out = html.apply(null, arguments);
200
+ built.push(out);
201
+ return out;
202
+ };
203
+ }
204
+ """
205
+
206
+
207
+ # The one shape a mis-parsed template reliably leaves behind: something in
208
+ # the output with no ``type``. That is htm's own half-built ``[tag, props,
209
+ # ...]`` coming back whole rather than being made into an element, so what
210
+ # the walk actually meets is the bare props object; a template that parsed
211
+ # hands back elements, whose type is always a tag name or a component.
212
+ #
213
+ # It is only ever given a template's own output -- every result the tap
214
+ # collected -- because a plain object is only wrong where an element was
215
+ # meant. One returned from a ``.map`` that builds a select's entries is
216
+ # data, and reading the rule onto those would condemn half the UI.
217
+ WALK = """
218
+ function walk(node, issues) {
219
+ if (node == null || node === undefined) return;
220
+ if (Array.isArray(node)) { node.forEach((child) => walk(child, issues)); return; }
221
+ if (typeof node !== 'object') return;
222
+ if (node.type === undefined) issues.push('an element without a tag name');
223
+ const kids = node.props && node.props.children;
224
+ if (kids != null) walk(kids, issues);
225
+ }
226
+ """
227
+
228
+
229
+ # Comment-shaped text in a template's markup. A block comment is reported
230
+ # wherever it sits, because nothing else in this UI's markup is written
231
+ # ``/* ... */``. A line comment is only reported when it starts a line: a
232
+ # static can begin mid-line, just after an interpolation, and ``${base}//x``
233
+ # is a path, not a comment -- as is the ``//`` in every URL.
234
+ MARKUP_COMMENT = (
235
+ re.compile(r"/\*.*?\*/", re.S),
236
+ re.compile(r"\n[ \t]*//[^\n]*"),
237
+ )
238
+
239
+
240
+ def markup_comments(statics: list[str]) -> list[str]:
241
+ """Find the comment-shaped text a template would render, in order."""
242
+ return [
243
+ match.group(0).strip()
244
+ for static in statics
245
+ for pattern in MARKUP_COMMENT
246
+ for match in pattern.finditer(static)
247
+ ]
248
+
249
+
250
+ @dataclass(frozen=True)
251
+ class Problem:
252
+ """One thing wrong, at one place, in the words a reader needs."""
253
+
254
+ path: Path
255
+ line: int
256
+ code: str
257
+ message: str
258
+
259
+ def render(self, root: Path) -> str:
260
+ """One line of ``path:line: CODE message``, relative to the root."""
261
+ where = (
262
+ self.path.relative_to(root) if self.path.is_relative_to(root) else self.path
263
+ )
264
+ return f"{where}:{self.line}: {self.code} {self.message}"
265
+
266
+
267
+ def check_file(path: Path, text: str, engine) -> list[Problem]:
268
+ """Evaluate one module's templates through the vendored parser."""
269
+ found: list[Problem] = []
270
+ for line, template in templates(text):
271
+ # Every identifier the template names becomes a parameter, every
272
+ # parameter a stub: the parse runs to completion no matter what the
273
+ # component was tooled against.
274
+ names = sorted(set(IDENTIFIER.findall(template)) - RESERVED - ENGINE_GLOBALS)
275
+ params = ", ".join(names)
276
+ args = ", ".join("stubFor()" for _ in names)
277
+ wrapped = (
278
+ f"(function ({params}) {{ var __seen = [], __built = []; "
279
+ f"var html = tapped(__seen, __built); "
280
+ f"var threw = null; "
281
+ f"try {{ {template}; }} catch (err) {{ threw = String(err); }} "
282
+ f"var issues = []; walk(__built, issues); "
283
+ f"if (threw !== null) issues = [threw]; "
284
+ f"issues = issues.filter(function (m, i) {{ return issues.indexOf(m) === i; }}); "
285
+ f"return JSON.stringify({{ issues: issues, statics: __seen }}); }})({args})"
286
+ )
287
+ try:
288
+ report = str(engine.eval(wrapped))
289
+ except Exception as err: # noqa: BLE001 - the engine refused the wrapper itself
290
+ found.append(Problem(path, line, "H002", f"could not be evaluated: {err}"))
291
+ continue
292
+ issues, statics = read_report(report)
293
+ if issues:
294
+ found.append(Problem(path, line, "H001", "; ".join(issues)))
295
+ # A template that never parsed has no markup to read; whatever the
296
+ # statics hold, the mis-parse above is the thing to fix first.
297
+ for comment in [] if issues else markup_comments(statics):
298
+ found.append(
299
+ Problem(
300
+ path,
301
+ line + template.count("\n", 0, max(template.find(comment), 0)),
302
+ "H004",
303
+ f"a comment inside the template, which the page shows as "
304
+ f"text: {shorten(comment)}",
305
+ )
306
+ )
307
+ return found
308
+
309
+
310
+ def shorten(text: str, width: int = 60) -> str:
311
+ """One line of it, enough to find it by."""
312
+ one = " ".join(text.split())
313
+ return one if len(one) <= width else f"{one[: width - 3]}..."
314
+
315
+
316
+ def read_report(report: str) -> tuple[list[str], list[str]]:
317
+ """Read back the run's findings; a malformed report is its own finding."""
318
+ import json
319
+
320
+ try:
321
+ doc = json.loads(report)
322
+ return [str(item) for item in doc["issues"]], [
323
+ str(part) for part in doc["statics"]
324
+ ]
325
+ except (ValueError, KeyError, TypeError):
326
+ return [f"unreportable: {report[:100]}"], []
327
+
328
+
329
+ def check_tree(root: Path, *, core: Path | None = None) -> list[Problem]:
330
+ """Check every module under ``js/`` against the vendored parser.
331
+
332
+ ``core`` names the shared package's static tree, which is where the
333
+ runtime lives for a program that imports the shared frontend: there is
334
+ one copy of Preact on a page, and a program that adopts this package
335
+ has no ``vendor/`` of its own.
336
+ """
337
+ if MiniRacer is None:
338
+ print("htmcheck: mini-racer is not installed; nothing was checked")
339
+ print(" install the engine with: uv sync --group browser")
340
+ return []
341
+ vendor = next(root.glob("vendor/*.js"), None)
342
+ if vendor is None and core is not None:
343
+ vendor = next(core.glob("vendor/*.js"), None)
344
+ if vendor is None:
345
+ return [Problem(root, 0, "H003", "no vendored runtime under vendor/")]
346
+ engine = MiniRacer()
347
+ engine.eval(VENDOR_EXPORT.sub(BINDINGS, vendor.read_text(encoding="utf-8")))
348
+ engine.eval(STUBS)
349
+ engine.eval(TAP)
350
+ engine.eval(WALK)
351
+ found: list[Problem] = []
352
+ for path in sorted(root.glob("js/*.js")):
353
+ found += check_file(path, path.read_text(encoding="utf-8"), engine)
354
+ return found
355
+
356
+
357
+ USAGE = (
358
+ "usage: python -m devicectl.devtools.htmcheck <static-root> "
359
+ "[--core <shared-static-root>]"
360
+ )
361
+
362
+
363
+ def main(argv: list[str] | None = None) -> int:
364
+ """Check the static tree named on the command line."""
365
+ if MiniRacer is None:
366
+ print(
367
+ "htmcheck: skipping -- mini-racer is not installed.\n"
368
+ " the engine lives in the browser group: uv sync --group browser"
369
+ )
370
+ return 0
371
+ args = list(sys.argv[1:] if argv is None else argv)
372
+ core: Path | None = None
373
+ if "--core" in args:
374
+ at = args.index("--core")
375
+ if at + 1 >= len(args):
376
+ print(USAGE)
377
+ return 2
378
+ core = Path(args[at + 1])
379
+ del args[at : at + 2]
380
+ if not args:
381
+ print(USAGE)
382
+ return 2
383
+ root = Path(args[0])
384
+ problems = check_tree(root, core=core)
385
+ for problem in problems:
386
+ print(problem.render(root.parent))
387
+ print(
388
+ f"htmcheck: {len(problems)} problem(s) in {root}"
389
+ if problems
390
+ else f"htmcheck: clean ({root})"
391
+ )
392
+ return 1 if problems else 0
393
+
394
+
395
+ if __name__ == "__main__":
396
+ raise SystemExit(main())