durable-worker 0.2.0__tar.gz → 0.4.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-worker
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Python worker SDK for nestjs-durable — run durable workflow steps in Python.
5
5
  Project-URL: Homepage, https://github.com/DavideCarvalho/nestjs-durable
6
6
  Project-URL: Repository, https://github.com/DavideCarvalho/nestjs-durable
@@ -0,0 +1,37 @@
1
+ """durable-worker — Python SDK for running nestjs-durable remote steps.
2
+
3
+ A worker registers step handlers by name and processes tasks dispatched by the orchestrator.
4
+ The wire protocol (task in, result out) is plain JSON and identical across languages, so the
5
+ same step name implemented here is callable from a TypeScript workflow via ``ctx.call``.
6
+ """
7
+
8
+ from .cancellation import Cancelled, CancellationRegistry
9
+ from .redis_runner import redis_url_from_env, run_redis_worker
10
+ from .routing import reply_target
11
+ from .worker import (
12
+ FatalError,
13
+ StepContext,
14
+ Worker,
15
+ current_step,
16
+ log,
17
+ set_process,
18
+ sub,
19
+ )
20
+
21
+ __all__ = [
22
+ "Worker",
23
+ "FatalError",
24
+ "StepContext",
25
+ "Cancelled",
26
+ "CancellationRegistry",
27
+ "reply_target",
28
+ # Context-local step access — record events from anywhere inside a handler without threading ctx.
29
+ "current_step",
30
+ "log",
31
+ "sub",
32
+ "set_process",
33
+ # Transport bootstrap helpers.
34
+ "run_redis_worker",
35
+ "redis_url_from_env",
36
+ ]
37
+ __version__ = "0.4.0"
@@ -0,0 +1,49 @@
1
+ """Cooperative cancellation for the Python worker.
2
+
3
+ The orchestrator broadcasts a ``{"kind": "cancel", "runId": ...}`` control message across every
4
+ instance (Redis pub/sub, same channel the TypeScript control plane uses). A long-running handler
5
+ can't be force-killed, so cancellation is *cooperative*: a runner subscribes to that channel and
6
+ records the cancelled run ids here, and the handler checks ``ctx.cancelled`` (or calls
7
+ ``ctx.raise_if_cancelled()``) at safe points to bail out early instead of finishing work whose
8
+ result will be discarded.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import threading
14
+ from typing import Any, Dict
15
+
16
+
17
+ class Cancelled(Exception):
18
+ """Raised by ``StepContext.raise_if_cancelled`` when the run has been cancelled."""
19
+
20
+ def __init__(self, run_id: str) -> None:
21
+ super().__init__(f"run {run_id} was cancelled")
22
+ self.run_id = run_id
23
+
24
+
25
+ class CancellationRegistry:
26
+ """Thread-safe set of cancelled run ids, fed by a control-channel subscription."""
27
+
28
+ def __init__(self) -> None:
29
+ self._cancelled: set[str] = set()
30
+ self._lock = threading.Lock()
31
+
32
+ def cancel(self, run_id: str) -> None:
33
+ with self._lock:
34
+ self._cancelled.add(run_id)
35
+
36
+ def is_cancelled(self, run_id: str) -> bool:
37
+ with self._lock:
38
+ return run_id in self._cancelled
39
+
40
+ def clear(self, run_id: str) -> None:
41
+ with self._lock:
42
+ self._cancelled.discard(run_id)
43
+
44
+ def on_control_message(self, msg: Dict[str, Any]) -> None:
45
+ """Feed a decoded control message; records the run id when it's a cancel."""
46
+ if isinstance(msg, dict) and msg.get("kind") == "cancel":
47
+ run_id = msg.get("runId")
48
+ if isinstance(run_id, str):
49
+ self.cancel(run_id)
@@ -0,0 +1,173 @@
1
+ """Run a :class:`Worker` against the BullMQ/Redis transport.
2
+
3
+ Consumes the orchestrator's per-group tasks queue and publishes results on the shared results
4
+ queue — the same queues a TypeScript ``BullMQTransport`` uses, so steps interoperate across
5
+ languages. Requires the optional ``bullmq`` extra: ``pip install durable-worker[redis]``.
6
+
7
+ It also subscribes to the orchestrator's control channel (``<prefix>-control``) so a long handler
8
+ can observe cooperative cancellation via ``ctx.cancelled`` — the cross-language half of
9
+ ``engine.cancel``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import json
16
+ import os
17
+ import socket
18
+ from typing import Any, Callable, Dict, Optional
19
+
20
+ from .cancellation import CancellationRegistry
21
+ from .worker import Worker
22
+
23
+ # Stable-ish id for the `from` field of control messages this worker publishes. It only has to
24
+ # DIFFER from the engine instanceIds (so a dashboard engine doesn't treat our progress events as its
25
+ # own echo and drop them) — host + pid is plenty and avoids importing a uuid/random dependency.
26
+ _INSTANCE_ID = f"py-{socket.gethostname()}-{os.getpid()}"
27
+
28
+
29
+ def redis_url_from_env(prefix: str = "REDIS") -> str:
30
+ """Build a ``redis://`` URL from ``{prefix}_HOST/_PORT/_USERNAME/_PASSWORD`` env vars. The
31
+ credentials are URL-encoded — a generated password often contains ``@ : /`` which would corrupt
32
+ the netloc if left raw. Defaults to ``localhost:6379`` with no auth. Handy for ``Worker.run``."""
33
+ from urllib.parse import quote
34
+
35
+ host = os.getenv(f"{prefix}_HOST", "localhost")
36
+ port = os.getenv(f"{prefix}_PORT", "6379")
37
+ user = os.getenv(f"{prefix}_USERNAME") or ""
38
+ password = os.getenv(f"{prefix}_PASSWORD") or ""
39
+ auth = f"{quote(user, safe='')}:{quote(password, safe='')}@" if (user or password) else ""
40
+ return f"redis://{auth}{host}:{port}"
41
+
42
+
43
+ def _names(prefix: str, group: str) -> tuple[str, str]:
44
+ # Must match the TS BullMQTransport: '<prefix>-tasks-<group>' and '<prefix>-results'.
45
+ return f"{prefix}-tasks-{group}", f"{prefix}-results"
46
+
47
+
48
+ def _control_channel(prefix: str) -> str:
49
+ # Mirrors BullMQTransport.controlChannel(): '<prefix>-control'.
50
+ return f"{prefix}-control"
51
+
52
+
53
+ def _progress_message(task: Dict[str, Any], event: Dict[str, Any]) -> str:
54
+ """A control-plane `{kind:'event'}` carrying a `step.progress` EngineEvent — the same envelope a
55
+ TS engine publishes, so a dashboard engine re-delivers it to its subscribers (live-tail). Carries
56
+ the single just-emitted step event; the `at` mirrors EngineEvent's (ms; the TS side `new Date()`s it)."""
57
+ return json.dumps(
58
+ {
59
+ "kind": "event",
60
+ "from": _INSTANCE_ID,
61
+ "event": {
62
+ "type": "step.progress",
63
+ "runId": task.get("runId"),
64
+ "seq": task.get("seq"),
65
+ "name": task.get("name"),
66
+ "event": event,
67
+ "at": event.get("at"),
68
+ },
69
+ }
70
+ )
71
+
72
+
73
+ async def run_redis_worker(
74
+ worker: Worker,
75
+ *,
76
+ group: str,
77
+ connection: str = "redis://localhost:6379",
78
+ prefix: str = "durable",
79
+ cancellation: Optional[CancellationRegistry] = None,
80
+ ) -> Any:
81
+ """Start a BullMQ worker that runs ``worker``'s handlers. Returns the bullmq Worker.
82
+
83
+ The returned worker runs in the background; ``await worker.close()`` to stop it. When a
84
+ :class:`CancellationRegistry` is given (one is created otherwise), the runner subscribes to the
85
+ control channel and feeds it, so handlers see ``ctx.cancelled``.
86
+ """
87
+
88
+ from bullmq import Queue as BullQueue # imported lazily so the SDK works without bullmq
89
+ from bullmq import Worker as BullWorker
90
+
91
+ tasks_name, results_name = _names(prefix, group)
92
+ results = BullQueue(results_name, {"connection": connection})
93
+ registry = cancellation or CancellationRegistry()
94
+ await _subscribe_control(connection, prefix, registry)
95
+ publish_progress = await _progress_publisher(connection, prefix)
96
+
97
+ async def process(job: Any, _token: str) -> None:
98
+ on_event = _make_on_event(job.data, publish_progress) if publish_progress else None
99
+ result = await worker.aprocess_task(
100
+ job.data, is_cancelled=registry.is_cancelled, on_event=on_event
101
+ )
102
+ await results.add("result", result, {"removeOnComplete": True, "removeOnFail": True})
103
+
104
+ return BullWorker(tasks_name, process, {"connection": connection})
105
+
106
+
107
+ async def _progress_publisher(
108
+ connection: str, prefix: str
109
+ ) -> Optional[Callable[[str], None]]:
110
+ """Build a thread-safe `publish(message)` that PUBLISHes on the control channel from the running
111
+ loop. Returns None if redis pub/sub isn't available (then `step.progress` streaming is simply off
112
+ — the events still ride back on the final result). The returned callable is safe to call from a
113
+ handler's executor thread: it hops back onto the loop via ``call_soon_threadsafe`` (the aioredis
114
+ client is bound to this loop, so publishing must happen there, not from the worker thread)."""
115
+ try:
116
+ import redis.asyncio as aioredis # lazy: only needed when streaming progress
117
+ except ImportError:
118
+ return None
119
+
120
+ client = aioredis.from_url(connection)
121
+ channel = _control_channel(prefix)
122
+ loop = asyncio.get_running_loop()
123
+
124
+ def publish(message: str) -> None:
125
+ async def _send() -> None:
126
+ try:
127
+ await client.publish(channel, message)
128
+ except Exception: # noqa: BLE001 — live-tail is best-effort; never fail the step
129
+ pass
130
+
131
+ try:
132
+ loop.call_soon_threadsafe(lambda: loop.create_task(_send()))
133
+ except RuntimeError:
134
+ pass # loop is closing/closed — drop the live event (the result still carries it)
135
+
136
+ return publish
137
+
138
+
139
+ def _make_on_event(
140
+ task: Dict[str, Any], publish: Callable[[str], None]
141
+ ) -> Callable[[Dict[str, Any]], None]:
142
+ """Per-task sink: turn each step event into a `step.progress` control message and publish it."""
143
+
144
+ def on_event(event: Dict[str, Any]) -> None:
145
+ publish(_progress_message(task, event))
146
+
147
+ return on_event
148
+
149
+
150
+ async def _subscribe_control(
151
+ connection: str, prefix: str, registry: CancellationRegistry
152
+ ) -> None:
153
+ """Best-effort: subscribe to the control channel and feed cancellations into ``registry``.
154
+ No-op (logged) if redis pub/sub isn't available — cancellation just won't be observed."""
155
+ try:
156
+ import redis.asyncio as aioredis # lazy: only needed for cooperative cancellation
157
+ except ImportError:
158
+ return
159
+
160
+ client = aioredis.from_url(connection)
161
+ pubsub = client.pubsub()
162
+ await pubsub.subscribe(_control_channel(prefix))
163
+
164
+ async def listen() -> None:
165
+ async for message in pubsub.listen():
166
+ if message.get("type") != "message":
167
+ continue
168
+ try:
169
+ registry.on_control_message(json.loads(message["data"]))
170
+ except (ValueError, TypeError):
171
+ pass # ignore malformed control messages
172
+
173
+ asyncio.create_task(listen())
@@ -0,0 +1,27 @@
1
+ """Reply routing for a worker that consumes several transports (multi-broker failover).
2
+
3
+ The orchestrator stamps each task with the id of the transport it was dispatched on
4
+ (``task["transport"]``). A worker that consumes more than one broker must send the *result* back on
5
+ the same broker — so failover is symmetric and the worker never picks a transport itself, it just
6
+ replies where it was told. This is a pure lookup over a ``{id: reply_target}`` mapping with a sane
7
+ fallback for the single-transport / unknown-id case.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Dict, Optional, TypeVar
13
+
14
+ T = TypeVar("T")
15
+
16
+
17
+ def reply_target(task: Dict[str, Any], targets: Dict[str, T]) -> Optional[T]:
18
+ """The reply target for ``task`` by its ``transport`` id; falls back to the lone target when the
19
+ id is absent (single transport) or unknown. Returns ``None`` only if ``targets`` is empty."""
20
+ if not targets:
21
+ return None
22
+ transport_id = task.get("transport")
23
+ if isinstance(transport_id, str) and transport_id in targets:
24
+ return targets[transport_id]
25
+ if len(targets) == 1:
26
+ return next(iter(targets.values()))
27
+ return None
@@ -0,0 +1,388 @@
1
+ """Core worker: a name->handler registry and the pure task->result dispatch.
2
+
3
+ Transport (Redis/BullMQ/NATS) is intentionally separate — `process_task` is a pure function of
4
+ the task, so it is fully testable without any broker. A transport adapter just feeds tasks in
5
+ and ships results out.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextvars
12
+ import inspect
13
+ import time
14
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
15
+
16
+ Handler = Callable[..., Union[Any, Awaitable[Any]]]
17
+
18
+ # The step a handler is currently running, bound for the duration of the call (in the loop for an
19
+ # async handler, in the executor thread for a blocking one). Lets code DEEP inside a handler — that
20
+ # never received `ctx` — record events via the module-level helpers below, without threading the
21
+ # context through every call. Outside a step it's None and the helpers are no-ops.
22
+ _current_step: "contextvars.ContextVar[Optional[StepContext]]" = contextvars.ContextVar(
23
+ "durable_current_step", default=None
24
+ )
25
+
26
+
27
+ def current_step() -> "Optional[StepContext]":
28
+ """The :class:`StepContext` of the step running on this task/thread, or None outside a step."""
29
+ return _current_step.get()
30
+
31
+
32
+ def log(level: str, message: str, data: Any = None) -> None:
33
+ """Record a log line on the current step (level: debug/info/warn/error). No-op outside a step."""
34
+ ctx = _current_step.get()
35
+ if ctx is not None:
36
+ getattr(ctx, level, ctx.info)(message, data)
37
+
38
+
39
+ def sub(name: str, status: str, message: Optional[str] = None, data: Any = None) -> None:
40
+ """Record a sub-process outcome (ok/failed/skipped) on the current step. No-op outside a step."""
41
+ ctx = _current_step.get()
42
+ if ctx is not None:
43
+ ctx.sub(name, status, message, data)
44
+
45
+
46
+ def set_process(name: Optional[str]) -> None:
47
+ """Tag the current step's subsequent log lines with sub-process ``name`` (None clears). No-op
48
+ outside a step — so the same business code runs on a non-durable path untouched."""
49
+ ctx = _current_step.get()
50
+ if ctx is not None:
51
+ ctx.process(name)
52
+
53
+
54
+ class FatalError(Exception):
55
+ """Raise inside a handler to signal a non-retryable failure (mirrors the TS ``FatalError``).
56
+
57
+ The engine will not retry the step regardless of its ``retries`` setting.
58
+ """
59
+
60
+ def __init__(self, message: str, code: str | None = None) -> None:
61
+ super().__init__(message)
62
+ self.code = code
63
+
64
+
65
+ class StepContext:
66
+ """Lets a handler record what happened inside a step — debug/info/warn/error lines and
67
+ per-sub-process outcomes. The events ride back on the result as ``events`` and the engine
68
+ checkpoints them, so the dashboard shows them under the step. The TypeScript counterpart is
69
+ the ``StepLogger`` handed to ``ctx.step`` — same ``StepEvent`` shape, so observability is
70
+ symmetric regardless of which language ran the step.
71
+
72
+ A handler opts in by declaring a second parameter::
73
+
74
+ @worker.step("processing")
75
+ def run(data, ctx):
76
+ for proc in data["procs"]:
77
+ ok = run_proc(proc)
78
+ ctx.sub(proc["name"], "ok" if ok else "failed")
79
+ return {"context": {...}}
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ run_id: Optional[str] = None,
85
+ is_cancelled: Optional[Callable[[str], bool]] = None,
86
+ seq: Optional[int] = None,
87
+ on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
88
+ ) -> None:
89
+ self.events: List[Dict[str, Any]] = []
90
+ self._run_id = run_id
91
+ self._is_cancelled = is_cancelled
92
+ # Step seq + a sink for live progress: when a transport supplies ``on_event``, every event is
93
+ # ALSO handed to it as it happens (e.g. published on the control plane as a ``step.progress``),
94
+ # so a dashboard tails a long step line-by-line instead of waiting for the final result.
95
+ self._seq = seq
96
+ self._on_event = on_event
97
+ # The sub-process a handler is currently inside (set via ``process``), stamped onto log lines
98
+ # so the dashboard groups a fan-out step's trail per sub-process.
99
+ self._process: Optional[str] = None
100
+
101
+ def process(self, name: Optional[str]) -> None:
102
+ """Mark the sub-process whose work is now running; subsequent log lines are tagged with it
103
+ (until changed or cleared with ``None``). A no-op for ``sub()`` outcome rows, which name
104
+ themselves. Lets a fan-out handler attribute its log trail to each sub-process."""
105
+ self._process = name
106
+
107
+ @property
108
+ def cancelled(self) -> bool:
109
+ """True once the run has been cancelled (a runner subscribed to the control channel marks
110
+ it). Check this at safe points in a long handler and return/raise to bail out early."""
111
+ return bool(
112
+ self._is_cancelled is not None
113
+ and self._run_id is not None
114
+ and self._is_cancelled(self._run_id)
115
+ )
116
+
117
+ def raise_if_cancelled(self) -> None:
118
+ """Raise :class:`Cancelled` if the run has been cancelled — a one-liner abort for handlers."""
119
+ if self.cancelled:
120
+ from .cancellation import Cancelled
121
+
122
+ raise Cancelled(self._run_id or "")
123
+
124
+ def _emit(
125
+ self,
126
+ level: str,
127
+ message: str,
128
+ name: Optional[str] = None,
129
+ status: Optional[str] = None,
130
+ data: Any = None,
131
+ ) -> None:
132
+ event: Dict[str, Any] = {"at": int(time.time() * 1000), "level": level, "message": message}
133
+ if name is not None:
134
+ event["name"] = name
135
+ if status is not None:
136
+ event["status"] = status
137
+ # Tag a log line (no outcome status) with the sub-process it was emitted inside, if any.
138
+ elif self._process is not None:
139
+ event["process"] = self._process
140
+ if data is not None:
141
+ event["data"] = data
142
+ self.events.append(event)
143
+ # Live progress: hand the event to the transport's sink so it streams now (best-effort — a
144
+ # broken sink must never fail the handler; the event is already captured for the result).
145
+ if self._on_event is not None:
146
+ try:
147
+ self._on_event(event)
148
+ except Exception: # noqa: BLE001 — live-tail is best-effort observability
149
+ pass
150
+
151
+ def debug(self, message: str, data: Any = None) -> None:
152
+ self._emit("debug", message, data=data)
153
+
154
+ def info(self, message: str, data: Any = None) -> None:
155
+ self._emit("info", message, data=data)
156
+
157
+ def warn(self, message: str, data: Any = None) -> None:
158
+ self._emit("warn", message, data=data)
159
+
160
+ def error(self, message: str, data: Any = None) -> None:
161
+ self._emit("error", message, data=data)
162
+
163
+ def sub(
164
+ self, name: str, status: str, message: Optional[str] = None, data: Any = None
165
+ ) -> None:
166
+ """Record a sub-step / sub-process outcome (e.g. one of N parallel p-processes)."""
167
+ level = "error" if status == "failed" else "warn" if status == "skipped" else "info"
168
+ self._emit(level, message or name, name=name, status=status, data=data)
169
+
170
+
171
+ class Worker:
172
+ """Registers step handlers by name and turns a dispatched task into a result.
173
+
174
+ Example::
175
+
176
+ worker = Worker(group="payments")
177
+
178
+ @worker.step("payments.charge-card")
179
+ async def charge(data):
180
+ res = await stripe.charge(data["orderId"], data["amountCents"])
181
+ return {"chargeId": res.id}
182
+ """
183
+
184
+ def __init__(self, group: str = "default") -> None:
185
+ self.group = group
186
+ self._handlers: Dict[str, Handler] = {}
187
+ self._blocking: Dict[str, bool] = {}
188
+
189
+ def step(self, name: str, *, blocking: bool = False) -> Callable[[Handler], Handler]:
190
+ """Decorator registering ``fn`` as the handler for step ``name``.
191
+
192
+ Set ``blocking=True`` for a synchronous handler that does CPU/DB work: the worker runs it in
193
+ a thread so the event loop stays free to renew the broker's job lock (otherwise a long step
194
+ looks stalled and gets redelivered). The step context is bound inside that thread too, so the
195
+ module-level :func:`log`/:func:`sub`/:func:`set_process` work from anywhere in the call.
196
+ """
197
+
198
+ def register(fn: Handler) -> Handler:
199
+ self._handlers[name] = fn
200
+ self._blocking[name] = blocking
201
+ return fn
202
+
203
+ return register
204
+
205
+ def handles(self, name: str) -> bool:
206
+ return name in self._handlers
207
+
208
+ def run(self, *, redis: str = "redis://localhost:6379", prefix: str = "durable") -> None:
209
+ """Run this worker against the Redis/BullMQ transport until the process is signalled, then
210
+ close gracefully. Owns the whole long-running bootstrap a worker process needs — the event
211
+ loop, SIGTERM/SIGINT handling, and the broker connection — so the entrypoint is one call::
212
+
213
+ worker = Worker(group="processing")
214
+
215
+ @worker.step("processing", blocking=True)
216
+ def handle(data, ctx): ...
217
+
218
+ worker.run(redis=redis_url_from_env())
219
+
220
+ Blocks until a shutdown signal closes the worker (which finishes the active job and releases
221
+ its lock). For finer control (your own loop/signals), use :func:`run_redis_worker` directly.
222
+ """
223
+ import signal as _signal
224
+
225
+ from .redis_runner import run_redis_worker
226
+
227
+ async def _main() -> None:
228
+ bull_worker = await run_redis_worker(
229
+ self, group=self.group, connection=redis, prefix=prefix
230
+ )
231
+ stop = asyncio.Event()
232
+ loop = asyncio.get_running_loop()
233
+
234
+ async def _graceful() -> None:
235
+ try:
236
+ await bull_worker.close()
237
+ finally:
238
+ stop.set()
239
+
240
+ for sig in (_signal.SIGTERM, _signal.SIGINT):
241
+ try:
242
+ loop.add_signal_handler(sig, lambda: asyncio.ensure_future(_graceful()))
243
+ except (NotImplementedError, RuntimeError):
244
+ pass # no signal support here (e.g. not the main thread) — rely on redelivery
245
+ await stop.wait()
246
+
247
+ asyncio.run(_main())
248
+
249
+ def process_task(
250
+ self,
251
+ task: Dict[str, Any],
252
+ is_cancelled: Optional[Callable[[str], bool]] = None,
253
+ on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
254
+ ) -> Dict[str, Any]:
255
+ """Run the handler for ``task`` and return a wire-format result.
256
+
257
+ Pure and synchronous from the caller's view (async handlers are awaited internally), so a
258
+ transport can simply ``result = worker.process_task(task); send(result)``. Pass
259
+ ``is_cancelled`` so the handler's ``ctx.cancelled`` reflects cooperative cancellation, and
260
+ ``on_event`` to receive each step event live (for ``step.progress`` streaming).
261
+ """
262
+
263
+ base = self._base(task)
264
+ handler = self._handlers.get(task["name"])
265
+ if handler is None:
266
+ return self._no_handler(base, task["name"])
267
+ ctx = self._ctx(task, is_cancelled, on_event)
268
+ try:
269
+ output = _run_bound(handler, task.get("input"), ctx)
270
+ if inspect.isawaitable(output):
271
+ output = asyncio.run(output)
272
+ return self._completed(base, output, ctx)
273
+ except Exception as err: # noqa: BLE001
274
+ return self._failure(base, err, ctx)
275
+
276
+ async def aprocess_task(
277
+ self,
278
+ task: Dict[str, Any],
279
+ is_cancelled: Optional[Callable[[str], bool]] = None,
280
+ on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
281
+ ) -> Dict[str, Any]:
282
+ """Async variant — awaits async handlers in the current loop. Use from a transport that
283
+ already runs inside an event loop (e.g. the BullMQ runner). A handler registered
284
+ ``blocking=True`` runs in a thread so the loop stays free to renew the job lock."""
285
+
286
+ base = self._base(task)
287
+ handler = self._handlers.get(task["name"])
288
+ if handler is None:
289
+ return self._no_handler(base, task["name"])
290
+ ctx = self._ctx(task, is_cancelled, on_event)
291
+ try:
292
+ if self._blocking.get(task["name"]):
293
+ loop = asyncio.get_running_loop()
294
+ output = await loop.run_in_executor(
295
+ None, _run_bound, handler, task.get("input"), ctx
296
+ )
297
+ else:
298
+ output = _run_bound(handler, task.get("input"), ctx)
299
+ if inspect.isawaitable(output):
300
+ # The context var was reset by _run_bound after the sync part returned the
301
+ # coroutine; re-bind it for the duration of the await so deep `log()` calls work.
302
+ token = _current_step.set(ctx)
303
+ try:
304
+ output = await output
305
+ finally:
306
+ _current_step.reset(token)
307
+ return self._completed(base, output, ctx)
308
+ except Exception as err: # noqa: BLE001
309
+ return self._failure(base, err, ctx)
310
+
311
+ @staticmethod
312
+ def _ctx(
313
+ task: Dict[str, Any],
314
+ is_cancelled: Optional[Callable[[str], bool]],
315
+ on_event: Optional[Callable[[Dict[str, Any]], None]],
316
+ ) -> StepContext:
317
+ return StepContext(
318
+ run_id=task.get("runId"),
319
+ is_cancelled=is_cancelled,
320
+ seq=task.get("seq"),
321
+ on_event=on_event,
322
+ )
323
+
324
+ @staticmethod
325
+ def _base(task: Dict[str, Any]) -> Dict[str, Any]:
326
+ # Stamp the worker's pickup time so the engine can report queue-wait (startedAt − enqueuedAt),
327
+ # mirroring the TypeScript ``runStepHandler``. Epoch ms keeps it language-neutral on the wire.
328
+ return {
329
+ "runId": task["runId"],
330
+ "seq": task["seq"],
331
+ "stepId": task["stepId"],
332
+ "startedAt": int(time.time() * 1000),
333
+ }
334
+
335
+ @staticmethod
336
+ def _completed(base: Dict[str, Any], output: Any, ctx: StepContext) -> Dict[str, Any]:
337
+ return _with_events({**base, "status": "completed", "output": output}, ctx)
338
+
339
+ @staticmethod
340
+ def _no_handler(base: Dict[str, Any], name: str) -> Dict[str, Any]:
341
+ return {
342
+ **base,
343
+ "status": "failed",
344
+ "error": {"message": f"no handler for {name}", "retryable": False},
345
+ }
346
+
347
+ @staticmethod
348
+ def _failure(base: Dict[str, Any], err: Exception, ctx: StepContext) -> Dict[str, Any]:
349
+ # Keep whatever the handler logged before it threw — a partial p-process run is exactly
350
+ # the case where the sub-process outcomes matter most.
351
+ if isinstance(err, FatalError):
352
+ error = {"message": str(err), "code": err.code, "retryable": False}
353
+ else:
354
+ error = {"message": str(err)}
355
+ return _with_events({**base, "status": "failed", "error": error}, ctx)
356
+
357
+
358
+ def _run_bound(handler: Handler, input_: Any, ctx: "StepContext") -> Any:
359
+ """Call ``handler`` with ``ctx`` bound as the current step for the duration of the call, so the
360
+ module-level :func:`log`/:func:`sub`/:func:`set_process` reach it from deep in the call tree.
361
+ Passes ``ctx`` as a second arg only if the handler declares one (plain ``def h(data)`` still
362
+ works). Also the target run in the executor thread for a ``blocking=True`` handler."""
363
+ token = _current_step.set(ctx)
364
+ try:
365
+ return handler(input_, ctx) if _wants_ctx(handler) else handler(input_)
366
+ finally:
367
+ _current_step.reset(token)
368
+
369
+
370
+ def _wants_ctx(handler: Handler) -> bool:
371
+ """True if ``handler`` can accept the step context as a second positional argument."""
372
+ try:
373
+ params = list(inspect.signature(handler).parameters.values())
374
+ except (ValueError, TypeError): # builtins / C functions without a signature
375
+ return False
376
+ positional = [
377
+ p
378
+ for p in params
379
+ if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
380
+ ]
381
+ has_var_positional = any(p.kind is inspect.Parameter.VAR_POSITIONAL for p in params)
382
+ return len(positional) >= 2 or has_var_positional
383
+
384
+
385
+ def _with_events(result: Dict[str, Any], ctx: StepContext) -> Dict[str, Any]:
386
+ if ctx.events:
387
+ result["events"] = ctx.events
388
+ return result
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "durable-worker"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "Python worker SDK for nestjs-durable — run durable workflow steps in Python."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -0,0 +1,68 @@
1
+ import unittest
2
+
3
+ from durable_worker import CancellationRegistry, Cancelled, Worker
4
+
5
+
6
+ def task(name="long.job", **over):
7
+ base = {"runId": "r1", "seq": 0, "name": name, "stepId": "r1:0", "input": {}, "attempt": 1}
8
+ base.update(over)
9
+ return base
10
+
11
+
12
+ class CancellationRegistryTest(unittest.TestCase):
13
+ def test_records_a_cancel_control_message(self):
14
+ reg = CancellationRegistry()
15
+ self.assertFalse(reg.is_cancelled("r1"))
16
+ reg.on_control_message({"kind": "cancel", "runId": "r1", "from": "x"})
17
+ self.assertTrue(reg.is_cancelled("r1"))
18
+
19
+ def test_ignores_non_cancel_messages(self):
20
+ reg = CancellationRegistry()
21
+ reg.on_control_message({"kind": "event", "event": {}})
22
+ reg.on_control_message({"nonsense": True})
23
+ self.assertFalse(reg.is_cancelled("r1"))
24
+
25
+
26
+ class StepContextCancellationTest(unittest.TestCase):
27
+ def test_handler_sees_cancellation_via_ctx(self):
28
+ reg = CancellationRegistry()
29
+ worker = Worker()
30
+ observed = {}
31
+
32
+ @worker.step("long.job")
33
+ def run(_data, ctx):
34
+ observed["before"] = ctx.cancelled
35
+ reg.cancel("r1")
36
+ observed["after"] = ctx.cancelled
37
+ return "done"
38
+
39
+ worker.process_task(task(), is_cancelled=reg.is_cancelled)
40
+ self.assertEqual(observed, {"before": False, "after": True})
41
+
42
+ def test_raise_if_cancelled_aborts_the_step(self):
43
+ reg = CancellationRegistry()
44
+ reg.cancel("r1")
45
+ worker = Worker()
46
+
47
+ @worker.step("long.job")
48
+ def run(_data, ctx):
49
+ ctx.raise_if_cancelled()
50
+ return "should not get here"
51
+
52
+ result = worker.process_task(task(), is_cancelled=reg.is_cancelled)
53
+ self.assertEqual(result["status"], "failed")
54
+ self.assertIn("cancelled", result["error"]["message"])
55
+
56
+ def test_cancelled_is_false_without_a_source(self):
57
+ worker = Worker()
58
+
59
+ @worker.step("long.job")
60
+ def run(_data, ctx):
61
+ return ctx.cancelled
62
+
63
+ result = worker.process_task(task())
64
+ self.assertEqual(result["output"], False)
65
+
66
+
67
+ if __name__ == "__main__":
68
+ unittest.main()
@@ -0,0 +1,51 @@
1
+ import unittest
2
+
3
+ from durable_worker import redis_url_from_env
4
+
5
+
6
+ class RedisUrlFromEnvTest(unittest.TestCase):
7
+ def setUp(self):
8
+ import os
9
+
10
+ self._saved = {
11
+ k: os.environ.pop(k, None)
12
+ for k in ("REDIS_HOST", "REDIS_PORT", "REDIS_USERNAME", "REDIS_PASSWORD")
13
+ }
14
+
15
+ def tearDown(self):
16
+ import os
17
+
18
+ for k, v in self._saved.items():
19
+ if v is None:
20
+ os.environ.pop(k, None)
21
+ else:
22
+ os.environ[k] = v
23
+
24
+ def test_defaults_to_localhost_without_auth(self):
25
+ self.assertEqual(redis_url_from_env(), "redis://localhost:6379")
26
+
27
+ def test_host_port_and_auth(self):
28
+ import os
29
+
30
+ os.environ["REDIS_HOST"] = "redis.internal"
31
+ os.environ["REDIS_PORT"] = "6380"
32
+ self.assertEqual(redis_url_from_env(), "redis://redis.internal:6380")
33
+ os.environ["REDIS_USERNAME"] = "default"
34
+ os.environ["REDIS_PASSWORD"] = "secret"
35
+ self.assertEqual(redis_url_from_env(), "redis://default:secret@redis.internal:6380")
36
+
37
+ def test_url_encodes_credentials(self):
38
+ import os
39
+
40
+ os.environ["REDIS_HOST"] = "redis.internal"
41
+ os.environ["REDIS_PORT"] = "6380"
42
+ os.environ["REDIS_USERNAME"] = "default"
43
+ # A password with @ : / would corrupt the netloc if left raw.
44
+ os.environ["REDIS_PASSWORD"] = "p@ss:w/rd"
45
+ self.assertEqual(
46
+ redis_url_from_env(), "redis://default:p%40ss%3Aw%2Frd@redis.internal:6380"
47
+ )
48
+
49
+
50
+ if __name__ == "__main__":
51
+ unittest.main()
@@ -0,0 +1,23 @@
1
+ import unittest
2
+
3
+ from durable_worker import reply_target
4
+
5
+
6
+ class ReplyTargetTest(unittest.TestCase):
7
+ def test_routes_by_task_transport_id(self):
8
+ targets = {"redis": "redis-results", "sqs": "sqs-results"}
9
+ self.assertEqual(reply_target({"transport": "sqs"}, targets), "sqs-results")
10
+ self.assertEqual(reply_target({"transport": "redis"}, targets), "redis-results")
11
+
12
+ def test_falls_back_to_the_lone_target_when_id_is_absent(self):
13
+ self.assertEqual(reply_target({}, {"only": "x"}), "x")
14
+
15
+ def test_unknown_id_with_multiple_targets_is_unroutable(self):
16
+ self.assertIsNone(reply_target({"transport": "nats"}, {"redis": "a", "sqs": "b"}))
17
+
18
+ def test_empty_targets_is_none(self):
19
+ self.assertIsNone(reply_target({"transport": "redis"}, {}))
20
+
21
+
22
+ if __name__ == "__main__":
23
+ unittest.main()
@@ -1,6 +1,15 @@
1
+ import asyncio
1
2
  import unittest
2
3
 
3
- from durable_worker import FatalError, StepContext, Worker
4
+ from durable_worker import (
5
+ FatalError,
6
+ StepContext,
7
+ Worker,
8
+ current_step,
9
+ log,
10
+ set_process,
11
+ sub,
12
+ )
4
13
 
5
14
 
6
15
  def task(name="payments.charge-card", **over):
@@ -136,6 +145,97 @@ class WorkerTest(unittest.TestCase):
136
145
  result = worker.process_task(task())
137
146
  self.assertEqual(result["events"][0]["message"], "async")
138
147
 
148
+ def test_logs_are_tagged_with_the_current_process(self):
149
+ worker = Worker()
150
+
151
+ @worker.step("payments.charge-card")
152
+ def charge(_data, ctx: StepContext):
153
+ ctx.process("proc-a")
154
+ ctx.info("inside a")
155
+ ctx.sub("proc-a", "ok")
156
+ ctx.process("proc-b")
157
+ ctx.debug("inside b")
158
+ ctx.process(None)
159
+ ctx.info("step-level")
160
+ return {"ok": True}
161
+
162
+ # process() itself emits nothing, so the events are: [info a, sub a, debug b, info step-level]
163
+ events = worker.process_task(task())["events"]
164
+ # log lines carry the sub-process that was running when they fired...
165
+ self.assertEqual(events[0]["process"], "proc-a")
166
+ self.assertEqual(events[2]["process"], "proc-b")
167
+ # ...the sub-process OUTCOME row names itself (no `process` tag)...
168
+ self.assertNotIn("process", events[1])
169
+ self.assertEqual(events[1]["status"], "ok")
170
+ # ...and a log after process(None) is untagged (step-level).
171
+ self.assertNotIn("process", events[3])
172
+
173
+ def test_on_event_streams_each_event_as_it_is_emitted(self):
174
+ worker = Worker()
175
+ live = []
176
+
177
+ @worker.step("payments.charge-card")
178
+ def charge(_data, ctx: StepContext):
179
+ ctx.info("first")
180
+ # the live sink saw the event the instant it was emitted, before the step returned
181
+ self.assertEqual([e["message"] for e in live], ["first"])
182
+ ctx.sub("proc-a", "ok")
183
+ return {"ok": True}
184
+
185
+ result = worker.process_task(task(), on_event=live.append)
186
+ self.assertEqual([e["message"] for e in live], ["first", "proc-a"])
187
+ # the same events still ride back on the result (live-tail doesn't replace the final record)
188
+ self.assertEqual(len(result["events"]), 2)
189
+
190
+
191
+ def test_module_level_helpers_reach_the_current_step(self):
192
+ worker = Worker()
193
+
194
+ def deep_business_logic():
195
+ # No `ctx` in scope here — the context-local helpers find it.
196
+ set_process("proc-a")
197
+ log("debug", "querying")
198
+ sub("proc-a", "ok")
199
+
200
+ @worker.step("payments.charge-card")
201
+ def charge(_data): # note: no ctx param — handler doesn't even take it
202
+ deep_business_logic()
203
+ return {"ok": True}
204
+
205
+ events = worker.process_task(task())["events"]
206
+ self.assertEqual(events[0]["message"], "querying")
207
+ self.assertEqual(events[0]["process"], "proc-a")
208
+ self.assertEqual(events[1]["name"], "proc-a")
209
+ self.assertEqual(events[1]["status"], "ok")
210
+
211
+ def test_module_level_helpers_are_noops_outside_a_step(self):
212
+ # Same business code on a non-durable path must not blow up.
213
+ self.assertIsNone(current_step())
214
+ log("info", "no step here")
215
+ sub("x", "ok")
216
+ set_process("x")
217
+
218
+ def test_blocking_handler_runs_in_a_thread_with_the_step_bound(self):
219
+ import threading
220
+
221
+ worker = Worker()
222
+ ran_on = {}
223
+
224
+ @worker.step("payments.charge-card", blocking=True)
225
+ def charge(_data):
226
+ ran_on["thread"] = threading.current_thread().name
227
+ log("info", "from the worker thread")
228
+ return {"ok": True}
229
+
230
+ async def drive():
231
+ return await worker.aprocess_task(task())
232
+
233
+ loop_thread = threading.current_thread().name
234
+ result = asyncio.run(drive())
235
+ self.assertEqual(result["events"][0]["message"], "from the worker thread")
236
+ # it really ran off the event-loop thread (so the lock keeps renewing)
237
+ self.assertNotEqual(ran_on["thread"], loop_thread)
238
+
139
239
 
140
240
  if __name__ == "__main__":
141
241
  unittest.main()
@@ -1,11 +0,0 @@
1
- """durable-worker — Python SDK for running nestjs-durable remote steps.
2
-
3
- A worker registers step handlers by name and processes tasks dispatched by the orchestrator.
4
- The wire protocol (task in, result out) is plain JSON and identical across languages, so the
5
- same step name implemented here is callable from a TypeScript workflow via ``ctx.call``.
6
- """
7
-
8
- from .worker import FatalError, StepContext, Worker
9
-
10
- __all__ = ["Worker", "FatalError", "StepContext"]
11
- __version__ = "0.2.0"
@@ -1,42 +0,0 @@
1
- """Run a :class:`Worker` against the BullMQ/Redis transport.
2
-
3
- Consumes the orchestrator's per-group tasks queue and publishes results on the shared results
4
- queue — the same queues a TypeScript ``BullMQTransport`` uses, so steps interoperate across
5
- languages. Requires the optional ``bullmq`` extra: ``pip install durable-worker[redis]``.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- from typing import Any
11
-
12
- from .worker import Worker
13
-
14
-
15
- def _names(prefix: str, group: str) -> tuple[str, str]:
16
- # Must match the TS BullMQTransport: '<prefix>-tasks-<group>' and '<prefix>-results'.
17
- return f"{prefix}-tasks-{group}", f"{prefix}-results"
18
-
19
-
20
- async def run_redis_worker(
21
- worker: Worker,
22
- *,
23
- group: str,
24
- connection: str = "redis://localhost:6379",
25
- prefix: str = "durable",
26
- ) -> Any:
27
- """Start a BullMQ worker that runs ``worker``'s handlers. Returns the bullmq Worker.
28
-
29
- The returned worker runs in the background; ``await worker.close()`` to stop it.
30
- """
31
-
32
- from bullmq import Queue as BullQueue # imported lazily so the SDK works without bullmq
33
- from bullmq import Worker as BullWorker
34
-
35
- tasks_name, results_name = _names(prefix, group)
36
- results = BullQueue(results_name, {"connection": connection})
37
-
38
- async def process(job: Any, _token: str) -> None:
39
- result = await worker.aprocess_task(job.data)
40
- await results.add("result", result, {"removeOnComplete": True, "removeOnFail": True})
41
-
42
- return BullWorker(tasks_name, process, {"connection": connection})
@@ -1,212 +0,0 @@
1
- """Core worker: a name->handler registry and the pure task->result dispatch.
2
-
3
- Transport (Redis/BullMQ/NATS) is intentionally separate — `process_task` is a pure function of
4
- the task, so it is fully testable without any broker. A transport adapter just feeds tasks in
5
- and ships results out.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import asyncio
11
- import inspect
12
- import time
13
- from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
14
-
15
- Handler = Callable[..., Union[Any, Awaitable[Any]]]
16
-
17
-
18
- class FatalError(Exception):
19
- """Raise inside a handler to signal a non-retryable failure (mirrors the TS ``FatalError``).
20
-
21
- The engine will not retry the step regardless of its ``retries`` setting.
22
- """
23
-
24
- def __init__(self, message: str, code: str | None = None) -> None:
25
- super().__init__(message)
26
- self.code = code
27
-
28
-
29
- class StepContext:
30
- """Lets a handler record what happened inside a step — debug/info/warn/error lines and
31
- per-sub-process outcomes. The events ride back on the result as ``events`` and the engine
32
- checkpoints them, so the dashboard shows them under the step. The TypeScript counterpart is
33
- the ``StepLogger`` handed to ``ctx.step`` — same ``StepEvent`` shape, so observability is
34
- symmetric regardless of which language ran the step.
35
-
36
- A handler opts in by declaring a second parameter::
37
-
38
- @worker.step("processing")
39
- def run(data, ctx):
40
- for proc in data["procs"]:
41
- ok = run_proc(proc)
42
- ctx.sub(proc["name"], "ok" if ok else "failed")
43
- return {"context": {...}}
44
- """
45
-
46
- def __init__(self) -> None:
47
- self.events: List[Dict[str, Any]] = []
48
-
49
- def _emit(
50
- self,
51
- level: str,
52
- message: str,
53
- name: Optional[str] = None,
54
- status: Optional[str] = None,
55
- data: Any = None,
56
- ) -> None:
57
- event: Dict[str, Any] = {"at": int(time.time() * 1000), "level": level, "message": message}
58
- if name is not None:
59
- event["name"] = name
60
- if status is not None:
61
- event["status"] = status
62
- if data is not None:
63
- event["data"] = data
64
- self.events.append(event)
65
-
66
- def debug(self, message: str, data: Any = None) -> None:
67
- self._emit("debug", message, data=data)
68
-
69
- def info(self, message: str, data: Any = None) -> None:
70
- self._emit("info", message, data=data)
71
-
72
- def warn(self, message: str, data: Any = None) -> None:
73
- self._emit("warn", message, data=data)
74
-
75
- def error(self, message: str, data: Any = None) -> None:
76
- self._emit("error", message, data=data)
77
-
78
- def sub(
79
- self, name: str, status: str, message: Optional[str] = None, data: Any = None
80
- ) -> None:
81
- """Record a sub-step / sub-process outcome (e.g. one of N parallel p-processes)."""
82
- level = "error" if status == "failed" else "warn" if status == "skipped" else "info"
83
- self._emit(level, message or name, name=name, status=status, data=data)
84
-
85
-
86
- class Worker:
87
- """Registers step handlers by name and turns a dispatched task into a result.
88
-
89
- Example::
90
-
91
- worker = Worker(group="payments")
92
-
93
- @worker.step("payments.charge-card")
94
- async def charge(data):
95
- res = await stripe.charge(data["orderId"], data["amountCents"])
96
- return {"chargeId": res.id}
97
- """
98
-
99
- def __init__(self, group: str = "default") -> None:
100
- self.group = group
101
- self._handlers: Dict[str, Handler] = {}
102
-
103
- def step(self, name: str) -> Callable[[Handler], Handler]:
104
- """Decorator registering ``fn`` as the handler for step ``name``."""
105
-
106
- def register(fn: Handler) -> Handler:
107
- self._handlers[name] = fn
108
- return fn
109
-
110
- return register
111
-
112
- def handles(self, name: str) -> bool:
113
- return name in self._handlers
114
-
115
- def process_task(self, task: Dict[str, Any]) -> Dict[str, Any]:
116
- """Run the handler for ``task`` and return a wire-format result.
117
-
118
- Pure and synchronous from the caller's view (async handlers are awaited internally), so a
119
- transport can simply ``result = worker.process_task(task); send(result)``.
120
- """
121
-
122
- base = self._base(task)
123
- handler = self._handlers.get(task["name"])
124
- if handler is None:
125
- return self._no_handler(base, task["name"])
126
- ctx = StepContext()
127
- try:
128
- output = self._invoke(handler, task.get("input"), ctx)
129
- if inspect.isawaitable(output):
130
- output = asyncio.run(output)
131
- return self._completed(base, output, ctx)
132
- except Exception as err: # noqa: BLE001
133
- return self._failure(base, err, ctx)
134
-
135
- async def aprocess_task(self, task: Dict[str, Any]) -> Dict[str, Any]:
136
- """Async variant — awaits async handlers in the current loop. Use from a transport that
137
- already runs inside an event loop (e.g. the BullMQ runner)."""
138
-
139
- base = self._base(task)
140
- handler = self._handlers.get(task["name"])
141
- if handler is None:
142
- return self._no_handler(base, task["name"])
143
- ctx = StepContext()
144
- try:
145
- output = self._invoke(handler, task.get("input"), ctx)
146
- if inspect.isawaitable(output):
147
- output = await output
148
- return self._completed(base, output, ctx)
149
- except Exception as err: # noqa: BLE001
150
- return self._failure(base, err, ctx)
151
-
152
- @staticmethod
153
- def _invoke(handler: Handler, input_: Any, ctx: StepContext) -> Any:
154
- """Call ``handler`` with the step input, passing ``ctx`` only if it declares a second
155
- parameter — so plain ``def handler(data)`` handlers keep working unchanged."""
156
- if _wants_ctx(handler):
157
- return handler(input_, ctx)
158
- return handler(input_)
159
-
160
- @staticmethod
161
- def _base(task: Dict[str, Any]) -> Dict[str, Any]:
162
- # Stamp the worker's pickup time so the engine can report queue-wait (startedAt − enqueuedAt),
163
- # mirroring the TypeScript ``runStepHandler``. Epoch ms keeps it language-neutral on the wire.
164
- return {
165
- "runId": task["runId"],
166
- "seq": task["seq"],
167
- "stepId": task["stepId"],
168
- "startedAt": int(time.time() * 1000),
169
- }
170
-
171
- @staticmethod
172
- def _completed(base: Dict[str, Any], output: Any, ctx: StepContext) -> Dict[str, Any]:
173
- return _with_events({**base, "status": "completed", "output": output}, ctx)
174
-
175
- @staticmethod
176
- def _no_handler(base: Dict[str, Any], name: str) -> Dict[str, Any]:
177
- return {
178
- **base,
179
- "status": "failed",
180
- "error": {"message": f"no handler for {name}", "retryable": False},
181
- }
182
-
183
- @staticmethod
184
- def _failure(base: Dict[str, Any], err: Exception, ctx: StepContext) -> Dict[str, Any]:
185
- # Keep whatever the handler logged before it threw — a partial p-process run is exactly
186
- # the case where the sub-process outcomes matter most.
187
- if isinstance(err, FatalError):
188
- error = {"message": str(err), "code": err.code, "retryable": False}
189
- else:
190
- error = {"message": str(err)}
191
- return _with_events({**base, "status": "failed", "error": error}, ctx)
192
-
193
-
194
- def _wants_ctx(handler: Handler) -> bool:
195
- """True if ``handler`` can accept the step context as a second positional argument."""
196
- try:
197
- params = list(inspect.signature(handler).parameters.values())
198
- except (ValueError, TypeError): # builtins / C functions without a signature
199
- return False
200
- positional = [
201
- p
202
- for p in params
203
- if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
204
- ]
205
- has_var_positional = any(p.kind is inspect.Parameter.VAR_POSITIONAL for p in params)
206
- return len(positional) >= 2 or has_var_positional
207
-
208
-
209
- def _with_events(result: Dict[str, Any], ctx: StepContext) -> Dict[str, Any]:
210
- if ctx.events:
211
- result["events"] = ctx.events
212
- return result
File without changes