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
devicectl/progress.py ADDED
@@ -0,0 +1,77 @@
1
+ """Terminal progress rendering for whatever takes long enough to need it.
2
+
3
+ Plain stderr ``#``/``-`` bars carrying a percentage, elapsed time and an
4
+ estimate of what is left. The live-updating single line is used only when
5
+ stderr is a TTY; piped, or in ``--debug``, the callers log discrete lines
6
+ instead. No progress-bar dependency -- a few dozen lines do the job.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+
13
+ # Width, in characters, of the textual progress bar.
14
+ PROGRESS_BAR_WIDTH = 24
15
+ # Don't redraw more often than this. It smooths the burst at the start of an
16
+ # upload, where the first chunks only fill a buffer, and it keeps a stream of
17
+ # small blocks from redrawing faster than a terminal can usefully show.
18
+ PROGRESS_MIN_INTERVAL_S = 0.1
19
+ # Granularity of a countdown while waiting for a device to come back.
20
+ PROGRESS_TICK_S = 1.0
21
+ # With no live bar to draw, log a progress line each time this fraction of the
22
+ # body has gone by (0.1 -> every ~10%).
23
+ PROGRESS_DEBUG_STEP = 0.1
24
+
25
+ BYTES_PER_KB = 1000
26
+ BYTES_PER_MB = 1_000_000
27
+ SECONDS_PER_MINUTE = 60
28
+ # Here because the two programs had each worked out for themselves, in four
29
+ # modules apiece, how long an hour and a day are.
30
+ SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE
31
+ SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR
32
+
33
+
34
+ def fmt_duration(seconds: float) -> str:
35
+ """Format a duration compactly, e.g. ``42s`` or ``3m05s``."""
36
+ total = int(seconds)
37
+ if total < SECONDS_PER_MINUTE:
38
+ return f"{total}s"
39
+ return f"{total // SECONDS_PER_MINUTE}m{total % SECONDS_PER_MINUTE:02d}s"
40
+
41
+
42
+ def bar(fraction: float) -> str:
43
+ """Return a fixed-width ``#``/``-`` bar for ``fraction`` clamped to [0, 1]."""
44
+ fraction = max(0.0, min(1.0, fraction))
45
+ filled = int(fraction * PROGRESS_BAR_WIDTH)
46
+ return "#" * filled + "-" * (PROGRESS_BAR_WIDTH - filled)
47
+
48
+
49
+ def write_live(text: str) -> None:
50
+ """Overwrite the current stderr line with ``text`` (only when stderr is a TTY)."""
51
+ if sys.stderr.isatty():
52
+ sys.stderr.write("\r\033[K" + text) # \r + clear-to-end-of-line
53
+ sys.stderr.flush()
54
+
55
+
56
+ def end_live() -> None:
57
+ """End a live progress line with a newline (only when stderr is a TTY)."""
58
+ if sys.stderr.isatty():
59
+ sys.stderr.write("\n")
60
+ sys.stderr.flush()
61
+
62
+
63
+ __all__ = [
64
+ "BYTES_PER_KB",
65
+ "BYTES_PER_MB",
66
+ "PROGRESS_BAR_WIDTH",
67
+ "PROGRESS_DEBUG_STEP",
68
+ "PROGRESS_MIN_INTERVAL_S",
69
+ "PROGRESS_TICK_S",
70
+ "SECONDS_PER_DAY",
71
+ "SECONDS_PER_HOUR",
72
+ "SECONDS_PER_MINUTE",
73
+ "bar",
74
+ "end_live",
75
+ "fmt_duration",
76
+ "write_live",
77
+ ]
devicectl/report.py ADDED
@@ -0,0 +1,67 @@
1
+ """Where a long operation says what it is doing.
2
+
3
+ A firmware upgrade takes minutes and has plenty to say meanwhile: which step
4
+ it is on, how far the upload has got, what the device answered while it
5
+ rebooted. A terminal draws that as a live line and a browser gets it over
6
+ an event stream, so the operation says it to a *reporter* rather than
7
+ printing it -- which is what lets both front ends drive the same code.
8
+
9
+ Every method is optional and none of them may raise: a reporter is called
10
+ from the middle of an upgrade, and a broken progress bar must never fail
11
+ one. :data:`SILENT` is the default everywhere -- it discards the lot, which
12
+ is what a test or a library caller wants.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Wait:
22
+ """How a wait for the device is going, for whoever draws the progress.
23
+
24
+ ``typical_s`` is how long this normally takes and ``deadline_s`` when we
25
+ give up; showing the two apart is what stops a two-minute reboot looking
26
+ like a fifteen-minute hang.
27
+ """
28
+
29
+ elapsed_s: float
30
+ deadline_s: float
31
+ typical_s: float
32
+ label: str # what the device last answered, in words
33
+ poll: int # how many times we have asked
34
+ next_poll_in_s: float | None # None while a request is in flight
35
+
36
+
37
+ class Reporter:
38
+ """Somewhere to report progress to. This one reports it nowhere."""
39
+
40
+ def step(self, message: str) -> None:
41
+ """Announce a new phase of the operation."""
42
+
43
+ def detail(self, message: str) -> None:
44
+ """Show a detail from inside the current phase."""
45
+
46
+ def warn(self, message: str) -> None:
47
+ """Flag something that went wrong but does not stop the operation."""
48
+
49
+ def sending(self, sent: int, total: int, elapsed_s: float, label: str = "") -> None:
50
+ """Note that ``sent`` of ``total`` units of the current transfer have moved.
51
+
52
+ ``label`` names the transfer for whoever draws it -- the file, or
53
+ what it is doing to it -- because an operation can have more than
54
+ one: a firmware upgrade downloads an image before it uploads it, and
55
+ a bar that says "Uploading" through both is lying for the first half.
56
+ """
57
+
58
+ def waiting(self, wait: Wait) -> None:
59
+ """Redraw whatever shows a wait in progress; called about once a second."""
60
+
61
+ def polled(self, wait: Wait) -> None:
62
+ """Record that a poll came back; ``wait.label`` says what it meant."""
63
+
64
+
65
+ SILENT = Reporter()
66
+
67
+ __all__ = ["SILENT", "Reporter", "Wait"]
devicectl/testing.py ADDED
@@ -0,0 +1,199 @@
1
+ """Check that a fake can be handed to code that expects the real thing.
2
+
3
+ Every one of these programs tests its commands against a fake device, and
4
+ nothing has ever held the fake to the shape of the thing it stands in for.
5
+ A fake drifts silently: the client grows a parameter, the fake does not,
6
+ and the tests go on passing against a device that cannot exist. Worse,
7
+ they pass against a *call* that cannot be made -- the fake answers
8
+ ``login()`` while every real caller says ``login(timeout=...)``.
9
+
10
+ :func:`stands_in_for` compares the two by signature, and says what would
11
+ break. It is not a type checker: it looks only at how each method may be
12
+ called, which is the part a fake gets wrong.
13
+
14
+ ``real`` may be a class or a :class:`~typing.Protocol`, and the difference
15
+ is how strict the answer is. A class is a menu -- a fake implements the
16
+ handful of methods its tests reach, and only those are compared. A
17
+ protocol is a contract -- every member of it must be there.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import inspect
23
+ from collections.abc import Container, Iterator
24
+ from typing import Any
25
+
26
+ __all__ = ["assert_stands_in_for", "stands_in_for"]
27
+
28
+ _POSITIONAL = (
29
+ inspect.Parameter.POSITIONAL_ONLY,
30
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
31
+ )
32
+
33
+
34
+ def _members(cls: type, ignore: Container[str]) -> Iterator[tuple[str, Any]]:
35
+ """Walk the public callables ``cls`` itself defines, in definition order."""
36
+ for name, value in vars(cls).items():
37
+ if name.startswith("_") or name in ignore:
38
+ continue
39
+ if isinstance(value, (staticmethod, classmethod)):
40
+ value = value.__func__
41
+ if callable(value):
42
+ yield name, value
43
+
44
+
45
+ def _is_protocol(cls: type) -> bool:
46
+ """Whether ``cls`` is a Protocol rather than an ordinary class."""
47
+ return bool(getattr(cls, "_is_protocol", False))
48
+
49
+
50
+ def _protocol_names(cls: type, ignore: Container[str]) -> list[str]:
51
+ """List every member a protocol asks an implementation for."""
52
+ names = set(getattr(cls, "__protocol_attrs__", ()))
53
+ if not names: # before 3.12 the set is not kept on the class
54
+ names = {n for n in vars(cls) if not n.startswith("_")}
55
+ names |= {
56
+ n for n in getattr(cls, "__annotations__", {}) if not n.startswith("_")
57
+ }
58
+ return sorted(n for n in names if n not in ignore)
59
+
60
+
61
+ def _params(fn: Any) -> list[inspect.Parameter]:
62
+ """``fn``'s parameters, without the one the instance fills in."""
63
+ try:
64
+ found = list(inspect.signature(fn).parameters.values())
65
+ except (TypeError, ValueError): # a C function with no signature to read
66
+ return []
67
+ return found[1:] if found and found[0].name in ("self", "cls") else found
68
+
69
+
70
+ def _absorbs(params: list[inspect.Parameter]) -> tuple[bool, bool]:
71
+ """Whether these parameters end in ``*args``, and whether in ``**kwargs``."""
72
+ kinds = {p.kind for p in params}
73
+ return (
74
+ inspect.Parameter.VAR_POSITIONAL in kinds,
75
+ inspect.Parameter.VAR_KEYWORD in kinds,
76
+ )
77
+
78
+
79
+ _EMPTY = inspect.Parameter.empty
80
+
81
+
82
+ def _wrong_position(
83
+ want: inspect.Parameter,
84
+ here: inspect.Parameter | None,
85
+ at: int,
86
+ absorbs: tuple[bool, bool],
87
+ ) -> str | None:
88
+ """Say why ``want`` could not be passed by position, or nothing."""
89
+ if here is None or here.kind not in _POSITIONAL:
90
+ return None if all(absorbs) else f"takes no {want.name} in position {at}"
91
+ # A positional-only parameter is passed by position alone, so the name
92
+ # the stand-in gives it is its own business.
93
+ if want.kind is not inspect.Parameter.POSITIONAL_ONLY and here.name != want.name:
94
+ return f"calls {want.name} '{here.name}'"
95
+ return None
96
+
97
+
98
+ def _wrong_keyword(
99
+ want: inspect.Parameter,
100
+ here: inspect.Parameter | None,
101
+ star_kwargs: bool,
102
+ ) -> str | None:
103
+ """Say why ``want`` could not be passed by name, or nothing."""
104
+ if here is not None and here.kind is not inspect.Parameter.POSITIONAL_ONLY:
105
+ return None
106
+ return None if star_kwargs else f"does not take {want.name}="
107
+
108
+
109
+ def _missing_star(want: inspect.Parameter, absorbs: tuple[bool, bool]) -> str | None:
110
+ """Say why a ``*args`` or ``**kwargs`` of ``want`` has nowhere to go."""
111
+ star_args, star_kwargs = absorbs
112
+ if want.kind is inspect.Parameter.VAR_POSITIONAL and not star_args:
113
+ return f"does not take *{want.name}"
114
+ if want.kind is inspect.Parameter.VAR_KEYWORD and not star_kwargs:
115
+ return f"does not take **{want.name}"
116
+ return None
117
+
118
+
119
+ def _call_differences(real: Any, fake: Any) -> list[str]:
120
+ """List every way ``real`` may be called that ``fake`` would not survive."""
121
+ wanted = _params(real)
122
+ given = _params(fake)
123
+ absorbs = _absorbs(given)
124
+ by_name = {p.name: p for p in given}
125
+ positional = [p for p in given if p.kind in _POSITIONAL]
126
+ problems: list[str] = []
127
+
128
+ at = 0
129
+ for want in wanted:
130
+ if want.kind in (
131
+ inspect.Parameter.VAR_POSITIONAL,
132
+ inspect.Parameter.VAR_KEYWORD,
133
+ ):
134
+ problems += filter(None, [_missing_star(want, absorbs)])
135
+ continue
136
+ if want.kind in _POSITIONAL:
137
+ here = positional[at] if at < len(positional) else None
138
+ at += 1
139
+ wrong = _wrong_position(want, here, at, absorbs)
140
+ else:
141
+ here = by_name.get(want.name)
142
+ wrong = _wrong_keyword(want, here, absorbs[1])
143
+ if wrong:
144
+ problems.append(wrong)
145
+ elif want.default is not _EMPTY and here is not None and here.default is _EMPTY:
146
+ problems.append(f"insists on {want.name}, which is optional")
147
+
148
+ asked = {p.name for p in wanted}
149
+ spares = positional[at:] + [
150
+ p for p in given if p.kind is inspect.Parameter.KEYWORD_ONLY
151
+ ]
152
+ for spare in spares:
153
+ if spare.default is _EMPTY and spare.name not in asked:
154
+ problems.append(f"insists on {spare.name}, which no caller passes")
155
+ return problems
156
+
157
+
158
+ def stands_in_for(real: type, fake: type, *, ignore: Container[str] = ()) -> list[str]:
159
+ """List what stops ``fake`` being handed to code that expects ``real``.
160
+
161
+ Returns a sentence per problem, and an empty list when there is none.
162
+ Names in ``ignore`` are skipped on both sides, which is how a fake keeps
163
+ a helper of its own that the real thing has no reason to grow.
164
+ """
165
+ complaints = []
166
+ # A protocol is a lower bound, so a stand-in for one may do more than it
167
+ # asks; a class is the whole menu, so anything extra is a call nothing
168
+ # makes. `serial.Serial` is the case that proves it: it satisfies every
169
+ # serial protocol worth writing and has thirty methods besides.
170
+ contract = _protocol_names(real, ignore) if _is_protocol(real) else None
171
+ for name, method in _members(fake, ignore):
172
+ if contract is not None and name not in contract:
173
+ continue
174
+ against = getattr(real, name, None)
175
+ if against is None:
176
+ complaints.append(
177
+ f"{fake.__name__}.{name} answers a call {real.__name__} does not take"
178
+ )
179
+ continue
180
+ for problem in _call_differences(against, method):
181
+ complaints.append(f"{fake.__name__}.{name} {problem}")
182
+
183
+ if contract is not None:
184
+ for name in contract:
185
+ if not hasattr(fake, name):
186
+ complaints.append(f"{fake.__name__} has no {name}")
187
+ return complaints
188
+
189
+
190
+ def assert_stands_in_for(
191
+ real: type, fake: type, *, ignore: Container[str] = ()
192
+ ) -> None:
193
+ """Fail the test, readably, if ``fake`` could not stand in for ``real``."""
194
+ problems = stands_in_for(real, fake, ignore=ignore)
195
+ if problems:
196
+ raise AssertionError(
197
+ f"{fake.__name__} cannot stand in for {real.__name__}:\n "
198
+ + "\n ".join(problems)
199
+ )
devicectl/trace.py ADDED
@@ -0,0 +1,333 @@
1
+ """A recording of what actually went over the wire, and the report it makes.
2
+
3
+ When a device refuses something, the sentence a program can show is the one
4
+ its protocol layer raised -- "no/short response", "bad CRC", "modbus
5
+ exception 2". That is enough to know it failed and never enough to know
6
+ why: the answer is in the bytes, in what was asked immediately before, and
7
+ in the gaps between them, none of which anybody is holding by the time the
8
+ message reaches a browser.
9
+
10
+ So: a ring buffer that can be switched on from the page, a hook the
11
+ protocol layer already has for ``--trace`` to feed it, and a plain-text
12
+ report to download and send to somebody. It is off until asked for -- a
13
+ few hundred frames a minute of a device that is working is nothing anyone
14
+ wants to keep -- and bounded when it is on, because the thing being
15
+ debugged is often a program left running overnight.
16
+
17
+ An entry need not be a frame. Half of a fault report is what somebody
18
+ asked for, so the same recording takes :meth:`Recorder.called` -- the
19
+ request a page made and the body it sent -- and prints it between the
20
+ frames it caused.
21
+
22
+ Nothing here knows a protocol. An entry is a direction, some bytes and a
23
+ note, and what those bytes *mean* is a ``decode`` function the program
24
+ supplies: :func:`render` calls it per frame and prints whatever it returns
25
+ under the hex. A program with no decoder gets the hex, which is still the
26
+ thing that was missing.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import threading
32
+ import time
33
+ from collections import deque
34
+ from dataclasses import dataclass
35
+ from typing import Any, Callable, Iterable, Sequence
36
+
37
+ # How many entries a recording keeps before the oldest fall off. A Modbus
38
+ # beat is two frames every three seconds, so this is a few hours of an idle
39
+ # page and a good many minutes of somebody actively poking at a device --
40
+ # and about four megabytes of report at the worst, which is still a file an
41
+ # email will take.
42
+ DEFAULT_LIMIT = 20000
43
+
44
+ # The directions an entry can have. They are two characters wide on
45
+ # purpose: the report puts them in a column.
46
+ TX = "TX"
47
+ RX = "RX"
48
+ NOTE = "--"
49
+ UI = "UI"
50
+
51
+ # How many bytes of a frame go on one line of the report.
52
+ HEX_WIDTH = 16
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Entry:
57
+ """One thing that happened on the link, as the report will print it."""
58
+
59
+ at: float
60
+ """When, as a wall clock time -- the report is read against a log."""
61
+
62
+ direction: str
63
+ """:data:`TX`, :data:`RX`, :data:`UI` for something the page asked for, or
64
+ :data:`NOTE` for anything else that is not a frame."""
65
+
66
+ data: bytes = b""
67
+ """The bytes themselves. Empty for a note, and for a read that timed out."""
68
+
69
+ note: str = ""
70
+ """What the program was doing, in its own words, or what a note says."""
71
+
72
+ detail: str = ""
73
+ """Text printed under the entry where the hex would go: a request body, a
74
+ decoded structure, anything that is already words rather than bytes."""
75
+
76
+
77
+ class Recorder:
78
+ """A bounded, thread-safe recording of a link, off until it is started.
79
+
80
+ The protocol layer calls :meth:`add` from whichever thread owns the
81
+ link; the page asks for :meth:`state` and :func:`render` from a request
82
+ thread. Hence the lock, and hence :meth:`add` doing as little as it
83
+ can while holding it.
84
+ """
85
+
86
+ def __init__(self, limit: int = DEFAULT_LIMIT) -> None:
87
+ """Make a recorder that keeps at most ``limit`` entries."""
88
+ self.limit = limit
89
+ self._lock = threading.Lock()
90
+ self._entries: deque[Entry] = deque(maxlen=limit)
91
+ self._on = False
92
+ self._started: float | None = None
93
+ self._dropped = 0
94
+ self._automatic = False
95
+ self._stopped: float | None = None
96
+
97
+ # --- switching it on and off --------------------------------------------------------
98
+
99
+ @property
100
+ def on(self) -> bool:
101
+ """Whether anything handed to :meth:`add` is being kept."""
102
+ return self._on
103
+
104
+ def start(self, automatic: bool = False) -> None:
105
+ """Begin recording, from empty.
106
+
107
+ From empty rather than from wherever the last one stopped: a
108
+ recording is made to reproduce one thing, and frames from an hour
109
+ ago are noise somebody then has to scroll past.
110
+
111
+ ``automatic`` is for a recording nobody asked for by hand -- one a
112
+ failure started (see the page's trace.js). It is kept so the page
113
+ can say so, and so that turning that behaviour off can stop the
114
+ recording it made without stopping one somebody started themselves.
115
+ """
116
+ with self._lock:
117
+ self._entries.clear()
118
+ self._dropped = 0
119
+ self._on = True
120
+ self._started = time.time()
121
+ self._stopped = None
122
+ self._automatic = automatic
123
+ self.say("recording started" + (" after a failure" if automatic else ""))
124
+
125
+ def stop(self) -> None:
126
+ """Stop recording, keeping what was recorded.
127
+
128
+ Kept deliberately: the ordinary way this is used is reproduce the
129
+ fault, stop, download. A stop that emptied the buffer would throw
130
+ away the recording at the moment it became worth having.
131
+ """
132
+ if not self._on:
133
+ return
134
+ self.say("recording stopped")
135
+ with self._lock:
136
+ self._on = False
137
+ self._automatic = False
138
+ self._stopped = time.time()
139
+
140
+ def clear(self) -> None:
141
+ """Throw away what was recorded, without changing whether it is on."""
142
+ with self._lock:
143
+ self._entries.clear()
144
+ self._dropped = 0
145
+ self._started = time.time() if self._on else None
146
+
147
+ # --- recording ----------------------------------------------------------------------
148
+
149
+ def add(
150
+ self, direction: str, data: bytes, note: str = "", detail: str = ""
151
+ ) -> None:
152
+ """Record one frame. Does nothing at all while the recorder is off."""
153
+ if not self._on:
154
+ return
155
+ with self._lock:
156
+ if len(self._entries) == self.limit:
157
+ self._dropped += 1
158
+ self._entries.append(
159
+ Entry(
160
+ at=time.time(),
161
+ direction=direction,
162
+ data=bytes(data),
163
+ note=note,
164
+ detail=detail,
165
+ )
166
+ )
167
+
168
+ def say(self, note: str) -> None:
169
+ """Record something that is not a frame: an error, a phase, a choice.
170
+
171
+ This is the half a byte log cannot carry. "no reply" is a silence,
172
+ and a silence looks exactly like the end of the recording unless
173
+ something writes it down.
174
+ """
175
+ self.add(NOTE, b"", note)
176
+
177
+ def called(self, note: str, detail: str = "") -> None:
178
+ """Record something somebody asked the program to do.
179
+
180
+ A recording of a wire answers what the program said; it never
181
+ answers why it said it. Half of a fault report is which button was
182
+ pressed and what was in the box beside it -- which, for a program
183
+ driven by a page, is the request the page made and the body it sent.
184
+ Those go in here, between the frames they caused.
185
+ """
186
+ self.add(UI, b"", note, detail=detail)
187
+
188
+ def hook(
189
+ self, note: Callable[[], str] | None = None
190
+ ) -> Callable[[str, bytes], None]:
191
+ """Return a ``log(direction, data)`` of the shape protocol layers take.
192
+
193
+ ``note`` is asked, per frame, what the program is doing right now --
194
+ which is the one thing the bytes cannot say and the first thing
195
+ anybody reading the report wants.
196
+ """
197
+ return lambda direction, data: self.add(
198
+ direction, data, note() if note is not None else ""
199
+ )
200
+
201
+ # --- what the page shows ------------------------------------------------------------
202
+
203
+ def entries(self) -> list[Entry]:
204
+ """Everything kept, oldest first."""
205
+ with self._lock:
206
+ return list(self._entries)
207
+
208
+ def state(self) -> dict[str, Any]:
209
+ """Describe the recording as the page draws it: on, how much, since when."""
210
+ with self._lock:
211
+ return {
212
+ "on": self._on,
213
+ "automatic": self._on and self._automatic,
214
+ "frames": len(self._entries),
215
+ "dropped": self._dropped,
216
+ "since": self._started,
217
+ # When it stopped, so a stopped recording says how long it
218
+ # ran rather than how long ago it started.
219
+ "until": None if self._on else self._stopped,
220
+ "limit": self.limit,
221
+ "bytes": sum(len(e.data) + len(e.detail) for e in self._entries),
222
+ }
223
+
224
+
225
+ def hexdump(data: bytes, width: int = HEX_WIDTH) -> list[str]:
226
+ """Split ``data`` into lines of space-separated hex, ``width`` bytes each."""
227
+ return [data[at : at + width].hex(" ") for at in range(0, len(data), width)]
228
+
229
+
230
+ def render(
231
+ recorder: Recorder,
232
+ *,
233
+ title: str,
234
+ facts: Sequence[tuple[str, Any]] = (),
235
+ decode: Callable[[str, bytes], str] | None = None,
236
+ preamble: str = "",
237
+ ) -> str:
238
+ """Render a recording as the plain-text report somebody downloads.
239
+
240
+ ``facts`` is what the report has to say for itself before the frames --
241
+ the program's version, the port, the speed, the device -- because a
242
+ trace read a week later by somebody else answers nothing without them.
243
+ ``decode`` turns one frame into a sentence; ``preamble`` is the program's
244
+ own paragraph about how to read what follows.
245
+ """
246
+ entries = recorder.entries()
247
+ state = recorder.state()
248
+ lines = [title, "=" * len(title), ""]
249
+ rows: list[tuple[str, str]] = [(str(k), _fact(v)) for k, v in facts]
250
+ rows.append(("recorded", _window(entries)))
251
+ rows.append(("frames", _count(state)))
252
+ pad = max((len(k) for k, _ in rows), default=0)
253
+ lines += [f"{k.ljust(pad)} {v}" for k, v in rows]
254
+ if preamble:
255
+ lines += ["", *preamble.strip().splitlines()]
256
+ lines += ["", "-" * 72, ""]
257
+ if not entries:
258
+ lines.append("Nothing was recorded.")
259
+ return "\n".join(lines) + "\n"
260
+ previous: float | None = None
261
+ for entry in entries:
262
+ lines += _frame(entry, previous, decode)
263
+ previous = entry.at
264
+ return "\n".join(lines) + "\n"
265
+
266
+
267
+ def _frame(
268
+ entry: Entry, previous: float | None, decode: Callable[[str, bytes], str] | None
269
+ ) -> list[str]:
270
+ """One entry, as the two or more lines the report prints it on."""
271
+ gap = "" if previous is None else f" (+{(entry.at - previous) * 1000:.0f} ms)"
272
+ head = f"{_clock(entry.at)} {entry.direction}{gap}"
273
+ if entry.note:
274
+ head += f" {entry.note}"
275
+ lines = [head]
276
+ indent = " " * 14
277
+ lines += [indent + line for line in hexdump(entry.data)]
278
+ lines += [indent + line for line in entry.detail.splitlines()]
279
+ if entry.direction == RX and not entry.data:
280
+ lines.append(indent + "(nothing)")
281
+ said = decode(entry.direction, entry.data) if decode and entry.data else ""
282
+ if said:
283
+ lines.append(indent + said)
284
+ return lines
285
+
286
+
287
+ def _clock(at: float) -> str:
288
+ """Render a wall clock time with milliseconds, the scale a frame lives on."""
289
+ return (
290
+ time.strftime("%H:%M:%S", time.localtime(at)) + f".{int(at * 1000) % 1000:03d}"
291
+ )
292
+
293
+
294
+ def _fact(value: Any) -> str:
295
+ """Render one header value, spelling a missing one out rather than blank."""
296
+ if value is None or value == "":
297
+ return "(not known)"
298
+ if isinstance(value, (list, tuple)):
299
+ return ", ".join(str(v) for v in value) or "(none)"
300
+ return str(value)
301
+
302
+
303
+ def _window(entries: Iterable[Entry]) -> str:
304
+ """When the recording starts and stops, and how long it ran."""
305
+ kept = list(entries)
306
+ if not kept:
307
+ return "(nothing)"
308
+ first, last = kept[0].at, kept[-1].at
309
+ day = time.strftime("%Y-%m-%d", time.localtime(first))
310
+ return f"{day} {_clock(first)} .. {_clock(last)} ({last - first:.1f} s)"
311
+
312
+
313
+ def _count(state: dict[str, Any]) -> str:
314
+ """How many frames were kept, and how many fell off the front."""
315
+ said = f"{state['frames']} kept, {state['bytes']} bytes"
316
+ dropped = int(state["dropped"] or 0)
317
+ if dropped:
318
+ said += f", {dropped} older one(s) dropped (the buffer holds {state['limit']})"
319
+ return said
320
+
321
+
322
+ __all__ = [
323
+ "DEFAULT_LIMIT",
324
+ "HEX_WIDTH",
325
+ "NOTE",
326
+ "RX",
327
+ "TX",
328
+ "UI",
329
+ "Entry",
330
+ "Recorder",
331
+ "hexdump",
332
+ "render",
333
+ ]
@@ -0,0 +1 @@
1
+ """The browser half: the event stream, the HTTP primitives, the server."""