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.
- taskoverview_notifier/__init__.py +8 -0
- taskoverview_notifier/_spawn.py +74 -0
- taskoverview_notifier/client.py +497 -0
- taskoverview_notifier/protocol.py +89 -0
- taskoverview_notifier/py.typed +0 -0
- taskoverview_notifier/server.py +366 -0
- taskoverview_notifier-1.0.0.dist-info/METADATA +99 -0
- taskoverview_notifier-1.0.0.dist-info/RECORD +11 -0
- taskoverview_notifier-1.0.0.dist-info/WHEEL +4 -0
- taskoverview_notifier-1.0.0.dist-info/entry_points.txt +2 -0
- taskoverview_notifier-1.0.0.dist-info/licenses/LICENSE +674 -0
|
@@ -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,,
|