taskoverview-notifier 1.0.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.
@@ -0,0 +1,366 @@
1
+ """TaskOverview relay server.
2
+
3
+ Keeps the list of jobs, pushes it to every connected widget, and marks jobs stale
4
+ when the producer that owns them disappears or stops sending updates.
5
+
6
+ Run it with ``taskoverview-server`` or ``python -m taskoverview_notifier.server``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import asyncio
13
+ import json
14
+ import logging
15
+ import signal
16
+ import time
17
+ import uuid
18
+ from dataclasses import dataclass
19
+ from typing import Any, Literal
20
+
21
+ from pydantic import ValidationError
22
+ from websockets.asyncio.server import ServerConnection, broadcast, serve
23
+ from websockets.exceptions import ConnectionClosed
24
+
25
+ from .protocol import (
26
+ PROTOCOL_VERSION,
27
+ DismissJob,
28
+ EndAllJobs,
29
+ EndJob,
30
+ Heartbeat,
31
+ Hello,
32
+ NewJob,
33
+ Resync,
34
+ UpdateJob,
35
+ producer_message,
36
+ widget_message,
37
+ )
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ DEFAULT_HOST = "localhost"
42
+ DEFAULT_PORT = 6789
43
+
44
+ # Ping clients often so a hung or killed producer is noticed within ~20s.
45
+ PING_INTERVAL = 10.0
46
+ PING_TIMEOUT = 10.0
47
+ HELLO_TIMEOUT = 10.0
48
+
49
+ StaleReason = Literal["disconnected", "timeout"]
50
+
51
+
52
+ def _now_ms() -> int:
53
+ return int(time.time() * 1000)
54
+
55
+
56
+ @dataclass
57
+ class JobState:
58
+ id: str
59
+ owner: str
60
+ title: str
61
+ description: str
62
+ progress: float | None
63
+ stale_after: float | None
64
+ created_at: int
65
+ updated_at: int
66
+ last_seen: float # time.monotonic() of the last producer message for this job
67
+ state: Literal["active", "stale"] = "active"
68
+ stale_reason: StaleReason | None = None
69
+ stale_since: int | None = None
70
+
71
+ def touch(self) -> None:
72
+ self.updated_at = _now_ms()
73
+ self.last_seen = time.monotonic()
74
+
75
+ def to_wire(self) -> dict[str, Any]:
76
+ return {
77
+ "id": self.id,
78
+ "title": self.title,
79
+ "description": self.description,
80
+ "progress": self.progress,
81
+ "state": self.state,
82
+ "staleReason": self.stale_reason,
83
+ "staleAfter": self.stale_after,
84
+ "createdAt": self.created_at,
85
+ "updatedAt": self.updated_at,
86
+ "staleSince": self.stale_since,
87
+ }
88
+
89
+
90
+ class ProtocolError(Exception):
91
+ """A well-formed message that can't be applied (unknown job, wrong owner, …)."""
92
+
93
+
94
+ class Server:
95
+ def __init__(
96
+ self,
97
+ host: str = DEFAULT_HOST,
98
+ port: int = DEFAULT_PORT,
99
+ *,
100
+ idle_timeout: float | None = None,
101
+ check_interval: float = 1.0,
102
+ ) -> None:
103
+ self.host = host
104
+ self.port = port
105
+ self.idle_timeout = idle_timeout
106
+ self.check_interval = check_interval
107
+
108
+ self.jobs: dict[str, JobState] = {}
109
+ self.widgets: set[ServerConnection] = set()
110
+ self.producers: dict[str, set[ServerConnection]] = {}
111
+
112
+ self._stop = asyncio.Event()
113
+ self.ready = asyncio.Event()
114
+ self._idle_since = time.monotonic()
115
+
116
+ # --- lifecycle ------------------------------------------------------------
117
+
118
+ async def run(self) -> None:
119
+ async with serve(
120
+ self._handle,
121
+ self.host,
122
+ self.port,
123
+ ping_interval=PING_INTERVAL,
124
+ ping_timeout=PING_TIMEOUT,
125
+ ) as ws_server:
126
+ # Resolve the real port (useful when started with port=0 in tests).
127
+ self.port = next(iter(ws_server.sockets)).getsockname()[1]
128
+ logger.info("Listening on ws://%s:%d", self.host, self.port)
129
+ self.ready.set()
130
+ watchdog = asyncio.create_task(self._watchdog())
131
+ try:
132
+ await self._stop.wait()
133
+ finally:
134
+ watchdog.cancel()
135
+ logger.info("Server stopped")
136
+
137
+ def stop(self) -> None:
138
+ self._stop.set()
139
+
140
+ # --- connections ----------------------------------------------------------
141
+
142
+ async def _handle(self, conn: ServerConnection) -> None:
143
+ try:
144
+ raw = await asyncio.wait_for(conn.recv(), HELLO_TIMEOUT)
145
+ hello = Hello.model_validate_json(raw)
146
+ except (TimeoutError, ConnectionClosed):
147
+ return
148
+ except ValidationError as e:
149
+ await self._send_error(conn, f"Expected a hello message first: {e}")
150
+ await conn.close(code=1008, reason="missing hello")
151
+ return
152
+
153
+ if hello.protocol != PROTOCOL_VERSION:
154
+ msg = f"Unsupported protocol {hello.protocol}, this server speaks {PROTOCOL_VERSION}"
155
+ await self._send_error(conn, msg)
156
+ await conn.close(code=1008, reason="protocol mismatch")
157
+ return
158
+
159
+ try:
160
+ if hello.role == "widget":
161
+ await self._serve_widget(conn)
162
+ else:
163
+ await self._serve_producer(conn, hello.client_id or uuid.uuid4().hex)
164
+ except ConnectionClosed:
165
+ pass
166
+
167
+ async def _serve_widget(self, conn: ServerConnection) -> None:
168
+ logger.info("Widget connected (%d total)", len(self.widgets) + 1)
169
+ self.widgets.add(conn)
170
+ try:
171
+ await conn.send(self._snapshot())
172
+ async for raw in conn:
173
+ try:
174
+ msg = widget_message.validate_json(raw)
175
+ except ValidationError as e:
176
+ await self._send_error(conn, str(e))
177
+ continue
178
+ if isinstance(msg, DismissJob):
179
+ if msg.id in self.jobs:
180
+ logger.info("Widget dismissed job %r", msg.id)
181
+ self._remove(msg.id, "dismissed")
182
+ elif isinstance(msg, Resync):
183
+ await conn.send(self._snapshot())
184
+ finally:
185
+ self.widgets.discard(conn)
186
+ logger.info("Widget disconnected")
187
+
188
+ async def _serve_producer(self, conn: ServerConnection, client_id: str) -> None:
189
+ logger.info("Producer %s connected", client_id[:8])
190
+ self.producers.setdefault(client_id, set()).add(conn)
191
+ self._reclaim(client_id)
192
+ try:
193
+ async for raw in conn:
194
+ try:
195
+ self._apply(client_id, producer_message.validate_json(raw))
196
+ except (ValidationError, ProtocolError) as e:
197
+ logger.warning("Rejected message from %s: %s", client_id[:8], e)
198
+ await self._send_error(conn, str(e))
199
+ finally:
200
+ conns = self.producers.get(client_id, set())
201
+ conns.discard(conn)
202
+ if not conns:
203
+ self.producers.pop(client_id, None)
204
+ self._mark_stale([j for j in self.jobs.values() if j.owner == client_id], "disconnected")
205
+ logger.info("Producer %s disconnected", client_id[:8])
206
+
207
+ # --- state changes --------------------------------------------------------
208
+
209
+ def _apply(self, client_id: str, msg: Any) -> None:
210
+ if isinstance(msg, NewJob):
211
+ spec = msg.job
212
+ existing = self.jobs.get(spec.id)
213
+ if existing and existing.owner != client_id and existing.state != "stale":
214
+ raise ProtocolError(f"Job {spec.id!r} already exists and belongs to another producer")
215
+ now = _now_ms()
216
+ job = JobState(
217
+ id=spec.id,
218
+ owner=client_id,
219
+ title=spec.title,
220
+ description=spec.description,
221
+ progress=spec.progress,
222
+ stale_after=spec.stale_after,
223
+ created_at=existing.created_at if existing else now,
224
+ updated_at=now,
225
+ last_seen=time.monotonic(),
226
+ )
227
+ self.jobs[spec.id] = job
228
+ self._publish(job)
229
+ elif isinstance(msg, UpdateJob):
230
+ job = self._owned(client_id, msg.id)
231
+ if "title" in msg.model_fields_set and msg.title is not None:
232
+ job.title = msg.title
233
+ if "description" in msg.model_fields_set and msg.description is not None:
234
+ job.description = msg.description
235
+ if "progress" in msg.model_fields_set:
236
+ job.progress = msg.progress
237
+ self._revive(job)
238
+ self._publish(job)
239
+ elif isinstance(msg, Heartbeat):
240
+ job = self._owned(client_id, msg.id)
241
+ was_stale = job.state == "stale"
242
+ self._revive(job)
243
+ if was_stale:
244
+ self._publish(job)
245
+ elif isinstance(msg, EndJob):
246
+ if msg.id not in self.jobs:
247
+ raise ProtocolError(f"Unknown job {msg.id!r}")
248
+ self._remove(msg.id, "ended")
249
+ elif isinstance(msg, EndAllJobs):
250
+ for job_id in [j.id for j in self.jobs.values() if j.owner == client_id]:
251
+ self._remove(job_id, "ended")
252
+
253
+ def _owned(self, client_id: str, job_id: str) -> JobState:
254
+ job = self.jobs.get(job_id)
255
+ if job is None:
256
+ raise ProtocolError(f"Unknown job {job_id!r}")
257
+ if job.owner != client_id:
258
+ raise ProtocolError(f"Job {job_id!r} belongs to another producer")
259
+ return job
260
+
261
+ @staticmethod
262
+ def _revive(job: JobState) -> None:
263
+ job.touch()
264
+ job.state = "active"
265
+ job.stale_reason = None
266
+ job.stale_since = None
267
+
268
+ def _reclaim(self, client_id: str) -> None:
269
+ for job in self.jobs.values():
270
+ if job.owner == client_id and job.state == "stale":
271
+ logger.info("Job %r reclaimed by its producer", job.id)
272
+ self._revive(job)
273
+ self._publish(job)
274
+
275
+ def _mark_stale(self, jobs: list[JobState], reason: StaleReason) -> None:
276
+ for job in jobs:
277
+ if job.state == "stale":
278
+ continue
279
+ logger.warning("Job %r is stale (%s)", job.id, reason)
280
+ job.state = "stale"
281
+ job.stale_reason = reason
282
+ job.stale_since = _now_ms()
283
+ self._publish(job)
284
+
285
+ def _remove(self, job_id: str, reason: Literal["ended", "dismissed"]) -> None:
286
+ if self.jobs.pop(job_id, None) is not None:
287
+ broadcast(self.widgets, json.dumps({"type": "jobRemoved", "id": job_id, "reason": reason}))
288
+
289
+ def _publish(self, job: JobState) -> None:
290
+ broadcast(self.widgets, json.dumps({"type": "jobUpserted", "job": job.to_wire()}))
291
+
292
+ def _snapshot(self) -> str:
293
+ return json.dumps(
294
+ {
295
+ "type": "snapshot",
296
+ "jobs": [j.to_wire() for j in self.jobs.values()],
297
+ "serverTime": _now_ms(),
298
+ }
299
+ )
300
+
301
+ @staticmethod
302
+ async def _send_error(conn: ServerConnection, message: str) -> None:
303
+ try:
304
+ await conn.send(json.dumps({"type": "error", "message": message}))
305
+ except ConnectionClosed:
306
+ pass
307
+
308
+ # --- periodic checks ------------------------------------------------------
309
+
310
+ async def _watchdog(self) -> None:
311
+ while True:
312
+ await asyncio.sleep(self.check_interval)
313
+ self.check_timeouts()
314
+ if self.idle_timeout is not None:
315
+ if self.producers or self.jobs:
316
+ self._idle_since = time.monotonic()
317
+ elif time.monotonic() - self._idle_since > self.idle_timeout:
318
+ logger.info("Idle for %.0fs, shutting down", self.idle_timeout)
319
+ self.stop()
320
+ return
321
+
322
+ def check_timeouts(self) -> None:
323
+ now = time.monotonic()
324
+ self._mark_stale(
325
+ [
326
+ j
327
+ for j in self.jobs.values()
328
+ if j.state == "active" and j.stale_after is not None and now - j.last_seen > j.stale_after
329
+ ],
330
+ "timeout",
331
+ )
332
+
333
+
334
+ def main(argv: list[str] | None = None) -> int:
335
+ parser = argparse.ArgumentParser(prog="taskoverview-server", description=__doc__.splitlines()[0])
336
+ parser.add_argument("--host", default=DEFAULT_HOST)
337
+ parser.add_argument("--port", type=int, default=DEFAULT_PORT)
338
+ parser.add_argument(
339
+ "--idle-timeout",
340
+ type=float,
341
+ default=None,
342
+ metavar="SECONDS",
343
+ help="exit after this long with no producers and no jobs (default: run forever)",
344
+ )
345
+ parser.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"])
346
+ args = parser.parse_args(argv)
347
+
348
+ logging.basicConfig(level=args.log_level, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
349
+
350
+ async def _run() -> None:
351
+ server = Server(args.host, args.port, idle_timeout=args.idle_timeout)
352
+ loop = asyncio.get_running_loop()
353
+ for sig in (signal.SIGINT, signal.SIGTERM):
354
+ loop.add_signal_handler(sig, server.stop)
355
+ await server.run()
356
+
357
+ try:
358
+ asyncio.run(_run())
359
+ except OSError as e:
360
+ logger.error("Could not start server on %s:%d: %s", args.host, args.port, e)
361
+ return 1
362
+ return 0
363
+
364
+
365
+ if __name__ == "__main__":
366
+ raise SystemExit(main())
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.5
2
+ Name: taskoverview-notifier
3
+ Version: 1.0.0
4
+ Summary: Show progress cards for long-running Python jobs on your macOS desktop via the TaskOverview Übersicht widget.
5
+ Project-URL: Homepage, https://github.com/paraversal/UebersichtTaskOverview
6
+ Project-URL: Issues, https://github.com/paraversal/UebersichtTaskOverview/issues
7
+ Project-URL: Protocol, https://github.com/paraversal/UebersichtTaskOverview/blob/main/PROTOCOL.md
8
+ Author-email: paraversal <104731964+paraversal@users.noreply.github.com>
9
+ License-Expression: GPL-3.0-or-later
10
+ License-File: LICENSE
11
+ Keywords: macos,notifications,progress,ubersicht,uebersicht,widget
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: MacOS X
14
+ Classifier: Framework :: AsyncIO
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: pydantic>=2.6
22
+ Requires-Dist: websockets>=14.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # taskoverview-notifier
26
+
27
+ Show progress cards for long-running Python jobs on your macOS desktop, using the
28
+ [TaskOverview](https://github.com/paraversal/UebersichtTaskOverview) Übersicht widget.
29
+
30
+ ```bash
31
+ pip install taskoverview-notifier
32
+ ```
33
+
34
+ You also need [Übersicht](https://tracesof.net/uebersicht/) and the TaskOverview widget
35
+ (see the main repository's README).
36
+
37
+ ## Usage
38
+
39
+ ```python
40
+ import asyncio
41
+ from taskoverview_notifier import Notifier
42
+
43
+ async def main():
44
+ async with Notifier() as notifier:
45
+ async with notifier.job("Downloading", "model.bin", stale_after=30) as job:
46
+ for i in range(101):
47
+ await job.update(progress=i / 100, description=f"{i}%")
48
+ await asyncio.sleep(0.05)
49
+
50
+ asyncio.run(main())
51
+ ```
52
+
53
+ Without asyncio:
54
+
55
+ ```python
56
+ from taskoverview_notifier import SyncNotifier
57
+
58
+ with SyncNotifier() as notifier:
59
+ with notifier.job("Rendering frames") as job:
60
+ for i, frame in enumerate(frames):
61
+ render(frame)
62
+ job.update(progress=(i + 1) / len(frames))
63
+ ```
64
+
65
+ You can also manage jobs by hand: `job = await notifier.new_job(...)`, then
66
+ `job.update(...)`, `job.heartbeat()` and `job.end()`.
67
+
68
+ - `progress` is a float from `0` to `1`. Leave it out for a card with no progress bar.
69
+ - `notifier.job(...)` ends the card when the block exits, including on an exception.
70
+ - `end_all_jobs()` ends every job created by that notifier (not other processes' jobs).
71
+
72
+ ## Stale jobs
73
+
74
+ If your process dies, gets killed or hangs while a job is open, the card is marked
75
+ **stale** instead of hanging around forever, and the widget shows **Refresh** and
76
+ **Dismiss** buttons on it.
77
+
78
+ - **Disconnected:** the connection to the server dropped. A crash is noticed
79
+ immediately. A hung asyncio event loop is noticed after about 20 seconds, when
80
+ WebSocket pings time out.
81
+ - **Timeout:** pass `stale_after=<seconds>` and the card goes stale if no update or
82
+ `heartbeat()` arrives in that time. Use this to catch a process that is alive
83
+ but stuck, for example in a blocking call.
84
+
85
+ A stale card becomes active again as soon as its process sends another update. If
86
+ a new process creates a job with the same `id` as a stale one, it takes the card over.
87
+
88
+ ## The server
89
+
90
+ Widgets and scripts talk through a small local WebSocket server on `localhost:6789`.
91
+ The first `Notifier` that finds no server starts one as a detached background
92
+ process. That server exits after 10 minutes with no jobs and no connected scripts.
93
+ Logs go to `~/Library/Logs/taskoverview-notifier/server.log`.
94
+
95
+ To keep it running permanently instead, run `taskoverview-server`, for example as a
96
+ launchd agent. Use `taskoverview-server --help` to see the options.
97
+
98
+ The wire protocol is documented in
99
+ [PROTOCOL.md](https://github.com/paraversal/UebersichtTaskOverview/blob/main/PROTOCOL.md).
@@ -0,0 +1,11 @@
1
+ taskoverview_notifier/__init__.py,sha256=CfrC09bs7dCxVumZwRZvXxgFM-cLN1VS6sAtf_eX2Dw,295
2
+ taskoverview_notifier/_spawn.py,sha256=4dOEmBkGlHHxHTAzdWrdyP0EGXQU7op_T_5jhcxSm7Y,2316
3
+ taskoverview_notifier/client.py,sha256=vqZ9oNsMa8nGmUPH5yfa4e4vP9j2rV6JJ5W9rICb9x4,17260
4
+ taskoverview_notifier/protocol.py,sha256=9Nqhcqq0B1MJTrJe5g_y-hz2aMLsiTDgF3ny0xweQeI,2346
5
+ taskoverview_notifier/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ taskoverview_notifier/server.py,sha256=wsDZDsrQQ6AVddE07VeroBTUldQZemLOls4ebQpREQc,12875
7
+ taskoverview_notifier-1.0.0.dist-info/METADATA,sha256=MX9EaOe7jpYGwcrdgyeWPXtqwFh8XoFWUnw-Ff6oGXc,3909
8
+ taskoverview_notifier-1.0.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
9
+ taskoverview_notifier-1.0.0.dist-info/entry_points.txt,sha256=nMCP2YkfCJNdcf7x-EVOdcRPhthepkMRkxBRi9Uw2qs,74
10
+ taskoverview_notifier-1.0.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
11
+ taskoverview_notifier-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ taskoverview-server = taskoverview_notifier.server:main