taskwire 0.1.2__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.
- {taskwire-0.1.2 → taskwire-0.2.0}/PKG-INFO +5 -5
- {taskwire-0.1.2 → taskwire-0.2.0}/README.md +1 -1
- {taskwire-0.1.2 → taskwire-0.2.0}/pyproject.toml +7 -5
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/__init__.py +7 -1
- taskwire-0.2.0/taskwire/calls.py +429 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/contrib/asgi.py +74 -0
- taskwire-0.2.0/taskwire/contrib/celery.py +185 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/contrib/viewsets.py +126 -15
- taskwire-0.2.0/taskwire/contrib/wsgi.py +125 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/reporter.py +23 -7
- taskwire-0.1.2/taskwire/contrib/celery.py +0 -352
- {taskwire-0.1.2 → taskwire-0.2.0}/.gitignore +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/LICENSE +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/README.md +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/sequences/.gitkeep +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/aggregate-counts.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/aggregate-waiting-input-dominates.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/dialog-request-open.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/envelope-cancel.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/envelope-progress.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/envelope-snapshot.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/error-dispatch-failed.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/progress-percent-absent.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/progress-percent-null.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/progress-private.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/progress-shared-origins.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/progress-state-cancelled.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/progress-state-done.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/progress-state-failed.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/progress-state-queued.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/progress-state-running.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/progress-state-waiting-input.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/register-empty-null-aggregate.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/register-v1-with-aggregate.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/result-kind-and-params.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/result-label-and-value-no-ref.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/result-ref-with-expires-at.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/snapshot-v1-wall-clock-rev.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/snapshot-waiting-input-uncollected-result.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/text-key-and-text.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/text-key-only.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/text-key-params-and-text.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/conformance/wire/text-text-only.json +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/ambient.py +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/conformance.py +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/contrib/__init__.py +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/contrib/fastapi.py +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/contrib/muxws.py +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/contrib/redis_store.py +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/contrib/schema.py +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/contrib/threads.py +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/decorators.py +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/delivery.py +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/headers.py +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/models.py +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/py.typed +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/reader.py +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/register.py +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/rest.py +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/settings.py +0 -0
- {taskwire-0.1.2 → taskwire-0.2.0}/taskwire/store.py +0 -0
- {taskwire-0.1.2 → 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.
|
|
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.
|
|
42
|
-
Requires-Dist: muxws>=0.
|
|
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.
|
|
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.
|
|
34
|
-
#
|
|
35
|
-
viewsets = ["dynamicforms-fastapi-viewsets>=0.
|
|
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
|
-
|
|
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.
|
|
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
|