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.
- devicectl/__init__.py +18 -0
- devicectl/cli/__init__.py +1 -0
- devicectl/cli/command.py +95 -0
- devicectl/cli/exits.py +32 -0
- devicectl/cli/fanout.py +142 -0
- devicectl/cli/main.py +69 -0
- devicectl/cli/output.py +299 -0
- devicectl/cli/parser.py +80 -0
- devicectl/cli/report.py +86 -0
- devicectl/cli/target.py +26 -0
- devicectl/clock.py +57 -0
- devicectl/devtools/__init__.py +6 -0
- devicectl/devtools/frontlint.py +935 -0
- devicectl/devtools/htmcheck.py +396 -0
- devicectl/devtools/rendercheck.py +384 -0
- devicectl/doctor.py +112 -0
- devicectl/errors.py +68 -0
- devicectl/fields.py +564 -0
- devicectl/meta.py +64 -0
- devicectl/paths.py +40 -0
- devicectl/progress.py +77 -0
- devicectl/report.py +67 -0
- devicectl/testing.py +199 -0
- devicectl/trace.py +333 -0
- devicectl/web/__init__.py +1 -0
- devicectl/web/agents.py +94 -0
- devicectl/web/events.py +171 -0
- devicectl/web/http.py +243 -0
- devicectl/web/progress.py +101 -0
- devicectl/web/server.py +1013 -0
- devicectl/web/static/core.css +3034 -0
- devicectl/web/static/js/api.js +198 -0
- devicectl/web/static/js/band.js +640 -0
- devicectl/web/static/js/chart.js +400 -0
- devicectl/web/static/js/drafts.js +312 -0
- devicectl/web/static/js/notify.js +272 -0
- devicectl/web/static/js/panels.js +432 -0
- devicectl/web/static/js/shell.js +672 -0
- devicectl/web/static/js/trace.js +133 -0
- devicectl/web/static/js/ui.js +1139 -0
- devicectl/web/static/vendor/preact-htm.module.js +27 -0
- devicectl/web/worker.py +697 -0
- devicectl_core-0.1.0.dist-info/METADATA +131 -0
- devicectl_core-0.1.0.dist-info/RECORD +47 -0
- devicectl_core-0.1.0.dist-info/WHEEL +4 -0
- devicectl_core-0.1.0.dist-info/licenses/LICENSE +287 -0
- 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."""
|