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,697 @@
1
+ """One thread owning the one connection, and everything queued behind it.
2
+
3
+ A device of this kind takes one conversation at a time -- a charger allows
4
+ a single session, a serial bus is a single wire -- so the web server cannot
5
+ let request threads talk to it directly. Instead every request hands a
6
+ callable to the worker, which owns the link on its own thread and runs them
7
+ one after another. A handler is then ordinary blocking code: it submits,
8
+ waits, and gets back what the callable returned or whatever it raised.
9
+
10
+ Three things fall out of that and are all here:
11
+
12
+ * **Jobs.** Work measured in minutes rather than milliseconds cannot hold a
13
+ request thread open, so it is queued as a :class:`Job` and followed on the
14
+ event stream instead.
15
+ * **A published state.** A page has to be able to say what the link is
16
+ doing and who else is waiting, so every transition is broadcast.
17
+ * **Idleness.** A device nobody is asking about should not be held. The
18
+ worker closes the link after a quiet spell, and a live refresh -- when the
19
+ page has asked for one -- is just a task it queues for itself.
20
+
21
+ What a link *is*, how it opens and what a task is handed are the program's
22
+ own: :class:`Worker` is generic over the link and calls back for those. Retry
23
+ deliberately is not here. One program retries at the authentication layer
24
+ (a 401 means log in again) and another at the protocol layer (a framing
25
+ fault is not a Modbus exception), and an abstraction over the two would
26
+ describe neither.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import itertools
32
+ import queue
33
+ import threading
34
+ import time
35
+ from collections import deque
36
+ from dataclasses import dataclass, field
37
+ from typing import Any, Callable, Generic, TypeVar
38
+
39
+ from devicectl.errors import DeviceError, describe
40
+ from devicectl.web.events import Broadcaster
41
+
42
+ LinkT = TypeVar("LinkT")
43
+
44
+ # How often the live refresh reads, and the range a page may ask for.
45
+ DEFAULT_POLL_INTERVAL_S = 3.0
46
+ MIN_POLL_INTERVAL_S = 1.0
47
+ MAX_POLL_INTERVAL_S = 60.0
48
+
49
+ # How long the link is held after the last thing anybody asked for. Long
50
+ # enough that clicking around a page does not reconnect between clicks,
51
+ # short enough that a forgotten tab gives the device back.
52
+ DEFAULT_IDLE_TIMEOUT_S = 45.0
53
+
54
+ # How long a request thread waits for its turn before giving up on it.
55
+ DEFAULT_TASK_TIMEOUT_S = 180.0
56
+
57
+ # How often the loop looks up when there is nothing queued.
58
+ LOOP_TICK_S = 0.25
59
+
60
+ # How many finished operations the activity ticker remembers.
61
+ ACTIVITY_HISTORY = 12
62
+
63
+ # How long a finished job stays answerable, for a page that was not looking.
64
+ JOB_RETENTION_S = 900.0
65
+
66
+ # The fastest a job's progress is published: more often than this is more
67
+ # often than a browser can usefully redraw.
68
+ JOB_NOTIFY_INTERVAL_S = 0.25
69
+
70
+ # How long `stop` waits for the thread to notice before leaving it to the
71
+ # interpreter. It is a daemon thread with nothing to write down.
72
+ STOP_JOIN_S = 0.1
73
+
74
+ _job_ids = itertools.count(1)
75
+
76
+
77
+ @dataclass
78
+ class Job:
79
+ """A long-running operation the UI follows rather than waits for."""
80
+
81
+ id: int
82
+ name: str
83
+ state: str = "queued" # queued | running | done | failed
84
+ progress: float | None = None
85
+ message: str = ""
86
+ error: str = ""
87
+ started: float = 0.0
88
+ finished: float = 0.0
89
+ result: dict[str, Any] = field(default_factory=dict)
90
+ """What the job produced, for the page to read once it is done.
91
+
92
+ An object rather than a bare value, because the page reads fields off
93
+ it and a job usually has more than one thing to say. A callable either
94
+ returns it or fills it in as it goes.
95
+ """
96
+ notify: Callable[["Job"], None] | None = field(default=None, repr=False)
97
+ _last_notify: float = field(default=0.0, repr=False)
98
+
99
+ def report(
100
+ self,
101
+ progress: float | None = None,
102
+ message: str | None = None,
103
+ *,
104
+ force: bool = False,
105
+ ) -> None:
106
+ """Update the job's progress and tell the browsers, at most 4x a second.
107
+
108
+ ``force`` is for a new phase, which is worth an immediate event. A
109
+ changed message is not enough on its own: a transfer that counts what
110
+ it has sent changes its message every block, and forcing on that
111
+ would publish an event per block and defeat the rate limit entirely.
112
+ A throttled message is not lost -- it goes out with the next event.
113
+ """
114
+ if progress is not None:
115
+ self.progress = max(0.0, min(1.0, float(progress)))
116
+ if message is not None:
117
+ self.message = message
118
+ now = time.monotonic()
119
+ if not force and now - self._last_notify < JOB_NOTIFY_INTERVAL_S:
120
+ return
121
+ self._last_notify = now
122
+ if self.notify is not None:
123
+ self.notify(self)
124
+
125
+ def as_dict(self) -> dict[str, Any]:
126
+ """Render the job for the UI."""
127
+ return {
128
+ "id": self.id,
129
+ "name": self.name,
130
+ "state": self.state,
131
+ "progress": self.progress,
132
+ "message": self.message,
133
+ "error": self.error,
134
+ "elapsed": (self.finished or time.time()) - self.started
135
+ if self.started
136
+ else 0.0,
137
+ } | ({"result": self.result} if self.result else {})
138
+
139
+
140
+ class Task:
141
+ """One unit of work, queued for the worker thread."""
142
+
143
+ def __init__(
144
+ self,
145
+ name: str,
146
+ fn: Callable[..., Any],
147
+ *,
148
+ job: Job | None = None,
149
+ quiet: bool = False,
150
+ exclusive: bool = False,
151
+ ) -> None:
152
+ """Prepare a task; ``quiet`` keeps polls out of the activity ticker."""
153
+ self.name = name
154
+ self.fn = fn
155
+ self.job = job
156
+ self.quiet = quiet
157
+ self.exclusive = exclusive
158
+ self.done = threading.Event()
159
+ self.result: Any = None
160
+ self.error: BaseException | None = None
161
+
162
+
163
+ class Worker(Generic[LinkT]):
164
+ """Owns the one link to the device and runs every request against it.
165
+
166
+ Subclass it and answer four questions: how a link is opened
167
+ (:meth:`_open_link`), how it is closed (:meth:`_close_link`), whether
168
+ there is anything to open one to (:meth:`_check_ready`), and what a task
169
+ is handed when it runs (:meth:`_invoke`, which by default is the link
170
+ itself). :meth:`_details` adds whatever else the page needs to draw the
171
+ header.
172
+ """
173
+
174
+ # The published names for the states the base moves through. They are
175
+ # class attributes because a program's page and stylesheet are written
176
+ # against its own words for them.
177
+ RELEASED = "released"
178
+ OPENING = "opening"
179
+ IDLE = "idle"
180
+ BUSY = "busy"
181
+ ERROR = "error"
182
+
183
+ thread_name = "device-worker"
184
+ task_timeout_s = DEFAULT_TASK_TIMEOUT_S
185
+
186
+ # What the page calls the live refresh in the link pill. A program's own
187
+ # word for it: the frontend checks these strings against the panels that
188
+ # wait on them, so it is not free to change.
189
+ poll_task_name = "Refreshing"
190
+
191
+ def __init__(
192
+ self,
193
+ events: Broadcaster,
194
+ *,
195
+ poll_interval: float = DEFAULT_POLL_INTERVAL_S,
196
+ idle_timeout: float = DEFAULT_IDLE_TIMEOUT_S,
197
+ ) -> None:
198
+ """Set up the worker; it opens nothing until it has work."""
199
+ self.events = events
200
+ self.poll_interval = poll_interval
201
+ self.idle_timeout = idle_timeout
202
+ self._link: LinkT | None = None
203
+ self._queue: queue.Queue[Task | None] = queue.Queue()
204
+ self._lock = threading.Lock()
205
+ self._thread: threading.Thread | None = None
206
+ self._stopping = threading.Event()
207
+ self._live = False
208
+ self._exclusive = False
209
+ self._release_requested = False
210
+ self._last_used = time.monotonic()
211
+ self._last_poll = 0.0
212
+ self._last_tick = 0
213
+ self._state = self.RELEASED
214
+ self._op = ""
215
+ self._op_quiet = False
216
+ self._op_progress: float | None = None
217
+ self._op_note = ""
218
+ self._last_progress = 0.0
219
+ self._error = ""
220
+ self._since = time.time()
221
+ self._activity: deque[dict[str, Any]] = deque(maxlen=ACTIVITY_HISTORY)
222
+ self._jobs: dict[int, Job] = {}
223
+ self._poll_fn: Callable[..., Any] | None = None
224
+
225
+ # --- what a program answers ---------------------------------------------------
226
+
227
+ def _open_link(self) -> LinkT:
228
+ """Open the link to the device and return it."""
229
+ raise NotImplementedError
230
+
231
+ def _close_link(self, link: LinkT) -> None:
232
+ """Hand the link back. Must not raise."""
233
+ raise NotImplementedError
234
+
235
+ def _check_ready(self) -> None:
236
+ """Raise the program's own error if there is nothing to talk to yet."""
237
+
238
+ def _invoke(self, task: Task, link: LinkT) -> Any:
239
+ """Run one task's callable. By default it is handed the link."""
240
+ if task.job is not None:
241
+ return task.fn(link, task.job)
242
+ return task.fn(link)
243
+
244
+ def _details(self) -> dict[str, Any]:
245
+ """Whatever else the page needs, on top of the shared link state."""
246
+ return {}
247
+
248
+ def _busy_state(self, task: Task) -> str:
249
+ """Return the state to publish while this task runs.
250
+
251
+ Almost always :attr:`BUSY`. A program that lets a task own the link
252
+ outright can say so instead, which is a different thing for a page to
253
+ draw: nothing else will get a turn until it finishes.
254
+ """
255
+ return self.BUSY
256
+
257
+ def _after_failure(self, exc: BaseException) -> None:
258
+ """React to a task that raised, before the link state is republished."""
259
+
260
+ # --- lifecycle ----------------------------------------------------------------
261
+
262
+ def start(self) -> None:
263
+ """Start the worker thread."""
264
+ self._thread = threading.Thread(
265
+ target=self._loop, name=self.thread_name, daemon=True
266
+ )
267
+ self._thread.start()
268
+ self._publish_link()
269
+
270
+ def stop(self, timeout: float | None = None) -> None:
271
+ """Stop the worker and drop the link.
272
+
273
+ Returning quickly matters more than unwinding tidily: this is what
274
+ Ctrl+C waits for, and the worker can be in the middle of a round
275
+ trip to a device on the far end of a slow link. Closing the link
276
+ from here does not interrupt a blocked read -- a socket closed under
277
+ a thread parked in ``recv`` on Linux stays parked -- so the thread is
278
+ asked to stop, given ``timeout`` to notice (:data:`STOP_JOIN_S` when
279
+ none is named), and then left to the interpreter, which is entitled
280
+ to do that because it is a daemon thread with nothing to write down.
281
+
282
+ ``timeout=0`` does not wait at all, which is what a teardown running
283
+ off a signal handler wants: blocking there is how a process hangs on
284
+ Ctrl+C instead of stopping on it.
285
+ """
286
+ wait = STOP_JOIN_S if timeout is None else timeout
287
+ self._stopping.set()
288
+ self._queue.put(None)
289
+ thread = self._thread
290
+ if thread is not None and wait > 0:
291
+ thread.join(timeout=wait)
292
+
293
+ @property
294
+ def stopping(self) -> bool:
295
+ """Whether the worker has been told to stand down."""
296
+ return self._stopping.is_set()
297
+
298
+ @property
299
+ def live(self) -> bool:
300
+ """Whether the live refresh is running."""
301
+ return self._live
302
+
303
+ def set_poll(
304
+ self, *, live: bool | None = None, interval: float | None = None
305
+ ) -> None:
306
+ """Turn the live refresh on or off, and set its interval."""
307
+ if interval is not None:
308
+ self.poll_interval = max(
309
+ MIN_POLL_INTERVAL_S, min(MAX_POLL_INTERVAL_S, float(interval))
310
+ )
311
+ if live is not None:
312
+ self._live = bool(live)
313
+ if live:
314
+ self._last_poll = 0.0 # refresh at once rather than on the beat
315
+ self._publish_link()
316
+
317
+ def set_poll_fn(self, fn: Callable[..., Any] | None) -> None:
318
+ """Install what a live refresh actually reads (set by the API layer).
319
+
320
+ It is run as an ordinary task, so it is handed whatever
321
+ :meth:`_invoke` hands one.
322
+ """
323
+ self._poll_fn = fn
324
+
325
+ def release(self) -> None:
326
+ """Ask the worker to hand the link back as soon as it is free."""
327
+ self._release_requested = True
328
+
329
+ # --- submitting work ----------------------------------------------------------
330
+
331
+ def run(
332
+ self,
333
+ name: str,
334
+ fn: Callable[..., Any],
335
+ *,
336
+ timeout: float | None = None,
337
+ quiet: bool = False,
338
+ ) -> Any:
339
+ """Run ``fn`` against the device and return its result.
340
+
341
+ Blocks the calling (request) thread until the worker gets to it.
342
+ Raises whatever ``fn`` raised, or :class:`~devicectl.errors.DeviceError`
343
+ via :meth:`_busy_error` if the queue did not reach it in time.
344
+ """
345
+ self._check_ready()
346
+ wait = self.task_timeout_s if timeout is None else timeout
347
+ task = Task(name, fn, quiet=quiet)
348
+ self._queue.put(task)
349
+ if not task.done.wait(wait):
350
+ raise self._busy_error(name, wait)
351
+ if task.error is not None:
352
+ raise task.error
353
+ return task.result
354
+
355
+ def run_soon(self, fn: Callable[..., Any], *, name: str = "") -> None:
356
+ """Queue work and do not wait for it.
357
+
358
+ Unnamed by default, so it stays out of the activity ticker: this is
359
+ for housekeeping the user did not ask for by name.
360
+ """
361
+ self._queue.put(Task(name, fn, quiet=True))
362
+
363
+ def start_job(
364
+ self, name: str, fn: Callable[..., Any], *, exclusive: bool = False
365
+ ) -> Job:
366
+ """Queue a long operation and return its :class:`Job` immediately.
367
+
368
+ ``exclusive`` is for work that owns the link outright -- a firmware
369
+ transfer, after which the device is not speaking its usual protocol
370
+ any more, so a live refresh in the gap would put the wrong bytes on
371
+ the wire. The poll timer and the idle timer both stand down for it.
372
+ """
373
+ self._check_ready()
374
+ job = Job(id=next(_job_ids), name=name, notify=self._on_job_progress)
375
+ with self._lock:
376
+ self._jobs[job.id] = job
377
+ self._forget_old_jobs()
378
+ self._publish_job(job)
379
+ if exclusive:
380
+ with self._lock:
381
+ self._exclusive = True
382
+ self._queue.put(Task(name, fn, job=job, exclusive=exclusive))
383
+ return job
384
+
385
+ def discard_pending(self, why: str) -> int:
386
+ """Drop everything queued but not started, and tell whoever waits on it.
387
+
388
+ Pointing the worker at a different device -- or giving up on the one
389
+ it was pointed at -- leaves a queue full of work meant for the old
390
+ one. Running it against the new one would be wrong, and leaving it
391
+ to time out makes the first two minutes on the new device two
392
+ minutes of failures arriving from the last. Dropped work fails at
393
+ once, with ``why`` as its reason.
394
+
395
+ The task already running is not touched: it holds the link, and the
396
+ worker has no way to interrupt a thread parked on a read. Returns
397
+ how many were dropped.
398
+ """
399
+ dropped = 0
400
+ while True:
401
+ try:
402
+ task = self._queue.get_nowait()
403
+ except queue.Empty:
404
+ break
405
+ if task is None: # the stop sentinel; leave it for the loop
406
+ self._queue.put(None)
407
+ break
408
+ task.error = DeviceError(f"{task.name or 'the request'}: {why}")
409
+ if task.exclusive:
410
+ with self._lock:
411
+ self._exclusive = False
412
+ self._finish_job(task.job, failure=task.error)
413
+ task.done.set()
414
+ dropped += 1
415
+ if dropped:
416
+ self._publish_link()
417
+ return dropped
418
+
419
+ def _busy_error(self, name: str, timeout: float) -> BaseException:
420
+ """Return the error a caller gets when it never got its turn."""
421
+ return TimeoutError(
422
+ f"the device is busy ({self._op or 'another operation'}); "
423
+ f"'{name}' did not get its turn within {timeout:.0f}s"
424
+ )
425
+
426
+ def job(self, job_id: int) -> Job | None:
427
+ """Look up one job by id."""
428
+ with self._lock:
429
+ return self._jobs.get(job_id)
430
+
431
+ def jobs(self) -> list[dict[str, Any]]:
432
+ """Every job the worker still remembers, oldest first."""
433
+ with self._lock:
434
+ return [job.as_dict() for job in sorted(self._jobs.values(), key=_job_key)]
435
+
436
+ def _forget_old_jobs(self) -> None:
437
+ """Drop finished jobs nobody is going to ask about any more."""
438
+ cutoff = time.time() - JOB_RETENTION_S
439
+ for job_id, job in list(self._jobs.items()):
440
+ if job.finished and job.finished < cutoff:
441
+ del self._jobs[job_id]
442
+
443
+ # --- state reporting ----------------------------------------------------------
444
+
445
+ def link_state(self) -> dict[str, Any]:
446
+ """Build the link snapshot published to every browser."""
447
+ with self._lock:
448
+ countdown = None
449
+ if self._link is not None and not self._live:
450
+ left = self.idle_timeout - (time.monotonic() - self._last_used)
451
+ countdown = max(0.0, round(left, 1))
452
+ state = {
453
+ "state": self._state,
454
+ "op": self._op,
455
+ # Whether the link is busy with something nobody asked for.
456
+ # The live refresh runs every few seconds and holds it for as
457
+ # long as it takes; a page that treated that the same as a
458
+ # firmware upload would blink every control on it on the beat.
459
+ "quiet": self._op_quiet,
460
+ "progress": self._op_progress,
461
+ # What the task running right now has got through, in its own
462
+ # words -- "412 records, page 5". Some long reads have no
463
+ # honest denominator to make a percentage out of, and a made-up
464
+ # one is worse than a count that is true.
465
+ "note": self._op_note,
466
+ "queued": max(0, self._queue.qsize()),
467
+ "since": self._since,
468
+ "error": self._error,
469
+ "live": self._live,
470
+ "pollInterval": self.poll_interval,
471
+ "releaseIn": countdown,
472
+ "idleTimeout": self.idle_timeout,
473
+ "activity": list(self._activity),
474
+ "clients": self.events.subscriber_count,
475
+ }
476
+ return state | self._details()
477
+
478
+ def _publish_link(self) -> None:
479
+ """Tell every browser what the link is doing."""
480
+ self.events.publish("link", self.link_state(), sticky=True)
481
+
482
+ def _on_job_progress(self, job: Job) -> None:
483
+ """Mirror a running job's progress onto the link, and publish both."""
484
+ with self._lock:
485
+ self._op_progress = job.progress
486
+ self._publish_job(job)
487
+ self._publish_link()
488
+
489
+ def _publish_job(self, job: Job) -> None:
490
+ """Tell every browser about one job's progress."""
491
+ self.events.publish("job", job.as_dict())
492
+
493
+ def _set_state(
494
+ self,
495
+ state: str,
496
+ op: str = "",
497
+ *,
498
+ error: str = "",
499
+ progress: float | None = None,
500
+ quiet: bool = False,
501
+ ) -> None:
502
+ """Move the link to a new state and publish it."""
503
+ with self._lock:
504
+ self._state = state
505
+ self._op = op
506
+ self._op_quiet = quiet
507
+ self._op_progress = progress
508
+ self._op_note = ""
509
+ self._error = error
510
+ self._since = time.time()
511
+ self._publish_link()
512
+
513
+ def progress(self, fraction: float | None = None, note: str = "") -> None:
514
+ """Say how the task running right now is getting on.
515
+
516
+ The counterpart to :meth:`Job.report` for work that is *not* a job:
517
+ a read the browser is waiting on, which holds the link for seconds
518
+ while it pages a device. Without this the page has only the pill's
519
+ "busy", which is the same thing it says for a write that lands in
520
+ 150 ms -- so a long walk is indistinguishable from a hang.
521
+
522
+ ``fraction`` is ``None`` where there is no honest denominator, and
523
+ ``note`` then carries what there *is* to say. Called from the worker
524
+ thread, from inside the task it describes, and throttled.
525
+ """
526
+ now = time.monotonic()
527
+ if now - self._last_progress < JOB_NOTIFY_INTERVAL_S:
528
+ return
529
+ self._last_progress = now
530
+ with self._lock:
531
+ self._op_progress = fraction
532
+ self._op_note = note
533
+ self._publish_link()
534
+
535
+ def _note_activity(self, name: str, seconds: float, ok: bool, detail: str) -> None:
536
+ """Add one finished operation to the ticker."""
537
+ with self._lock:
538
+ self._activity.appendleft(
539
+ {
540
+ "op": name,
541
+ "seconds": round(seconds, 3),
542
+ "ok": ok,
543
+ "at": time.time(),
544
+ "detail": detail,
545
+ }
546
+ )
547
+
548
+ # --- the worker thread --------------------------------------------------------
549
+
550
+ def _loop(self) -> None:
551
+ """Run tasks one at a time, polling and releasing in the gaps."""
552
+ while not self._stopping.is_set():
553
+ try:
554
+ task = self._queue.get(timeout=LOOP_TICK_S)
555
+ except queue.Empty:
556
+ task = None
557
+ if task is None:
558
+ if self._stopping.is_set():
559
+ break
560
+ self._idle_work()
561
+ continue
562
+ self._run_task(task)
563
+ self._close("shutting down")
564
+
565
+ def _idle_work(self) -> None:
566
+ """Between tasks: refresh the live view, or hand the link back."""
567
+ if self._exclusive:
568
+ return # something owns the link outright; do not interrupt it
569
+ if self._release_requested:
570
+ self._release_requested = False
571
+ self._close("released")
572
+ return
573
+ now = time.monotonic()
574
+ # Polling with nothing selected would fail on every beat and turn the
575
+ # link red for something the user has not asked for yet.
576
+ if self._live and self._poll_fn is not None and self._can_poll():
577
+ if now - self._last_poll >= self.poll_interval:
578
+ self._last_poll = now
579
+ self._run_task(Task(self.poll_task_name, self._poll_fn, quiet=True))
580
+ return
581
+ if self._link is not None and now - self._last_used >= self.idle_timeout:
582
+ self._close("idle")
583
+ elif self._link is not None and int(now) != self._last_tick:
584
+ self._last_tick = int(now)
585
+ self._publish_link() # keep the release countdown moving
586
+ with self._lock:
587
+ self._forget_old_jobs()
588
+
589
+ def _can_poll(self) -> bool:
590
+ """Whether a live refresh has anything to read from."""
591
+ try:
592
+ self._check_ready()
593
+ except Exception: # noqa: BLE001 - "not yet" is an answer, not a fault
594
+ return False
595
+ return True
596
+
597
+ def _run_task(self, task: Task) -> None:
598
+ """Run one task, reporting the link state around it."""
599
+ if self._release_requested and not task.exclusive:
600
+ self._release_requested = False
601
+ self._close("released")
602
+ started = time.monotonic()
603
+ job = task.job
604
+ if job is not None:
605
+ job.state = "running"
606
+ job.started = time.time()
607
+ self._publish_job(job)
608
+ try:
609
+ link = self._require_link(quiet=task.quiet)
610
+ self._set_state(self._busy_state(task), task.name, quiet=task.quiet)
611
+ task.result = self._invoke(task, link)
612
+ except BaseException as exc: # noqa: BLE001 - reported, never swallowed
613
+ task.error = exc
614
+ self._after_failure(exc)
615
+ self._finish_job(job, failure=exc)
616
+ if not task.quiet:
617
+ self._note_activity(
618
+ task.name, time.monotonic() - started, False, describe(exc)
619
+ )
620
+ else:
621
+ self._finish_job(job, result=task.result)
622
+ if not task.quiet:
623
+ self._note_activity(task.name, time.monotonic() - started, True, "")
624
+ finally:
625
+ task.done.set()
626
+ self._last_used = time.monotonic()
627
+ with self._lock:
628
+ self._exclusive = False
629
+ if self._link is not None:
630
+ self._set_state(self.IDLE)
631
+
632
+ def _finish_job(
633
+ self,
634
+ job: Job | None,
635
+ *,
636
+ result: Any = None,
637
+ failure: BaseException | None = None,
638
+ ) -> None:
639
+ """Close a job's record and tell the page, if this task had one."""
640
+ if job is None:
641
+ return
642
+ job.finished = time.time()
643
+ if failure is not None:
644
+ job.state = "failed"
645
+ job.error = describe(failure)
646
+ else:
647
+ # A callable either returns its result or has been filling it in
648
+ # as it went; anything else means the latter.
649
+ if isinstance(result, dict):
650
+ job.result = result
651
+ job.state = "done"
652
+ job.progress = 1.0
653
+ self._publish_job(job)
654
+
655
+ def _require_link(self, *, quiet: bool = False) -> LinkT:
656
+ """Return the live link, opening it if it is not held."""
657
+ if self._link is not None:
658
+ return self._link
659
+ self._check_ready()
660
+ self._set_state(self.OPENING, self._opening_note(), quiet=quiet)
661
+ self._link = self._open_link()
662
+ return self._link
663
+
664
+ def _opening_note(self) -> str:
665
+ """Return what the page says while the link is being opened."""
666
+ return "Connecting"
667
+
668
+ def _close(self, why: str, *, quiet: bool = False) -> None:
669
+ """Hand the link back, if one is held."""
670
+ link = self._link
671
+ self._link = None
672
+ if link is not None:
673
+ self._close_link(link)
674
+ if not quiet:
675
+ self._set_state(self.RELEASED, why if link is not None else "")
676
+
677
+
678
+ def _job_key(job: Job) -> tuple[float, int]:
679
+ """Sort jobs by start time, then id (queued ones have no start time)."""
680
+ return (job.started or float("inf"), job.id)
681
+
682
+
683
+ __all__ = [
684
+ "ACTIVITY_HISTORY",
685
+ "DEFAULT_IDLE_TIMEOUT_S",
686
+ "DEFAULT_POLL_INTERVAL_S",
687
+ "DEFAULT_TASK_TIMEOUT_S",
688
+ "JOB_NOTIFY_INTERVAL_S",
689
+ "JOB_RETENTION_S",
690
+ "LOOP_TICK_S",
691
+ "MAX_POLL_INTERVAL_S",
692
+ "MIN_POLL_INTERVAL_S",
693
+ "STOP_JOIN_S",
694
+ "Job",
695
+ "Task",
696
+ "Worker",
697
+ ]