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
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""The Celery worker entry (TW-CELERY-*).
|
|
2
|
+
|
|
3
|
+
Imports Celery; nothing in `taskwire/` outside `contrib` may (TW-CORE-006).
|
|
4
|
+
|
|
5
|
+
## The one thing to get right
|
|
6
|
+
|
|
7
|
+
**In a Celery deployment exactly one process owns the terminal write, and it is the worker**
|
|
8
|
+
(TW-CELERY-004, TW-INV-009). The web side writes `queued` and - only if dispatch itself failed -
|
|
9
|
+
`failed` with `code: "dispatch_failed"` and `retryable: true`, because an unreachable broker is
|
|
10
|
+
transient and a frontend may legitimately offer a retry.
|
|
11
|
+
|
|
12
|
+
The web side must **not** open `operation()` for a task it dispatches (TW-CELERY-005). Middleware
|
|
13
|
+
never runs in the worker, so the contextvar would be unbound there and every `progress.set()` in the
|
|
14
|
+
task would be a silent no-op - a progress bar that never moves, with nothing logged. The middleware
|
|
15
|
+
that wraps a *non*-Celery action does open one, because the web process is the worker there
|
|
16
|
+
(TW-CELERY-006).
|
|
17
|
+
|
|
18
|
+
## The blocked-worker problem is deliberately unsolved
|
|
19
|
+
|
|
20
|
+
A dialog has no deadline (TW-DLG-005), so **an unanswered question holds its worker until Celery's
|
|
21
|
+
own execution timeout kills the task** (TW-CELERY-010). At that point this module writes `failed`
|
|
22
|
+
with `code: "dialog_timeout"` and `retryable: true`, and withdraws the operation's open dialogs.
|
|
23
|
+
|
|
24
|
+
What you can do about it today, none of which is a solution:
|
|
25
|
+
|
|
26
|
+
- **Route interactive tasks to their own queue and worker pool**, so a wedged pool cannot starve
|
|
27
|
+
everything else::
|
|
28
|
+
|
|
29
|
+
app.conf.task_routes = {"myapp.tasks.interactive_*": {"queue": "interactive"}}
|
|
30
|
+
|
|
31
|
+
then run that queue with its own workers and its own concurrency.
|
|
32
|
+
- **Never `ask()` inside a database transaction** (TW-DLG-017). It waits for a human, and a
|
|
33
|
+
transaction held across it holds its locks for as long as the user takes.
|
|
34
|
+
- **A high-volume interactive flow wants a workflow engine**, which taskwire deliberately does not
|
|
35
|
+
implement. It reports on work and asks questions about it; it does not orchestrate, schedule or
|
|
36
|
+
resume.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import functools
|
|
42
|
+
import logging
|
|
43
|
+
import sys
|
|
44
|
+
|
|
45
|
+
from collections.abc import Callable
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
from celery.exceptions import SoftTimeLimitExceeded
|
|
49
|
+
|
|
50
|
+
from ..ambient import bind as ambient_bind, unbind as ambient_unbind
|
|
51
|
+
from ..decorators import declared_result_kind
|
|
52
|
+
from ..headers import CONNECTION_HEADER, TOKEN_HEADER # noqa: F401 - re-exported for callers
|
|
53
|
+
from ..models import Error, Text
|
|
54
|
+
from ..reporter import mark_failed, mark_queued, operation
|
|
55
|
+
from ..settings import configured
|
|
56
|
+
|
|
57
|
+
logger = logging.getLogger("taskwire.celery")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
DISPATCH_FAILED = "dispatch_failed"
|
|
61
|
+
DIALOG_TIMEOUT = "dialog_timeout"
|
|
62
|
+
"""The two reserved error codes, both `retryable: true` (TW-PROG-005, TW-CELERY-004, TW-DLG-006)."""
|
|
63
|
+
|
|
64
|
+
TOKEN_KWARG = "_taskwire_token"
|
|
65
|
+
SESSION_KWARG = "_taskwire_session"
|
|
66
|
+
"""The two Celery kwargs that carry an operation into a viewset worker (TW-CELERY-003).
|
|
67
|
+
|
|
68
|
+
Underscore-prefixed, and not `token` / `session`, because these travel beside an action's **own**
|
|
69
|
+
arguments rather than a task body's: a viewset action is free to declare a parameter called `token`,
|
|
70
|
+
and a caller that read that one as an operation address would write a namespace's progress to
|
|
71
|
+
whatever the application happened to mean by the word. `wrap_sync_runner` consumes them, so no action
|
|
72
|
+
ever sees them.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def taskwire_task(
|
|
77
|
+
fn: Callable[..., Any] | None = None,
|
|
78
|
+
*,
|
|
79
|
+
result_kind: str | None = None,
|
|
80
|
+
) -> Callable[..., Any]:
|
|
81
|
+
"""Wrap a plain Celery task body so it runs inside an `operation()`.
|
|
82
|
+
|
|
83
|
+
Uses the **synchronous** form (TW-CELERY-008), which creates and owns a private event loop for
|
|
84
|
+
that thread - a Celery worker has none of its own, and every blocking primitive here has to work
|
|
85
|
+
under one that drives it with `run_until_complete` (TW-CELERY-001).
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
@app.task(bind=True, soft_time_limit=600)
|
|
89
|
+
@taskwire_task(result_kind="acme.import_report")
|
|
90
|
+
def import_rows(self, reporter, *, rows):
|
|
91
|
+
for index, row in enumerate(rows):
|
|
92
|
+
reporter.sync.set(percent=100 * index / len(rows))
|
|
93
|
+
insert(row)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The task body must declare a parameter named `reporter`; the wrapper passes it by that keyword,
|
|
97
|
+
and passes `None` when the caller was opted out. `token` and `session` cross
|
|
98
|
+
in as **plain strings** (TW-CELERY-003) and the namespace is *not* re-resolved here: there is no
|
|
99
|
+
request in a worker, `session_resolver` is the only producer of a namespace (TW-AMB-008), and a
|
|
100
|
+
namespace that crossed a process boundary travels as inert data.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def decorate(body: Callable[..., Any]) -> Callable[..., Any]:
|
|
104
|
+
declared = result_kind if result_kind is not None else declared_result_kind(body)
|
|
105
|
+
|
|
106
|
+
@functools.wraps(body)
|
|
107
|
+
def run(*args: Any, token: str | None = None, session: str | None = None, **kwargs: Any) -> Any:
|
|
108
|
+
if token is None or session is None:
|
|
109
|
+
# No namespace means the application opted this caller out (TW-AMB-009). Run the
|
|
110
|
+
# body anyway - reporting is an accelerator, and refusing to do the work because
|
|
111
|
+
# nobody is watching would be the wrong failure.
|
|
112
|
+
return body(*args, reporter=None, **kwargs)
|
|
113
|
+
|
|
114
|
+
# TW-CELERY-009: `result_kind` was written once by the web process's `queued` write.
|
|
115
|
+
# One is passed here only for the case where there was no such write, and the store
|
|
116
|
+
# ignores it otherwise - the field is write-once (TW-PROG-006). There is no exception.
|
|
117
|
+
with operation(token, session=session, result_kind=declared) as reporter:
|
|
118
|
+
try:
|
|
119
|
+
return body(*args, reporter=reporter, **kwargs)
|
|
120
|
+
except SoftTimeLimitExceeded:
|
|
121
|
+
# TW-DLG-006: an unanswered question is what this death usually is, and the code
|
|
122
|
+
# says so only when it is - a worker killed while grinding rows would otherwise
|
|
123
|
+
# tell its reader to answer a question nobody asked. `reporter.asking` is local
|
|
124
|
+
# and free, which is what makes the distinction affordable here, of all places.
|
|
125
|
+
#
|
|
126
|
+
# Named rather than committed: the exception is on its way out of `operation()`,
|
|
127
|
+
# whose exit makes the one terminal write this operation gets (TW-API-001). When
|
|
128
|
+
# it is not a question, nothing is named and the exception's own class is what
|
|
129
|
+
# the exit derives the code from, as it does for every other failure.
|
|
130
|
+
if reporter.asking:
|
|
131
|
+
reporter.name_error(
|
|
132
|
+
DIALOG_TIMEOUT,
|
|
133
|
+
Text(key="taskwire.dialog_timeout"),
|
|
134
|
+
True, # noqa: FBT003 - positional to match `name_error(code, message, retryable)`
|
|
135
|
+
)
|
|
136
|
+
raise
|
|
137
|
+
|
|
138
|
+
return run
|
|
139
|
+
|
|
140
|
+
return decorate if fn is None else decorate(fn)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def dispatch(
|
|
144
|
+
task: Any,
|
|
145
|
+
*,
|
|
146
|
+
token: str,
|
|
147
|
+
session: str | None,
|
|
148
|
+
connection: str | None = None,
|
|
149
|
+
result_kind: str | None = None,
|
|
150
|
+
title: Text | None = None,
|
|
151
|
+
data: dict | None = None,
|
|
152
|
+
**task_kwargs: Any,
|
|
153
|
+
) -> None:
|
|
154
|
+
"""The web side of a dispatch: write `queued`, hand off, and write `failed` only if handing off failed.
|
|
155
|
+
|
|
156
|
+
**This must not open `operation()`** (TW-CELERY-005). The block would close when the request
|
|
157
|
+
ends, long before the worker started, and the terminal write it produced would land on top of -
|
|
158
|
+
or under - the worker's real one. The worker owns the terminal write and this side owns exactly
|
|
159
|
+
two: `queued`, and `failed` when the broker could not be reached.
|
|
160
|
+
|
|
161
|
+
A dispatch failure is `retryable: true` (TW-CELERY-004): an unreachable broker is transient, and
|
|
162
|
+
a frontend may legitimately offer the user a retry rather than reporting a dead operation.
|
|
163
|
+
"""
|
|
164
|
+
await mark_queued(
|
|
165
|
+
token,
|
|
166
|
+
session=session,
|
|
167
|
+
result_kind=result_kind if result_kind is not None else declared_result_kind(task),
|
|
168
|
+
title=title,
|
|
169
|
+
data=data,
|
|
170
|
+
connection=connection,
|
|
171
|
+
)
|
|
172
|
+
try:
|
|
173
|
+
task.delay(token=token, session=session, **task_kwargs)
|
|
174
|
+
except Exception as broker_failure: # noqa: BLE001 - reported to the user, then re-raised
|
|
175
|
+
logger.warning("taskwire: dispatch failed for %s", token, exc_info=True)
|
|
176
|
+
await mark_failed(
|
|
177
|
+
token,
|
|
178
|
+
session=session,
|
|
179
|
+
error=Error(code=DISPATCH_FAILED, message=Text(key="taskwire.dispatch_failed"), retryable=True),
|
|
180
|
+
)
|
|
181
|
+
raise broker_failure
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def wrap_sync_runner(
|
|
185
|
+
runner: Callable[[Any], Any],
|
|
186
|
+
kwargs: dict[str, Any],
|
|
187
|
+
) -> tuple[Callable[[Any], Any], dict[str, Any]]:
|
|
188
|
+
"""Wrap a worker's `run_until_complete` so what it drives runs inside an `operation()`.
|
|
189
|
+
|
|
190
|
+
The worker-side entry for a **viewset action** dispatched to Celery (TW-CELERY-007), and the twin
|
|
191
|
+
of `taskwire_task` for a caller that owns a loop already:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
run, kwargs = wrap_sync_runner(loop.run_until_complete, kwargs)
|
|
195
|
+
kwargs = _reconstruct_kwargs(original_endpoint, kwargs, cls)
|
|
196
|
+
result = run(lifecycle_runner(original_endpoint, instance, cls, lifecycle, *args, **kwargs))
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Both arguments come back: `runner` wrapped, and `kwargs` without `_taskwire_token` /
|
|
200
|
+
`_taskwire_session`, so the action is called with its own arguments and nothing else. The order
|
|
201
|
+
of those two lines is load-bearing - a reconstruction pass that has already run hands the action
|
|
202
|
+
a keyword it never declared. A call carrying neither key gets both arguments back unchanged,
|
|
203
|
+
which is what lets one worker entry serve viewsets that never heard of taskwire (TW-AMB-009).
|
|
204
|
+
|
|
205
|
+
`token` and `session` cross as **plain strings** (TW-CELERY-003) and the namespace is not
|
|
206
|
+
re-resolved: `session_resolver` is the only thing that produces one (TW-AMB-008), there is no
|
|
207
|
+
request in a worker, and a namespace that crossed a process boundary is inert data. No
|
|
208
|
+
`result_kind` is passed either - the web process's `queued` write fixed it and the field is
|
|
209
|
+
write-once (TW-CELERY-009, TW-PROG-006, TW-INV-009).
|
|
210
|
+
|
|
211
|
+
Reporting closes before `run` returns, and therefore before the caller publishes the result. That
|
|
212
|
+
ordering is why TW-CELERY-007 bans `task_prerun` / `task_postrun`: a completion written after the
|
|
213
|
+
result has been pushed lets a client see the answer before `done`.
|
|
214
|
+
"""
|
|
215
|
+
token = _plain_string(kwargs.get(TOKEN_KWARG), TOKEN_KWARG)
|
|
216
|
+
session = _plain_string(kwargs.get(SESSION_KWARG), SESSION_KWARG)
|
|
217
|
+
if token is None or session is None:
|
|
218
|
+
return runner, kwargs
|
|
219
|
+
|
|
220
|
+
remaining = {name: value for name, value in kwargs.items() if name not in (TOKEN_KWARG, SESSION_KWARG)}
|
|
221
|
+
return functools.partial(_drive, runner, token=token, session=session), remaining
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _drive(runner: Callable[[Any], Any], awaitable: Any, *, token: str, session: str) -> Any:
|
|
225
|
+
"""Enter, drive and leave one operation - all with the caller's runner, all from this frame.
|
|
226
|
+
|
|
227
|
+
Three turns of the caller's loop rather than an `async with` inside the awaitable, and both
|
|
228
|
+
halves of that decide something.
|
|
229
|
+
|
|
230
|
+
**One loop.** The opening write, the keepalive, every `await progress.set()` the action makes and
|
|
231
|
+
the terminal write all run on the loop the worker is already driving. `operation()`'s synchronous
|
|
232
|
+
form owns a second loop on a second thread (TW-API-002), which is right for a synchronous task
|
|
233
|
+
body and wrong here: an action is `async def`, so its own awaits would run on the caller's loop
|
|
234
|
+
while the operation ran on the other one, and a `redis.asyncio` client whose connections were
|
|
235
|
+
opened on one loop cannot serve the other.
|
|
236
|
+
|
|
237
|
+
**One frame.** A worker's execution limit is a signal raised at whatever the thread is executing,
|
|
238
|
+
and a thread waiting on an action is inside `runner`, not inside the action. An `async with
|
|
239
|
+
operation(...)` in the awaitable is left suspended by that, its `__aexit__` never runs, and the
|
|
240
|
+
operation expires on its TTL with no terminal write at all. This `finally` is in the frame the
|
|
241
|
+
signal unwinds, so the write happens (TW-CELERY-004, TW-DLG-006).
|
|
242
|
+
|
|
243
|
+
The ambient binding is made here for the same reason `__enter__` makes it in the calling thread:
|
|
244
|
+
a `contextvars.Token` may only be reset in the context that created it, and the task `runner`
|
|
245
|
+
creates for `__aenter__` discards its own (TW-AMB-005). Resetting in the `finally` is what leaves
|
|
246
|
+
the next task on a pooled worker thread unbound rather than writing to the previous operation's
|
|
247
|
+
key (TW-INV-011).
|
|
248
|
+
|
|
249
|
+
A store that cannot be reached at entry leaves the action to run **unreported** rather than
|
|
250
|
+
refusing to run it, and a failure at the terminal write is logged rather than raised: reporting is
|
|
251
|
+
an accelerator, and a broker or store outage must not replace the answer the action produced.
|
|
252
|
+
"""
|
|
253
|
+
scope = operation(token, session=session)
|
|
254
|
+
scope.bind_ambient_in_caller()
|
|
255
|
+
try:
|
|
256
|
+
reporter = runner(scope.__aenter__())
|
|
257
|
+
except Exception: # noqa: BLE001 - an unreachable store is not a reason to refuse the work
|
|
258
|
+
logger.warning("taskwire: no operation for %s; the action runs unreported", token, exc_info=True)
|
|
259
|
+
return runner(awaitable)
|
|
260
|
+
|
|
261
|
+
ambient = ambient_bind(reporter)
|
|
262
|
+
try:
|
|
263
|
+
try:
|
|
264
|
+
return runner(awaitable)
|
|
265
|
+
except SoftTimeLimitExceeded:
|
|
266
|
+
# TW-DLG-006, as `taskwire_task` names it, and on the same condition: `dialog_timeout` is
|
|
267
|
+
# what a reader can act on when the operation was holding a question, and a lie when it
|
|
268
|
+
# was not.
|
|
269
|
+
if reporter.asking:
|
|
270
|
+
reporter.name_error(
|
|
271
|
+
DIALOG_TIMEOUT,
|
|
272
|
+
Text(key="taskwire.dialog_timeout"),
|
|
273
|
+
True, # noqa: FBT003 - positional to match `name_error(code, message, retryable)`
|
|
274
|
+
)
|
|
275
|
+
raise
|
|
276
|
+
finally:
|
|
277
|
+
ambient_unbind(ambient)
|
|
278
|
+
try:
|
|
279
|
+
runner(scope.__aexit__(*sys.exc_info()))
|
|
280
|
+
except Exception: # noqa: BLE001 - logged here, never in place of the action's own outcome
|
|
281
|
+
logger.warning("taskwire: the terminal write failed for %s", token, exc_info=True)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _plain_string(value: Any, name: str) -> str | None:
|
|
285
|
+
"""TW-CELERY-003: both cross as plain strings, and anything else addresses nothing.
|
|
286
|
+
|
|
287
|
+
A wrapper object stringified into a key writes an operation to an address no reader holds, and it
|
|
288
|
+
does it silently - so a value of the wrong shape is refused loudly and the action runs unreported.
|
|
289
|
+
"""
|
|
290
|
+
if value is None:
|
|
291
|
+
return None
|
|
292
|
+
if not isinstance(value, str) or not value:
|
|
293
|
+
logger.warning("taskwire: %s arrived as %r, not a plain string (TW-CELERY-003)", name, value)
|
|
294
|
+
return None
|
|
295
|
+
return value
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def configure_worker(store: Any) -> None:
|
|
299
|
+
"""A worker configures its own store and `NullTransport`, and **never pushes** (TW-CELERY-002).
|
|
300
|
+
|
|
301
|
+
Store and transport configuration must not travel in a serialized context: they are objects with
|
|
302
|
+
connections, the serialized form would be a copy nobody owns, and a worker that pushed would
|
|
303
|
+
duplicate every envelope the reader is already delivering (TW-INV-002).
|
|
304
|
+
|
|
305
|
+
The worker writes; the web process's reader pushes. That is the whole division.
|
|
306
|
+
"""
|
|
307
|
+
from ..transport import NullTransport
|
|
308
|
+
|
|
309
|
+
configured.store = store
|
|
310
|
+
configured.transport = NullTransport()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
async def _celery_dispatch_hook(kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
314
|
+
"""The client-side hook fastapi-viewsets calls just before `send_task` (TW-CELERY-007).
|
|
315
|
+
|
|
316
|
+
Reads `context["taskwire_token"]` / `["taskwire_session"]` - present only if the application
|
|
317
|
+
registered `taskwire_context_processor` (or its own equivalent) for this `celery_viewset_client`
|
|
318
|
+
call, and declared a `context: Context` parameter for the action to carry it. Neither is required
|
|
319
|
+
for the call itself; an action with no `context` kwarg or no token in it is one this deployment
|
|
320
|
+
opted out of tracking (TW-AMB-009), and this returns `{}`.
|
|
321
|
+
"""
|
|
322
|
+
from .viewsets import CONNECTION_KEY, SESSION_KEY, TOKEN_KEY
|
|
323
|
+
|
|
324
|
+
context = kwargs.get("context")
|
|
325
|
+
if context is None or TOKEN_KEY not in context or SESSION_KEY not in context:
|
|
326
|
+
return {}
|
|
327
|
+
token = await context[TOKEN_KEY]
|
|
328
|
+
session = await context[SESSION_KEY]
|
|
329
|
+
if token is None or session is None:
|
|
330
|
+
return {}
|
|
331
|
+
connection = await context[CONNECTION_KEY] if CONNECTION_KEY in context else None
|
|
332
|
+
|
|
333
|
+
# No `result_kind`: the hook has no route-specific information to derive one from, and the field
|
|
334
|
+
# is write-once (TW-PROG-006) - an operation dispatched this way keeps the generic rendering a
|
|
335
|
+
# missing `result_kind` gets, rather than one guessed from nothing.
|
|
336
|
+
await mark_queued(token, session=session, connection=connection)
|
|
337
|
+
return {TOKEN_KWARG: token, SESSION_KWARG: session}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def register_taskwire_celery_viewset() -> None:
|
|
341
|
+
"""Wire taskwire into both of fastapi-viewsets' Celery viewset hooks (TW-CELERY-007).
|
|
342
|
+
|
|
343
|
+
Call once: `set_celery_kwargs_hook` on the worker, `set_celery_dispatch_hook` wherever the web
|
|
344
|
+
process dispatches. Both degrade to a no-op for a call taskwire was never told to track
|
|
345
|
+
(TW-AMB-009), so this is safe to call even for a deployment where some `celery_viewset` actions
|
|
346
|
+
carry no `context: Context` parameter and no token at all.
|
|
347
|
+
"""
|
|
348
|
+
from fastapi_viewsets.decorators.celery_viewset.client import set_celery_dispatch_hook
|
|
349
|
+
from fastapi_viewsets.decorators.celery_viewset.server import set_celery_kwargs_hook
|
|
350
|
+
|
|
351
|
+
set_celery_kwargs_hook(wrap_sync_runner)
|
|
352
|
+
set_celery_dispatch_hook(_celery_dispatch_hook)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""The FastAPI adapter. Imports FastAPI; nothing in `taskwire/` outside `contrib` may (TW-CORE-006).
|
|
2
|
+
|
|
3
|
+
The adapter does exactly two things and deliberately no more: it builds a `Client` from the request,
|
|
4
|
+
and it translates `(status, payload)` into a response. Every decision the endpoints make lives in
|
|
5
|
+
`taskwire.rest`, which knows nothing about any framework - so the same behaviour is testable
|
|
6
|
+
without a server, and an application on a different framework reimplements only this file.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, Request, Response
|
|
14
|
+
|
|
15
|
+
from ..delivery import Client
|
|
16
|
+
from ..headers import CONNECTION_HEADER
|
|
17
|
+
from ..rest import collect_result, dismiss_result, get_operations, get_snapshot, reply_to_dialog, request_cancel
|
|
18
|
+
from ..settings import configured, settings
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_client(request: Request) -> Client:
|
|
22
|
+
"""TW-REST-002. `session_resolver` fills `session`; the header fills `connection`.
|
|
23
|
+
|
|
24
|
+
`session_resolver` is the only producer of a namespace (TW-AMB-008). A `None` return is legal
|
|
25
|
+
and means the application opted this caller out entirely - not "nobody is logged in", since an
|
|
26
|
+
anonymous visitor still has a session (TW-AMB-009).
|
|
27
|
+
"""
|
|
28
|
+
resolver = configured.session_resolver
|
|
29
|
+
session = resolver(request) if resolver is not None else None
|
|
30
|
+
return Client(
|
|
31
|
+
session=session, # type: ignore[arg-type]
|
|
32
|
+
connection=request.headers.get(CONNECTION_HEADER),
|
|
33
|
+
request=request,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def router(prefix: str | None = None) -> APIRouter:
|
|
38
|
+
"""Mount point for the six endpoints of TW-REST-004, under `prefix`."""
|
|
39
|
+
api = APIRouter(prefix=prefix if prefix is not None else settings.rest_prefix)
|
|
40
|
+
|
|
41
|
+
# The register is the collection, so it lives at the collection's own URL and carries no extra
|
|
42
|
+
# segment (TW-REST-004). Nothing follows the prefix that a token could be mistaken for.
|
|
43
|
+
@api.get("")
|
|
44
|
+
async def operations(request: Request, response: Response) -> Any:
|
|
45
|
+
status, payload = await get_operations(build_client(request))
|
|
46
|
+
response.status_code = status
|
|
47
|
+
return payload
|
|
48
|
+
|
|
49
|
+
# Declared before `/{token}`: FastAPI matches in declaration order, and the longer path must be
|
|
50
|
+
# offered first or the token route swallows it.
|
|
51
|
+
@api.post("/{token}/dialogs/{did}")
|
|
52
|
+
async def dialog_reply(token: str, did: str, request: Request, response: Response) -> Any:
|
|
53
|
+
body = await request.json() if await request.body() else {}
|
|
54
|
+
status, payload = await reply_to_dialog(build_client(request), token, did, body)
|
|
55
|
+
response.status_code = status
|
|
56
|
+
return payload
|
|
57
|
+
|
|
58
|
+
@api.post("/{token}/cancel")
|
|
59
|
+
async def cancel(token: str, request: Request, response: Response) -> Any:
|
|
60
|
+
status, payload = await request_cancel(build_client(request), token)
|
|
61
|
+
response.status_code = status
|
|
62
|
+
return payload
|
|
63
|
+
|
|
64
|
+
@api.post("/{token}/collect")
|
|
65
|
+
async def collect(token: str, request: Request, response: Response) -> Any:
|
|
66
|
+
status, payload = await collect_result(build_client(request), token)
|
|
67
|
+
response.status_code = status
|
|
68
|
+
return payload
|
|
69
|
+
|
|
70
|
+
@api.post("/{token}/dismiss")
|
|
71
|
+
async def dismiss(token: str, request: Request, response: Response) -> Any:
|
|
72
|
+
status, payload = await dismiss_result(build_client(request), token)
|
|
73
|
+
response.status_code = status
|
|
74
|
+
return payload
|
|
75
|
+
|
|
76
|
+
@api.get("/{token}")
|
|
77
|
+
async def snapshot(token: str, request: Request, response: Response) -> Any:
|
|
78
|
+
status, payload = await get_snapshot(build_client(request), token)
|
|
79
|
+
response.status_code = status
|
|
80
|
+
return payload
|
|
81
|
+
|
|
82
|
+
return api
|