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.
@@ -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)