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,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