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