taskwire 0.1.1__tar.gz → 0.2.0__tar.gz

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.
Files changed (62) hide show
  1. {taskwire-0.1.1 → taskwire-0.2.0}/PKG-INFO +5 -5
  2. {taskwire-0.1.1 → taskwire-0.2.0}/README.md +1 -1
  3. {taskwire-0.1.1 → taskwire-0.2.0}/pyproject.toml +7 -5
  4. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/__init__.py +7 -1
  5. taskwire-0.2.0/taskwire/calls.py +429 -0
  6. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/contrib/asgi.py +74 -0
  7. taskwire-0.2.0/taskwire/contrib/celery.py +185 -0
  8. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/contrib/viewsets.py +126 -15
  9. taskwire-0.2.0/taskwire/contrib/wsgi.py +125 -0
  10. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/reporter.py +23 -7
  11. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/store.py +3 -1
  12. taskwire-0.1.1/taskwire/contrib/celery.py +0 -352
  13. {taskwire-0.1.1 → taskwire-0.2.0}/.gitignore +0 -0
  14. {taskwire-0.1.1 → taskwire-0.2.0}/LICENSE +0 -0
  15. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/README.md +0 -0
  16. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/sequences/.gitkeep +0 -0
  17. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/aggregate-counts.json +0 -0
  18. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/aggregate-waiting-input-dominates.json +0 -0
  19. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/dialog-request-open.json +0 -0
  20. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/envelope-cancel.json +0 -0
  21. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/envelope-progress.json +0 -0
  22. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/envelope-snapshot.json +0 -0
  23. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/error-dispatch-failed.json +0 -0
  24. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/progress-percent-absent.json +0 -0
  25. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/progress-percent-null.json +0 -0
  26. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/progress-private.json +0 -0
  27. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/progress-shared-origins.json +0 -0
  28. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/progress-state-cancelled.json +0 -0
  29. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/progress-state-done.json +0 -0
  30. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/progress-state-failed.json +0 -0
  31. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/progress-state-queued.json +0 -0
  32. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/progress-state-running.json +0 -0
  33. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/progress-state-waiting-input.json +0 -0
  34. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/register-empty-null-aggregate.json +0 -0
  35. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/register-v1-with-aggregate.json +0 -0
  36. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/result-kind-and-params.json +0 -0
  37. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/result-label-and-value-no-ref.json +0 -0
  38. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/result-ref-with-expires-at.json +0 -0
  39. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/snapshot-v1-wall-clock-rev.json +0 -0
  40. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/snapshot-waiting-input-uncollected-result.json +0 -0
  41. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/text-key-and-text.json +0 -0
  42. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/text-key-only.json +0 -0
  43. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/text-key-params-and-text.json +0 -0
  44. {taskwire-0.1.1 → taskwire-0.2.0}/conformance/wire/text-text-only.json +0 -0
  45. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/ambient.py +0 -0
  46. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/conformance.py +0 -0
  47. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/contrib/__init__.py +0 -0
  48. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/contrib/fastapi.py +0 -0
  49. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/contrib/muxws.py +0 -0
  50. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/contrib/redis_store.py +0 -0
  51. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/contrib/schema.py +0 -0
  52. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/contrib/threads.py +0 -0
  53. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/decorators.py +0 -0
  54. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/delivery.py +0 -0
  55. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/headers.py +0 -0
  56. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/models.py +0 -0
  57. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/py.typed +0 -0
  58. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/reader.py +0 -0
  59. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/register.py +0 -0
  60. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/rest.py +0 -0
  61. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/settings.py +0 -0
  62. {taskwire-0.1.1 → taskwire-0.2.0}/taskwire/transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: taskwire
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Progress reporting and awaitable dialogs for long-running server-side operations.
5
5
  Project-URL: Homepage, https://github.com/velis74/taskwire
6
6
  Project-URL: Repository, https://github.com/velis74/taskwire
@@ -38,12 +38,12 @@ Requires-Dist: ruff; extra == 'dev'
38
38
  Provides-Extra: fastapi
39
39
  Requires-Dist: fastapi>=0.100; extra == 'fastapi'
40
40
  Provides-Extra: muxws
41
- Requires-Dist: dynamicforms-fastapi-viewsets[muxws]>=0.5.4; extra == 'muxws'
42
- Requires-Dist: muxws>=0.3.1; extra == 'muxws'
41
+ Requires-Dist: dynamicforms-fastapi-viewsets[muxws]>=0.9.0; extra == 'muxws'
42
+ Requires-Dist: muxws>=0.4.1; extra == 'muxws'
43
43
  Provides-Extra: redis
44
44
  Requires-Dist: redis>=5.0; extra == 'redis'
45
45
  Provides-Extra: viewsets
46
- Requires-Dist: dynamicforms-fastapi-viewsets>=0.5.4; extra == 'viewsets'
46
+ Requires-Dist: dynamicforms-fastapi-viewsets>=0.9.0; extra == 'viewsets'
47
47
  Description-Content-Type: text/markdown
48
48
 
49
49
  # <img src="taskwire-icon.svg" alt="" width="40" height="40" style="vertical-align: middle"> taskwire
@@ -73,7 +73,7 @@ current state, and asking for it is always enough.
73
73
 
74
74
  The layering is the shape of the source tree:
75
75
 
76
- ```
76
+ ```text
77
77
  protocol documents, envelopes, the six kinds, the state
78
78
  │ machines, validation. Knows of no transport.
79
79
  ┌───────────┼───────────┐
@@ -25,7 +25,7 @@ current state, and asking for it is always enough.
25
25
 
26
26
  The layering is the shape of the source tree:
27
27
 
28
- ```
28
+ ```text
29
29
  protocol documents, envelopes, the six kinds, the state
30
30
  │ machines, validation. Knows of no transport.
31
31
  ┌───────────┼───────────┐
@@ -30,12 +30,14 @@ dependencies = [] # DELIBERATE: core has no runtime dependency of any kind (TW
30
30
  redis = ["redis>=5.0"]
31
31
  celery = ["celery>=5.3", "nest_asyncio>=1.6"]
32
32
  fastapi = ["fastapi>=0.100"]
33
- # >=0.5.4: set_celery_dispatch_hook there takes the call's kwargs (0.5.2 called it with none), which
34
- # is the signature register_taskwire_celery_viewset's hook expects (TW-CELERY-007).
35
- viewsets = ["dynamicforms-fastapi-viewsets>=0.5.4"]
33
+ # >=0.9.0: the execution hook, the Idempotency-Key helpers and a muxws command that runs to the end
34
+ # of its action when its stream ends early (TW-CALL-*).
35
+ viewsets = ["dynamicforms-fastapi-viewsets>=0.9.0"]
36
36
  # The socket carries the six request/response calls as viewset commands (TW-REST-004), so the muxws
37
- # extra needs the viewset layer's muxws half as well as muxws itself.
38
- muxws = ["muxws>=0.3.1", "dynamicforms-fastapi-viewsets[muxws]>=0.5.4"]
37
+ # extra needs the viewset layer's muxws half as well as muxws itself. >=0.4.1: `Peer.close()` sends
38
+ # every queued frame before closing the socket, so the `goaway` of `goaway_if_stale` (TW-WS-007)
39
+ # reaches the client.
40
+ muxws = ["muxws>=0.4.1", "dynamicforms-fastapi-viewsets[muxws]>=0.9.0"]
39
41
  demo = ["uvicorn>=0.27"]
40
42
  dev = ["coverage", "pytest", "pytest-asyncio", "pytest-cov", "hypothesis", "httpx", "ruff"]
41
43
 
@@ -12,9 +12,10 @@ outside. Nothing in the core imports a framework, a driver or a UI library (TW-C
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
- __version__ = "0.1.1"
15
+ __version__ = "0.2.0"
16
16
 
17
17
  from .ambient import ask, current_reporter, progress
18
+ from .calls import Call, call_token, execute_call, hand_off, receive_call
18
19
  from .conformance import run_conformance
19
20
  from .decorators import (
20
21
  declared_result_kind,
@@ -73,6 +74,8 @@ from .transport import LocalTransport, NullTransport, TaskwireTransport
73
74
  __all__ = [
74
75
  "Aggregate",
75
76
  "Button",
77
+ "Call",
78
+ "call_token",
76
79
  "Client",
77
80
  "Commit",
78
81
  "DialogAnswer",
@@ -126,6 +129,9 @@ __all__ = [
126
129
  "new_token",
127
130
  "TOKEN_HEADER",
128
131
  "operation",
132
+ "receive_call",
133
+ "execute_call",
134
+ "hand_off",
129
135
  "reset_configuration",
130
136
  "run_conformance",
131
137
  "settings",
@@ -0,0 +1,429 @@
1
+ """One call - an HTTP request, a command on a stream, a queued job - tracked as an operation (TW-CALL-*).
2
+
3
+ The caller names the operation: `X-Taskwire-Token` when it sends one, otherwise the key of its
4
+ `Idempotency-Key` header when that key is a UUID (`call_token`). Three places see a tracked call, and
5
+ each has one primitive:
6
+
7
+ - **where it is received**, `receive_call()`: writes `queued`, and `failed` when the call ends before
8
+ anything executed it;
9
+ - **where it executes**, `execute_call()`: `operation()` around the work, with the ambient reporter
10
+ bound;
11
+ - **where the work is handed to another process**, `hand_off()`: the values that cross the boundary,
12
+ and the terminal write left to the process that receives them.
13
+
14
+ The adapters do nothing but read a framework's request into a `Call` and place these primitives:
15
+ `taskwire.contrib.asgi.track_calls`, `taskwire.contrib.wsgi.track_calls` and
16
+ `taskwire.contrib.viewsets.TaskwireMiddleware`. Both context managers have an asynchronous and a
17
+ synchronous form, as `operation()` has (TW-API-002).
18
+
19
+ ```python
20
+ async with receive_call(call, dispatch_errors=(BrokerDown,)):
21
+ async with execute_call(call) as reporter:
22
+ ... # `taskwire.progress` reports into `call.token`
23
+ ```
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import contextlib
30
+ import contextvars
31
+ import logging
32
+
33
+ from collections.abc import Iterable, Iterator, Mapping
34
+ from dataclasses import dataclass
35
+ from typing import Any
36
+
37
+ from .headers import TOKEN_HEADER
38
+ from .models import Error, ProgressState, Text
39
+ from .reporter import mark_failed, mark_queued, operation, OperationScope, Reporter
40
+ from .rest import _is_uuid
41
+ from .settings import configured
42
+ from .store import key as make_key
43
+
44
+ logger = logging.getLogger("taskwire.calls")
45
+
46
+ IDEMPOTENCY_KEY_HEADER = "Idempotency-Key"
47
+ """The header whose key names a call that carries no `X-Taskwire-Token` (TW-CALL-001)."""
48
+
49
+ SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
50
+ """Methods whose `Idempotency-Key` does not make a call tracked (TW-CALL-002)."""
51
+
52
+ NOT_EXECUTED = "not_executed"
53
+ """A tracked call ended before anything executed it. `retryable: false` (TW-CALL-005)."""
54
+
55
+ DISPATCH_FAILED = "dispatch_failed"
56
+ """The work could not be handed to the process that executes it. `retryable: true` (TW-CALL-005,
57
+ TW-CELERY-004)."""
58
+
59
+ DIALOG_TIMEOUT = "dialog_timeout"
60
+ """An execution limit ended the work while it held an open question. `retryable: true` (TW-DLG-006)."""
61
+
62
+ HTTP_STATUS = "http_status"
63
+ """The call was answered with an error status and no exception (TW-CALL-006)."""
64
+
65
+
66
+ def status_error(status: int) -> Error | None:
67
+ """The error of a call answered with HTTP `status`, or None below 400 (TW-CALL-006).
68
+
69
+ `http_status` with `params={"status": status}`, `retryable: true` for 502, 503 and 504.
70
+ """
71
+ if status < 400:
72
+ return None
73
+ return Error(
74
+ code=HTTP_STATUS,
75
+ message=Text(key="taskwire.http_status", params={"status": status}, text=f"HTTP {status}"),
76
+ retryable=status in (502, 503, 504),
77
+ )
78
+
79
+
80
+ class _Unsuccessful(Exception): # noqa: N818 - never raised; the exit's stand-in for a recorded failure
81
+ pass
82
+
83
+
84
+ def _header_pairs(headers: Mapping[str, str] | Iterable[tuple[str, str]]) -> Iterable[tuple[str, str]]:
85
+ if isinstance(headers, Mapping):
86
+ multi = getattr(headers, "multi_items", None)
87
+ return multi() if callable(multi) else headers.items()
88
+ return headers
89
+
90
+
91
+ def _sf_string(value: str) -> str | None:
92
+ """The content of a Structured Field string (RFC 8941 §3.3.3), or None for anything else."""
93
+ value = value.strip(" ")
94
+ if len(value) < 2 or value[0] != '"' or value[-1] != '"':
95
+ return None
96
+ content: list[str] = []
97
+ escaped = False
98
+ for char in value[1:-1]:
99
+ if escaped:
100
+ if char not in '"\\':
101
+ return None
102
+ content.append(char)
103
+ escaped = False
104
+ elif char == "\\":
105
+ escaped = True
106
+ elif char == '"' or not " " <= char <= "~":
107
+ return None
108
+ else:
109
+ content.append(char)
110
+ return None if escaped else "".join(content)
111
+
112
+
113
+ def call_token(headers: Mapping[str, str] | Iterable[tuple[str, str]], method: str | None = None) -> str | None:
114
+ """The token a call is tracked under, or None for an untracked call (TW-CALL-001, TW-CALL-002).
115
+
116
+ `headers` is a mapping or a sequence of `(name, value)` pairs; names compare case-insensitively.
117
+ `X-Taskwire-Token` is the token when present. Otherwise the token is the key of a single
118
+ `Idempotency-Key` header holding a Structured Field string whose content is a UUID, for any
119
+ `method` outside `SAFE_METHODS`. A missing, repeated or malformed `Idempotency-Key` leaves the call
120
+ untracked and is never an error.
121
+ """
122
+ token_name, key_name = TOKEN_HEADER.lower(), IDEMPOTENCY_KEY_HEADER.lower()
123
+ keys: list[str] = []
124
+ for name, value in _header_pairs(headers):
125
+ lowered = name.lower()
126
+ if lowered == token_name and value:
127
+ return value
128
+ if lowered == key_name:
129
+ keys.append(value)
130
+ if (method is not None and method.upper() in SAFE_METHODS) or len(keys) != 1:
131
+ return None
132
+ key = _sf_string(keys[0])
133
+ return key if key is not None and _is_uuid(key) else None
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class Call:
138
+ """A tracked call: its token, the namespace `session_resolver` produced for it, and the two fields
139
+ the `queued` write fixes (TW-PROG-006)."""
140
+
141
+ token: str
142
+ session: str
143
+ connection: str | None = None
144
+ """`X-Taskwire-Connection` of the call; the operation's `origin_connection`."""
145
+ result_kind: str | None = None
146
+ """None makes the operation private (TW-PRIV-001)."""
147
+
148
+ def carried(self) -> dict[str, str]:
149
+ """The values that cross a process boundary: `token` and `session`, as plain strings
150
+ (TW-CELERY-003). The keyword arguments `taskwire_task` takes."""
151
+ return {"token": self.token, "session": self.session}
152
+
153
+ @classmethod
154
+ def from_carried(cls, carried: Mapping[str, Any]) -> Call | None:
155
+ """The call `carried()` produced, or None when either value is missing or not a non-empty
156
+ string. The namespace is not resolved again (TW-AMB-008)."""
157
+ token, session = carried.get("token"), carried.get("session")
158
+ if not isinstance(token, str) or not token or not isinstance(session, str) or not session:
159
+ return None
160
+ return cls(token=token, session=session)
161
+
162
+
163
+ _receipt: contextvars.ContextVar[CallReceipt | None] = contextvars.ContextVar("taskwire_call_receipt", default=None)
164
+ _execution: contextvars.ContextVar[CallExecution | None] = contextvars.ContextVar(
165
+ "taskwire_call_execution", default=None
166
+ )
167
+
168
+
169
+ def _run(coroutine: Any) -> Any:
170
+ """Run `coroutine` to completion on a loop of its own, for the synchronous forms."""
171
+ loop = asyncio.new_event_loop()
172
+ try:
173
+ return loop.run_until_complete(coroutine)
174
+ finally:
175
+ loop.close()
176
+
177
+
178
+ class CallReceipt:
179
+ """What `receive_call()` returns: an async and a sync context manager yielding the `Call`."""
180
+
181
+ def __init__(
182
+ self,
183
+ call: Call | None,
184
+ dispatch_errors: tuple[type[BaseException], ...],
185
+ pending_errors: tuple[type[BaseException], ...],
186
+ ) -> None:
187
+ self.call = call
188
+ self._dispatch_errors = dispatch_errors
189
+ self._pending_errors = pending_errors
190
+ self._marker: contextvars.Token | None = None
191
+ self.started = False
192
+ """True once `execute_call()` was entered for this call in this context."""
193
+
194
+ def _active(self) -> bool:
195
+ if self.call is None:
196
+ return False
197
+ outer = _receipt.get()
198
+ return outer is None or outer.call is None or outer.call.token != self.call.token
199
+
200
+ async def _open(self) -> None:
201
+ call = self.call
202
+ store = configured.store
203
+ if call is None or store is None:
204
+ return
205
+ try:
206
+ k = make_key(call.session, call.token)
207
+ existing = await store.snapshot(k)
208
+ if existing is not None and existing.progress.is_terminal:
209
+ await store.drop(k)
210
+ await mark_queued(
211
+ call.token, session=call.session, connection=call.connection, result_kind=call.result_kind
212
+ )
213
+ except Exception: # noqa: BLE001 - reporting is an accelerator; the call runs
214
+ logger.warning("taskwire: no queued write for %s; the call runs", call.token, exc_info=True)
215
+
216
+ async def _close(self, exc: BaseException | None) -> None:
217
+ call = self.call
218
+ store = configured.store
219
+ if call is None or store is None or self.started:
220
+ return
221
+ if exc is not None and isinstance(exc, self._pending_errors):
222
+ return
223
+ try:
224
+ snapshot = await store.snapshot(make_key(call.session, call.token))
225
+ if snapshot is None or ProgressState(snapshot.progress.state) is not ProgressState.QUEUED:
226
+ return
227
+ if exc is not None and isinstance(exc, self._dispatch_errors):
228
+ error = Error(code=DISPATCH_FAILED, message=Text(key="taskwire.dispatch_failed"), retryable=True)
229
+ else:
230
+ error = Error(code=NOT_EXECUTED, message=Text(key="taskwire.not_executed"), retryable=False)
231
+ await mark_failed(call.token, session=call.session, error=error)
232
+ except Exception: # noqa: BLE001 - reporting never replaces the call's own outcome
233
+ logger.warning("taskwire: could not end the unexecuted operation %s", call.token, exc_info=True)
234
+
235
+ async def __aenter__(self) -> Call | None:
236
+ if self._active():
237
+ await self._open()
238
+ self._marker = _receipt.set(self)
239
+ return self.call
240
+
241
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
242
+ if self._marker is not None:
243
+ _receipt.reset(self._marker)
244
+ self._marker = None
245
+ await self._close(exc)
246
+ return False
247
+
248
+ def __enter__(self) -> Call | None:
249
+ if self._active():
250
+ _run(self._open())
251
+ self._marker = _receipt.set(self)
252
+ return self.call
253
+
254
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
255
+ if self._marker is not None:
256
+ _receipt.reset(self._marker)
257
+ self._marker = None
258
+ _run(self._close(exc))
259
+ return False
260
+
261
+
262
+ def receive_call(
263
+ call: Call | None,
264
+ *,
265
+ dispatch_errors: tuple[type[BaseException], ...] = (),
266
+ pending_errors: tuple[type[BaseException], ...] = (),
267
+ ) -> CallReceipt:
268
+ """Where a call is received: `queued` on entry, and `failed` for a call nothing executed (TW-CALL-003,
269
+ TW-CALL-005).
270
+
271
+ Entry drops a terminal document under the token, since the call executes anew, and writes `queued`
272
+ with `call.result_kind` and `call.connection`; an existing non-terminal document is left as it is.
273
+ Exit writes `failed` when the operation is still `queued` and `execute_call()` was not entered for
274
+ the call in this context: `dispatch_failed` (`retryable: true`) when the exception leaving the block
275
+ is one of `dispatch_errors`, nothing when it is one of `pending_errors` (the work may still execute
276
+ elsewhere, and `active_ttl` expires the document otherwise), and `not_executed` (`retryable: false`)
277
+ in every other case, a clean exit included.
278
+
279
+ `call=None` and a store-less configuration make it a no-op, and so does a block nested in one
280
+ already receiving the same token. A store failure is logged and never raised.
281
+ """
282
+ return CallReceipt(call, dispatch_errors, pending_errors)
283
+
284
+
285
+ class CallExecution:
286
+ """What `execute_call()` returns: an async and a sync context manager yielding the `Reporter`, or
287
+ None for an untracked or nested execution."""
288
+
289
+ def __init__(self, call: Call | None, timeout_errors: tuple[type[BaseException], ...]) -> None:
290
+ self.call = call
291
+ self._timeout_errors = timeout_errors
292
+ self._scope: OperationScope | None = None
293
+ self._reporter: Reporter | None = None
294
+ self._marker: contextvars.Token | None = None
295
+ self._failure: Error | None = None
296
+
297
+ def fail(self, error: Error) -> None:
298
+ """End the operation `failed` with `error` when the block exits without an exception: for a
299
+ framework that answers a failed call instead of raising (TW-CALL-006)."""
300
+ self._failure = error
301
+
302
+ def _begin(self) -> OperationScope | None:
303
+ call = self.call
304
+ if call is None or _execution.get() is not None:
305
+ return None
306
+ receipt = _receipt.get()
307
+ if receipt is not None and receipt.call is not None and receipt.call.token == call.token:
308
+ receipt.started = True
309
+ return operation(call.token, session=call.session, connection=call.connection, result_kind=call.result_kind)
310
+
311
+ def _entered(self, scope: OperationScope, reporter: Reporter) -> Reporter:
312
+ self._scope, self._reporter = scope, reporter
313
+ self._marker = _execution.set(self)
314
+ return reporter
315
+
316
+ def _ending(self, exc: BaseException | None) -> OperationScope | None:
317
+ scope, reporter = self._scope, self._reporter
318
+ if scope is None or reporter is None:
319
+ return None
320
+ _execution.reset(self._marker)
321
+ self._scope = self._reporter = self._marker = None
322
+ if exc is not None and isinstance(exc, self._timeout_errors) and reporter.asking:
323
+ reporter.name_error(
324
+ DIALOG_TIMEOUT,
325
+ Text(key="taskwire.dialog_timeout"),
326
+ True, # noqa: FBT003 - positional to match `name_error(code, message, retryable)`
327
+ )
328
+ return scope
329
+
330
+ def _failed_entry(self) -> None:
331
+ logger.warning("taskwire: no operation for %s; the call runs unreported", self.call.token, exc_info=True)
332
+
333
+ def _failed_exit(self) -> None:
334
+ logger.warning("taskwire: the terminal write failed for %s", self.call.token, exc_info=True)
335
+
336
+ async def __aenter__(self) -> Reporter | None:
337
+ scope = self._begin()
338
+ if scope is None:
339
+ return None
340
+ try:
341
+ reporter = await scope.__aenter__()
342
+ except Exception: # noqa: BLE001 - an unreachable store is not a reason to refuse the work
343
+ self._failed_entry()
344
+ return None
345
+ return self._entered(scope, reporter)
346
+
347
+ def _outcome(self, exc_type: Any, exc: Any, tb: Any) -> tuple[Any, Any, Any]:
348
+ failure, self._failure = self._failure, None
349
+ if exc is not None or failure is None or self._reporter is None:
350
+ return exc_type, exc, tb
351
+ self._reporter.name_error(failure.code, failure.message, failure.retryable)
352
+ return _Unsuccessful, _Unsuccessful(), None
353
+
354
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
355
+ outcome = self._outcome(exc_type, exc, tb)
356
+ scope = self._ending(exc)
357
+ if scope is not None:
358
+ try:
359
+ await scope.__aexit__(*outcome)
360
+ except Exception: # noqa: BLE001 - logged, never in place of the call's own outcome
361
+ self._failed_exit()
362
+ return False
363
+
364
+ def __enter__(self) -> Reporter | None:
365
+ scope = self._begin()
366
+ if scope is None:
367
+ return None
368
+ try:
369
+ reporter = scope.__enter__()
370
+ except Exception: # noqa: BLE001 - an unreachable store is not a reason to refuse the work
371
+ self._failed_entry()
372
+ return None
373
+ return self._entered(scope, reporter)
374
+
375
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
376
+ outcome = self._outcome(exc_type, exc, tb)
377
+ scope = self._ending(exc)
378
+ if scope is not None:
379
+ try:
380
+ scope.__exit__(*outcome)
381
+ except Exception: # noqa: BLE001 - logged, never in place of the call's own outcome
382
+ self._failed_exit()
383
+ return False
384
+
385
+
386
+ def execute_call(call: Call | None, *, timeout_errors: tuple[type[BaseException], ...] = ()) -> CallExecution:
387
+ """Where a call executes: `operation()` around the work (TW-CALL-004).
388
+
389
+ Enters `operation(call.token, session=..., connection=..., result_kind=...)`, which flips `queued`
390
+ to `running`, binds the ambient reporter and writes exactly one terminal state on exit (TW-API-001),
391
+ and marks the call started for the `receive_call()` block around it. When an exception in
392
+ `timeout_errors` ends the block while the operation holds an open question, the terminal error is
393
+ `dialog_timeout` with `retryable: true` (TW-DLG-006).
394
+
395
+ Yields None, and reports nothing, for `call=None` and inside another `execute_call()` block: work
396
+ the executing call does on its own behalf belongs to its operation. A store failure on entry leaves
397
+ the work to run unreported; a failure of the terminal write is logged.
398
+ """
399
+ return CallExecution(call, timeout_errors)
400
+
401
+
402
+ @contextlib.contextmanager
403
+ def hand_off() -> Iterator[dict[str, str]]:
404
+ """Hand the executing call's operation to another process (TW-CALL-007).
405
+
406
+ Yields `Call.carried()` of the call `execute_call()` is executing in this context, `{}` outside
407
+ one. A block that exits cleanly leaves the terminal write to the receiving process: the
408
+ `execute_call()` exit writes no state. An exception leaving the block names `dispatch_failed`
409
+ (`retryable: true`) as the operation's error, and the `execute_call()` exit writes it.
410
+
411
+ ```python
412
+ with hand_off() as carried:
413
+ import_rows.delay(rows=rows, **carried) # a `taskwire_task` continues the operation
414
+ ```
415
+ """
416
+ execution = _execution.get()
417
+ if execution is None or execution._scope is None or execution._reporter is None:
418
+ yield {}
419
+ return
420
+ try:
421
+ yield execution.call.carried()
422
+ except BaseException:
423
+ execution._reporter.name_error(
424
+ DISPATCH_FAILED,
425
+ Text(key="taskwire.dispatch_failed"),
426
+ True, # noqa: FBT003 - positional to match `name_error(code, message, retryable)`
427
+ )
428
+ raise
429
+ execution._scope.hand_off()
@@ -3,6 +3,8 @@
3
3
  This is the proof that TW-REST-001 is real rather than aspirational: `taskwire.rest` is reachable
4
4
  over HTTP with nothing installed but Python. It is also the smallest complete example of what an
5
5
  adapter has to do - build a `Client`, call a handler, serialise the result.
6
+
7
+ `track_calls` is the call-tracking adapter for any ASGI application (TW-CALL-104).
6
8
  """
7
9
 
8
10
  from __future__ import annotations
@@ -12,6 +14,7 @@ import json
12
14
  from collections.abc import Callable
13
15
  from typing import Any
14
16
 
17
+ from ..calls import Call, call_token, execute_call, receive_call, status_error
15
18
  from ..delivery import Client
16
19
  from ..headers import CONNECTION_HEADER_BYTES as CONNECTION_HEADER
17
20
  from ..rest import collect_result, dismiss_result, get_operations, get_snapshot, reply_to_dialog, request_cancel
@@ -89,3 +92,74 @@ def app(prefix: str | None = None) -> Callable[..., Any]:
89
92
  await send({"type": "http.response.body", "body": body})
90
93
 
91
94
  return application
95
+
96
+
97
+ def _under(path: str, mount: str) -> bool:
98
+ root = mount.rstrip("/")
99
+ return path == root or path.startswith(root + "/")
100
+
101
+
102
+ def track_calls(
103
+ app: Callable[..., Any],
104
+ *,
105
+ exclude_prefix: str | None = None,
106
+ session_resolver: Callable[[dict[str, Any]], str | None] | None = None,
107
+ result_kind: Callable[[dict[str, Any]], str | None] | None = None,
108
+ ) -> Callable[..., Any]:
109
+ """ASGI middleware tracking every HTTP request of `app` that carries a token (TW-CALL-104).
110
+
111
+ ```python
112
+ app = track_calls(app)
113
+ ```
114
+
115
+ A request is tracked when `taskwire.calls.call_token()` finds a token in its headers and the
116
+ namespace resolver returns one: `session_resolver`, else the configured one, called with the ASGI
117
+ scope. The request runs inside `receive_call()` and `execute_call()`, so the application reports
118
+ through `taskwire.progress` and hands work to another process with `taskwire.calls.hand_off()`.
119
+ A response status of 400 or more ends the operation `failed` with `http_status`. `result_kind`,
120
+ called with the scope, fixes the operation's result kind; without it the operation is private.
121
+ Requests under `exclude_prefix` (default: `settings.rest_prefix`, taskwire's own endpoints) and
122
+ every other scope type pass through untouched.
123
+ """
124
+ mount = exclude_prefix if exclude_prefix is not None else settings.rest_prefix
125
+
126
+ def _call(scope: dict[str, Any]) -> Call | None:
127
+ if scope.get("type") != "http" or _under(scope.get("path", ""), mount):
128
+ return None
129
+ headers = [(name.decode("latin-1"), value.decode("latin-1")) for name, value in scope.get("headers") or ()]
130
+ token = call_token(headers, scope.get("method"))
131
+ resolver = session_resolver if session_resolver is not None else configured.session_resolver
132
+ if token is None or resolver is None:
133
+ return None
134
+ session = resolver(scope)
135
+ if session is None:
136
+ return None
137
+ return Call(
138
+ token=token,
139
+ session=session,
140
+ connection=_header(scope, CONNECTION_HEADER),
141
+ result_kind=result_kind(scope) if result_kind is not None else None,
142
+ )
143
+
144
+ async def middleware(scope: dict[str, Any], receive: Any, send: Any) -> None:
145
+ call = _call(scope)
146
+ if call is None:
147
+ await app(scope, receive, send)
148
+ return
149
+
150
+ status: list[int] = []
151
+
152
+ async def observed(message: dict[str, Any]) -> None:
153
+ if message.get("type") == "http.response.start":
154
+ status.append(int(message["status"]))
155
+ await send(message)
156
+
157
+ async with receive_call(call):
158
+ execution = execute_call(call)
159
+ async with execution:
160
+ await app(scope, receive, observed)
161
+ error = status_error(status[0]) if status else None
162
+ if error is not None:
163
+ execution.fail(error)
164
+
165
+ return middleware