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,94 @@
1
+ """Reading a browser's user agent into something a person can act on.
2
+
3
+ The link pill counts the browsers on the event stream; this is what tells
4
+ "my other tab" from "someone else on the network holding the device". Two
5
+ tabs of one browser share an address and a user agent, so what can be said
6
+ is coarse -- which is fine, because the question being answered is only
7
+ whether the other watcher is me.
8
+
9
+ None of this is a security control. A user agent is a stranger's header,
10
+ so it is read for a label and never trusted: :func:`name_watchers` drops
11
+ anything unprintable, because those lines are printed to a terminal.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ # Enough of a user-agent reading to tell two browsers on the same machine
19
+ # apart, in the order that matters: Edge and Opera both claim Chrome, Chrome
20
+ # claims Safari, and every Android is also a Linux.
21
+ _BROWSERS = (
22
+ ("Edg/", "Edge"),
23
+ ("OPR/", "Opera"),
24
+ ("Firefox/", "Firefox"),
25
+ ("Chrome/", "Chrome"),
26
+ ("Safari/", "Safari"),
27
+ ("curl/", "curl"),
28
+ )
29
+ _SYSTEMS = (
30
+ ("Android", "Android"),
31
+ ("iPhone", "iPhone"),
32
+ ("iPad", "iPad"),
33
+ ("Windows", "Windows"),
34
+ ("Macintosh", "macOS"),
35
+ ("CrOS", "ChromeOS"),
36
+ ("X11", "Linux"),
37
+ ("Linux", "Linux"),
38
+ )
39
+
40
+ # How much of an unrecognised user agent is worth showing.
41
+ AGENT_SNIPPET = 40
42
+
43
+ # Addresses not worth naming: a tab on this machine is the ordinary case.
44
+ LOOPBACK = ("127.0.0.1", "::1", "localhost")
45
+
46
+
47
+ def describe_agent(agent: str) -> str:
48
+ """Return a short name for a browser ("Firefox on Linux")."""
49
+ if not agent.strip():
50
+ return "unknown client"
51
+ browser = next((name for token, name in _BROWSERS if token in agent), "")
52
+ system = next((name for token, name in _SYSTEMS if token in agent), "")
53
+ if browser and system:
54
+ return f"{browser} on {system}"
55
+ return browser or system or agent.split()[0][:AGENT_SNIPPET]
56
+
57
+
58
+ def client_json(client: dict[str, Any]) -> dict[str, Any]:
59
+ """One watching browser, with its user agent read into something short."""
60
+ return {
61
+ "id": client.get("id"),
62
+ "address": client.get("address") or "",
63
+ "port": client.get("port"),
64
+ "agent": client.get("agent") or "",
65
+ "label": describe_agent(str(client.get("agent") or "")),
66
+ "since": client.get("since"),
67
+ }
68
+
69
+
70
+ def name_watchers(clients: list[dict[str, Any]]) -> list[str]:
71
+ """Name the browsers on the stream: one line each, oldest first.
72
+
73
+ Two tabs of one browser share a user agent and an address, so they are
74
+ counted together rather than listed twice. A watcher somewhere else on
75
+ the network is named with the address it came from, which is the part
76
+ that tells it from a tab of my own. The user agent is a stranger's
77
+ header and these lines are printed to a terminal, so anything
78
+ unprintable in it is dropped.
79
+ """
80
+ counted: dict[str, int] = {}
81
+ for client in clients:
82
+ label = describe_agent(str(client.get("agent") or ""))
83
+ address = str(client.get("address") or "")
84
+ if address and address not in LOOPBACK:
85
+ label = f"{label} at {address}"
86
+ label = "".join(ch for ch in label if ch.isprintable())
87
+ counted[label] = counted.get(label, 0) + 1
88
+ return [
89
+ f"{label} ({count} tabs)" if count > 1 else label
90
+ for label, count in counted.items()
91
+ ]
92
+
93
+
94
+ __all__ = ["LOOPBACK", "client_json", "describe_agent", "name_watchers"]
@@ -0,0 +1,171 @@
1
+ """Fan-out of backend events to every connected browser.
2
+
3
+ One :class:`Broadcaster` per server. Anything worth telling the UI about --
4
+ the state of the link, a fresh reading, a job's progress -- is published
5
+ here, and every open ``/api/events`` stream gets a copy. Publishers never
6
+ block: each subscriber owns a bounded queue, and a subscriber that cannot
7
+ keep up loses its oldest events rather than stalling the worker that owns
8
+ the device.
9
+
10
+ Some events describe *state* rather than an occurrence (the link is idle,
11
+ the reading is this). Those are published as ``sticky``: the broadcaster
12
+ remembers the latest one per name and replays them to a new subscriber, so a
13
+ browser that connects (or reconnects) sees the current world immediately
14
+ instead of waiting for something to change.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import queue
20
+ import threading
21
+ from dataclasses import dataclass
22
+ from typing import Any
23
+
24
+ # How many events one slow subscriber may fall behind before it starts
25
+ # losing the oldest ones. A browser reading a local socket is never this
26
+ # far behind unless it has stopped reading altogether.
27
+ MAX_QUEUED_EVENTS = 256
28
+
29
+ # How long a stream waits for an event before writing an SSE comment, so a
30
+ # connection that died without a FIN is noticed rather than held forever.
31
+ HEARTBEAT_INTERVAL_S = 15.0
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Event:
36
+ """One thing that happened, addressed to every listening browser."""
37
+
38
+ name: str
39
+ data: dict[str, Any]
40
+ seq: int
41
+
42
+
43
+ class Subscription:
44
+ """One browser's view of the event stream."""
45
+
46
+ def __init__(
47
+ self,
48
+ broadcaster: "Broadcaster",
49
+ backlog: list[Event],
50
+ client: dict[str, Any] | None = None,
51
+ ) -> None:
52
+ """Start a subscription pre-loaded with the current sticky state."""
53
+ self._broadcaster = broadcaster
54
+ self._queue: queue.Queue[Event | None] = queue.Queue(MAX_QUEUED_EVENTS)
55
+ self._closed = False
56
+ self.client: dict[str, Any] = dict(client or {})
57
+ """Who is on the other end -- see :meth:`Broadcaster.clients`."""
58
+ for event in backlog:
59
+ self._offer(event)
60
+
61
+ def _offer(self, event: Event | None) -> None:
62
+ """Queue an event, dropping the oldest if this subscriber lags."""
63
+ try:
64
+ self._queue.put_nowait(event)
65
+ except queue.Full:
66
+ try:
67
+ self._queue.get_nowait() # drop the oldest, keep the newest
68
+ except queue.Empty: # pragma: no cover - another thread drained it
69
+ pass
70
+ try:
71
+ self._queue.put_nowait(event)
72
+ except queue.Full: # pragma: no cover - refilled in between
73
+ pass
74
+
75
+ def get(self, timeout: float = HEARTBEAT_INTERVAL_S) -> Event | None:
76
+ """Wait for the next event; None on timeout or once closed."""
77
+ try:
78
+ return self._queue.get(timeout=timeout)
79
+ except queue.Empty:
80
+ return None
81
+
82
+ def close(self) -> None:
83
+ """Stop the subscription and wake a reader blocked in :meth:`get`."""
84
+ if not self._closed:
85
+ self._closed = True
86
+ self._broadcaster.unsubscribe(self)
87
+ self._offer(None)
88
+
89
+ @property
90
+ def closed(self) -> bool:
91
+ """Whether this subscription has been closed."""
92
+ return self._closed
93
+
94
+ def __enter__(self) -> "Subscription":
95
+ """Allow use as a context manager, so streams always unsubscribe."""
96
+ return self
97
+
98
+ def __exit__(self, *exc: object) -> None:
99
+ """Close the subscription on the way out."""
100
+ self.close()
101
+
102
+
103
+ class Broadcaster:
104
+ """Publishes events to every open subscription."""
105
+
106
+ def __init__(self) -> None:
107
+ """Start with no subscribers and no remembered state."""
108
+ self._lock = threading.Lock()
109
+ self._subscribers: set[Subscription] = set()
110
+ self._sticky: dict[str, Event] = {}
111
+ self._seq = 0
112
+ self._clients = 0
113
+
114
+ def publish(self, name: str, data: dict[str, Any], *, sticky: bool = False) -> None:
115
+ """Send one event to every subscriber (and remember it, if sticky)."""
116
+ with self._lock:
117
+ self._seq += 1
118
+ event = Event(name=name, data=data, seq=self._seq)
119
+ if sticky:
120
+ self._sticky[name] = event
121
+ subscribers = list(self._subscribers)
122
+ for subscriber in subscribers:
123
+ subscriber._offer(event)
124
+
125
+ def subscribe(self, client: dict[str, Any] | None = None) -> Subscription:
126
+ """Open a subscription, pre-loaded with the latest sticky events.
127
+
128
+ ``client`` describes who opened it -- address, browser, when -- and
129
+ is handed back by :meth:`clients` so the UI can say who else is
130
+ watching rather than only how many.
131
+ """
132
+ with self._lock:
133
+ backlog = [self._sticky[name] for name in sorted(self._sticky)]
134
+ self._clients += 1
135
+ described = {"id": str(self._clients), **(client or {})}
136
+ subscription = Subscription(self, backlog, described)
137
+ self._subscribers.add(subscription)
138
+ return subscription
139
+
140
+ def clients(self) -> list[dict[str, Any]]:
141
+ """Describe every browser currently listening, oldest connection first."""
142
+ with self._lock:
143
+ watchers = [dict(s.client) for s in self._subscribers if s.client]
144
+ return sorted(watchers, key=lambda c: (c.get("since") or 0, c.get("id") or ""))
145
+
146
+ def unsubscribe(self, subscription: Subscription) -> None:
147
+ """Forget a subscription (called by :meth:`Subscription.close`)."""
148
+ with self._lock:
149
+ self._subscribers.discard(subscription)
150
+
151
+ def sticky(self, name: str) -> dict[str, Any] | None:
152
+ """Return the latest remembered event of one name, if any."""
153
+ with self._lock:
154
+ event = self._sticky.get(name)
155
+ return dict(event.data) if event else None
156
+
157
+ @property
158
+ def subscriber_count(self) -> int:
159
+ """How many browsers are currently listening."""
160
+ with self._lock:
161
+ return len(self._subscribers)
162
+
163
+ def shutdown(self) -> None:
164
+ """Close every subscription, ending all open streams."""
165
+ with self._lock:
166
+ subscribers = list(self._subscribers)
167
+ for subscriber in subscribers:
168
+ subscriber.close()
169
+
170
+
171
+ __all__ = ["HEARTBEAT_INTERVAL_S", "Broadcaster", "Event", "Subscription"]
devicectl/web/http.py ADDED
@@ -0,0 +1,243 @@
1
+ """The pieces of HTTP the API handlers actually touch.
2
+
3
+ The server itself is :mod:`http.server` and the handlers are plain functions,
4
+ so what sits between them is small: a parsed request, a reply ready to write,
5
+ a routing entry that says whether an endpoint changes anything, one
6
+ exception carrying the status to answer with, and a way to say what a
7
+ request was for a recording to keep.
8
+
9
+ None of it is a framework. It exists so that a handler is a function of
10
+ ``(context, request) -> response`` and can be called directly from a test
11
+ without a socket.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import shutil
18
+ import tempfile
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any, Callable, Generic, TypeVar
22
+ from urllib.parse import parse_qs
23
+
24
+ from devicectl.errors import DeviceError
25
+
26
+ # The context a handler is given is the program's own -- what it holds is
27
+ # entirely up to the program -- so the routing table is generic over it and
28
+ # the shared server never looks inside.
29
+ CtxT = TypeVar("CtxT")
30
+
31
+ # Every status either half of the transport answers with, in one place: the
32
+ # server module had grown its own copy of three of these, and a program that
33
+ # wants to say 404 should not have to know which of the two to ask.
34
+ HTTP_OK = 200
35
+ HTTP_SEE_OTHER = 303
36
+ HTTP_BAD_REQUEST = 400
37
+ HTTP_FORBIDDEN = 403
38
+ HTTP_NOT_FOUND = 404
39
+ HTTP_METHOD_NOT_ALLOWED = 405
40
+ HTTP_CONFLICT = 409
41
+ HTTP_PAYLOAD_TOO_LARGE = 413
42
+ HTTP_SERVER_ERROR = 500
43
+ HTTP_UNAVAILABLE = 503
44
+
45
+ # Query-string spellings of "yes". Anything else, including an empty value,
46
+ # is false: `?force` alone is not an assertion that force is wanted.
47
+ TRUTHY = ("1", "true", "yes", "on")
48
+
49
+
50
+ class ApiError(DeviceError):
51
+ """A request that cannot be served, with the status to answer."""
52
+
53
+ def __init__(self, status: int, message: str) -> None:
54
+ """Record the HTTP status alongside the message."""
55
+ super().__init__(message)
56
+ self.status = status
57
+ self.message = message
58
+
59
+
60
+ @dataclass
61
+ class Request:
62
+ """One parsed HTTP request."""
63
+
64
+ method: str
65
+ path: str
66
+ query: dict[str, str] = field(default_factory=dict)
67
+ body: bytes = b""
68
+
69
+ def json(self) -> dict[str, Any]:
70
+ """Parse the body as a JSON object."""
71
+ if not self.body:
72
+ return {}
73
+ try:
74
+ doc = json.loads(self.body.decode("utf-8"))
75
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
76
+ raise ApiError(HTTP_BAD_REQUEST, f"malformed JSON body: {exc}") from None
77
+ if not isinstance(doc, dict):
78
+ raise ApiError(HTTP_BAD_REQUEST, "expected a JSON object")
79
+ return doc
80
+
81
+ def param(self, name: str, default: str = "") -> str:
82
+ """One query-string parameter."""
83
+ return self.query.get(name, default)
84
+
85
+ def flag(self, name: str, default: bool = False) -> bool:
86
+ """One query-string parameter read as a boolean."""
87
+ raw = self.query.get(name)
88
+ if raw is None:
89
+ return default
90
+ return raw.strip().lower() in TRUTHY
91
+
92
+
93
+ @dataclass
94
+ class Response:
95
+ """One reply, ready to write."""
96
+
97
+ status: int = 200
98
+ body: bytes = b""
99
+ content_type: str = "application/json; charset=utf-8"
100
+ headers: dict[str, str] = field(default_factory=dict)
101
+
102
+
103
+ def ok(payload: Any, status: int = 200) -> Response:
104
+ """Render a JSON reply.
105
+
106
+ ``default=str`` is the last resort for a value no schema function
107
+ converted -- a ``datetime`` or an ``Enum`` that reached here intact.
108
+ Rendering it as its own text is a worse answer than the right type but a
109
+ much better one than a 500 in the middle of a page.
110
+ """
111
+ return Response(
112
+ status=status, body=json.dumps(payload, default=str).encode("utf-8")
113
+ )
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class Route(Generic[CtxT]):
118
+ """One endpoint: what runs it, and whether it changes anything.
119
+
120
+ Generic over the context so a program's routing table is checked against
121
+ the context its own handlers take, while the server that dispatches it
122
+ never needs to know what that is.
123
+ """
124
+
125
+ handler: Callable[[CtxT, Request], Response]
126
+ write: bool = False
127
+ """Refused outright when the server was started ``--read-only``."""
128
+
129
+ raw_body: bool = False
130
+ """Takes an uploaded file rather than JSON."""
131
+
132
+
133
+ # How much of a request body a recording keeps. Long enough for every
134
+ # settings change a page sends in one go, short enough that a mistyped
135
+ # upload does not become the report.
136
+ MAX_RECORDED_BODY = 1000
137
+
138
+
139
+ def describe_call(
140
+ request: Request, status: int | None = None, error: str = ""
141
+ ) -> tuple[str, str]:
142
+ """Say what a request was, and how it went, for a recording to keep.
143
+
144
+ Two calls per request rather than one: the arriving request, with its
145
+ body, goes in before the work starts, and the outcome goes in after it,
146
+ so a recording reads in the order things happened -- what was asked,
147
+ what that put on the wire, and what came back to the page. ``status``
148
+ of ``None`` is the first of those.
149
+
150
+ Returns the one-line headline and the body beneath it, which is empty
151
+ for the outcome: repeating the payload under the answer would double
152
+ every request in the report.
153
+ """
154
+ where = request.path
155
+ if request.query:
156
+ where += "?" + "&".join(f"{k}={v}" for k, v in request.query.items())
157
+ head = f"{request.method} {where}"
158
+ if status is None:
159
+ return head, _body(request.body)
160
+ return f"{head} -> {status}" + (f" {error}" if error else ""), ""
161
+
162
+
163
+ def _body(raw: bytes) -> str:
164
+ """Render a request body as something a person can read, or describe it.
165
+
166
+ An uploaded firmware image is a megabyte of nothing anybody can read and
167
+ is said to be that; JSON is re-rendered rather than passed through, so a
168
+ page that sends one long line is not one long line here.
169
+ """
170
+ if not raw:
171
+ return ""
172
+ try:
173
+ text = raw.decode("utf-8")
174
+ except UnicodeDecodeError:
175
+ return f"({len(raw)} bytes, not text)"
176
+ try:
177
+ text = json.dumps(json.loads(text), indent=2)
178
+ except json.JSONDecodeError:
179
+ pass
180
+ if len(text) > MAX_RECORDED_BODY:
181
+ return text[:MAX_RECORDED_BODY] + f"\n... ({len(raw)} bytes in all)"
182
+ return text
183
+
184
+
185
+ def parse_query(raw: str) -> dict[str, str]:
186
+ """Flatten a query string to the last value of each parameter."""
187
+ return {k: v[-1] for k, v in parse_qs(raw, keep_blank_values=True).items()}
188
+
189
+
190
+ def spool(
191
+ req: Request,
192
+ *,
193
+ max_bytes: int,
194
+ prefix: str,
195
+ suffix: str = "",
196
+ too_large: str = "that file is larger than anything this takes",
197
+ ) -> Path:
198
+ """Write an uploaded body to a temporary file, for code that wants a path.
199
+
200
+ The uploaded name is kept where the browser sent one, because a firmware
201
+ image or a settings export is often identified by it; ``suffix`` names
202
+ the extension to give a file that arrived without one. The file goes in
203
+ a directory of its own so :func:`discard` can remove both without
204
+ guessing what else is in there.
205
+ """
206
+ if not req.body:
207
+ raise ApiError(HTTP_BAD_REQUEST, "no file content was uploaded")
208
+ if len(req.body) > max_bytes:
209
+ raise ApiError(HTTP_PAYLOAD_TOO_LARGE, too_large)
210
+ name = Path(req.param("filename", "upload")).name or "upload"
211
+ directory = Path(tempfile.mkdtemp(prefix=prefix))
212
+ path = directory / (name if Path(name).suffix else name + suffix)
213
+ path.write_bytes(req.body)
214
+ return path
215
+
216
+
217
+ def discard(path: Path) -> None:
218
+ """Remove a spooled upload and the directory it was written into."""
219
+ shutil.rmtree(path.parent, ignore_errors=True)
220
+
221
+
222
+ __all__ = [
223
+ "HTTP_BAD_REQUEST",
224
+ "HTTP_CONFLICT",
225
+ "HTTP_FORBIDDEN",
226
+ "HTTP_METHOD_NOT_ALLOWED",
227
+ "HTTP_NOT_FOUND",
228
+ "HTTP_OK",
229
+ "HTTP_PAYLOAD_TOO_LARGE",
230
+ "HTTP_SEE_OTHER",
231
+ "HTTP_SERVER_ERROR",
232
+ "HTTP_UNAVAILABLE",
233
+ "MAX_RECORDED_BODY",
234
+ "ApiError",
235
+ "Request",
236
+ "Response",
237
+ "Route",
238
+ "describe_call",
239
+ "discard",
240
+ "ok",
241
+ "parse_query",
242
+ "spool",
243
+ ]
@@ -0,0 +1,101 @@
1
+ """A long operation's progress, turned into events for the browser.
2
+
3
+ The terminal draws a bar per phase, but a browser gets one bar per job, and
4
+ the work behind it usually comes in parts of very different length: a
5
+ download, then a transfer, then whatever the device does on its own once it
6
+ has the file. Each part gets a slice of the one bar, so it keeps moving
7
+ instead of sitting at 100% for three minutes.
8
+
9
+ The slices are guesses about duration and nothing depends on them being
10
+ right. A program with only one phase leaves :class:`Phases` alone and the
11
+ transfer fills the bar.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+
18
+ from devicectl.report import Reporter, Wait
19
+ from devicectl.web.worker import Job
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Phases:
24
+ """Where each part of a long job ends, on the one bar the page draws."""
25
+
26
+ sending_ends: float = 1.0
27
+ """Where the transfer finishes. Below 1.0 leaves room for a wait."""
28
+
29
+ waiting_ends: float = 1.0
30
+ """Where the wait for the device finishes, leaving the last sliver for
31
+ whatever confirms the operation actually took."""
32
+
33
+
34
+ ONE_PHASE = Phases()
35
+
36
+
37
+ class JobReporter(Reporter):
38
+ """Report a long operation's progress onto a :class:`Job`.
39
+
40
+ ``start`` is where the transfer begins on the bar -- 0 when the file was
41
+ uploaded, further along when it had to be fetched first. Warnings are
42
+ kept as well as shown, because the job's result is what the browser has
43
+ left to look at once the run is over.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ job: Job,
49
+ *,
50
+ start: float = 0.0,
51
+ phases: Phases = ONE_PHASE,
52
+ unit: str = "",
53
+ ) -> None:
54
+ """Report onto ``job``, with the transfer starting at ``start``.
55
+
56
+ ``unit`` names what is being counted -- "block", "page" -- and turns
57
+ the transfer into a countable line ("block 12 of 340") beside the
58
+ bar. Left empty, the bar moves and says nothing, which is right
59
+ where the count is bytes and the bar already says it.
60
+ """
61
+ self.job = job
62
+ self.start = start
63
+ self.phases = phases
64
+ self.unit = unit
65
+ self.warnings: list[str] = []
66
+
67
+ def step(self, message: str) -> None:
68
+ """Show the new phase as the job's message."""
69
+ self.job.report(message=message, force=True)
70
+
71
+ def detail(self, message: str) -> None:
72
+ """Show a detail as the job's message; the browser has one line."""
73
+ self.job.report(message=message, force=True)
74
+
75
+ def warn(self, message: str) -> None:
76
+ """Show a warning and keep it for the job's result."""
77
+ self.warnings.append(message)
78
+ self.job.report(message=f"Warning: {message}", force=True)
79
+
80
+ def sending(self, sent: int, total: int, elapsed_s: float, label: str = "") -> None:
81
+ """Move the bar across the transfer's slice."""
82
+ span = self.phases.sending_ends - self.start
83
+ counted = f"{self.unit} {sent} of {total}" if self.unit else None
84
+ self.job.report(self.start + span * (sent / total if total else 1.0), counted)
85
+
86
+ def waiting(self, wait: Wait) -> None:
87
+ """Move the bar across the waiting slice, by elapsed time."""
88
+ self.job.report(self._waiting_progress(wait))
89
+
90
+ def polled(self, wait: Wait) -> None:
91
+ """Move the bar, and say what the device last answered."""
92
+ self.job.report(self._waiting_progress(wait), wait.label)
93
+
94
+ def _waiting_progress(self, wait: Wait) -> float:
95
+ """Map elapsed waiting time onto the waiting slice of the bar."""
96
+ span = self.phases.waiting_ends - self.phases.sending_ends
97
+ done = min(wait.elapsed_s / wait.deadline_s, 1.0) if wait.deadline_s else 1.0
98
+ return self.phases.sending_ends + span * done
99
+
100
+
101
+ __all__ = ["ONE_PHASE", "JobReporter", "Phases"]