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,8 @@
|
|
|
1
|
+
"""Report progress of long-running jobs to the TaskOverview Übersicht widget."""
|
|
2
|
+
|
|
3
|
+
from ._spawn import ServerUnavailable
|
|
4
|
+
from .client import Job, Notifier, SyncJob, SyncNotifier
|
|
5
|
+
|
|
6
|
+
__version__ = "1.0.0"
|
|
7
|
+
|
|
8
|
+
__all__ = ["Job", "Notifier", "ServerUnavailable", "SyncJob", "SyncNotifier", "__version__"]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Start the server as a detached background process when none is running."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# An auto-started server exits after this long with no producers and no jobs.
|
|
16
|
+
AUTO_SPAWN_IDLE_TIMEOUT = 600
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ServerUnavailable(ConnectionError):
|
|
20
|
+
"""No server is reachable and one could not be started."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def server_log_path() -> Path:
|
|
24
|
+
if sys.platform == "darwin":
|
|
25
|
+
base = Path.home() / "Library" / "Logs" / "taskoverview-notifier"
|
|
26
|
+
else:
|
|
27
|
+
base = Path(tempfile.gettempdir()) / "taskoverview-notifier"
|
|
28
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
return base / "server.log"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_listening(host: str, port: int, timeout: float = 0.3) -> bool:
|
|
33
|
+
try:
|
|
34
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
35
|
+
return True
|
|
36
|
+
except OSError:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def ensure_server(host: str, port: int, timeout: float = 5.0) -> None:
|
|
41
|
+
"""Make sure a server is listening on host:port, starting one if needed."""
|
|
42
|
+
if is_listening(host, port):
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
log_path = server_log_path()
|
|
46
|
+
logger.info("No TaskOverview server on %s:%d, starting one (log: %s)", host, port, log_path)
|
|
47
|
+
with open(log_path, "ab") as log:
|
|
48
|
+
subprocess.Popen(
|
|
49
|
+
[
|
|
50
|
+
sys.executable,
|
|
51
|
+
"-m",
|
|
52
|
+
"taskoverview_notifier.server",
|
|
53
|
+
"--host",
|
|
54
|
+
host,
|
|
55
|
+
"--port",
|
|
56
|
+
str(port),
|
|
57
|
+
"--idle-timeout",
|
|
58
|
+
str(AUTO_SPAWN_IDLE_TIMEOUT),
|
|
59
|
+
],
|
|
60
|
+
stdin=subprocess.DEVNULL,
|
|
61
|
+
stdout=log,
|
|
62
|
+
stderr=log,
|
|
63
|
+
start_new_session=True, # survive this process exiting or crashing
|
|
64
|
+
close_fds=True,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# If two scripts race to start a server, one fails to bind and exits;
|
|
68
|
+
# both then connect to the winner, so just wait for *a* listener.
|
|
69
|
+
deadline = time.monotonic() + timeout
|
|
70
|
+
while time.monotonic() < deadline:
|
|
71
|
+
if is_listening(host, port):
|
|
72
|
+
return
|
|
73
|
+
time.sleep(0.05)
|
|
74
|
+
raise ServerUnavailable(f"Started a server but nothing is listening on {host}:{port}; see {log_path}")
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""Producer clients: report jobs to the TaskOverview widget.
|
|
2
|
+
|
|
3
|
+
``Notifier`` is the asyncio client and ``SyncNotifier`` is the blocking one. Both keep one
|
|
4
|
+
persistent connection, reconnect automatically, and re-send their open jobs after a
|
|
5
|
+
reconnect, so a server restart doesn't lose cards.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import AsyncIterator, Iterator
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from types import TracebackType
|
|
20
|
+
from typing import Any, Self
|
|
21
|
+
|
|
22
|
+
from websockets.asyncio.client import ClientConnection
|
|
23
|
+
from websockets.asyncio.client import connect as connect_async
|
|
24
|
+
from websockets.exceptions import ConnectionClosed, WebSocketException
|
|
25
|
+
from websockets.protocol import State
|
|
26
|
+
from websockets.sync.client import ClientConnection as SyncClientConnection
|
|
27
|
+
from websockets.sync.client import connect as connect_sync
|
|
28
|
+
|
|
29
|
+
from ._spawn import ensure_server
|
|
30
|
+
from .protocol import PROTOCOL_VERSION
|
|
31
|
+
from .server import DEFAULT_HOST, DEFAULT_PORT, PING_INTERVAL, PING_TIMEOUT
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
RECONNECT_MIN = 0.5 # seconds
|
|
36
|
+
RECONNECT_MAX = 10.0
|
|
37
|
+
|
|
38
|
+
# Errors that mean "the server isn't reachable right now".
|
|
39
|
+
_CONNECTION_ERRORS = (OSError, WebSocketException)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _check_progress(progress: float | None) -> None:
|
|
43
|
+
if progress is not None and not 0.0 <= progress <= 1.0:
|
|
44
|
+
raise ValueError(f"progress must be between 0 and 1, got {progress!r}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class _JobRecord:
|
|
49
|
+
id: str
|
|
50
|
+
title: str
|
|
51
|
+
description: str
|
|
52
|
+
progress: float | None
|
|
53
|
+
stale_after: float | None
|
|
54
|
+
|
|
55
|
+
def new_job_message(self) -> dict[str, Any]:
|
|
56
|
+
return {
|
|
57
|
+
"type": "newJob",
|
|
58
|
+
"job": {
|
|
59
|
+
"id": self.id,
|
|
60
|
+
"title": self.title,
|
|
61
|
+
"description": self.description,
|
|
62
|
+
"progress": self.progress,
|
|
63
|
+
"staleAfter": self.stale_after,
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _NotifierBase:
|
|
69
|
+
"""Message building and open-job bookkeeping shared by both clients."""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
host: str = DEFAULT_HOST,
|
|
74
|
+
port: int = DEFAULT_PORT,
|
|
75
|
+
*,
|
|
76
|
+
spawn_server: bool = True,
|
|
77
|
+
client_id: str | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
self.host = host
|
|
80
|
+
self.port = port
|
|
81
|
+
self.spawn_server = spawn_server
|
|
82
|
+
self.client_id = client_id or uuid.uuid4().hex
|
|
83
|
+
self._jobs: dict[str, _JobRecord] = {}
|
|
84
|
+
# Jobs ended while disconnected; their endJob is sent after reconnecting.
|
|
85
|
+
self._unsent_ends: set[str] = set()
|
|
86
|
+
self._closed = True
|
|
87
|
+
self._reconnecting = False
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def url(self) -> str:
|
|
91
|
+
return f"ws://{self.host}:{self.port}"
|
|
92
|
+
|
|
93
|
+
def _hello(self) -> str:
|
|
94
|
+
return json.dumps(
|
|
95
|
+
{"type": "hello", "role": "producer", "protocol": PROTOCOL_VERSION, "clientId": self.client_id}
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def _resend_messages(self) -> list[str]:
|
|
99
|
+
"""Messages that restore this notifier's jobs on a fresh connection."""
|
|
100
|
+
msgs = [json.dumps(job.new_job_message()) for job in self._jobs.values()]
|
|
101
|
+
msgs += [json.dumps({"type": "endJob", "id": id}) for id in self._unsent_ends]
|
|
102
|
+
return msgs
|
|
103
|
+
|
|
104
|
+
def _end(self, ids: list[str]) -> None:
|
|
105
|
+
for id in ids:
|
|
106
|
+
self._jobs.pop(id, None)
|
|
107
|
+
self._unsent_ends.update(ids)
|
|
108
|
+
|
|
109
|
+
def _sent(self, msg: dict[str, Any]) -> None:
|
|
110
|
+
if msg["type"] == "endJob":
|
|
111
|
+
self._unsent_ends.discard(msg["id"])
|
|
112
|
+
elif msg["type"] == "endAllJobs":
|
|
113
|
+
self._unsent_ends.clear()
|
|
114
|
+
|
|
115
|
+
def _offline(self, msg: dict[str, Any], error: BaseException | None = None) -> None:
|
|
116
|
+
# Best effort: never crash the caller's script because the display is unreachable.
|
|
117
|
+
# Open jobs (with their latest state) and pending ends are replayed on reconnect.
|
|
118
|
+
if error is not None:
|
|
119
|
+
logger.warning("TaskOverview server unreachable (%s); retrying in the background", error)
|
|
120
|
+
else:
|
|
121
|
+
logger.debug("Reconnecting; %s will be replayed later", msg["type"])
|
|
122
|
+
|
|
123
|
+
def _new(
|
|
124
|
+
self, title: str, description: str, progress: float | None, stale_after: float | None, id: str | None
|
|
125
|
+
) -> _JobRecord:
|
|
126
|
+
_check_progress(progress)
|
|
127
|
+
if stale_after is not None and stale_after <= 0:
|
|
128
|
+
raise ValueError("stale_after must be positive")
|
|
129
|
+
record = _JobRecord(id or uuid.uuid4().hex, title, description, progress, stale_after)
|
|
130
|
+
self._jobs[record.id] = record
|
|
131
|
+
return record
|
|
132
|
+
|
|
133
|
+
def _update(
|
|
134
|
+
self, id: str, title: str | None, description: str | None, progress: float | None
|
|
135
|
+
) -> dict[str, Any]:
|
|
136
|
+
if title is None and description is None and progress is None:
|
|
137
|
+
raise ValueError("update needs at least one of title, description or progress")
|
|
138
|
+
_check_progress(progress)
|
|
139
|
+
msg: dict[str, Any] = {"type": "updateJob", "id": id}
|
|
140
|
+
record = self._jobs.get(id)
|
|
141
|
+
for key, value in (("title", title), ("description", description), ("progress", progress)):
|
|
142
|
+
if value is not None:
|
|
143
|
+
msg[key] = value
|
|
144
|
+
if record:
|
|
145
|
+
setattr(record, key, value)
|
|
146
|
+
return msg
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def _log_server_message(raw: str | bytes) -> None:
|
|
150
|
+
try:
|
|
151
|
+
msg = json.loads(raw)
|
|
152
|
+
except ValueError:
|
|
153
|
+
return
|
|
154
|
+
if msg.get("type") == "error":
|
|
155
|
+
logger.warning("TaskOverview server rejected a message: %s", msg.get("message"))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# --- asyncio ------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class Job:
|
|
162
|
+
"""Handle for a job created with :meth:`Notifier.new_job`."""
|
|
163
|
+
|
|
164
|
+
def __init__(self, notifier: Notifier, id: str) -> None:
|
|
165
|
+
self._notifier = notifier
|
|
166
|
+
self.id = id
|
|
167
|
+
|
|
168
|
+
async def update(
|
|
169
|
+
self, *, title: str | None = None, description: str | None = None, progress: float | None = None
|
|
170
|
+
) -> None:
|
|
171
|
+
await self._notifier.update_job(self.id, title=title, description=description, progress=progress)
|
|
172
|
+
|
|
173
|
+
async def heartbeat(self) -> None:
|
|
174
|
+
await self._notifier.heartbeat(self.id)
|
|
175
|
+
|
|
176
|
+
async def end(self) -> None:
|
|
177
|
+
await self._notifier.end_job(self.id)
|
|
178
|
+
|
|
179
|
+
def __repr__(self) -> str:
|
|
180
|
+
return f"Job(id={self.id!r})"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class Notifier(_NotifierBase):
|
|
184
|
+
"""Asyncio client.
|
|
185
|
+
|
|
186
|
+
>>> async with Notifier() as notifier:
|
|
187
|
+
... async with notifier.job("Downloading", "model.bin") as job:
|
|
188
|
+
... await job.update(progress=0.5)
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
_ws: ClientConnection | None = None
|
|
192
|
+
_reader: asyncio.Task[None] | None = None
|
|
193
|
+
_lock: asyncio.Lock | None = None
|
|
194
|
+
|
|
195
|
+
async def __aenter__(self) -> Self:
|
|
196
|
+
await self.connect()
|
|
197
|
+
return self
|
|
198
|
+
|
|
199
|
+
async def __aexit__(self, *_: object) -> None:
|
|
200
|
+
await self.close()
|
|
201
|
+
|
|
202
|
+
async def connect(self) -> None:
|
|
203
|
+
"""Connect, starting a server if needed. Raises if no server can be reached."""
|
|
204
|
+
self._closed = False
|
|
205
|
+
self._lock = asyncio.Lock()
|
|
206
|
+
await self._ensure_connected()
|
|
207
|
+
|
|
208
|
+
async def close(self) -> None:
|
|
209
|
+
"""Disconnect. Jobs that are still open will show up as stale on the widget."""
|
|
210
|
+
self._closed = True
|
|
211
|
+
if self._reader:
|
|
212
|
+
self._reader.cancel()
|
|
213
|
+
self._reader = None
|
|
214
|
+
if self._ws:
|
|
215
|
+
await self._ws.close()
|
|
216
|
+
self._ws = None
|
|
217
|
+
|
|
218
|
+
async def _ensure_connected(self) -> ClientConnection:
|
|
219
|
+
assert self._lock is not None
|
|
220
|
+
async with self._lock:
|
|
221
|
+
if self._ws is not None and self._ws.state is State.OPEN:
|
|
222
|
+
return self._ws
|
|
223
|
+
if self._closed:
|
|
224
|
+
raise RuntimeError("Notifier is closed")
|
|
225
|
+
if self.spawn_server:
|
|
226
|
+
await asyncio.to_thread(ensure_server, self.host, self.port)
|
|
227
|
+
ws = await connect_async(self.url, ping_interval=PING_INTERVAL, ping_timeout=PING_TIMEOUT)
|
|
228
|
+
await ws.send(self._hello())
|
|
229
|
+
for data in self._resend_messages():
|
|
230
|
+
await ws.send(data)
|
|
231
|
+
self._unsent_ends.clear()
|
|
232
|
+
self._ws = ws
|
|
233
|
+
self._reader = asyncio.create_task(self._read_loop(ws))
|
|
234
|
+
return ws
|
|
235
|
+
|
|
236
|
+
async def _read_loop(self, ws: ClientConnection) -> None:
|
|
237
|
+
with contextlib.suppress(ConnectionClosed):
|
|
238
|
+
async for raw in ws:
|
|
239
|
+
self._log_server_message(raw)
|
|
240
|
+
if self._closed or self._ws is not ws:
|
|
241
|
+
return
|
|
242
|
+
# The server went away: reconnect in the background (restarting it if we may),
|
|
243
|
+
# so the cards come back even if this script never sends another update.
|
|
244
|
+
logger.warning("Lost connection to TaskOverview server, reconnecting")
|
|
245
|
+
self._reconnecting = True
|
|
246
|
+
delay = RECONNECT_MIN
|
|
247
|
+
try:
|
|
248
|
+
while not self._closed:
|
|
249
|
+
try:
|
|
250
|
+
await self._ensure_connected()
|
|
251
|
+
logger.info("Reconnected to TaskOverview server")
|
|
252
|
+
return
|
|
253
|
+
except _CONNECTION_ERRORS as e:
|
|
254
|
+
logger.debug("Reconnect failed: %s", e)
|
|
255
|
+
await asyncio.sleep(delay)
|
|
256
|
+
delay = min(delay * 2, RECONNECT_MAX)
|
|
257
|
+
finally:
|
|
258
|
+
self._reconnecting = False
|
|
259
|
+
|
|
260
|
+
async def _send(self, msg: dict[str, Any]) -> None:
|
|
261
|
+
if self._closed:
|
|
262
|
+
raise RuntimeError("Notifier is not connected; use `async with Notifier()` or call connect()")
|
|
263
|
+
if self._reconnecting:
|
|
264
|
+
return self._offline(msg)
|
|
265
|
+
try:
|
|
266
|
+
ws = await self._ensure_connected()
|
|
267
|
+
await ws.send(json.dumps(msg))
|
|
268
|
+
except _CONNECTION_ERRORS as e:
|
|
269
|
+
return self._offline(msg, e)
|
|
270
|
+
self._sent(msg)
|
|
271
|
+
|
|
272
|
+
async def new_job(
|
|
273
|
+
self,
|
|
274
|
+
title: str,
|
|
275
|
+
description: str = "",
|
|
276
|
+
*,
|
|
277
|
+
progress: float | None = None,
|
|
278
|
+
stale_after: float | None = None,
|
|
279
|
+
id: str | None = None,
|
|
280
|
+
) -> Job:
|
|
281
|
+
"""Show a new card. ``stale_after`` marks it stale if no update arrives for that many seconds."""
|
|
282
|
+
record = self._new(title, description, progress, stale_after, id)
|
|
283
|
+
await self._send(record.new_job_message())
|
|
284
|
+
return Job(self, record.id)
|
|
285
|
+
|
|
286
|
+
async def update_job(
|
|
287
|
+
self,
|
|
288
|
+
id: str,
|
|
289
|
+
*,
|
|
290
|
+
title: str | None = None,
|
|
291
|
+
description: str | None = None,
|
|
292
|
+
progress: float | None = None,
|
|
293
|
+
) -> None:
|
|
294
|
+
await self._send(self._update(id, title, description, progress))
|
|
295
|
+
|
|
296
|
+
async def heartbeat(self, id: str) -> None:
|
|
297
|
+
await self._send({"type": "heartbeat", "id": id})
|
|
298
|
+
|
|
299
|
+
async def end_job(self, id: str) -> None:
|
|
300
|
+
self._end([id])
|
|
301
|
+
await self._send({"type": "endJob", "id": id})
|
|
302
|
+
|
|
303
|
+
async def end_all_jobs(self) -> None:
|
|
304
|
+
"""End every job this notifier created."""
|
|
305
|
+
self._end(list(self._jobs))
|
|
306
|
+
await self._send({"type": "endAllJobs"})
|
|
307
|
+
|
|
308
|
+
@contextlib.asynccontextmanager
|
|
309
|
+
async def job(
|
|
310
|
+
self,
|
|
311
|
+
title: str,
|
|
312
|
+
description: str = "",
|
|
313
|
+
*,
|
|
314
|
+
progress: float | None = None,
|
|
315
|
+
stale_after: float | None = None,
|
|
316
|
+
id: str | None = None,
|
|
317
|
+
) -> AsyncIterator[Job]:
|
|
318
|
+
"""Create a job and end it when the block exits, even on error."""
|
|
319
|
+
job = await self.new_job(title, description, progress=progress, stale_after=stale_after, id=id)
|
|
320
|
+
try:
|
|
321
|
+
yield job
|
|
322
|
+
finally:
|
|
323
|
+
await job.end()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# --- blocking -----------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class SyncJob:
|
|
330
|
+
"""Handle for a job created with :meth:`SyncNotifier.new_job`."""
|
|
331
|
+
|
|
332
|
+
def __init__(self, notifier: SyncNotifier, id: str) -> None:
|
|
333
|
+
self._notifier = notifier
|
|
334
|
+
self.id = id
|
|
335
|
+
|
|
336
|
+
def update(
|
|
337
|
+
self, *, title: str | None = None, description: str | None = None, progress: float | None = None
|
|
338
|
+
) -> None:
|
|
339
|
+
self._notifier.update_job(self.id, title=title, description=description, progress=progress)
|
|
340
|
+
|
|
341
|
+
def heartbeat(self) -> None:
|
|
342
|
+
self._notifier.heartbeat(self.id)
|
|
343
|
+
|
|
344
|
+
def end(self) -> None:
|
|
345
|
+
self._notifier.end_job(self.id)
|
|
346
|
+
|
|
347
|
+
def __repr__(self) -> str:
|
|
348
|
+
return f"SyncJob(id={self.id!r})"
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class SyncNotifier(_NotifierBase):
|
|
352
|
+
"""Blocking client, for scripts that don't use asyncio.
|
|
353
|
+
|
|
354
|
+
>>> with SyncNotifier() as notifier:
|
|
355
|
+
... with notifier.job("Rendering") as job:
|
|
356
|
+
... job.update(progress=0.5)
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
_ws: SyncClientConnection | None = None
|
|
360
|
+
_stack: contextlib.ExitStack | None = None
|
|
361
|
+
|
|
362
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
363
|
+
super().__init__(*args, **kwargs)
|
|
364
|
+
self._lock = threading.RLock()
|
|
365
|
+
|
|
366
|
+
def __enter__(self) -> Self:
|
|
367
|
+
self.connect()
|
|
368
|
+
return self
|
|
369
|
+
|
|
370
|
+
def __exit__(
|
|
371
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
372
|
+
) -> None:
|
|
373
|
+
self.close()
|
|
374
|
+
|
|
375
|
+
def connect(self) -> None:
|
|
376
|
+
"""Connect, starting a server if needed. Raises if no server can be reached."""
|
|
377
|
+
self._closed = False
|
|
378
|
+
self._ensure_connected()
|
|
379
|
+
|
|
380
|
+
def close(self) -> None:
|
|
381
|
+
"""Disconnect. Jobs that are still open will show up as stale on the widget."""
|
|
382
|
+
self._closed = True
|
|
383
|
+
with self._lock:
|
|
384
|
+
if self._stack:
|
|
385
|
+
self._stack.close()
|
|
386
|
+
self._ws = self._stack = None
|
|
387
|
+
|
|
388
|
+
def _ensure_connected(self) -> SyncClientConnection:
|
|
389
|
+
with self._lock:
|
|
390
|
+
if self._ws is not None and self._ws.state is State.OPEN:
|
|
391
|
+
return self._ws
|
|
392
|
+
if self._closed:
|
|
393
|
+
raise RuntimeError("SyncNotifier is closed")
|
|
394
|
+
if self._stack:
|
|
395
|
+
self._stack.close()
|
|
396
|
+
if self.spawn_server:
|
|
397
|
+
ensure_server(self.host, self.port)
|
|
398
|
+
stack = contextlib.ExitStack()
|
|
399
|
+
ws = stack.enter_context(
|
|
400
|
+
connect_sync(self.url, ping_interval=PING_INTERVAL, ping_timeout=PING_TIMEOUT)
|
|
401
|
+
)
|
|
402
|
+
ws.send(self._hello())
|
|
403
|
+
for data in self._resend_messages():
|
|
404
|
+
ws.send(data)
|
|
405
|
+
self._unsent_ends.clear()
|
|
406
|
+
self._ws, self._stack = ws, stack
|
|
407
|
+
threading.Thread(
|
|
408
|
+
target=self._read_loop, args=(ws,), daemon=True, name="taskoverview-reader"
|
|
409
|
+
).start()
|
|
410
|
+
return ws
|
|
411
|
+
|
|
412
|
+
def _read_loop(self, ws: SyncClientConnection) -> None:
|
|
413
|
+
with contextlib.suppress(ConnectionClosed):
|
|
414
|
+
for raw in ws:
|
|
415
|
+
self._log_server_message(raw)
|
|
416
|
+
if self._closed or self._ws is not ws:
|
|
417
|
+
return
|
|
418
|
+
logger.warning("Lost connection to TaskOverview server, reconnecting")
|
|
419
|
+
self._reconnecting = True
|
|
420
|
+
delay = RECONNECT_MIN
|
|
421
|
+
try:
|
|
422
|
+
while not self._closed:
|
|
423
|
+
try:
|
|
424
|
+
self._ensure_connected()
|
|
425
|
+
logger.info("Reconnected to TaskOverview server")
|
|
426
|
+
return
|
|
427
|
+
except _CONNECTION_ERRORS as e:
|
|
428
|
+
logger.debug("Reconnect failed: %s", e)
|
|
429
|
+
time.sleep(delay)
|
|
430
|
+
delay = min(delay * 2, RECONNECT_MAX)
|
|
431
|
+
finally:
|
|
432
|
+
self._reconnecting = False
|
|
433
|
+
|
|
434
|
+
def _send(self, msg: dict[str, Any]) -> None:
|
|
435
|
+
if self._closed:
|
|
436
|
+
raise RuntimeError("SyncNotifier is not connected; use `with SyncNotifier()` or call connect()")
|
|
437
|
+
if self._reconnecting:
|
|
438
|
+
return self._offline(msg)
|
|
439
|
+
try:
|
|
440
|
+
ws = self._ensure_connected()
|
|
441
|
+
ws.send(json.dumps(msg))
|
|
442
|
+
except _CONNECTION_ERRORS as e:
|
|
443
|
+
return self._offline(msg, e)
|
|
444
|
+
self._sent(msg)
|
|
445
|
+
|
|
446
|
+
def new_job(
|
|
447
|
+
self,
|
|
448
|
+
title: str,
|
|
449
|
+
description: str = "",
|
|
450
|
+
*,
|
|
451
|
+
progress: float | None = None,
|
|
452
|
+
stale_after: float | None = None,
|
|
453
|
+
id: str | None = None,
|
|
454
|
+
) -> SyncJob:
|
|
455
|
+
"""Show a new card. ``stale_after`` marks it stale if no update arrives for that many seconds."""
|
|
456
|
+
record = self._new(title, description, progress, stale_after, id)
|
|
457
|
+
self._send(record.new_job_message())
|
|
458
|
+
return SyncJob(self, record.id)
|
|
459
|
+
|
|
460
|
+
def update_job(
|
|
461
|
+
self,
|
|
462
|
+
id: str,
|
|
463
|
+
*,
|
|
464
|
+
title: str | None = None,
|
|
465
|
+
description: str | None = None,
|
|
466
|
+
progress: float | None = None,
|
|
467
|
+
) -> None:
|
|
468
|
+
self._send(self._update(id, title, description, progress))
|
|
469
|
+
|
|
470
|
+
def heartbeat(self, id: str) -> None:
|
|
471
|
+
self._send({"type": "heartbeat", "id": id})
|
|
472
|
+
|
|
473
|
+
def end_job(self, id: str) -> None:
|
|
474
|
+
self._end([id])
|
|
475
|
+
self._send({"type": "endJob", "id": id})
|
|
476
|
+
|
|
477
|
+
def end_all_jobs(self) -> None:
|
|
478
|
+
"""End every job this notifier created."""
|
|
479
|
+
self._end(list(self._jobs))
|
|
480
|
+
self._send({"type": "endAllJobs"})
|
|
481
|
+
|
|
482
|
+
@contextlib.contextmanager
|
|
483
|
+
def job(
|
|
484
|
+
self,
|
|
485
|
+
title: str,
|
|
486
|
+
description: str = "",
|
|
487
|
+
*,
|
|
488
|
+
progress: float | None = None,
|
|
489
|
+
stale_after: float | None = None,
|
|
490
|
+
id: str | None = None,
|
|
491
|
+
) -> Iterator[SyncJob]:
|
|
492
|
+
"""Create a job and end it when the block exits, even on error."""
|
|
493
|
+
job = self.new_job(title, description, progress=progress, stale_after=stale_after, id=id)
|
|
494
|
+
try:
|
|
495
|
+
yield job
|
|
496
|
+
finally:
|
|
497
|
+
job.end()
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Wire messages for the TaskOverview protocol. See PROTOCOL.md in the repository root."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, model_validator
|
|
8
|
+
from pydantic.alias_generators import to_camel
|
|
9
|
+
|
|
10
|
+
PROTOCOL_VERSION = 1
|
|
11
|
+
|
|
12
|
+
Progress = Annotated[float, Field(ge=0.0, le=1.0)]
|
|
13
|
+
JobId = Annotated[str, Field(min_length=1, max_length=200)]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Message(BaseModel):
|
|
17
|
+
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, extra="ignore")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Hello(_Message):
|
|
21
|
+
type: Literal["hello"]
|
|
22
|
+
role: Literal["producer", "widget"]
|
|
23
|
+
protocol: int = PROTOCOL_VERSION
|
|
24
|
+
client_id: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# --- producer -> server -------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NewJobSpec(_Message):
|
|
31
|
+
id: JobId
|
|
32
|
+
title: str
|
|
33
|
+
description: str = ""
|
|
34
|
+
progress: Progress | None = None
|
|
35
|
+
stale_after: Annotated[float, Field(gt=0)] | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NewJob(_Message):
|
|
39
|
+
type: Literal["newJob"]
|
|
40
|
+
job: NewJobSpec
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class UpdateJob(_Message):
|
|
44
|
+
type: Literal["updateJob"]
|
|
45
|
+
id: JobId
|
|
46
|
+
title: str | None = None
|
|
47
|
+
description: str | None = None
|
|
48
|
+
# An explicit `null` clears the progress bar; leaving the field out keeps it.
|
|
49
|
+
progress: Progress | None = None
|
|
50
|
+
|
|
51
|
+
@model_validator(mode="after")
|
|
52
|
+
def _at_least_one_change(self) -> UpdateJob:
|
|
53
|
+
if not {"title", "description", "progress"} & self.model_fields_set:
|
|
54
|
+
raise ValueError("updateJob needs at least one of title, description or progress")
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Heartbeat(_Message):
|
|
59
|
+
type: Literal["heartbeat"]
|
|
60
|
+
id: JobId
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class EndJob(_Message):
|
|
64
|
+
type: Literal["endJob"]
|
|
65
|
+
id: JobId
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class EndAllJobs(_Message):
|
|
69
|
+
type: Literal["endAllJobs"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
ProducerMessage = Annotated[NewJob | UpdateJob | Heartbeat | EndJob | EndAllJobs, Field(discriminator="type")]
|
|
73
|
+
producer_message: TypeAdapter[ProducerMessage] = TypeAdapter(ProducerMessage)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --- widget -> server ---------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class DismissJob(_Message):
|
|
80
|
+
type: Literal["dismissJob"]
|
|
81
|
+
id: JobId
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Resync(_Message):
|
|
85
|
+
type: Literal["resync"]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
WidgetMessage = Annotated[DismissJob | Resync, Field(discriminator="type")]
|
|
89
|
+
widget_message: TypeAdapter[WidgetMessage] = TypeAdapter(WidgetMessage)
|
|
File without changes
|