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,204 @@
|
|
|
1
|
+
"""Pydantic mirrors of the wire documents (§3), for the viewset layer only.
|
|
2
|
+
|
|
3
|
+
Imports pydantic; nothing in `taskwire/` outside `contrib` may (TW-CORE-006). **The core stays
|
|
4
|
+
dependency-free**, and that is not a stylistic preference: `pip install taskwire` has to work in a
|
|
5
|
+
bare script, and the dataclasses in `models.py` are the normative shapes. These are a projection of
|
|
6
|
+
them onto a schema language, kept here so the projection can never become the definition.
|
|
7
|
+
|
|
8
|
+
## Why mirror at all
|
|
9
|
+
|
|
10
|
+
`fastapi-viewsets` types a viewset over a `BaseModel` - it introspects fields to build the OpenAPI
|
|
11
|
+
schema, to derive filter models, and to strip the pk from create bodies. Handing it a dataclass
|
|
12
|
+
would work for none of that, and the schema is the point: it is what the FE proxy validates itself
|
|
13
|
+
against, and what a reader of `/docs` sees.
|
|
14
|
+
|
|
15
|
+
## The one deliberate difference from `to_dict()`
|
|
16
|
+
|
|
17
|
+
`to_dict()` **omits** an absent optional field; these models **emit it as null**. Both are legal on
|
|
18
|
+
the wire and parse identically, because TW-CORE-010 requires absent and null to be indistinguishable
|
|
19
|
+
to a reader - a rule written for exactly this kind of second producer. `schema_test.py` asserts the
|
|
20
|
+
equivalence by feeding every conformance fixture through both paths.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel, Field
|
|
28
|
+
|
|
29
|
+
from ..models import DialogState, ProgressState, WIRE_VERSION
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Text(BaseModel):
|
|
33
|
+
"""TW-TXT-001. Any subset of the three is valid; a frontend with a catalogue uses `key`."""
|
|
34
|
+
|
|
35
|
+
key: str | None = None
|
|
36
|
+
params: dict[str, Any] | None = None
|
|
37
|
+
text: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Error(BaseModel):
|
|
41
|
+
"""TW-PROG-005. No stack trace, deliberately - this document reaches a browser."""
|
|
42
|
+
|
|
43
|
+
code: str
|
|
44
|
+
message: Text = Field(default_factory=Text)
|
|
45
|
+
retryable: bool = False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ResultRef(BaseModel):
|
|
49
|
+
"""A pointer to an artefact the **application** owns (TW-RES-007). taskwire stores it, never serves it."""
|
|
50
|
+
|
|
51
|
+
href: str
|
|
52
|
+
mime: str | None = None
|
|
53
|
+
bytes: int | None = None
|
|
54
|
+
expires_at: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Result(BaseModel):
|
|
58
|
+
"""TW-RES-002. `kind` names the component that collects this; taskwire never validates the set."""
|
|
59
|
+
|
|
60
|
+
kind: str
|
|
61
|
+
params: dict[str, Any] | None = None
|
|
62
|
+
label: Text | None = None
|
|
63
|
+
value: Any = None
|
|
64
|
+
ref: ResultRef | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Button(BaseModel):
|
|
68
|
+
"""A choice on a dialog. There is no `default` flag - a default button's only use is answering a
|
|
69
|
+
timeout, and there are no timeouts (TW-DLG-005)."""
|
|
70
|
+
|
|
71
|
+
id: str
|
|
72
|
+
label: Text | None = None
|
|
73
|
+
style: str | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Input(BaseModel):
|
|
77
|
+
"""A value collected alongside the choice. `type` is one of TW-DLG-004's four and nothing more."""
|
|
78
|
+
|
|
79
|
+
name: str
|
|
80
|
+
type: str = "string"
|
|
81
|
+
required: bool = False
|
|
82
|
+
label: Text | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class DialogReply(BaseModel):
|
|
86
|
+
"""The body of an answer: which button, and the values collected with it."""
|
|
87
|
+
|
|
88
|
+
button: str
|
|
89
|
+
values: dict[str, Any] = Field(default_factory=dict)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class DialogRequest(BaseModel):
|
|
93
|
+
"""One asking. `id` is *this asking*, `dialog_id` is *which question* (TW-DLG-001).
|
|
94
|
+
|
|
95
|
+
Only `id` is ever a key or a path segment. Keying by `dialog_id` would let two concurrent
|
|
96
|
+
operations rendering the same component answer each other's question (TW-DLG-002).
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
id: str
|
|
100
|
+
dialog_id: str
|
|
101
|
+
buttons: list[Button] = Field(default_factory=list)
|
|
102
|
+
inputs: list[Input] = Field(default_factory=list)
|
|
103
|
+
params: dict[str, Any] | None = None
|
|
104
|
+
state: DialogState = DialogState.OPEN
|
|
105
|
+
created_at: str
|
|
106
|
+
reply: DialogReply | None = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Progress(BaseModel):
|
|
110
|
+
"""§3.2, the one document per operation.
|
|
111
|
+
|
|
112
|
+
`state` is never written by a caller - it is derived from lifecycle events and nothing else
|
|
113
|
+
(TW-PROG-012) - and `result_kind` / `origin_session` / `origin_connection` are fixed at the
|
|
114
|
+
opening write and never rewritten (TW-PROG-006).
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
state: ProgressState = ProgressState.QUEUED
|
|
118
|
+
percent: float | None = None
|
|
119
|
+
title: Text | None = None
|
|
120
|
+
label: Text | None = None
|
|
121
|
+
icon: str | None = None
|
|
122
|
+
data: dict[str, Any] = Field(default_factory=dict)
|
|
123
|
+
error: Error | None = None
|
|
124
|
+
result: Result | None = None
|
|
125
|
+
result_kind: str | None = None
|
|
126
|
+
origin_session: str = ""
|
|
127
|
+
origin_connection: str | None = None
|
|
128
|
+
created_at: str
|
|
129
|
+
updated_at: str
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class Snapshot(BaseModel):
|
|
133
|
+
"""§3.6. The complete current state of one operation - which is what makes reconnect a single GET."""
|
|
134
|
+
|
|
135
|
+
v: int = WIRE_VERSION
|
|
136
|
+
token: str
|
|
137
|
+
rev: int
|
|
138
|
+
progress: Progress
|
|
139
|
+
dialogs: list[DialogRequest] = Field(default_factory=list)
|
|
140
|
+
cancel_requested: bool = False
|
|
141
|
+
server_time: str
|
|
142
|
+
poll_after_ms: int = 5000
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class OperationSummary(BaseModel):
|
|
146
|
+
"""§3.7. One register row: the whole snapshot, plus what a list view needs lifted out of it.
|
|
147
|
+
|
|
148
|
+
`token` is the pk the viewset is keyed on. That is not a convenience - the token IS the identity
|
|
149
|
+
of an operation, and there is no other key anywhere in the system (TW-KEY-001).
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
token: str
|
|
153
|
+
snapshot: Snapshot
|
|
154
|
+
result_kind: str | None = None
|
|
155
|
+
origin_session: str = ""
|
|
156
|
+
result: Result | None = None
|
|
157
|
+
needs_attention: bool = False
|
|
158
|
+
progress_delivery: bool = True
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class Aggregate(BaseModel):
|
|
162
|
+
"""§3.8a. Counts and a dominated state.
|
|
163
|
+
|
|
164
|
+
**There is no `percent` and no rule may add one** (TW-REG-011). Two operations' self-reported
|
|
165
|
+
percentages are not commensurable - one is 40 % through a row count, the other through a byte
|
|
166
|
+
count - so any figure derived across them is invented. The field is absent from the wire rather
|
|
167
|
+
than null on it, which is why it is absent from this model too.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
state: ProgressState
|
|
171
|
+
title: Text = Field(default_factory=lambda: Text(key="taskwire.aggregate.title"))
|
|
172
|
+
data: dict[str, int] = Field(default_factory=dict)
|
|
173
|
+
label: Text | None = None
|
|
174
|
+
updated_at: str
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class Register(BaseModel):
|
|
178
|
+
"""§3.8. Every shared operation of one namespace, sorted, with its aggregate.
|
|
179
|
+
|
|
180
|
+
Returned by `GET {prefix}` rather than as a `list[OperationSummary]`: this document carries
|
|
181
|
+
`poll_after_ms`, which TW-REST-014 makes binding on the client's next poll, and the aggregate
|
|
182
|
+
computed over the same read.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
v: int = WIRE_VERSION
|
|
186
|
+
operations: list[OperationSummary] = Field(default_factory=list)
|
|
187
|
+
aggregate: Aggregate | None = None
|
|
188
|
+
server_time: str
|
|
189
|
+
poll_after_ms: int = 5000
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class Accepted(BaseModel):
|
|
193
|
+
"""TW-CANCEL-003: a cancel request is an **acknowledgement**, never a completion.
|
|
194
|
+
|
|
195
|
+
The operation stops when its own code next looks, which may be never - taskwire does not revoke
|
|
196
|
+
tasks, signal threads or interrupt blocking code (TW-CANCEL-009). Answering `202` with this
|
|
197
|
+
rather than `200` with a state is the protocol declining to claim otherwise.
|
|
198
|
+
|
|
199
|
+
Collect, dismiss and a dialog reply answer `204` and no body at all, so they have no model here:
|
|
200
|
+
what they changed is visible in the next read, and inventing a body for them would be a second
|
|
201
|
+
place for the same fact to be wrong.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
detail: str | None = None
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Carrying the ambient reporter across a thread boundary (TW-AMB-006).
|
|
2
|
+
|
|
3
|
+
`contextvars` propagate along `await`, and they propagate into `asyncio.to_thread`. They do **not**
|
|
4
|
+
propagate into `loop.run_in_executor`, which is the trap: the call looks like the same thing, the
|
|
5
|
+
code inside it runs, and every `await progress.set(...)` in there is a silent no-op (TW-AMB-002). The
|
|
6
|
+
symptom is a progress bar that stops moving for exactly the duration of one phase, with nothing
|
|
7
|
+
logged anywhere and no exception to catch.
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
# The trap. Nothing raises; the reporting inside `heavy` simply vanishes.
|
|
11
|
+
await loop.run_in_executor(None, heavy)
|
|
12
|
+
|
|
13
|
+
# Either of these carries the binding:
|
|
14
|
+
await asyncio.to_thread(heavy)
|
|
15
|
+
await loop.run_in_executor(None, copy_context_to(heavy))
|
|
16
|
+
```
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import contextvars
|
|
22
|
+
import functools
|
|
23
|
+
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from typing import Any, TypeVar
|
|
26
|
+
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def copy_context_to(fn: Callable[..., T]) -> Callable[..., T]:
|
|
31
|
+
"""Wrap `fn` so it runs in a copy of the caller's context.
|
|
32
|
+
|
|
33
|
+
Take the copy **now**, at wrap time, on the calling thread - that is the whole trick. Copying
|
|
34
|
+
inside the wrapper would copy the executor thread's context, which is the empty one this helper
|
|
35
|
+
exists to avoid.
|
|
36
|
+
"""
|
|
37
|
+
context = contextvars.copy_context()
|
|
38
|
+
|
|
39
|
+
@functools.wraps(fn)
|
|
40
|
+
def run(*args: Any, **kwargs: Any) -> T:
|
|
41
|
+
return context.run(fn, *args, **kwargs)
|
|
42
|
+
|
|
43
|
+
return run
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""The request/response half of the protocol, as a `fastapi-viewsets` ViewSet.
|
|
2
|
+
|
|
3
|
+
Imports FastAPI and fastapi-viewsets; nothing in `taskwire/` outside `contrib` may (TW-CORE-006).
|
|
4
|
+
|
|
5
|
+
## What an application does
|
|
6
|
+
|
|
7
|
+
One call, and the whole REST surface exists::
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, FastAPI
|
|
10
|
+
from taskwire.contrib.viewsets import register_taskwire_rest
|
|
11
|
+
|
|
12
|
+
app = FastAPI()
|
|
13
|
+
router = APIRouter()
|
|
14
|
+
register_taskwire_rest(router) # <- the entire REST transport
|
|
15
|
+
app.include_router(router)
|
|
16
|
+
|
|
17
|
+
That call registers the context processor taskwire needs, applies `@route_viewset` and mounts six
|
|
18
|
+
endpoints - **on REST and on muxws both**, since `route_viewset` publishes a viewset on the two
|
|
19
|
+
transports at once. There is deliberately nothing else to wire: an application that has to remember a
|
|
20
|
+
second call is an application where somebody eventually forgets it, and the failure would be a
|
|
21
|
+
progress bar that never moves with nothing logged.
|
|
22
|
+
|
|
23
|
+
## Why a viewset rather than a hand-rolled router
|
|
24
|
+
|
|
25
|
+
Three things fall out of it that are awkward to build twice:
|
|
26
|
+
|
|
27
|
+
- **`@action_configuration`** supplies the middle level of `raise_on_cancel`'s three-level
|
|
28
|
+
resolution (settings -> per-viewset/per-action -> per-call keyword). taskwire's core knows only
|
|
29
|
+
the outer two, because a mechanism resolving configuration from settings through a class to a
|
|
30
|
+
method is meaningless without viewsets - so this level exists only here.
|
|
31
|
+
- **The middleware chain** - auth, session, rate limiting - applies to taskwire's endpoints the same
|
|
32
|
+
way it applies to the application's own, instead of taskwire inventing a second way to be
|
|
33
|
+
protected.
|
|
34
|
+
- **One schema.** The FE proxy validates itself against `/schema`, so an endpoint that moved and a
|
|
35
|
+
client that did not find each other at startup rather than in production.
|
|
36
|
+
|
|
37
|
+
## What is NOT here
|
|
38
|
+
|
|
39
|
+
`taskwire.rest` holds every decision these endpoints make and imports no framework at all
|
|
40
|
+
(TW-REST-001). This module builds a `Client`, calls a handler and translates `(status, payload)`.
|
|
41
|
+
That split is why the same behaviour is testable with no server running, why `contrib/asgi.py` can
|
|
42
|
+
serve the identical protocol with nothing installed but Python, and why the muxws transport can
|
|
43
|
+
reach the same handlers over a stream instead of over HTTP.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
# NO `from __future__ import annotations` in this module, deliberately.
|
|
47
|
+
#
|
|
48
|
+
# `route_viewset` introspects these signatures with a plain `inspect.signature()` and hands the
|
|
49
|
+
# return annotation on as the response model. Under the future import every annotation is a *string*,
|
|
50
|
+
# so `-> None` arrives as the truthy `"None"` and FastAPI rejects it against a 204. The rule is
|
|
51
|
+
# narrow but it has teeth: a module a third-party decorator reads signatures off must expose real
|
|
52
|
+
# objects, not text. Everything else in `taskwire/` keeps the import.
|
|
53
|
+
|
|
54
|
+
from typing import Any
|
|
55
|
+
|
|
56
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
57
|
+
from fastapi_viewsets.conf import settings as viewset_settings
|
|
58
|
+
from fastapi_viewsets.context import Context
|
|
59
|
+
from fastapi_viewsets.decorators.route_viewset import route_viewset
|
|
60
|
+
from fastapi_viewsets.response_classes import NOT_FOUND_RESPONSE
|
|
61
|
+
|
|
62
|
+
from ..delivery import Client
|
|
63
|
+
from ..headers import CONNECTION_HEADER, TOKEN_HEADER # noqa: F401 - re-exported
|
|
64
|
+
from ..rest import collect_result, dismiss_result, get_operations, get_snapshot, reply_to_dialog, request_cancel
|
|
65
|
+
from ..settings import configured, settings
|
|
66
|
+
from . import schema
|
|
67
|
+
|
|
68
|
+
# The three keys the context processor contributes. Namespaced, because the context dict is shared
|
|
69
|
+
# with every other processor the application has configured.
|
|
70
|
+
SESSION_KEY = "taskwire_session"
|
|
71
|
+
"""Where the namespace `session_resolver` returned sits in the context."""
|
|
72
|
+
|
|
73
|
+
CONNECTION_KEY = "taskwire_connection"
|
|
74
|
+
"""Where the value of `CONNECTION_HEADER`, or `None`, sits in the context."""
|
|
75
|
+
|
|
76
|
+
TOKEN_KEY = "taskwire_token"
|
|
77
|
+
"""Where the value of `TOKEN_HEADER`, or `None`, sits in the context.
|
|
78
|
+
|
|
79
|
+
Read by `taskwire.contrib.celery`'s dispatch hook (TW-CELERY-007): a `celery_viewset_client` action
|
|
80
|
+
has no `Request` of its own, only whatever a context processor put in `context` before dispatch, and
|
|
81
|
+
this is the one key that carries the token across that boundary.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def taskwire_context_processor(request: Request, _viewset: Any = None) -> dict[str, Any]:
|
|
86
|
+
"""Lift the namespace, the connection id and the token off the live request, once, per action.
|
|
87
|
+
|
|
88
|
+
A viewset endpoint never receives the `Request` - `route_viewset` reserves that parameter name
|
|
89
|
+
and strips it before FastAPI sees the signature - so this processor is the supported way in, and
|
|
90
|
+
it is the *only* place taskwire calls `session_resolver` on the REST path.
|
|
91
|
+
|
|
92
|
+
A `None` namespace is legal and means the application opted this caller out entirely
|
|
93
|
+
(TW-AMB-009). It does not mean "nobody is logged in": an anonymous visitor still has a session,
|
|
94
|
+
and conflating the two would hand every anonymous visitor the same register.
|
|
95
|
+
"""
|
|
96
|
+
resolver = configured.session_resolver
|
|
97
|
+
return {
|
|
98
|
+
SESSION_KEY: resolver(request) if resolver is not None else None,
|
|
99
|
+
CONNECTION_KEY: request.headers.get(CONNECTION_HEADER),
|
|
100
|
+
TOKEN_KEY: request.headers.get(TOKEN_HEADER),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def _client(context: Context) -> Client:
|
|
105
|
+
"""Build the `Client` every handler in `taskwire.rest` takes."""
|
|
106
|
+
return Client(
|
|
107
|
+
session=await context[SESSION_KEY],
|
|
108
|
+
connection=await context[CONNECTION_KEY],
|
|
109
|
+
request=None,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _unwrap(status: int, payload: dict[str, Any] | None) -> dict[str, Any]:
|
|
114
|
+
"""Translate `(status, payload)` into a return value or an `HTTPException`, and nothing more.
|
|
115
|
+
|
|
116
|
+
Every status this raises on was decided in `taskwire.rest`; none is invented here. In particular
|
|
117
|
+
404 covers both "unknown token" and "somebody else's token" (TW-SEC-002) - a 403 would confirm
|
|
118
|
+
that a token exists in *some* namespace, which turns the endpoint into an oracle for enumerating
|
|
119
|
+
other people's operations.
|
|
120
|
+
"""
|
|
121
|
+
if status >= 400:
|
|
122
|
+
raise HTTPException(status_code=status, detail=(payload or {}).get("detail", "error"))
|
|
123
|
+
return payload or {}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _check(status: int, payload: dict[str, Any] | None) -> None:
|
|
127
|
+
"""`_unwrap` for the actions that answer `204` and no body.
|
|
128
|
+
|
|
129
|
+
Collect, dismiss and a dialog reply change something the *next read* reports; none of them has a
|
|
130
|
+
body of its own. Returning `None` here is what makes FastAPI send an empty 204 rather than the
|
|
131
|
+
`null` an untyped return would serialise to.
|
|
132
|
+
"""
|
|
133
|
+
_unwrap(status, payload)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TaskwireViewSet:
|
|
137
|
+
"""The six endpoints of TW-REST-004, keyed by token.
|
|
138
|
+
|
|
139
|
+
The token is the pk, which is not a convenience: it **is** the identity of an operation and
|
|
140
|
+
there is no other key anywhere in the system (TW-KEY-001). It is an address rather than an
|
|
141
|
+
authorization grant (TW-SEC-003) - holding one lets you watch an operation you could already
|
|
142
|
+
reach, and the namespace decided that before any of these methods ran.
|
|
143
|
+
|
|
144
|
+
## Why this declares its own routes instead of inheriting the CRUD mixins
|
|
145
|
+
|
|
146
|
+
taskwire is not a collection of one type. `list` answers a `Register` - operations, the
|
|
147
|
+
aggregate, and the `poll_after_ms` that TW-REST-014 makes binding on the next poll - while
|
|
148
|
+
`retrieve` answers a `Snapshot`. `ImplMixin` is generic over a single `T`, so wearing it here
|
|
149
|
+
would mean stubbing five `container_*` methods that can never run, and the stubs would be the
|
|
150
|
+
only evidence that the shape was wrong.
|
|
151
|
+
|
|
152
|
+
What the viewset layer is actually wanted for arrives regardless: the middleware chain,
|
|
153
|
+
`@action_configuration`, one OpenAPI schema and the `/schema` endpoint the FE proxy validates
|
|
154
|
+
itself against.
|
|
155
|
+
|
|
156
|
+
There is no `create`: an operation is started by the application's own action, and the token is
|
|
157
|
+
the entire link between that command and taskwire's commentary on it (TW-CORE-007). Nor is there
|
|
158
|
+
`update` or `destroy` - progress is written by the operation and by nothing else (TW-PROG-012),
|
|
159
|
+
and an operation ends by finishing, failing or being cancelled, never by being deleted.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
__router = APIRouter()
|
|
163
|
+
|
|
164
|
+
@__router.get("", summary="Every shared operation of this namespace, with its aggregate")
|
|
165
|
+
async def list_items(self, context: Context) -> schema.Register:
|
|
166
|
+
"""`GET {prefix}` - the central endpoint, and the one an ordinary client polls.
|
|
167
|
+
|
|
168
|
+
Takes no parameters (TW-REG-003), so there is none that could name another namespace: the
|
|
169
|
+
register a caller sees is decided entirely by `session_resolver`.
|
|
170
|
+
|
|
171
|
+
Returns the whole `Register` rather than `list[OperationSummary]` because `poll_after_ms` is
|
|
172
|
+
binding on the client's next poll (TW-REST-014) and the aggregate is computed over the same
|
|
173
|
+
read. Splitting them would cost a second round trip to learn how long to wait before the
|
|
174
|
+
first one.
|
|
175
|
+
"""
|
|
176
|
+
status, payload = await get_operations(await _client(context))
|
|
177
|
+
return schema.Register.model_validate(_unwrap(status, payload))
|
|
178
|
+
|
|
179
|
+
@__router.get("{token}", responses=NOT_FOUND_RESPONSE, summary="The complete current state of one operation")
|
|
180
|
+
async def retrieve(self, context: Context, token: str) -> schema.Snapshot:
|
|
181
|
+
"""`GET {prefix}/{token}` - everything about one operation, in one document.
|
|
182
|
+
|
|
183
|
+
This is what makes reconnect a single GET (TW-CORE-005): there is no event log, no sequence
|
|
184
|
+
number and no replay endpoint anywhere in the system, and a client is entitled to current
|
|
185
|
+
state only.
|
|
186
|
+
"""
|
|
187
|
+
status, payload = await get_snapshot(await _client(context), token)
|
|
188
|
+
return schema.Snapshot.model_validate(_unwrap(status, payload))
|
|
189
|
+
|
|
190
|
+
@__router.post("{token}/cancel", status_code=202, summary="Ask an operation to stop")
|
|
191
|
+
async def cancel(self, context: Context, token: str) -> schema.Accepted:
|
|
192
|
+
"""`202`, deliberately - an acknowledgement, never a completion (TW-CANCEL-003).
|
|
193
|
+
|
|
194
|
+
The operation stops when its own code next looks, which may be never. taskwire does not
|
|
195
|
+
revoke tasks, signal threads or interrupt blocking code (TW-CANCEL-009), and a `200` here
|
|
196
|
+
would claim a power the library does not have.
|
|
197
|
+
|
|
198
|
+
An operation holding an uncollected result refuses with `409` (TW-CANCEL-010): its work is
|
|
199
|
+
over and there is nothing left to interrupt, so the affordance there is dismiss, not cancel.
|
|
200
|
+
"""
|
|
201
|
+
status, payload = await request_cancel(await _client(context), token)
|
|
202
|
+
return schema.Accepted.model_validate(_unwrap(status, payload))
|
|
203
|
+
|
|
204
|
+
@__router.post(
|
|
205
|
+
"{token}/collect", status_code=204, response_model=None, summary="Take the parked result and release it"
|
|
206
|
+
)
|
|
207
|
+
async def collect(self, context: Context, token: str) -> None:
|
|
208
|
+
"""The result is handed over exactly once (TW-RES-010): whoever gets `204` owns it.
|
|
209
|
+
|
|
210
|
+
A second caller gets `409`. That matters with several tabs open on one namespace - without a
|
|
211
|
+
single arbiter, two tabs would each believe they had collected the same export. The value
|
|
212
|
+
itself was already in the snapshot the caller read; this call releases it, which is why
|
|
213
|
+
there is no body to return.
|
|
214
|
+
"""
|
|
215
|
+
_check(*await collect_result(await _client(context), token))
|
|
216
|
+
|
|
217
|
+
@__router.post(
|
|
218
|
+
"{token}/dismiss", status_code=204, response_model=None, summary="Discard the parked result without taking it"
|
|
219
|
+
)
|
|
220
|
+
async def dismiss(self, context: Context, token: str) -> None:
|
|
221
|
+
"""The same act as collect under a different name (TW-REST-008), differing only in
|
|
222
|
+
idempotence: dismissing what is already gone is not an error, collecting it twice is."""
|
|
223
|
+
_check(*await dismiss_result(await _client(context), token))
|
|
224
|
+
|
|
225
|
+
@__router.post("{token}/dialogs/{did}", status_code=204, response_model=None, summary="Answer an open question")
|
|
226
|
+
async def dialog_reply(
|
|
227
|
+
self,
|
|
228
|
+
context: Context,
|
|
229
|
+
token: str,
|
|
230
|
+
did: str,
|
|
231
|
+
reply: schema.DialogReply,
|
|
232
|
+
) -> None:
|
|
233
|
+
"""`did` is *this asking*, never the `dialog_id` (TW-DLG-001, TW-DLG-002).
|
|
234
|
+
|
|
235
|
+
The store is the single arbiter and first answer wins (TW-STORE-003). A second tab answering
|
|
236
|
+
the same question gets `409`, and the client library treats that as a normal outcome of a
|
|
237
|
+
namespace-wide question rather than as a fault (TW-DLG-011) - the losing modal simply closes.
|
|
238
|
+
"""
|
|
239
|
+
_check(*await reply_to_dialog(await _client(context), token, did, reply.model_dump()))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
_registered: set[int] = set()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def register_taskwire_rest(
|
|
246
|
+
router: APIRouter,
|
|
247
|
+
base_path: str | None = None,
|
|
248
|
+
*,
|
|
249
|
+
register_muxws: bool | None = None,
|
|
250
|
+
) -> type[TaskwireViewSet]:
|
|
251
|
+
"""Mount the whole request/response surface on `router`. The only call an application makes.
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
router = APIRouter()
|
|
255
|
+
register_taskwire_rest(router)
|
|
256
|
+
app.include_router(router)
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
**The same six endpoints answer over both transports.** `route_viewset` publishes them on REST
|
|
260
|
+
and - through `fastapi_viewsets.mux_ws` - on muxws, where `process_command` dispatches a stream
|
|
261
|
+
into the identical routes with the identical validation, middleware chain and response models.
|
|
262
|
+
A client speaking only WebSocket therefore reaches exactly the surface a client speaking only
|
|
263
|
+
HTTP does, and there is no second implementation to keep in step.
|
|
264
|
+
|
|
265
|
+
`register_muxws` decides whether the muxws half is published: `None` defers to fastapi-viewsets'
|
|
266
|
+
own `viewsets_register_muxws` (True unless the application says otherwise), and `False`
|
|
267
|
+
leaves a deployment with REST alone. It is the only knob, because the two transports differ in
|
|
268
|
+
nothing else.
|
|
269
|
+
|
|
270
|
+
The context processor is **transport-blind**. `process_command` supplies the WebSocket
|
|
271
|
+
handshake's own headers as the baseline for every command, so `session_resolver` reads the same
|
|
272
|
+
cookie or `Authorization` header on a stream that it reads on a request, and `CONNECTION_HEADER`
|
|
273
|
+
arrives the same way - stated on the stream when the client states it. Nothing here branches on
|
|
274
|
+
how the call arrived, and nothing may.
|
|
275
|
+
|
|
276
|
+
Idempotent per router: calling it twice mounts one copy, because `route_viewset` registers
|
|
277
|
+
routes as a side effect and a doubly-mounted viewset answers every request twice through two
|
|
278
|
+
middleware chains.
|
|
279
|
+
"""
|
|
280
|
+
if id(router) in _registered:
|
|
281
|
+
return TaskwireViewSet
|
|
282
|
+
_registered.add(id(router))
|
|
283
|
+
|
|
284
|
+
if taskwire_context_processor not in viewset_settings.viewsets_context_processors:
|
|
285
|
+
viewset_settings.viewsets_context_processors.append(taskwire_context_processor)
|
|
286
|
+
|
|
287
|
+
return route_viewset(
|
|
288
|
+
router,
|
|
289
|
+
base_path if base_path is not None else settings.rest_prefix,
|
|
290
|
+
pk_field_name="token",
|
|
291
|
+
register_muxws=register_muxws,
|
|
292
|
+
)(TaskwireViewSet)
|
taskwire/decorators.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""The three result decorators (§5.1).
|
|
2
|
+
|
|
3
|
+
**They are sugar over `result_kind` and nothing else** (TW-API-005). Each wraps a task or an action
|
|
4
|
+
and declares the kind its operation will produce, so the opening `queued` write already carries it -
|
|
5
|
+
which is what makes the operation classifiable and scopable on its first push (TW-PROG-009).
|
|
6
|
+
|
|
7
|
+
An application declares its own kind by passing `result_kind` directly. These add no mechanism the
|
|
8
|
+
keyword does not already have, and taskwire never validates the string against a known set: the
|
|
9
|
+
frontend maps `kind` to a component exactly as it maps `dialog_id`, so a new kind is a frontend
|
|
10
|
+
change and not a protocol one.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import functools
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from typing import Any, TypeVar
|
|
19
|
+
|
|
20
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
21
|
+
|
|
22
|
+
RESULT_KIND_ATTRIBUTE = "__taskwire_result_kind__"
|
|
23
|
+
"""Where a decorated callable carries its declaration.
|
|
24
|
+
|
|
25
|
+
An attribute rather than a registry: a registry would be a second place the kind could disagree with
|
|
26
|
+
the operation, and it would outlive the import that filled it.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
RESULT_PARAMS_ATTRIBUTE = "__taskwire_result_params__"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _declare(fn: F, kind: str, params: dict[str, Any] | None = None) -> F:
|
|
33
|
+
@functools.wraps(fn)
|
|
34
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
35
|
+
return fn(*args, **kwargs)
|
|
36
|
+
|
|
37
|
+
setattr(wrapper, RESULT_KIND_ATTRIBUTE, kind)
|
|
38
|
+
if params is not None:
|
|
39
|
+
setattr(wrapper, RESULT_PARAMS_ATTRIBUTE, params)
|
|
40
|
+
return wrapper # type: ignore[return-value]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def downloadable_result(fn: F) -> F:
|
|
44
|
+
"""`result_kind="taskwire.download"` - a `ref` the user fetches.
|
|
45
|
+
|
|
46
|
+
taskwire stores the pointer and never the file (TW-RES-007): it does not create, serve, refresh,
|
|
47
|
+
proxy, validate or delete what is behind the href, and has no opinion about who may fetch it.
|
|
48
|
+
Set `result_ttl` no longer than your own artefact retention (TW-RET-003), or the entry outlives
|
|
49
|
+
the file and the download is dead by the time somebody clicks it.
|
|
50
|
+
"""
|
|
51
|
+
return _declare(fn, "taskwire.download")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def panel_result(kind: str) -> Callable[[F], F]:
|
|
55
|
+
"""`result_kind=kind` - a `value` a component renders. The application names it.
|
|
56
|
+
|
|
57
|
+
`@panel_result("acme.import_report")`. The name is the application's, taskwire never checks it,
|
|
58
|
+
and the frontend maps it to a component exactly as it maps a `dialog_id`.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def decorate(fn: F) -> F:
|
|
62
|
+
return _declare(fn, kind)
|
|
63
|
+
|
|
64
|
+
return decorate
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def dialog_result(dialog_id: str) -> Callable[[F], F]:
|
|
68
|
+
"""`result_kind="taskwire.dialog"` with `params.dialog_id` set - a component to open.
|
|
69
|
+
|
|
70
|
+
The way to say "this operation finishes by showing the user something", for an operation that
|
|
71
|
+
would otherwise have nothing to collect and would therefore be private (TW-PRIV-001).
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def decorate(fn: F) -> F:
|
|
75
|
+
return _declare(fn, "taskwire.dialog", {"dialog_id": dialog_id})
|
|
76
|
+
|
|
77
|
+
return decorate
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def declared_result_kind(fn: Any) -> str | None:
|
|
81
|
+
"""The kind a decorated callable declared, or `None`. Used when the operation is opened."""
|
|
82
|
+
return getattr(fn, RESULT_KIND_ATTRIBUTE, None)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def declared_result_params(fn: Any) -> dict[str, Any] | None:
|
|
86
|
+
"""The params a decorated callable declared, or `None`. `dialog_result` is the only decorator
|
|
87
|
+
that sets them."""
|
|
88
|
+
return getattr(fn, RESULT_PARAMS_ATTRIBUTE, None)
|