taskwire 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.
taskwire/models.py ADDED
@@ -0,0 +1,683 @@
1
+ """The wire documents of specification §3, as plain dataclasses.
2
+
3
+ No pydantic, no attrs, no typing_extensions: core has zero runtime dependencies and that is a hard
4
+ constraint rather than an aspiration (TW-CORE-006). Every model hand-writes `to_dict` / `from_dict`.
5
+
6
+ Two conventions run through the whole file:
7
+
8
+ **Absent and null are the same thing to a parser** (TW-CORE-010). `to_dict` omits an optional field
9
+ that is `None` rather than writing `null`, which keeps the wire small; `from_dict` treats a missing
10
+ key and a `null` identically. Required fields are always written, `null` included where the shape
11
+ says the value may be null.
12
+
13
+ **`Aggregate` is its own shape, not a `Progress` with a hole in it** (TW-REG-009, TW-REG-011). It
14
+ carries no percentage and there is no field to set to `None`. Reusing `Progress` here is exactly how
15
+ a percentage gets added back by someone reading `percent: float | None` as an invitation.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+
22
+ from dataclasses import dataclass, field
23
+ from datetime import datetime, timezone
24
+ from enum import Enum
25
+ from typing import Any
26
+
27
+ WIRE_VERSION = 1
28
+ """TW-CORE-011: bumped only on a breaking wire change, never on an implementation change. Package
29
+ semver tracks the implementation and says nothing about this number."""
30
+
31
+
32
+ def now_iso() -> str:
33
+ """RFC 3339 UTC with a `Z` suffix (TW-CORE-010). The only timestamp format in the system."""
34
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
35
+
36
+
37
+ def to_iso(value: datetime | str | None) -> str | None:
38
+ """Normalise a datetime or an already-formatted string to the one timestamp format."""
39
+ if value is None:
40
+ return None
41
+ if isinstance(value, str):
42
+ return value
43
+ if value.tzinfo is None:
44
+ value = value.replace(tzinfo=timezone.utc)
45
+ return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
46
+
47
+
48
+ def encoded_size(value: Any) -> int:
49
+ """Encoded byte length, which is what `max_data_bytes` is measured in (TW-PROG-008)."""
50
+ return len(json.dumps(value, separators=(",", ":"), default=str).encode("utf-8"))
51
+
52
+
53
+ class ProgressState(str, Enum):
54
+ """TW-PROG-001. There is no `received`: an operation begins at `queued`."""
55
+
56
+ QUEUED = "queued"
57
+ RUNNING = "running"
58
+ WAITING_INPUT = "waiting_input"
59
+ DONE = "done"
60
+ FAILED = "failed"
61
+ CANCELLED = "cancelled"
62
+
63
+
64
+ TERMINAL_STATES = frozenset({ProgressState.DONE, ProgressState.FAILED, ProgressState.CANCELLED})
65
+ """The three states after which the store freezes `state` (TW-STORE-010, TW-INV-014)."""
66
+
67
+ ACTIVE_STATES = frozenset({ProgressState.QUEUED, ProgressState.RUNNING})
68
+ """The two states that make a register "active" for the polling cadence and the FIFO selection."""
69
+
70
+
71
+ class DialogState(str, Enum):
72
+ """§4.2. There is no `expired`: a dialog has no deadline to expire against (TW-DLG-005)."""
73
+
74
+ OPEN = "open"
75
+ ANSWERED = "answered"
76
+ CANCELLED = "cancelled"
77
+
78
+
79
+ class EnvelopeKind(str, Enum):
80
+ """TW-CORE-007: the vocabulary is closed. A seventh kind is a breaking change.
81
+
82
+ `SNAPSHOT` is not a seventh kind (TW-CORE-008) - it is the pushed kinds bundled into one
83
+ document, spelled here only because it arrives on the same path. `DIALOG_REPLY` and `WATCH`
84
+ originate with the client and MUST NOT be pushed to one (TW-CORE-009).
85
+ """
86
+
87
+ PROGRESS = "progress"
88
+ DIALOG_OPEN = "dialog.open"
89
+ DIALOG_CLOSE = "dialog.close"
90
+ DIALOG_REPLY = "dialog.reply"
91
+ CANCEL = "cancel"
92
+ WATCH = "watch"
93
+ SNAPSHOT = "snapshot"
94
+
95
+
96
+ PUSHED_KINDS = frozenset(
97
+ {
98
+ EnvelopeKind.PROGRESS,
99
+ EnvelopeKind.DIALOG_OPEN,
100
+ EnvelopeKind.DIALOG_CLOSE,
101
+ EnvelopeKind.CANCEL,
102
+ EnvelopeKind.SNAPSHOT,
103
+ }
104
+ )
105
+ """What may travel server to client. `dialog.reply` and `watch` may not (TW-CORE-009)."""
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class Text:
110
+ """TW-TXT-001: every human-facing string is one of these. Any subset of the three is valid.
111
+
112
+ `key` is an i18n catalogue key, `text` a pre-composed fallback for a frontend with no catalogue
113
+ or no such key, and `params` the interpolation values. A frontend that has the key uses it and
114
+ ignores `text`; one that does not falls back.
115
+ """
116
+
117
+ key: str | None = None
118
+ params: dict[str, Any] | None = None
119
+ text: str | None = None
120
+
121
+ def to_dict(self) -> dict[str, Any]:
122
+ out: dict[str, Any] = {}
123
+ if self.key is not None:
124
+ out["key"] = self.key
125
+ if self.params:
126
+ out["params"] = self.params
127
+ if self.text is not None:
128
+ out["text"] = self.text
129
+ return out
130
+
131
+ @classmethod
132
+ def from_dict(cls, raw: dict[str, Any] | None) -> Text | None:
133
+ if raw is None:
134
+ return None
135
+ return cls(key=raw.get("key"), params=raw.get("params"), text=raw.get("text"))
136
+
137
+
138
+ def t(key: str | None = None, *, text: str | None = None, **params: Any) -> Text:
139
+ """Sugar for building a `Text`. `t("acme.importing", rows=1200)`."""
140
+ return Text(key=key, params=params or None, text=text)
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class Error:
145
+ """TW-PROG-005. Carries no stack trace, deliberately: this document reaches a browser."""
146
+
147
+ code: str
148
+ message: Text
149
+ retryable: bool = False
150
+
151
+ def to_dict(self) -> dict[str, Any]:
152
+ return {"code": self.code, "message": self.message.to_dict(), "retryable": self.retryable}
153
+
154
+ @classmethod
155
+ def from_dict(cls, raw: dict[str, Any] | None) -> Error | None:
156
+ if raw is None:
157
+ return None
158
+ return cls(
159
+ code=raw["code"],
160
+ message=Text.from_dict(raw.get("message")) or Text(),
161
+ retryable=bool(raw.get("retryable") or False),
162
+ )
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class ResultRef:
167
+ """A pointer to an artefact the **application** owns.
168
+
169
+ TW-RES-007: taskwire never creates, serves, refreshes, proxies, validates or deletes what is
170
+ behind `href`, and has no opinion about who may fetch it. It stores a pointer, never a file.
171
+ """
172
+
173
+ href: str
174
+ mime: str | None = None
175
+ bytes: int | None = None
176
+ expires_at: str | None = None
177
+
178
+ def to_dict(self) -> dict[str, Any]:
179
+ out: dict[str, Any] = {"href": self.href}
180
+ if self.mime is not None:
181
+ out["mime"] = self.mime
182
+ if self.bytes is not None:
183
+ out["bytes"] = self.bytes
184
+ if self.expires_at is not None:
185
+ out["expires_at"] = self.expires_at
186
+ return out
187
+
188
+ @classmethod
189
+ def from_dict(cls, raw: dict[str, Any] | None) -> ResultRef | None:
190
+ if raw is None:
191
+ return None
192
+ return cls(
193
+ href=raw["href"],
194
+ mime=raw.get("mime"),
195
+ bytes=raw.get("bytes"),
196
+ expires_at=to_iso(raw.get("expires_at")),
197
+ )
198
+
199
+
200
+ @dataclass(frozen=True)
201
+ class Result:
202
+ """TW-RES-002. `kind` names the component that collects this, mapped exactly as `dialog_id` is.
203
+
204
+ An application adds its own kinds by passing its own string; taskwire never validates the set
205
+ (TW-API-005).
206
+ """
207
+
208
+ kind: str
209
+ params: dict[str, Any] | None = None
210
+ label: Text | None = None
211
+ value: Any = None
212
+ ref: ResultRef | None = None
213
+
214
+ def to_dict(self) -> dict[str, Any]:
215
+ out: dict[str, Any] = {"kind": self.kind}
216
+ if self.params is not None:
217
+ out["params"] = self.params
218
+ if self.label is not None:
219
+ out["label"] = self.label.to_dict()
220
+ if self.value is not None:
221
+ out["value"] = self.value
222
+ if self.ref is not None:
223
+ out["ref"] = self.ref.to_dict()
224
+ return out
225
+
226
+ @classmethod
227
+ def from_dict(cls, raw: dict[str, Any] | None) -> Result | None:
228
+ if raw is None:
229
+ return None
230
+ return cls(
231
+ kind=raw["kind"],
232
+ params=raw.get("params"),
233
+ label=Text.from_dict(raw.get("label")),
234
+ value=raw.get("value"),
235
+ ref=ResultRef.from_dict(raw.get("ref")),
236
+ )
237
+
238
+ @classmethod
239
+ def download(
240
+ cls,
241
+ *,
242
+ href: str,
243
+ mime: str | None = None,
244
+ bytes: int | None = None, # noqa: A002 - the wire field is named `bytes`
245
+ expires_at: datetime | str | None = None,
246
+ label: Text | None = None,
247
+ ) -> Result:
248
+ """A `ref` a user fetches. Kind `taskwire.download`."""
249
+ return cls(
250
+ kind="taskwire.download",
251
+ label=label,
252
+ ref=ResultRef(href=href, mime=mime, bytes=bytes, expires_at=to_iso(expires_at)),
253
+ )
254
+
255
+ @classmethod
256
+ def panel(cls, kind: str, value: Any, *, label: Text | None = None) -> Result:
257
+ """A `value` a component renders. The application names the kind."""
258
+ return cls(kind=kind, value=value, label=label)
259
+
260
+ @classmethod
261
+ def dialog(cls, dialog_id: str, *, params: dict[str, Any] | None = None) -> Result:
262
+ """`params.dialog_id` names a component to open. Kind `taskwire.dialog`."""
263
+ merged = dict(params or {})
264
+ merged["dialog_id"] = dialog_id
265
+ return cls(kind="taskwire.dialog", params=merged)
266
+
267
+
268
+ @dataclass(frozen=True)
269
+ class Button:
270
+ """A choice on a dialog.
271
+
272
+ There is no `default` flag: a default button's only use is answering a timeout, and there are no
273
+ timeouts (TW-DLG-005).
274
+ """
275
+
276
+ id: str
277
+ label: Text | None = None
278
+ style: str | None = None
279
+
280
+ def to_dict(self) -> dict[str, Any]:
281
+ out: dict[str, Any] = {"id": self.id}
282
+ if self.label is not None:
283
+ out["label"] = self.label.to_dict()
284
+ if self.style is not None:
285
+ out["style"] = self.style
286
+ return out
287
+
288
+ @classmethod
289
+ def from_dict(cls, raw: dict[str, Any]) -> Button:
290
+ return cls(
291
+ id=raw["id"],
292
+ label=Text.from_dict(raw.get("label")),
293
+ style=raw.get("style"),
294
+ )
295
+
296
+
297
+ INPUT_TYPES = frozenset({"string", "number", "boolean", "date"})
298
+ """TW-DLG-004: exactly these four and nothing more."""
299
+
300
+
301
+ @dataclass(frozen=True)
302
+ class Input:
303
+ """A value collected alongside the choice."""
304
+
305
+ name: str
306
+ type: str = "string"
307
+ required: bool = False
308
+ label: Text | None = None
309
+
310
+ def to_dict(self) -> dict[str, Any]:
311
+ out: dict[str, Any] = {"name": self.name, "type": self.type, "required": self.required}
312
+ if self.label is not None:
313
+ out["label"] = self.label.to_dict()
314
+ return out
315
+
316
+ @classmethod
317
+ def from_dict(cls, raw: dict[str, Any]) -> Input:
318
+ # `raw.get("type", "string")` would be wrong, and subtly: a default passed to `dict.get`
319
+ # fires only on a *missing* key, so an explicit `"type": null` would sail past it and
320
+ # produce an `Input` whose type is `None` - which is not one of TW-DLG-004's four. Absent
321
+ # and null must parse identically (TW-CORE-010), so the fallback has to be on the value.
322
+ return cls(
323
+ name=raw["name"],
324
+ type=raw.get("type") or "string",
325
+ required=bool(raw.get("required") or False),
326
+ label=Text.from_dict(raw.get("label")),
327
+ )
328
+
329
+
330
+ @dataclass(frozen=True)
331
+ class DialogReply:
332
+ """Wire shape of an answer: which button, and the values collected with it."""
333
+
334
+ button: str
335
+ values: dict[str, Any] = field(default_factory=dict)
336
+
337
+ def to_dict(self) -> dict[str, Any]:
338
+ return {"button": self.button, "values": self.values}
339
+
340
+ @classmethod
341
+ def from_dict(cls, raw: dict[str, Any] | None) -> DialogReply | None:
342
+ if raw is None:
343
+ return None
344
+ return cls(button=raw["button"], values=raw.get("values") or {})
345
+
346
+
347
+ @dataclass(frozen=True)
348
+ class DialogAnswer:
349
+ """What `ask()` returns.
350
+
351
+ There is no `timed_out` flag, because there is no second way for `ask()` to return
352
+ (TW-DLG-007): it returns only when the store arbitrated a reply.
353
+ """
354
+
355
+ button: str
356
+ values: dict[str, Any] = field(default_factory=dict)
357
+
358
+
359
+ @dataclass
360
+ class DialogRequest:
361
+ """One asking. `id` is *this asking*; `dialog_id` is *which question* (TW-DLG-001).
362
+
363
+ `id` is the only one of the two ever used as a key, a path segment or a store argument. Keying
364
+ by `dialog_id` would let two concurrent operations rendering the same component answer each
365
+ other's question (TW-DLG-002, TW-INV-008).
366
+ """
367
+
368
+ id: str
369
+ dialog_id: str
370
+ buttons: list[Button] = field(default_factory=list)
371
+ inputs: list[Input] = field(default_factory=list)
372
+ params: dict[str, Any] | None = None
373
+ state: ProgressState | DialogState = DialogState.OPEN
374
+ created_at: str = field(default_factory=now_iso)
375
+ reply: DialogReply | None = None
376
+
377
+ def to_dict(self) -> dict[str, Any]:
378
+ out: dict[str, Any] = {
379
+ "id": self.id,
380
+ "dialog_id": self.dialog_id,
381
+ "buttons": [b.to_dict() for b in self.buttons],
382
+ "state": DialogState(self.state).value,
383
+ "created_at": self.created_at,
384
+ }
385
+ if self.inputs:
386
+ out["inputs"] = [i.to_dict() for i in self.inputs]
387
+ if self.params is not None:
388
+ out["params"] = self.params
389
+ if self.reply is not None:
390
+ out["reply"] = self.reply.to_dict()
391
+ return out
392
+
393
+ @classmethod
394
+ def from_dict(cls, raw: dict[str, Any]) -> DialogRequest:
395
+ return cls(
396
+ id=raw["id"],
397
+ dialog_id=raw["dialog_id"],
398
+ buttons=[Button.from_dict(b) for b in raw.get("buttons") or []],
399
+ inputs=[Input.from_dict(i) for i in raw.get("inputs") or []],
400
+ params=raw.get("params"),
401
+ state=DialogState(raw.get("state") or "open"),
402
+ created_at=raw.get("created_at") or now_iso(),
403
+ reply=DialogReply.from_dict(raw.get("reply")),
404
+ )
405
+
406
+
407
+ @dataclass
408
+ class Progress:
409
+ """§3.2. The one document per operation.
410
+
411
+ `result_kind`, `origin_session` and `origin_connection` are fixed at the opening write and never
412
+ rewritten (TW-PROG-006): an operation is private or shared from start to finish, and there is no
413
+ promotion rule. `state` is never written by a caller - it is derived from lifecycle events and
414
+ from nothing else (TW-PROG-012).
415
+ """
416
+
417
+ state: ProgressState = ProgressState.QUEUED
418
+ percent: float | None = None
419
+ title: Text | None = None
420
+ label: Text | None = None
421
+ icon: str | None = None
422
+ data: dict[str, Any] = field(default_factory=dict)
423
+ error: Error | None = None
424
+ result: Result | None = None
425
+ result_kind: str | None = None
426
+ origin_session: str = ""
427
+ origin_connection: str | None = None
428
+ created_at: str = field(default_factory=now_iso)
429
+ updated_at: str = field(default_factory=now_iso)
430
+
431
+ def to_dict(self) -> dict[str, Any]:
432
+ out: dict[str, Any] = {
433
+ "state": ProgressState(self.state).value,
434
+ "percent": self.percent,
435
+ "data": self.data,
436
+ "result_kind": self.result_kind,
437
+ "origin_session": self.origin_session,
438
+ "origin_connection": self.origin_connection,
439
+ "created_at": self.created_at,
440
+ "updated_at": self.updated_at,
441
+ }
442
+ if self.title is not None:
443
+ out["title"] = self.title.to_dict()
444
+ if self.label is not None:
445
+ out["label"] = self.label.to_dict()
446
+ if self.icon is not None:
447
+ out["icon"] = self.icon
448
+ if self.error is not None:
449
+ out["error"] = self.error.to_dict()
450
+ if self.result is not None:
451
+ out["result"] = self.result.to_dict()
452
+ return out
453
+
454
+ @classmethod
455
+ def from_dict(cls, raw: dict[str, Any]) -> Progress:
456
+ return cls(
457
+ state=ProgressState(raw["state"]),
458
+ percent=raw.get("percent"),
459
+ title=Text.from_dict(raw.get("title")),
460
+ label=Text.from_dict(raw.get("label")),
461
+ icon=raw.get("icon"),
462
+ data=raw.get("data") or {},
463
+ error=Error.from_dict(raw.get("error")),
464
+ result=Result.from_dict(raw.get("result")),
465
+ result_kind=raw.get("result_kind"),
466
+ origin_session=raw.get("origin_session") or "",
467
+ origin_connection=raw.get("origin_connection"),
468
+ created_at=raw.get("created_at") or now_iso(),
469
+ updated_at=raw.get("updated_at") or now_iso(),
470
+ )
471
+
472
+ @property
473
+ def is_terminal(self) -> bool:
474
+ """`done`, `failed` or `cancelled` - the states after which `state` is frozen."""
475
+ return ProgressState(self.state) in TERMINAL_STATES
476
+
477
+ @property
478
+ def is_shared(self) -> bool:
479
+ """TW-PRIV-001: declaring a `result_kind` at start is the whole of being shared."""
480
+ return self.result_kind is not None
481
+
482
+
483
+ @dataclass
484
+ class Snapshot:
485
+ """§3.6. The complete current state of one operation."""
486
+
487
+ token: str
488
+ rev: int
489
+ progress: Progress
490
+ dialogs: list[DialogRequest] = field(default_factory=list)
491
+ cancel_requested: bool = False
492
+ server_time: str = field(default_factory=now_iso)
493
+ poll_after_ms: int = 5000
494
+ v: int = WIRE_VERSION
495
+
496
+ def to_dict(self) -> dict[str, Any]:
497
+ # `v` is required and first: a client reading REST determines the wire version by the same
498
+ # path as one reading a push, and no document a client can receive is unversioned
499
+ # (TW-CORE-012).
500
+ return {
501
+ "v": self.v,
502
+ "token": self.token,
503
+ "rev": self.rev,
504
+ "progress": self.progress.to_dict(),
505
+ "dialogs": [d.to_dict() for d in self.dialogs],
506
+ "cancel_requested": self.cancel_requested,
507
+ "server_time": self.server_time,
508
+ "poll_after_ms": self.poll_after_ms,
509
+ }
510
+
511
+ @classmethod
512
+ def from_dict(cls, raw: dict[str, Any]) -> Snapshot:
513
+ return cls(
514
+ v=raw.get("v") or WIRE_VERSION,
515
+ token=raw["token"],
516
+ rev=raw["rev"],
517
+ progress=Progress.from_dict(raw["progress"]),
518
+ dialogs=[DialogRequest.from_dict(d) for d in raw.get("dialogs") or []],
519
+ cancel_requested=bool(raw.get("cancel_requested") or False),
520
+ server_time=raw.get("server_time") or now_iso(),
521
+ poll_after_ms=raw.get("poll_after_ms") or 5000,
522
+ )
523
+
524
+ @property
525
+ def open_dialogs(self) -> list[DialogRequest]:
526
+ """The askings still awaiting an answer."""
527
+ return [d for d in self.dialogs if DialogState(d.state) is DialogState.OPEN]
528
+
529
+ @property
530
+ def needs_attention(self) -> bool:
531
+ """TW-REG-004: `waiting_input`, **derived** rather than read off `progress.state`.
532
+
533
+ TW-PROG-013's two causes are not alike and both are checked here: an open dialog holds a
534
+ worker, an uncollected result holds nothing at all. Reading the stored `state` instead would
535
+ make this agree with whatever the last writer happened to put there, which is precisely the
536
+ bug TW-PROG-010 forbids.
537
+ """
538
+ if self.open_dialogs:
539
+ return True
540
+ return self.progress.result is not None and not self.progress.is_terminal
541
+
542
+
543
+ @dataclass
544
+ class OperationSummary:
545
+ """§3.7. One register row: the whole snapshot plus what a list view needs lifted out of it."""
546
+
547
+ token: str
548
+ snapshot: Snapshot
549
+ result_kind: str | None
550
+ origin_session: str
551
+ result: Result | None
552
+ needs_attention: bool
553
+ progress_delivery: bool = True
554
+
555
+ def to_dict(self) -> dict[str, Any]:
556
+ return {
557
+ "token": self.token,
558
+ "snapshot": self.snapshot.to_dict(),
559
+ "result_kind": self.result_kind,
560
+ "origin_session": self.origin_session,
561
+ "result": self.result.to_dict() if self.result is not None else None,
562
+ "needs_attention": self.needs_attention,
563
+ "progress_delivery": self.progress_delivery,
564
+ }
565
+
566
+ @classmethod
567
+ def from_dict(cls, raw: dict[str, Any]) -> OperationSummary:
568
+ return cls(
569
+ token=raw["token"],
570
+ snapshot=Snapshot.from_dict(raw["snapshot"]),
571
+ result_kind=raw.get("result_kind"),
572
+ origin_session=raw.get("origin_session") or "",
573
+ result=Result.from_dict(raw.get("result")),
574
+ needs_attention=bool(raw.get("needs_attention", False)),
575
+ progress_delivery=(True if raw.get("progress_delivery") is None else bool(raw["progress_delivery"])),
576
+ )
577
+
578
+
579
+ @dataclass
580
+ class Aggregate:
581
+ """§3.8a. Counts and a dominated state.
582
+
583
+ **There is no `percent` here and no rule may reintroduce one** (TW-REG-011). There is no unit in
584
+ which two operations' self-reported percentages are commensurable, so there is no honest number
585
+ to publish; the field is absent from the wire rather than `null` on it. A footer that wants a
586
+ bar draws one selected operation's own percentage (TW-REG-013).
587
+
588
+ The server never emits a `done` or `failed` state here (TW-REG-015): a terminal entry with
589
+ nothing to collect has already left the register, so no count and no domination can see one.
590
+ """
591
+
592
+ state: ProgressState
593
+ data: dict[str, int]
594
+ title: Text = field(default_factory=lambda: Text(key="taskwire.aggregate.title"))
595
+ label: Text | None = None
596
+ updated_at: str = field(default_factory=now_iso)
597
+
598
+ def to_dict(self) -> dict[str, Any]:
599
+ out: dict[str, Any] = {
600
+ "state": ProgressState(self.state).value,
601
+ "title": self.title.to_dict(),
602
+ "data": self.data,
603
+ "updated_at": self.updated_at,
604
+ }
605
+ if self.label is not None:
606
+ out["label"] = self.label.to_dict()
607
+ return out
608
+
609
+ @classmethod
610
+ def from_dict(cls, raw: dict[str, Any]) -> Aggregate:
611
+ return cls(
612
+ state=ProgressState(raw["state"]),
613
+ data=raw.get("data") or {},
614
+ title=Text.from_dict(raw.get("title")) or Text(key="taskwire.aggregate.title"),
615
+ label=Text.from_dict(raw.get("label")),
616
+ updated_at=raw.get("updated_at") or now_iso(),
617
+ )
618
+
619
+
620
+ @dataclass
621
+ class Register:
622
+ """§3.8. Every shared operation of one namespace, sorted, with its aggregate."""
623
+
624
+ operations: list[OperationSummary] = field(default_factory=list)
625
+ aggregate: Aggregate | None = None
626
+ server_time: str = field(default_factory=now_iso)
627
+ poll_after_ms: int = 5000
628
+ v: int = WIRE_VERSION
629
+
630
+ def to_dict(self) -> dict[str, Any]:
631
+ return {
632
+ "v": self.v,
633
+ "operations": [o.to_dict() for o in self.operations],
634
+ "aggregate": self.aggregate.to_dict() if self.aggregate is not None else None,
635
+ "server_time": self.server_time,
636
+ "poll_after_ms": self.poll_after_ms,
637
+ }
638
+
639
+ @classmethod
640
+ def from_dict(cls, raw: dict[str, Any]) -> Register:
641
+ raw_aggregate = raw.get("aggregate")
642
+ return cls(
643
+ v=raw.get("v") or WIRE_VERSION,
644
+ operations=[OperationSummary.from_dict(o) for o in raw.get("operations") or []],
645
+ aggregate=Aggregate.from_dict(raw_aggregate) if raw_aggregate else None,
646
+ server_time=raw.get("server_time") or now_iso(),
647
+ poll_after_ms=raw.get("poll_after_ms") or 5000,
648
+ )
649
+
650
+
651
+ @dataclass
652
+ class Envelope:
653
+ """§3.9. One pushed document, server to client.
654
+
655
+ An envelope never travels client to server (TW-CORE-009). `rev` gates the progress document
656
+ only: a client applies `dialog.open` and `dialog.close` by dialog id regardless of `rev`, and
657
+ treats `cancel` as idempotent (TW-REV-006).
658
+ """
659
+
660
+ token: str
661
+ rev: int
662
+ kind: EnvelopeKind
663
+ body: dict[str, Any] = field(default_factory=dict)
664
+ v: int = WIRE_VERSION
665
+
666
+ def to_dict(self) -> dict[str, Any]:
667
+ return {
668
+ "v": self.v,
669
+ "token": self.token,
670
+ "rev": self.rev,
671
+ "kind": EnvelopeKind(self.kind).value,
672
+ "body": self.body,
673
+ }
674
+
675
+ @classmethod
676
+ def from_dict(cls, raw: dict[str, Any]) -> Envelope:
677
+ return cls(
678
+ v=raw.get("v") or WIRE_VERSION,
679
+ token=raw["token"],
680
+ rev=raw["rev"],
681
+ kind=EnvelopeKind(raw["kind"]),
682
+ body=raw.get("body") or {},
683
+ )
taskwire/py.typed ADDED
File without changes