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/web/agents.py
ADDED
|
@@ -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"]
|
devicectl/web/events.py
ADDED
|
@@ -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"]
|