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/__init__.py +135 -0
- taskwire/ambient.py +161 -0
- taskwire/conformance.py +474 -0
- taskwire/contrib/__init__.py +17 -0
- taskwire/contrib/asgi.py +91 -0
- taskwire/contrib/celery.py +352 -0
- taskwire/contrib/fastapi.py +82 -0
- taskwire/contrib/muxws.py +370 -0
- taskwire/contrib/redis_store.py +575 -0
- taskwire/contrib/schema.py +204 -0
- taskwire/contrib/threads.py +43 -0
- taskwire/contrib/viewsets.py +292 -0
- taskwire/decorators.py +88 -0
- taskwire/delivery.py +123 -0
- taskwire/headers.py +36 -0
- taskwire/models.py +683 -0
- taskwire/py.typed +0 -0
- taskwire/reader.py +120 -0
- taskwire/register.py +182 -0
- taskwire/reporter.py +1162 -0
- taskwire/rest.py +312 -0
- taskwire/settings.py +126 -0
- taskwire/store.py +647 -0
- taskwire/transport.py +95 -0
- taskwire-0.1.0.dist-info/METADATA +138 -0
- taskwire-0.1.0.dist-info/RECORD +28 -0
- taskwire-0.1.0.dist-info/WHEEL +4 -0
- taskwire-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|