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/rest.py ADDED
@@ -0,0 +1,312 @@
1
+ """Framework-free REST handlers: `(client, token, body) -> (status, payload)`.
2
+
3
+ TW-REST-001: the REST path works with **zero** optional dependencies installed. Nothing in this
4
+ module imports a web framework, and nothing in it knows what one looks like; `contrib.fastapi` and
5
+ `contrib.asgi` are the only places a framework is imported, and they do nothing but translate.
6
+
7
+ That split is what makes the REST implementation of the protocol testable without a server, and it
8
+ is why an application on a framework taskwire has never heard of can mount these handlers in about
9
+ twenty lines.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import uuid
16
+
17
+ from dataclasses import replace
18
+ from typing import Any
19
+
20
+ from .delivery import Client
21
+ from .models import DialogReply, DialogState, Envelope, EnvelopeKind, INPUT_TYPES, now_iso, ProgressState
22
+ from .register import build_register, CEILING_POLL_MS, poll_after_ms
23
+ from .settings import configured, settings
24
+ from .store import DialogResolution, key as make_key
25
+
26
+ logger = logging.getLogger("taskwire.rest")
27
+
28
+ Response = tuple[int, dict[str, Any] | None]
29
+
30
+
31
+ def _is_uuid(token: str) -> bool:
32
+ """TW-REST-006: reject a malformed token **before** touching the store.
33
+
34
+ An unclaimed token is simply unknown (TW-REST-005); a malformed one never gets far enough to
35
+ ask, which keeps a scan of the token space from costing a store read each.
36
+ """
37
+ try:
38
+ uuid.UUID(str(token))
39
+ except (ValueError, AttributeError, TypeError):
40
+ return False
41
+ return True
42
+
43
+
44
+ def _not_found() -> Response:
45
+ """TW-SEC-002: unknown and foreign tokens both return 404, never 403.
46
+
47
+ A 403 would confirm that the token exists in somebody's namespace, which turns the endpoint into
48
+ an oracle for enumerating other people's operations.
49
+ """
50
+ return 404, {"detail": "not_found"}
51
+
52
+
53
+ async def get_operations(client: Client) -> Response:
54
+ """`GET {prefix}` - the central endpoint, and the one an ordinary client polls.
55
+
56
+ Always returns the whole register and takes no parameters (TW-REG-003), so there is none that
57
+ could name another namespace.
58
+ """
59
+ if client.session is None:
60
+ # A `session_resolver` that returned None means the application opted this caller out of
61
+ # taskwire entirely (TW-AMB-009). 404, never 401 or 403.
62
+ # NOTE: not a spec rule - the status is this module's choice.
63
+ return _not_found()
64
+
65
+ store = configured.store
66
+ if store is None:
67
+ return 200, {
68
+ "v": 1,
69
+ "operations": [],
70
+ "aggregate": None,
71
+ "server_time": now_iso(),
72
+ "poll_after_ms": CEILING_POLL_MS,
73
+ }
74
+
75
+ snapshots = await store.list_operations(client.session)
76
+ return 200, build_register(snapshots, client).to_dict()
77
+
78
+
79
+ async def get_snapshot(client: Client, token: str) -> Response:
80
+ """`GET {prefix}/{token}` - one operation, private or shared.
81
+
82
+ Privacy governs listing and fan-out, never reach (TW-PRIV-002, TW-SEC-003): a private operation
83
+ is served unchanged to any caller of its namespace that holds the token. The token is an
84
+ address, and the namespace is the only authorization scope.
85
+
86
+ The payload carries no `progress_delivery` (TW-REST-007) - that is a fact about a *pair*, a
87
+ client and an operation, not about a document.
88
+ """
89
+ if client.session is None:
90
+ return _not_found()
91
+ if not _is_uuid(token):
92
+ return _not_found()
93
+
94
+ store = configured.store
95
+ if store is None:
96
+ return _not_found()
97
+
98
+ snapshot = await store.snapshot(make_key(client.session, token))
99
+ if snapshot is None:
100
+ return _not_found()
101
+
102
+ snapshot.server_time = now_iso()
103
+ snapshot.poll_after_ms = poll_after_ms([snapshot])
104
+ return 200, snapshot.to_dict()
105
+
106
+
107
+ def _validate_reply(dialog: Any, body: dict[str, Any]) -> str | None:
108
+ """TW-DLG-015: validate **before** touching the store, and reject with 422.
109
+
110
+ Validating afterwards would mean the arbiter had already flipped the dialog to `answered` on a
111
+ reply the operation cannot use - and first-answer-wins makes that irreversible: the tab that
112
+ could have answered correctly gets a 409.
113
+ """
114
+ button = body.get("button")
115
+ if not isinstance(button, str):
116
+ return "button is required"
117
+ if button not in {b.id for b in dialog.buttons}:
118
+ return f"button {button!r} is not one of this dialog's buttons"
119
+
120
+ values = body.get("values") or {}
121
+ if not isinstance(values, dict):
122
+ return "values must be an object"
123
+ for field in dialog.inputs:
124
+ if field.required and values.get(field.name) is None:
125
+ return f"input {field.name!r} is required"
126
+ given = values.get(field.name)
127
+ if given is None:
128
+ continue
129
+ if field.type not in INPUT_TYPES:
130
+ return f"input {field.name!r} declares an unknown type {field.type!r}"
131
+ if field.type == "number" and not isinstance(given, (int, float)):
132
+ return f"input {field.name!r} must be a number"
133
+ if field.type == "boolean" and not isinstance(given, bool):
134
+ return f"input {field.name!r} must be a boolean"
135
+ if field.type in {"string", "date"} and not isinstance(given, str):
136
+ return f"input {field.name!r} must be a string"
137
+ return None
138
+
139
+
140
+ async def reply_to_dialog(client: Client, token: str, did: str, body: dict[str, Any]) -> Response:
141
+ """`POST {prefix}/{token}/dialogs/{did}` - the reply path (TW-REST-004).
142
+
143
+ The status matrix is the whole contract (section 6.2):
144
+
145
+ | outcome | status | why |
146
+ |---|---|---|
147
+ | accepted | `204` | this caller won the race |
148
+ | unknown token or `did` | `404` | and never 403 (TW-SEC-002) |
149
+ | another tab answered first | `409` | body carries the dialog and the WINNING reply (TW-DLG-010) |
150
+ | the dialog was withdrawn | `410` | the operation ended or was cancelled under it |
151
+ | malformed reply | `422` | rejected before the store is touched (TW-DLG-015) |
152
+
153
+ A losing client must close its dialog **quietly** on the 409 and surface nothing - no error, no
154
+ toast, no stack trace. A second tab answering first is a normal outcome of a namespace-wide
155
+ question, not a fault, and the client library resolves rather than rejects on it (TW-DLG-011).
156
+ """
157
+ if client.session is None or not _is_uuid(token):
158
+ return _not_found()
159
+ store = configured.store
160
+ if store is None:
161
+ return _not_found()
162
+
163
+ key = make_key(client.session, token)
164
+ snapshot = await store.snapshot(key)
165
+ if snapshot is None:
166
+ return _not_found()
167
+ dialog = next((d for d in snapshot.dialogs if d.id == did), None)
168
+ if dialog is None:
169
+ return _not_found()
170
+
171
+ problem = _validate_reply(dialog, body or {})
172
+ if problem is not None:
173
+ return 422, {"detail": problem}
174
+
175
+ reply = DialogReply(button=body["button"], values=body.get("values") or {})
176
+ resolution = await store.resolve_dialog(key, did, reply)
177
+ if resolution is DialogResolution.ACCEPTED:
178
+ return 204, None
179
+ if resolution is DialogResolution.NOT_FOUND:
180
+ return _not_found()
181
+
182
+ # The loser is told who won, so it can render the answer rather than merely closing (TW-DLG-010).
183
+ after = await store.snapshot(key)
184
+ settled = next((d for d in (after.dialogs if after else []) if d.id == did), None)
185
+ payload = {"detail": resolution.value, "dialog": settled.to_dict() if settled else None}
186
+ if resolution is DialogResolution.CANCELLED or (
187
+ settled is not None and DialogState(settled.state) is DialogState.CANCELLED
188
+ ):
189
+ return 410, payload
190
+ return 409, payload
191
+
192
+
193
+ async def request_cancel(client: Client, token: str) -> Response:
194
+ """`POST {prefix}/{token}/cancel` - **202, not 200** (TW-REST-009).
195
+
196
+ The cancel was *requested*. Only the terminal `cancelled` state confirms it happened, and it may
197
+ never happen at all: cancellation is cooperative (TW-CANCEL-009), so taskwire does not revoke a
198
+ Celery task, send a signal or kill a thread. It writes a sticky flag and the operation notices at
199
+ its next progress call.
200
+
201
+ The flag is never cleared (TW-CANCEL-001). An operation holding an uncollected result refuses
202
+ with 409 (TW-CANCEL-010): its work is over and there is nothing left to interrupt, so the
203
+ affordance on such an entry is dismiss rather than cancel.
204
+ """
205
+ if client.session is None or not _is_uuid(token):
206
+ return _not_found()
207
+ store = configured.store
208
+ if store is None:
209
+ return _not_found()
210
+
211
+ key = make_key(client.session, token)
212
+ snapshot = await store.snapshot(key)
213
+ if snapshot is None:
214
+ return _not_found()
215
+
216
+ progress = snapshot.progress
217
+ if progress.is_terminal:
218
+ return 409, {"detail": "already_terminal"}
219
+ if progress.result is not None:
220
+ return 409, {"detail": "holds_an_uncollected_result"}
221
+
222
+ rev = await store.request_cancel(key)
223
+ await store.publish(
224
+ client.session,
225
+ Envelope(token=token, rev=rev, kind=EnvelopeKind.CANCEL, body={}),
226
+ )
227
+ return 202, {"detail": "requested"}
228
+
229
+
230
+ async def _release(client: Client, token: str, *, idempotent: bool) -> Response:
231
+ """The shared body of collect and dismiss (TW-REST-008).
232
+
233
+ **They are the same act with two names.** Collect is "I have taken it", dismiss is "I do not
234
+ want it"; both release the result, both fire `result_released`, and both remove the entry for
235
+ every tab at once. Only their idempotence differs, and for a concrete reason: dismissing twice is
236
+ harmless, but an application that fires `result_released` twice deletes its artefact twice.
237
+ """
238
+ if client.session is None or not _is_uuid(token):
239
+ return _not_found()
240
+ store = configured.store
241
+ if store is None:
242
+ return _not_found()
243
+
244
+ key = make_key(client.session, token)
245
+ snapshot = await store.snapshot(key)
246
+ if snapshot is None:
247
+ # Nothing at this address: unknown, foreign, or a tombstone whose TTL is up. Dismiss is
248
+ # idempotent, and that is exactly the state a dismissing caller asked for. Collect is not -
249
+ # there is nothing to hand back.
250
+ return (204, None) if idempotent else _not_found()
251
+
252
+ if snapshot.progress.result is None:
253
+ # Nothing to release. A document that has already ended is a tombstone (TW-RET-004), and
254
+ # that is exactly the state a dismissing caller asked for, so dismiss stays idempotent; a
255
+ # queued, running or asking one is not, because releasing is not cancelling and a blocked
256
+ # worker is not a collectable result (TW-REST-008).
257
+ if idempotent and snapshot.progress.is_terminal:
258
+ return 204, None
259
+ return 409, {"detail": "no_uncollected_result"}
260
+
261
+ released = await store.release_result(key)
262
+ if released is None:
263
+ return (204, None) if idempotent else (409, {"detail": "no_uncollected_result"})
264
+
265
+ # TW-RES-010: the release is what ends the operation, so this is where `done` is written. One
266
+ # write and one envelope: it takes the entry out of the register (TW-REG-007) and leaves the
267
+ # tombstone a client that was not watching reads afterwards (TW-RET-004) - which is the only
268
+ # thing that can tell it "collected and finished" from "expired".
269
+ after = await store.snapshot(key)
270
+ if after is not None:
271
+ ended = replace(after.progress, state=ProgressState.DONE, updated_at=now_iso())
272
+ written = await store.commit(key, ended, settings.tombstone_ttl)
273
+ await store.publish(
274
+ client.session,
275
+ Envelope(token=token, rev=written.rev, kind=EnvelopeKind.PROGRESS, body=ended.to_dict()),
276
+ )
277
+ # TW-RES-010: the event must CARRY THE RELEASED RESULT. `release_result` clears `result` off the
278
+ # document as part of the same atomic step, so a snapshot read afterwards has none - and an
279
+ # application that deletes its own artefact in this hook would have nothing to delete it by.
280
+ # The released Result is put back on the copy the hook is handed.
281
+ _fire_result_released(after, released)
282
+ return 204, None
283
+
284
+
285
+ def _fire_result_released(snapshot: Any, released: Any) -> None:
286
+ """TW-RES-011: the application's hook, and it may not prevent the release.
287
+
288
+ taskwire deletes nothing on an application's behalf - it stores a pointer, never a file
289
+ (TW-RES-007) - so this is where an application deletes its own artefact. An exception out of it
290
+ is swallowed and logged: the result is already released, and raising here would leave the caller
291
+ believing a release failed that in fact happened.
292
+ """
293
+ hook = configured.result_released
294
+ if hook is None or snapshot is None:
295
+ return
296
+ # The hook is given the operation as it was when it still held the result, because that is the
297
+ # only form in which the `ref` it needs is present.
298
+ snapshot.progress = replace(snapshot.progress, result=released)
299
+ try:
300
+ hook(snapshot)
301
+ except Exception: # noqa: BLE001 - deliberately swallowed; see TW-RES-011
302
+ logger.warning("taskwire: result_released hook raised; the result is released regardless", exc_info=True)
303
+
304
+
305
+ async def collect_result(client: Client, token: str) -> Response:
306
+ """`POST {prefix}/{token}/collect` - "I have taken it". Not idempotent (TW-REST-008)."""
307
+ return await _release(client, token, idempotent=False)
308
+
309
+
310
+ async def dismiss_result(client: Client, token: str) -> Response:
311
+ """`POST {prefix}/{token}/dismiss` - "I do not want it". Idempotent (TW-REST-008)."""
312
+ return await _release(client, token, idempotent=True)
taskwire/settings.py ADDED
@@ -0,0 +1,126 @@
1
+ """Server settings (specification §8.1) and the configured callables that are not settings.
2
+
3
+ There is no `dialog_timeout`: a dialog has no deadline at all (TW-DLG-005), and it may not be
4
+ reintroduced under another name.
5
+
6
+ Retention is state-driven (TW-RET-001). `active_ttl` covers an operation that is queued, running or
7
+ asking, `result_ttl` an uncollected result, and `tombstone_ttl` the terminal document after the
8
+ register has let it go. There is no timer that ages out a live operation.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING: # pragma: no cover - imports for typing only, never at runtime
18
+ from .delivery import Client, OperationRef
19
+ from .models import Snapshot
20
+
21
+
22
+ @dataclass
23
+ class Settings:
24
+ """The tunables of §8.1. Mutate the module-level `settings` singleton, or pass to `configure()`."""
25
+
26
+ active_ttl: float = 3600.0
27
+ """Document TTL while the operation is `queued`, `running` or asking. Seconds."""
28
+
29
+ tombstone_ttl: float = 900.0
30
+ """How long a terminal document stays readable at its own address after it leaves the register
31
+ (TW-RET-004). Seconds; 15 minutes.
32
+
33
+ A client that was not looking at the moment an operation ended learns *that* it ended from the
34
+ absence of a row - and nothing at all about why. The tombstone is what carries the reason: the
35
+ terminal state, its `error`, and the dialogs in the state the withdrawal left them. It is
36
+ unlistable and uncountable; only the token reaches it."""
37
+
38
+ result_ttl: float = 604800.0
39
+ """Backstop on an uncollected result. Seconds; 7 days. Must be aligned with the application's
40
+ own artefact retention (TW-RET-003) - taskwire stores a pointer and deletes nothing."""
41
+
42
+ progress_interval: float = 0.25
43
+ """Latest-wins coalescing window for commits (TW-THR-001). Seconds, float, no unit suffix in
44
+ the name - matching `push_timeout` and `max_overrides`."""
45
+
46
+ push_timeout: float = 2.0
47
+ """Bound on any single `notify()` call (TW-TR-003). Seconds."""
48
+
49
+ max_data_bytes: int = 16384
50
+ """Encoded cap on `data`, dialog `params` and `result` (TW-PROG-008). Enforced by raising, never
51
+ by truncating."""
52
+
53
+ max_overrides: int = 64
54
+ """Per-connection override map bound (TW-DEL-008). A declaration above it is rejected whole,
55
+ leaving the previous map in force."""
56
+
57
+ raise_on_cancel: bool = True
58
+ """Global default for whether the next progress call raises once a cancel is requested
59
+ (TW-CANCEL-003). Overridable per action and per call."""
60
+
61
+ rest_prefix: str = "/taskwire"
62
+ """Mount point of the handlers (TW-REST-003)."""
63
+
64
+ @property
65
+ def keepalive_period(self) -> float:
66
+ """Derived, not configurable (TW-RET-002): the keepalive runs four times per TTL."""
67
+ return self.active_ttl / 4
68
+
69
+
70
+ settings = Settings()
71
+
72
+
73
+ @dataclass
74
+ class _Configured:
75
+ """The ports and callables `configure()` installs.
76
+
77
+ These are not settings. A setting is a number a deployment tunes; these are the objects the
78
+ library is wired to, and there is exactly one wiring per process.
79
+ """
80
+
81
+ store: Any = None
82
+ transport: Any = None
83
+ session_resolver: Callable[[Any], str | None] | None = None
84
+ push_filter: Callable[[Client, OperationRef], bool] = field(default=lambda _client, _operation: True)
85
+ result_released: Callable[[Snapshot], None] | None = None
86
+
87
+
88
+ configured = _Configured()
89
+
90
+
91
+ def configure(
92
+ *,
93
+ store: Any = None,
94
+ transport: Any = None,
95
+ session_resolver: Callable[[Any], str | None] | None = None,
96
+ push_filter: Callable[[Client, OperationRef], bool] | None = None,
97
+ result_released: Callable[[Snapshot], None] | None = None,
98
+ ) -> None:
99
+ """Wire the library to its store, its transport and the application's callables.
100
+
101
+ `session_resolver` is the only producer of a namespace (TW-AMB-008). There is deliberately no
102
+ second derivation path, and a namespace that crosses a process boundary travels as inert data
103
+ rather than being resolved again on the far side.
104
+
105
+ Every argument is optional so that a test or an application can replace one thing without
106
+ restating the rest.
107
+ """
108
+ if store is not None:
109
+ configured.store = store
110
+ if transport is not None:
111
+ configured.transport = transport
112
+ if session_resolver is not None:
113
+ configured.session_resolver = session_resolver
114
+ if push_filter is not None:
115
+ configured.push_filter = push_filter
116
+ if result_released is not None:
117
+ configured.result_released = result_released
118
+
119
+
120
+ def reset_configuration() -> None:
121
+ """Return the process to its unconfigured state. For tests; applications configure once."""
122
+ configured.store = None
123
+ configured.transport = None
124
+ configured.session_resolver = None
125
+ configured.push_filter = lambda _client, _operation: True
126
+ configured.result_released = None