taskwire 0.1.2__tar.gz → 0.3.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 (63) hide show
  1. {taskwire-0.1.2 → taskwire-0.3.0}/PKG-INFO +13 -5
  2. {taskwire-0.1.2 → taskwire-0.3.0}/README.md +1 -1
  3. {taskwire-0.1.2 → taskwire-0.3.0}/pyproject.toml +26 -9
  4. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/__init__.py +10 -2
  5. taskwire-0.3.0/taskwire/calls.py +491 -0
  6. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/contrib/asgi.py +74 -0
  7. taskwire-0.3.0/taskwire/contrib/celery.py +185 -0
  8. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/contrib/muxws.py +20 -29
  9. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/contrib/redis_store.py +80 -14
  10. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/contrib/viewsets.py +194 -16
  11. taskwire-0.3.0/taskwire/contrib/viewsets_events.py +291 -0
  12. taskwire-0.3.0/taskwire/contrib/wsgi.py +125 -0
  13. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/delivery.py +26 -0
  14. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/reader.py +8 -1
  15. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/reporter.py +31 -11
  16. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/settings.py +29 -0
  17. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/transport.py +5 -0
  18. taskwire-0.1.2/taskwire/contrib/celery.py +0 -352
  19. {taskwire-0.1.2 → taskwire-0.3.0}/.gitignore +0 -0
  20. {taskwire-0.1.2 → taskwire-0.3.0}/LICENSE +0 -0
  21. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/README.md +0 -0
  22. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/sequences/.gitkeep +0 -0
  23. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/aggregate-counts.json +0 -0
  24. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/aggregate-waiting-input-dominates.json +0 -0
  25. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/dialog-request-open.json +0 -0
  26. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/envelope-cancel.json +0 -0
  27. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/envelope-progress.json +0 -0
  28. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/envelope-snapshot.json +0 -0
  29. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/error-dispatch-failed.json +0 -0
  30. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/progress-percent-absent.json +0 -0
  31. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/progress-percent-null.json +0 -0
  32. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/progress-private.json +0 -0
  33. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/progress-shared-origins.json +0 -0
  34. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/progress-state-cancelled.json +0 -0
  35. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/progress-state-done.json +0 -0
  36. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/progress-state-failed.json +0 -0
  37. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/progress-state-queued.json +0 -0
  38. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/progress-state-running.json +0 -0
  39. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/progress-state-waiting-input.json +0 -0
  40. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/register-empty-null-aggregate.json +0 -0
  41. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/register-v1-with-aggregate.json +0 -0
  42. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/result-kind-and-params.json +0 -0
  43. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/result-label-and-value-no-ref.json +0 -0
  44. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/result-ref-with-expires-at.json +0 -0
  45. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/snapshot-v1-wall-clock-rev.json +0 -0
  46. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/snapshot-waiting-input-uncollected-result.json +0 -0
  47. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/text-key-and-text.json +0 -0
  48. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/text-key-only.json +0 -0
  49. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/text-key-params-and-text.json +0 -0
  50. {taskwire-0.1.2 → taskwire-0.3.0}/conformance/wire/text-text-only.json +0 -0
  51. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/ambient.py +0 -0
  52. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/conformance.py +0 -0
  53. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/contrib/__init__.py +0 -0
  54. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/contrib/fastapi.py +0 -0
  55. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/contrib/schema.py +0 -0
  56. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/contrib/threads.py +0 -0
  57. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/decorators.py +0 -0
  58. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/headers.py +0 -0
  59. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/models.py +0 -0
  60. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/py.typed +0 -0
  61. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/register.py +0 -0
  62. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/rest.py +0 -0
  63. {taskwire-0.1.2 → taskwire-0.3.0}/taskwire/store.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: taskwire
3
- Version: 0.1.2
3
+ Version: 0.3.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
@@ -28,22 +28,30 @@ Requires-Dist: nest-asyncio>=1.6; extra == 'celery'
28
28
  Provides-Extra: demo
29
29
  Requires-Dist: uvicorn>=0.27; extra == 'demo'
30
30
  Provides-Extra: dev
31
+ Requires-Dist: celery>=5.3; extra == 'dev'
31
32
  Requires-Dist: coverage; extra == 'dev'
33
+ Requires-Dist: dynamicforms-fastapi-viewsets>=0.11.1; extra == 'dev'
34
+ Requires-Dist: dynamicforms-fastapi-viewsets[muxws]>=0.11.1; extra == 'dev'
35
+ Requires-Dist: fastapi>=0.100; extra == 'dev'
32
36
  Requires-Dist: httpx; extra == 'dev'
33
37
  Requires-Dist: hypothesis; extra == 'dev'
38
+ Requires-Dist: muxws>=0.4.1; extra == 'dev'
39
+ Requires-Dist: nest-asyncio>=1.6; extra == 'dev'
34
40
  Requires-Dist: pytest; extra == 'dev'
35
41
  Requires-Dist: pytest-asyncio; extra == 'dev'
36
42
  Requires-Dist: pytest-cov; extra == 'dev'
43
+ Requires-Dist: redis>=5.0; extra == 'dev'
37
44
  Requires-Dist: ruff; extra == 'dev'
45
+ Requires-Dist: uvicorn>=0.27; extra == 'dev'
38
46
  Provides-Extra: fastapi
39
47
  Requires-Dist: fastapi>=0.100; extra == 'fastapi'
40
48
  Provides-Extra: muxws
41
- Requires-Dist: dynamicforms-fastapi-viewsets[muxws]>=0.5.4; extra == 'muxws'
42
- Requires-Dist: muxws>=0.3.1; extra == 'muxws'
49
+ Requires-Dist: dynamicforms-fastapi-viewsets[muxws]>=0.11.1; extra == 'muxws'
50
+ Requires-Dist: muxws>=0.4.1; extra == 'muxws'
43
51
  Provides-Extra: redis
44
52
  Requires-Dist: redis>=5.0; extra == 'redis'
45
53
  Provides-Extra: viewsets
46
- Requires-Dist: dynamicforms-fastapi-viewsets>=0.5.4; extra == 'viewsets'
54
+ Requires-Dist: dynamicforms-fastapi-viewsets>=0.11.1; extra == 'viewsets'
47
55
  Description-Content-Type: text/markdown
48
56
 
49
57
  # <img src="taskwire-icon.svg" alt="" width="40" height="40" style="vertical-align: middle"> taskwire
@@ -73,7 +81,7 @@ current state, and asking for it is always enough.
73
81
 
74
82
  The layering is the shape of the source tree:
75
83
 
76
- ```
84
+ ```text
77
85
  protocol documents, envelopes, the six kinds, the state
78
86
  │ machines, validation. Knows of no transport.
79
87
  ┌───────────┼───────────┐
@@ -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,14 +30,31 @@ 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.11.1: the execution hook and the Idempotency-Key helpers; a muxws command that runs to the end
34
+ # of its action when its stream ends early (TW-CALL-*); delivery filters that run on poll as on push,
35
+ # which `contrib/viewsets_events.py` relies on for the display decision (TW-EVT-004); and an executor
36
+ # that reports its outcomes in its own terms - `ActionInterruptedError`, `ActionOutcomeUnknownError`,
37
+ # `Middleware.executor_started()` and `Middleware.on_transition()` - so that `contrib/viewsets.py`
38
+ # imports no Celery (TW-CALL-101, TW-CALL-105), and that reports a process exit to `on_transition()`
39
+ # in every pool.
40
+ viewsets = ["dynamicforms-fastapi-viewsets>=0.11.1"]
36
41
  # 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"]
42
+ # extra needs the viewset layer's muxws half as well as muxws itself. >=0.4.1: `Peer.close()` sends
43
+ # every queued frame before closing the socket, so the `goaway` of `goaway_if_stale` (TW-WS-007)
44
+ # reaches the client.
45
+ muxws = ["muxws>=0.4.1", "dynamicforms-fastapi-viewsets[muxws]>=0.11.1"]
39
46
  demo = ["uvicorn>=0.27"]
40
- dev = ["coverage", "pytest", "pytest-asyncio", "pytest-cov", "hypothesis", "httpx", "ruff"]
47
+ # Everything the test suite, the linters and the demo need.
48
+ dev = [
49
+ "taskwire[redis,celery,fastapi,viewsets,muxws,demo]",
50
+ "coverage",
51
+ "pytest",
52
+ "pytest-asyncio",
53
+ "pytest-cov",
54
+ "hypothesis",
55
+ "httpx",
56
+ "ruff",
57
+ ]
41
58
 
42
59
  [project.urls]
43
60
  Homepage = "https://github.com/velis74/taskwire"
@@ -48,13 +65,13 @@ Documentation = "https://docs.velis.si/taskwire/"
48
65
  [tool.hatch.version]
49
66
  path = "taskwire/__init__.py"
50
67
 
51
- # The release tool stamps the two manifests by itself. `ts/index.ts` is the third place the version
68
+ # The release tool stamps the two manifests by itself. `vue/index.ts` is the third place the version
52
69
  # is written down - the npm half exports it as `VERSION` - and nothing about a Python manifest points
53
70
  # at it, so it is named here. `packaging_test.py::test_python_and_npm_versions_match` and
54
- # `ts/packaging.spec.ts` both compare against `package.json`, transitively pinning all three, but only
71
+ # `vue/packaging.spec.ts` both compare against `package.json`, transitively pinning all three, but only
55
72
  # once a release has actually bumped this file too.
56
73
  [tool.publish]
57
- additional_files = ["ts/index.ts"]
74
+ additional_files = ["vue/index.ts"]
58
75
 
59
76
  [tool.hatch.build.targets.wheel]
60
77
  packages = ["taskwire"]
@@ -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.2"
15
+ __version__ = "0.3.0"
16
16
 
17
17
  from .ambient import ask, current_reporter, progress
18
+ from .calls import Call, call_token, end_call, execute_call, hand_off, receive_call
18
19
  from .conformance import run_conformance
19
20
  from .decorators import (
20
21
  declared_result_kind,
@@ -59,7 +60,7 @@ from .reporter import (
59
60
  Reporter,
60
61
  SyncReporter,
61
62
  )
62
- from .settings import configure, configured, reset_configuration, Settings, settings
63
+ from .settings import configure, configure_executor, configured, reset_configuration, Settings, settings
63
64
  from .store import (
64
65
  Commit,
65
66
  DialogResolution,
@@ -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",
@@ -117,6 +120,7 @@ __all__ = [
117
120
  "declared_result_kind",
118
121
  "declared_result_params",
119
122
  "configure",
123
+ "configure_executor",
120
124
  "configured",
121
125
  "CONNECTION_HEADER",
122
126
  "key",
@@ -126,6 +130,10 @@ __all__ = [
126
130
  "new_token",
127
131
  "TOKEN_HEADER",
128
132
  "operation",
133
+ "receive_call",
134
+ "end_call",
135
+ "execute_call",
136
+ "hand_off",
129
137
  "reset_configuration",
130
138
  "run_conformance",
131
139
  "settings",
@@ -0,0 +1,491 @@
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
+ A process that learns that an execution ended without writing its terminal state - an executor that
15
+ ended it from outside - ends the operation with `end_call()`.
16
+
17
+ The adapters do nothing but read a framework's request into a `Call` and place these primitives:
18
+ `taskwire.contrib.asgi.track_calls`, `taskwire.contrib.wsgi.track_calls` and
19
+ `taskwire.contrib.viewsets.TaskwireMiddleware`. Both context managers have an asynchronous and a
20
+ synchronous form, as `operation()` has (TW-API-002).
21
+
22
+ ```python
23
+ async with receive_call(call, dispatch_errors=(BrokerDown,)):
24
+ async with execute_call(call) as reporter:
25
+ ... # `taskwire.progress` reports into `call.token`
26
+ ```
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import contextlib
33
+ import contextvars
34
+ import logging
35
+
36
+ from collections.abc import Iterable, Iterator, Mapping
37
+ from dataclasses import dataclass
38
+ from typing import Any
39
+
40
+ from .headers import TOKEN_HEADER
41
+ from .models import DialogState, Error, ProgressState, Text
42
+ from .reporter import mark_failed, mark_queued, operation, OperationScope, Reporter
43
+ from .rest import _is_uuid
44
+ from .settings import configured
45
+ from .store import key as make_key
46
+
47
+ logger = logging.getLogger("taskwire.calls")
48
+
49
+ IDEMPOTENCY_KEY_HEADER = "Idempotency-Key"
50
+ """The header whose key names a call that carries no `X-Taskwire-Token` (TW-CALL-001)."""
51
+
52
+ SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
53
+ """Methods whose `Idempotency-Key` does not make a call tracked (TW-CALL-002)."""
54
+
55
+ NOT_EXECUTED = "not_executed"
56
+ """A tracked call ended before anything executed it. `retryable: false` (TW-CALL-005)."""
57
+
58
+ DISPATCH_FAILED = "dispatch_failed"
59
+ """The work could not be handed to the process that executes it. `retryable: true` (TW-CALL-005,
60
+ TW-CELERY-004)."""
61
+
62
+ DIALOG_TIMEOUT = "dialog_timeout"
63
+ """An execution limit ended the work while it held an open question. `retryable: true` (TW-DLG-006)."""
64
+
65
+ INTERRUPTED = "interrupted"
66
+ """The executor ended the work after it started, with no open question: a time limit, a termination, the
67
+ loss of the executing process. `message.params.reason` names which when the executor says. `retryable:
68
+ true` (TW-CALL-008)."""
69
+
70
+ HTTP_STATUS = "http_status"
71
+ """The call was answered with an error status and no exception (TW-CALL-006)."""
72
+
73
+
74
+ def status_error(status: int) -> Error | None:
75
+ """The error of a call answered with HTTP `status`, or None below 400 (TW-CALL-006).
76
+
77
+ `http_status` with `params={"status": status}`, `retryable: true` for 502, 503 and 504.
78
+ """
79
+ if status < 400:
80
+ return None
81
+ return Error(
82
+ code=HTTP_STATUS,
83
+ message=Text(key="taskwire.http_status", params={"status": status}, text=f"HTTP {status}"),
84
+ retryable=status in (502, 503, 504),
85
+ )
86
+
87
+
88
+ def dialog_timeout_error() -> Error:
89
+ """The error of work an execution limit ended while it held an open question (TW-DLG-006)."""
90
+ return Error(code=DIALOG_TIMEOUT, message=Text(key="taskwire.dialog_timeout"), retryable=True)
91
+
92
+
93
+ def interrupted_error(reason: str | None = None) -> Error:
94
+ """The error of work the executor ended after it started (TW-CALL-008): `interrupted`, with
95
+ `message.params.reason` when `reason` is given."""
96
+ return Error(
97
+ code=INTERRUPTED,
98
+ message=Text(key="taskwire.interrupted", params={"reason": reason} if reason is not None else None),
99
+ retryable=True,
100
+ )
101
+
102
+
103
+ class _Unsuccessful(Exception): # noqa: N818 - never raised; the exit's stand-in for a recorded failure
104
+ pass
105
+
106
+
107
+ def _header_pairs(headers: Mapping[str, str] | Iterable[tuple[str, str]]) -> Iterable[tuple[str, str]]:
108
+ if isinstance(headers, Mapping):
109
+ multi = getattr(headers, "multi_items", None)
110
+ return multi() if callable(multi) else headers.items()
111
+ return headers
112
+
113
+
114
+ def _sf_string(value: str) -> str | None:
115
+ """The content of a Structured Field string (RFC 8941 §3.3.3), or None for anything else."""
116
+ value = value.strip(" ")
117
+ if len(value) < 2 or value[0] != '"' or value[-1] != '"':
118
+ return None
119
+ content: list[str] = []
120
+ escaped = False
121
+ for char in value[1:-1]:
122
+ if escaped:
123
+ if char not in '"\\':
124
+ return None
125
+ content.append(char)
126
+ escaped = False
127
+ elif char == "\\":
128
+ escaped = True
129
+ elif char == '"' or not " " <= char <= "~":
130
+ return None
131
+ else:
132
+ content.append(char)
133
+ return None if escaped else "".join(content)
134
+
135
+
136
+ def call_token(headers: Mapping[str, str] | Iterable[tuple[str, str]], method: str | None = None) -> str | None:
137
+ """The token a call is tracked under, or None for an untracked call (TW-CALL-001, TW-CALL-002).
138
+
139
+ `headers` is a mapping or a sequence of `(name, value)` pairs; names compare case-insensitively.
140
+ `X-Taskwire-Token` is the token when present. Otherwise the token is the key of a single
141
+ `Idempotency-Key` header holding a Structured Field string whose content is a UUID, for any
142
+ `method` outside `SAFE_METHODS`. A missing, repeated or malformed `Idempotency-Key` leaves the call
143
+ untracked and is never an error.
144
+ """
145
+ token_name, key_name = TOKEN_HEADER.lower(), IDEMPOTENCY_KEY_HEADER.lower()
146
+ keys: list[str] = []
147
+ for name, value in _header_pairs(headers):
148
+ lowered = name.lower()
149
+ if lowered == token_name and value:
150
+ return value
151
+ if lowered == key_name:
152
+ keys.append(value)
153
+ if (method is not None and method.upper() in SAFE_METHODS) or len(keys) != 1:
154
+ return None
155
+ key = _sf_string(keys[0])
156
+ return key if key is not None and _is_uuid(key) else None
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class Call:
161
+ """A tracked call: its token, the namespace `session_resolver` produced for it, and the two fields
162
+ the `queued` write fixes (TW-PROG-006)."""
163
+
164
+ token: str
165
+ session: str
166
+ connection: str | None = None
167
+ """`X-Taskwire-Connection` of the call; the operation's `origin_connection`."""
168
+ result_kind: str | None = None
169
+ """None makes the operation private (TW-PRIV-001)."""
170
+
171
+ def carried(self) -> dict[str, str]:
172
+ """The values that cross a process boundary: `token` and `session`, as plain strings
173
+ (TW-CELERY-003). The keyword arguments `taskwire_task` takes."""
174
+ return {"token": self.token, "session": self.session}
175
+
176
+ @classmethod
177
+ def from_carried(cls, carried: Mapping[str, Any]) -> Call | None:
178
+ """The call `carried()` produced, or None when either value is missing or not a non-empty
179
+ string. The namespace is not resolved again (TW-AMB-008)."""
180
+ token, session = carried.get("token"), carried.get("session")
181
+ if not isinstance(token, str) or not token or not isinstance(session, str) or not session:
182
+ return None
183
+ return cls(token=token, session=session)
184
+
185
+
186
+ _receipt: contextvars.ContextVar[CallReceipt | None] = contextvars.ContextVar("taskwire_call_receipt", default=None)
187
+ _execution: contextvars.ContextVar[CallExecution | None] = contextvars.ContextVar(
188
+ "taskwire_call_execution", default=None
189
+ )
190
+
191
+
192
+ def _run(coroutine: Any) -> Any:
193
+ """Run `coroutine` to completion on a loop of its own, for the synchronous forms."""
194
+ loop = asyncio.new_event_loop()
195
+ try:
196
+ return loop.run_until_complete(coroutine)
197
+ finally:
198
+ loop.close()
199
+
200
+
201
+ class CallReceipt:
202
+ """What `receive_call()` returns: an async and a sync context manager yielding the `Call`."""
203
+
204
+ def __init__(
205
+ self,
206
+ call: Call | None,
207
+ dispatch_errors: tuple[type[BaseException], ...],
208
+ pending_errors: tuple[type[BaseException], ...],
209
+ ) -> None:
210
+ self.call = call
211
+ self._dispatch_errors = dispatch_errors
212
+ self._pending_errors = pending_errors
213
+ self._marker: contextvars.Token | None = None
214
+ self.started = False
215
+ """True once `execute_call()` was entered for this call in this context."""
216
+
217
+ def _active(self) -> bool:
218
+ if self.call is None:
219
+ return False
220
+ outer = _receipt.get()
221
+ return outer is None or outer.call is None or outer.call.token != self.call.token
222
+
223
+ async def _open(self) -> None:
224
+ call = self.call
225
+ store = configured.store
226
+ if call is None or store is None:
227
+ return
228
+ try:
229
+ k = make_key(call.session, call.token)
230
+ existing = await store.snapshot(k)
231
+ if existing is not None and existing.progress.is_terminal:
232
+ await store.drop(k)
233
+ await mark_queued(
234
+ call.token, session=call.session, connection=call.connection, result_kind=call.result_kind
235
+ )
236
+ except Exception: # noqa: BLE001 - reporting is an accelerator; the call runs
237
+ logger.warning("taskwire: no queued write for %s; the call runs", call.token, exc_info=True)
238
+
239
+ async def _close(self, exc: BaseException | None) -> None:
240
+ call = self.call
241
+ store = configured.store
242
+ if call is None or store is None or self.started:
243
+ return
244
+ if exc is not None and isinstance(exc, self._pending_errors):
245
+ return
246
+ try:
247
+ snapshot = await store.snapshot(make_key(call.session, call.token))
248
+ if snapshot is None or ProgressState(snapshot.progress.state) is not ProgressState.QUEUED:
249
+ return
250
+ if exc is not None and isinstance(exc, self._dispatch_errors):
251
+ error = Error(code=DISPATCH_FAILED, message=Text(key="taskwire.dispatch_failed"), retryable=True)
252
+ else:
253
+ error = Error(code=NOT_EXECUTED, message=Text(key="taskwire.not_executed"), retryable=False)
254
+ await mark_failed(call.token, session=call.session, error=error)
255
+ except Exception: # noqa: BLE001 - reporting never replaces the call's own outcome
256
+ logger.warning("taskwire: could not end the unexecuted operation %s", call.token, exc_info=True)
257
+
258
+ async def __aenter__(self) -> Call | None:
259
+ if self._active():
260
+ await self._open()
261
+ self._marker = _receipt.set(self)
262
+ return self.call
263
+
264
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
265
+ if self._marker is not None:
266
+ _receipt.reset(self._marker)
267
+ self._marker = None
268
+ await self._close(exc)
269
+ return False
270
+
271
+ def __enter__(self) -> Call | None:
272
+ if self._active():
273
+ _run(self._open())
274
+ self._marker = _receipt.set(self)
275
+ return self.call
276
+
277
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
278
+ if self._marker is not None:
279
+ _receipt.reset(self._marker)
280
+ self._marker = None
281
+ _run(self._close(exc))
282
+ return False
283
+
284
+
285
+ def receive_call(
286
+ call: Call | None,
287
+ *,
288
+ dispatch_errors: tuple[type[BaseException], ...] = (),
289
+ pending_errors: tuple[type[BaseException], ...] = (),
290
+ ) -> CallReceipt:
291
+ """Where a call is received: `queued` on entry, and `failed` for a call nothing executed (TW-CALL-003,
292
+ TW-CALL-005).
293
+
294
+ Entry drops a terminal document under the token, since the call executes anew, and writes `queued`
295
+ with `call.result_kind` and `call.connection`; an existing non-terminal document is left as it is.
296
+ Exit writes `failed` when the operation is still `queued` and `execute_call()` was not entered for
297
+ the call in this context: `dispatch_failed` (`retryable: true`) when the exception leaving the block
298
+ is one of `dispatch_errors`, nothing when it is one of `pending_errors` (the work may still execute
299
+ elsewhere, and `active_ttl` expires the document otherwise), and `not_executed` (`retryable: false`)
300
+ in every other case, a clean exit included.
301
+
302
+ `call=None` and a store-less configuration make it a no-op, and so does a block nested in one
303
+ already receiving the same token. A store failure is logged and never raised.
304
+ """
305
+ return CallReceipt(call, dispatch_errors, pending_errors)
306
+
307
+
308
+ class CallExecution:
309
+ """What `execute_call()` returns: an async and a sync context manager yielding the `Reporter`, or
310
+ None for an untracked or nested execution."""
311
+
312
+ def __init__(self, call: Call | None, timeout_errors: tuple[type[BaseException], ...]) -> None:
313
+ self.call = call
314
+ self._timeout_errors = timeout_errors
315
+ self._scope: OperationScope | None = None
316
+ self._reporter: Reporter | None = None
317
+ self._marker: contextvars.Token | None = None
318
+ self._failure: Error | None = None
319
+
320
+ def fail(self, error: Error) -> None:
321
+ """End the operation `failed` with `error` when the block exits without an exception: for a
322
+ framework that answers a failed call instead of raising (TW-CALL-006)."""
323
+ self._failure = error
324
+
325
+ def _begin(self) -> OperationScope | None:
326
+ call = self.call
327
+ if call is None or _execution.get() is not None:
328
+ return None
329
+ receipt = _receipt.get()
330
+ if receipt is not None and receipt.call is not None and receipt.call.token == call.token:
331
+ receipt.started = True
332
+ return operation(call.token, session=call.session, connection=call.connection, result_kind=call.result_kind)
333
+
334
+ def _entered(self, scope: OperationScope, reporter: Reporter) -> Reporter:
335
+ self._scope, self._reporter = scope, reporter
336
+ self._marker = _execution.set(self)
337
+ return reporter
338
+
339
+ def _ending(self, exc: BaseException | None) -> OperationScope | None:
340
+ scope, reporter = self._scope, self._reporter
341
+ if scope is None or reporter is None:
342
+ return None
343
+ _execution.reset(self._marker)
344
+ self._scope = self._reporter = self._marker = None
345
+ if exc is not None and isinstance(exc, self._timeout_errors) and reporter.asking:
346
+ reporter.name_error(
347
+ DIALOG_TIMEOUT,
348
+ Text(key="taskwire.dialog_timeout"),
349
+ True, # noqa: FBT003 - positional to match `name_error(code, message, retryable)`
350
+ )
351
+ return scope
352
+
353
+ def _failed_entry(self) -> None:
354
+ logger.warning("taskwire: no operation for %s; the call runs unreported", self.call.token, exc_info=True)
355
+
356
+ def _failed_exit(self) -> None:
357
+ logger.warning("taskwire: the terminal write failed for %s", self.call.token, exc_info=True)
358
+
359
+ async def __aenter__(self) -> Reporter | None:
360
+ scope = self._begin()
361
+ if scope is None:
362
+ return None
363
+ try:
364
+ reporter = await scope.__aenter__()
365
+ except Exception: # noqa: BLE001 - an unreachable store is not a reason to refuse the work
366
+ self._failed_entry()
367
+ return None
368
+ return self._entered(scope, reporter)
369
+
370
+ def _outcome(self, exc_type: Any, exc: Any, tb: Any) -> tuple[Any, Any, Any]:
371
+ failure, self._failure = self._failure, None
372
+ if exc is not None or failure is None or self._reporter is None:
373
+ return exc_type, exc, tb
374
+ self._reporter.name_error(failure.code, failure.message, failure.retryable)
375
+ return _Unsuccessful, _Unsuccessful(), None
376
+
377
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
378
+ outcome = self._outcome(exc_type, exc, tb)
379
+ scope = self._ending(exc)
380
+ if scope is not None:
381
+ try:
382
+ await scope.__aexit__(*outcome)
383
+ except Exception: # noqa: BLE001 - logged, never in place of the call's own outcome
384
+ self._failed_exit()
385
+ return False
386
+
387
+ def __enter__(self) -> Reporter | None:
388
+ scope = self._begin()
389
+ if scope is None:
390
+ return None
391
+ try:
392
+ reporter = scope.__enter__()
393
+ except Exception: # noqa: BLE001 - an unreachable store is not a reason to refuse the work
394
+ self._failed_entry()
395
+ return None
396
+ return self._entered(scope, reporter)
397
+
398
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
399
+ outcome = self._outcome(exc_type, exc, tb)
400
+ scope = self._ending(exc)
401
+ if scope is not None:
402
+ try:
403
+ scope.__exit__(*outcome)
404
+ except Exception: # noqa: BLE001 - logged, never in place of the call's own outcome
405
+ self._failed_exit()
406
+ return False
407
+
408
+
409
+ def execute_call(call: Call | None, *, timeout_errors: tuple[type[BaseException], ...] = ()) -> CallExecution:
410
+ """Where a call executes: `operation()` around the work (TW-CALL-004).
411
+
412
+ Enters `operation(call.token, session=..., connection=..., result_kind=...)`, which flips `queued`
413
+ to `running`, binds the ambient reporter and writes exactly one terminal state on exit (TW-API-001),
414
+ and marks the call started for the `receive_call()` block around it. When an exception in
415
+ `timeout_errors` ends the block while the operation holds an open question, the terminal error is
416
+ `dialog_timeout` with `retryable: true` (TW-DLG-006).
417
+
418
+ Yields None, and reports nothing, for `call=None` and inside another `execute_call()` block: work
419
+ the executing call does on its own behalf belongs to its operation. A store failure on entry leaves
420
+ the work to run unreported; a failure of the terminal write is logged.
421
+ """
422
+ return CallExecution(call, timeout_errors)
423
+
424
+
425
+ async def end_call(
426
+ call: Call | None,
427
+ error: Error,
428
+ *,
429
+ queued_only: bool = False,
430
+ asking_error: Error | None = None,
431
+ ) -> bool:
432
+ """End the operation of a call whose execution ended without writing its terminal state
433
+ (TW-CALL-008). For the process that observes such an end from outside the execution.
434
+
435
+ Writes `failed` with `error`, withdrawing the open dialogs in the same step, when the operation is
436
+ `queued`, `running` or `waiting_input` - only when `queued` with `queued_only`. With an open dialog
437
+ the error is `asking_error` when one is given. An operation holding a parked result is left as it
438
+ is: its work finished. Returns whether it wrote.
439
+
440
+ `call=None` and a store-less configuration make it a no-op. A store failure is logged and never
441
+ raised.
442
+ """
443
+ store = configured.store
444
+ if call is None or store is None:
445
+ return False
446
+ try:
447
+ snapshot = await store.snapshot(make_key(call.session, call.token))
448
+ if snapshot is None or snapshot.progress.is_terminal or snapshot.progress.result is not None:
449
+ return False
450
+ if queued_only and ProgressState(snapshot.progress.state) is not ProgressState.QUEUED:
451
+ return False
452
+ asking = any(DialogState(dialog.state) is DialogState.OPEN for dialog in snapshot.dialogs)
453
+ await mark_failed(
454
+ call.token,
455
+ session=call.session,
456
+ error=asking_error if asking and asking_error is not None else error,
457
+ )
458
+ except Exception: # noqa: BLE001 - reporting is an accelerator
459
+ logger.warning("taskwire: could not end the operation %s", call.token, exc_info=True)
460
+ return False
461
+ return True
462
+
463
+
464
+ @contextlib.contextmanager
465
+ def hand_off() -> Iterator[dict[str, str]]:
466
+ """Hand the executing call's operation to another process (TW-CALL-007).
467
+
468
+ Yields `Call.carried()` of the call `execute_call()` is executing in this context, `{}` outside
469
+ one. A block that exits cleanly leaves the terminal write to the receiving process: the
470
+ `execute_call()` exit writes no state. An exception leaving the block names `dispatch_failed`
471
+ (`retryable: true`) as the operation's error, and the `execute_call()` exit writes it.
472
+
473
+ ```python
474
+ with hand_off() as carried:
475
+ import_rows.delay(rows=rows, **carried) # a `taskwire_task` continues the operation
476
+ ```
477
+ """
478
+ execution = _execution.get()
479
+ if execution is None or execution._scope is None or execution._reporter is None:
480
+ yield {}
481
+ return
482
+ try:
483
+ yield execution.call.carried()
484
+ except BaseException:
485
+ execution._reporter.name_error(
486
+ DISPATCH_FAILED,
487
+ Text(key="taskwire.dispatch_failed"),
488
+ True, # noqa: FBT003 - positional to match `name_error(code, message, retryable)`
489
+ )
490
+ raise
491
+ execution._scope.hand_off()