durable-workflow 0.4.0__tar.gz → 0.4.2__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 (58) hide show
  1. {durable_workflow-0.4.0/src/durable_workflow.egg-info → durable_workflow-0.4.2}/PKG-INFO +1 -1
  2. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/pyproject.toml +1 -1
  3. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/client.py +42 -4
  4. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/sync.py +4 -2
  5. durable_workflow-0.4.2/src/durable_workflow/testing.py +454 -0
  6. {durable_workflow-0.4.0 → durable_workflow-0.4.2/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  7. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_client.py +28 -0
  8. durable_workflow-0.4.2/tests/test_testing_harness.py +414 -0
  9. durable_workflow-0.4.0/src/durable_workflow/testing.py +0 -267
  10. durable_workflow-0.4.0/tests/test_testing_harness.py +0 -221
  11. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/LICENSE +0 -0
  12. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/README.md +0 -0
  13. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/setup.cfg +0 -0
  14. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/__init__.py +0 -0
  15. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/_avro.py +0 -0
  16. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/activity.py +0 -0
  17. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/auth_composition.py +0 -0
  18. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/errors.py +0 -0
  19. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/external_storage.py +0 -0
  20. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/external_task_input.py +0 -0
  21. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/external_task_result.py +0 -0
  22. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/interceptors.py +0 -0
  23. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/invocable.py +0 -0
  24. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/metrics.py +0 -0
  25. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/py.typed +0 -0
  26. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/retry_policy.py +0 -0
  27. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/serializer.py +0 -0
  28. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/worker.py +0 -0
  29. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow/workflow.py +0 -0
  30. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
  31. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  32. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow.egg-info/requires.txt +0 -0
  33. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/src/durable_workflow.egg-info/top_level.txt +0 -0
  34. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_activity_context.py +0 -0
  35. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_auth_composition.py +0 -0
  36. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_control_plane_parity_fixtures.py +0 -0
  37. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_errors.py +0 -0
  38. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_external_storage.py +0 -0
  39. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_external_task_input.py +0 -0
  40. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_external_task_result.py +0 -0
  41. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_golden_history_replay.py +0 -0
  42. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_history_event_contract.py +0 -0
  43. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_invocable.py +0 -0
  44. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_metrics.py +0 -0
  45. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_order_processing_example.py +0 -0
  46. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_public_boundary_scanner.py +0 -0
  47. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_queries.py +0 -0
  48. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_readme_quickstart.py +0 -0
  49. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_replay.py +0 -0
  50. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_retry_policy.py +0 -0
  51. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_schedules.py +0 -0
  52. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_serializer.py +0 -0
  53. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_signals.py +0 -0
  54. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_sleep.py +0 -0
  55. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_sync.py +0 -0
  56. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_updates.py +0 -0
  57. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_wait_condition.py +0 -0
  58. {durable_workflow-0.4.0 → durable_workflow-0.4.2}/tests/test_worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Python SDK for the Durable Workflow server (language-neutral HTTP protocol)
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "Python SDK for the Durable Workflow server (language-neutral HTTP protocol)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -20,6 +20,7 @@ from __future__ import annotations
20
20
 
21
21
  import asyncio
22
22
  import time
23
+ import warnings
23
24
  from collections.abc import Callable
24
25
  from dataclasses import dataclass
25
26
  from importlib.metadata import PackageNotFoundError
@@ -87,6 +88,32 @@ def _route_for_metrics(path: str) -> str:
87
88
  return "/" + "/".join(parts)
88
89
 
89
90
 
91
+ def _resolve_namespace_name(
92
+ name: str | None,
93
+ namespace_alias: str | None,
94
+ *,
95
+ method: str,
96
+ ) -> str:
97
+ """Resolve the namespace name accepted via ``name=`` or the deprecated ``namespace=`` alias."""
98
+ if namespace_alias is not None:
99
+ if name is not None:
100
+ raise TypeError(
101
+ f"{method}() received both 'name' and the deprecated alias "
102
+ "'namespace'; pass only 'name'."
103
+ )
104
+ warnings.warn(
105
+ f"{method}() argument 'namespace' is deprecated since 0.4.1; "
106
+ "use 'name' to match describe_namespace, create_namespace, and "
107
+ "update_namespace.",
108
+ DeprecationWarning,
109
+ stacklevel=3,
110
+ )
111
+ name = namespace_alias
112
+ if name is None:
113
+ raise TypeError(f"{method}() missing required argument: 'name'")
114
+ return name
115
+
116
+
90
117
  @dataclass
91
118
  class WorkflowExecution:
92
119
  """Current server view of one workflow execution."""
@@ -1308,14 +1335,25 @@ class Client:
1308
1335
 
1309
1336
  async def set_namespace_external_storage(
1310
1337
  self,
1311
- namespace: str,
1338
+ name: str | None = None,
1312
1339
  *,
1313
1340
  driver: str,
1314
1341
  enabled: bool = True,
1315
1342
  threshold_bytes: int | None = None,
1316
1343
  config: dict[str, Any] | None = None,
1344
+ namespace: str | None = None,
1317
1345
  ) -> NamespaceDescription:
1318
- """Configure external payload storage for a namespace."""
1346
+ """Configure external payload storage for a namespace.
1347
+
1348
+ The first positional argument is the namespace ``name``, matching
1349
+ :meth:`describe_namespace`, :meth:`create_namespace`, and
1350
+ :meth:`update_namespace`. The ``namespace=`` keyword is accepted as a
1351
+ deprecated alias from the 0.4.0 release and emits a
1352
+ :class:`DeprecationWarning`; it will be removed in a future release.
1353
+ """
1354
+ name = _resolve_namespace_name(
1355
+ name, namespace, method="set_namespace_external_storage"
1356
+ )
1319
1357
  body: dict[str, Any] = {
1320
1358
  "driver": driver,
1321
1359
  "enabled": enabled,
@@ -1327,9 +1365,9 @@ class Client:
1327
1365
 
1328
1366
  data = await self._request(
1329
1367
  "PUT",
1330
- f"/namespaces/{quote(namespace, safe='')}/external-storage",
1368
+ f"/namespaces/{quote(name, safe='')}/external-storage",
1331
1369
  json=body,
1332
- context=namespace,
1370
+ context=name,
1333
1371
  )
1334
1372
  if not isinstance(data, dict):
1335
1373
  raise ServerError(
@@ -354,20 +354,22 @@ class Client:
354
354
 
355
355
  def set_namespace_external_storage(
356
356
  self,
357
- namespace: str,
357
+ name: str | None = None,
358
358
  *,
359
359
  driver: str,
360
360
  enabled: bool = True,
361
361
  threshold_bytes: int | None = None,
362
362
  config: dict[str, Any] | None = None,
363
+ namespace: str | None = None,
363
364
  ) -> NamespaceDescription:
364
365
  result: NamespaceDescription = _run(
365
366
  self._async.set_namespace_external_storage(
366
- namespace,
367
+ name,
367
368
  driver=driver,
368
369
  enabled=enabled,
369
370
  threshold_bytes=threshold_bytes,
370
371
  config=config,
372
+ namespace=namespace,
371
373
  )
372
374
  )
373
375
  return result
@@ -0,0 +1,454 @@
1
+ """In-process test harness for workflow authoring.
2
+
3
+ :class:`WorkflowEnvironment` drives a workflow to completion in a single
4
+ Python process, without a running server or worker. It reuses the same
5
+ :func:`durable_workflow.workflow.replay` machinery the worker uses, but
6
+ resolves yielded commands against user-registered activity mocks and
7
+ auto-fires timers / side-effects / search-attribute upserts so tests do
8
+ not need a real clock or Redis.
9
+
10
+ Typical use::
11
+
12
+ def test_my_workflow():
13
+ env = WorkflowEnvironment()
14
+ env.register_activity_result("charge_card", {"id": "ch_1"})
15
+ env.register_activity_result("send_receipt", None)
16
+ result = env.execute_workflow(OrderWorkflow, "order-1", {"amount": 42})
17
+ assert result == {"status": "complete", "charge_id": "ch_1"}
18
+
19
+ When a workflow yields :class:`~durable_workflow.workflow.ContinueAsNew`,
20
+ the harness records a ``WorkflowContinuedAsNew`` event on the completing
21
+ run, resets history, and starts a new run with the command's arguments.
22
+ :attr:`WorkflowEnvironment.runs` exposes the per-run record (input,
23
+ workflow type, history events, and terminal command) so tests can assert
24
+ on the whole chain. The chain length is bounded by
25
+ ``continue_as_new_limit``; exceeding it raises :exc:`RuntimeError`.
26
+
27
+ For regression-testing workflow code against production histories, use
28
+ :func:`replay_history` — it hands the real durable history straight to
29
+ the worker's replayer and surfaces any non-determinism as a raised
30
+ exception.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ from collections.abc import Callable, Iterable
37
+ from dataclasses import dataclass, field
38
+ from pathlib import Path
39
+ from typing import Any
40
+
41
+ from . import serializer
42
+ from .errors import WorkflowCancelled, WorkflowFailed, WorkflowTerminated
43
+ from .workflow import (
44
+ CompleteWorkflow,
45
+ ContinueAsNew,
46
+ FailWorkflow,
47
+ RecordSideEffect,
48
+ RecordVersionMarker,
49
+ ReplayOutcome,
50
+ ScheduleActivity,
51
+ StartChildWorkflow,
52
+ StartTimer,
53
+ UpsertSearchAttributes,
54
+ registry,
55
+ replay,
56
+ )
57
+
58
+
59
+ @dataclass
60
+ class WorkflowRunRecord:
61
+ """One run within an ``execute_workflow`` chain.
62
+
63
+ A single-run workflow produces one record with :attr:`terminal` of
64
+ type :class:`~durable_workflow.workflow.CompleteWorkflow` or
65
+ :class:`~durable_workflow.workflow.FailWorkflow`. A workflow that
66
+ yields :class:`~durable_workflow.workflow.ContinueAsNew` produces
67
+ one record per link; the terminal of each non-final link is the
68
+ ``ContinueAsNew`` command itself and the final link terminates as
69
+ usual.
70
+ """
71
+
72
+ workflow_type: str
73
+ input: list[Any]
74
+ history: list[dict[str, Any]] = field(default_factory=list)
75
+ terminal: Any = None
76
+
77
+
78
+ class WorkflowEnvironment:
79
+ """Drives a workflow to completion against user-registered activity mocks.
80
+
81
+ :param iteration_limit: upper bound on per-run replay iterations
82
+ (guard against workflows that never yield a terminal command).
83
+ :param continue_as_new_limit: upper bound on the number of
84
+ continue-as-new links in one ``execute_workflow`` chain. The
85
+ first run counts as link 1; a workflow that continues five
86
+ times runs six links. Exceeding the limit raises
87
+ :exc:`RuntimeError`.
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ *,
93
+ iteration_limit: int = 1000,
94
+ continue_as_new_limit: int = 50,
95
+ ) -> None:
96
+ self._activity_results: dict[str, Any] = {}
97
+ self._activity_fns: dict[str, Callable[..., Any]] = {}
98
+ self._child_workflow_results: dict[str, Any] = {}
99
+ self._pending_signals: list[tuple[str, list[Any]]] = []
100
+ self._queued_signals_by_run: dict[int, list[tuple[str, list[Any]]]] = {}
101
+ self._iteration_limit = iteration_limit
102
+ self._continue_as_new_limit = continue_as_new_limit
103
+ self._registered_workflows: dict[str, type] = {}
104
+ self._runs: list[WorkflowRunRecord] = []
105
+
106
+ def register_activity_result(self, name: str, result: Any) -> None:
107
+ """Canned response: every call to ``name`` returns ``result``."""
108
+ self._activity_results[name] = result
109
+
110
+ def register_activity(self, name: str, fn: Callable[..., Any]) -> None:
111
+ """Callable mock: ``fn(*arguments)`` is invoked for each scheduled call.
112
+
113
+ Use this when the test needs the mock to vary with arguments (e.g.
114
+ look up by order id) or to capture invocations.
115
+ """
116
+ self._activity_fns[name] = fn
117
+
118
+ def register_child_workflow_result(self, workflow_type: str, result: Any) -> None:
119
+ """Canned response for child workflow completions."""
120
+ self._child_workflow_results[workflow_type] = result
121
+
122
+ def register_workflow(self, workflow_cls: type) -> None:
123
+ """Make ``workflow_cls`` resolvable by name for continue-as-new.
124
+
125
+ ``continue_as_new(workflow_type="other-name")`` looks up the
126
+ next workflow class by its ``@workflow.defn`` name. The class
127
+ passed to :meth:`execute_workflow` is registered automatically;
128
+ call this method for additional workflow classes the chain may
129
+ continue into.
130
+ """
131
+ name = getattr(workflow_cls, "__workflow_name__", None)
132
+ if not isinstance(name, str) or not name:
133
+ raise TypeError(
134
+ f"{workflow_cls!r} is not a workflow class; "
135
+ "decorate it with @workflow.defn(name=...) first."
136
+ )
137
+ self._registered_workflows[name] = workflow_cls
138
+
139
+ def signal(
140
+ self,
141
+ name: str,
142
+ args: list[Any] | None = None,
143
+ *,
144
+ run: int | None = None,
145
+ ) -> None:
146
+ """Queue a signal to be delivered before the next iteration.
147
+
148
+ Signals are drained in the order they were queued and injected
149
+ into the workflow history as ``SignalReceived`` events; the
150
+ replayer dispatches each to its registered ``@workflow.signal``
151
+ handler.
152
+
153
+ Pass ``run`` to target a specific link in a continue-as-new
154
+ chain (``run=1`` is the first run, ``run=2`` the run that
155
+ starts after the first ``ContinueAsNew``, and so on). Without
156
+ ``run`` the signal is consumed at the current front of the
157
+ chain — on the first run before the first iteration, and on
158
+ later iterations in whatever link happens to be running.
159
+ """
160
+ normalized = (name, list(args) if args is not None else [])
161
+ if run is None:
162
+ self._pending_signals.append(normalized)
163
+ return
164
+ if run < 1:
165
+ raise ValueError("run number must be 1 or greater")
166
+ self._queued_signals_by_run.setdefault(run, []).append(normalized)
167
+
168
+ @property
169
+ def runs(self) -> list[WorkflowRunRecord]:
170
+ """Per-run records produced by the most recent ``execute_workflow`` call.
171
+
172
+ Empty before the first ``execute_workflow`` call. Reset at the
173
+ start of each call.
174
+ """
175
+ return list(self._runs)
176
+
177
+ @property
178
+ def run_count(self) -> int:
179
+ """Number of runs in the most recent ``execute_workflow`` chain."""
180
+ return len(self._runs)
181
+
182
+ def execute_workflow(
183
+ self,
184
+ workflow_cls: type,
185
+ *args: Any,
186
+ run_id: str = "test-run",
187
+ ) -> Any:
188
+ """Drive ``workflow_cls`` to a terminal state and return its result.
189
+
190
+ Raises :class:`~durable_workflow.errors.WorkflowFailed` if the
191
+ workflow ended in the ``failed`` state. Activities that do not
192
+ have a registered mock raise :class:`KeyError` so tests fail
193
+ loudly on missing fixtures.
194
+
195
+ If the workflow yields
196
+ :class:`~durable_workflow.workflow.ContinueAsNew`, the harness
197
+ starts a new run with the new input and, if the command named a
198
+ ``workflow_type``, the matching registered workflow class. The
199
+ chain is bounded by ``continue_as_new_limit`` and returns the
200
+ terminal result of the final run.
201
+ """
202
+ self._runs = []
203
+ self.register_workflow(workflow_cls)
204
+ current_cls = workflow_cls
205
+ current_args: list[Any] = list(args)
206
+ current_run_id = run_id
207
+
208
+ for link_index in range(self._continue_as_new_limit):
209
+ history: list[dict[str, Any]] = []
210
+ link_number = link_index + 1
211
+ record = WorkflowRunRecord(
212
+ workflow_type=_workflow_name(current_cls),
213
+ input=list(current_args),
214
+ history=history,
215
+ )
216
+ self._runs.append(record)
217
+
218
+ if link_index == 0:
219
+ self._drain_pending_signals_into(history)
220
+ for queued in self._queued_signals_by_run.pop(link_number, []):
221
+ history.append(_signal_event(*queued))
222
+
223
+ continuation: ContinueAsNew | None = None
224
+ terminal_outcome: Any = _SENTINEL
225
+ for _ in range(self._iteration_limit):
226
+ self._drain_pending_signals_into(history)
227
+ outcome = replay(
228
+ current_cls, history, list(current_args), run_id=current_run_id
229
+ )
230
+ terminal_outcome, continuation = self._apply_commands(outcome, history)
231
+ if continuation is not None or terminal_outcome is not _SENTINEL:
232
+ break
233
+ else:
234
+ raise RuntimeError(
235
+ f"workflow {record.workflow_type!r} did not terminate within "
236
+ f"{self._iteration_limit} iterations; check for missing "
237
+ "activity mocks or signals that never satisfy a wait."
238
+ )
239
+
240
+ if continuation is not None:
241
+ record.terminal = continuation
242
+ history.append(_continued_as_new_event(continuation))
243
+ current_cls = self._resolve_next_workflow(continuation, current_cls)
244
+ current_args = list(continuation.arguments)
245
+ current_run_id = f"{current_run_id}-continued"
246
+ continue
247
+
248
+ record.terminal = terminal_outcome
249
+ if isinstance(terminal_outcome, WorkflowFailed):
250
+ raise terminal_outcome
251
+ if isinstance(terminal_outcome, CompleteWorkflow):
252
+ return terminal_outcome.result
253
+ return terminal_outcome
254
+
255
+ raise RuntimeError(
256
+ f"workflow chained continue-as-new past the {self._continue_as_new_limit}"
257
+ " link limit; raise continue_as_new_limit or tighten the workflow's "
258
+ "termination condition."
259
+ )
260
+
261
+ def _resolve_next_workflow(
262
+ self, continuation: ContinueAsNew, current_cls: type
263
+ ) -> type:
264
+ target_name = continuation.workflow_type
265
+ if not target_name:
266
+ return current_cls
267
+ current_name = getattr(current_cls, "__workflow_name__", None)
268
+ if target_name == current_name:
269
+ return current_cls
270
+ if target_name in self._registered_workflows:
271
+ return self._registered_workflows[target_name]
272
+ global_registry = registry()
273
+ if target_name in global_registry:
274
+ return global_registry[target_name]
275
+ raise KeyError(
276
+ f"continue_as_new targeted workflow type {target_name!r} "
277
+ "but no matching class is registered; call "
278
+ "env.register_workflow(YourWorkflow) before execute_workflow()."
279
+ )
280
+
281
+ def _drain_pending_signals_into(self, history: list[dict[str, Any]]) -> None:
282
+ while self._pending_signals:
283
+ name, sig_args = self._pending_signals.pop(0)
284
+ history.append(_signal_event(name, sig_args))
285
+
286
+ def _apply_commands(
287
+ self, outcome: ReplayOutcome, history: list[dict[str, Any]]
288
+ ) -> tuple[Any, ContinueAsNew | None]:
289
+ for cmd in outcome.commands:
290
+ if isinstance(cmd, CompleteWorkflow):
291
+ return cmd, None
292
+ if isinstance(cmd, FailWorkflow):
293
+ return WorkflowFailed(cmd.message, cmd.exception_type), None
294
+ if isinstance(cmd, ContinueAsNew):
295
+ return _SENTINEL, cmd
296
+ if isinstance(cmd, ScheduleActivity):
297
+ history.append(self._resolve_activity(cmd))
298
+ elif isinstance(cmd, StartTimer):
299
+ history.append({"event_type": "TimerFired", "payload": {}})
300
+ elif isinstance(cmd, StartChildWorkflow):
301
+ history.append(self._resolve_child_workflow(cmd))
302
+ elif isinstance(cmd, RecordSideEffect):
303
+ history.append(
304
+ {
305
+ "event_type": "SideEffectRecorded",
306
+ "payload": {
307
+ "result": serializer.envelope(cmd.result),
308
+ "payload_codec": serializer.AVRO_CODEC,
309
+ },
310
+ }
311
+ )
312
+ elif isinstance(cmd, UpsertSearchAttributes):
313
+ history.append(
314
+ {"event_type": "SearchAttributesUpserted", "payload": {}}
315
+ )
316
+ elif isinstance(cmd, RecordVersionMarker):
317
+ history.append(
318
+ {
319
+ "event_type": "VersionMarkerRecorded",
320
+ "payload": {"version": cmd.version},
321
+ }
322
+ )
323
+ else:
324
+ raise TypeError(f"unsupported command in test harness: {cmd!r}")
325
+ return _SENTINEL, None
326
+
327
+ def _resolve_activity(self, cmd: ScheduleActivity) -> dict[str, Any]:
328
+ if cmd.activity_type in self._activity_fns:
329
+ result = self._activity_fns[cmd.activity_type](*cmd.arguments)
330
+ elif cmd.activity_type in self._activity_results:
331
+ result = self._activity_results[cmd.activity_type]
332
+ else:
333
+ raise KeyError(
334
+ f"no mock registered for activity {cmd.activity_type!r}; "
335
+ "call env.register_activity_result() or env.register_activity()."
336
+ )
337
+ return {
338
+ "event_type": "ActivityCompleted",
339
+ "payload": {
340
+ "result": serializer.envelope(result),
341
+ "payload_codec": serializer.AVRO_CODEC,
342
+ },
343
+ }
344
+
345
+ def _resolve_child_workflow(self, cmd: StartChildWorkflow) -> dict[str, Any]:
346
+ if cmd.workflow_type not in self._child_workflow_results:
347
+ raise KeyError(
348
+ f"no mock registered for child workflow {cmd.workflow_type!r}; "
349
+ "call env.register_child_workflow_result()."
350
+ )
351
+ return {
352
+ "event_type": "ChildRunCompleted",
353
+ "payload": {
354
+ "result": serializer.envelope(self._child_workflow_results[cmd.workflow_type]),
355
+ "payload_codec": serializer.AVRO_CODEC,
356
+ },
357
+ }
358
+
359
+
360
+ def _workflow_name(cls: type) -> str:
361
+ name = getattr(cls, "__workflow_name__", None)
362
+ return name if isinstance(name, str) and name else cls.__name__
363
+
364
+
365
+ def _signal_event(name: str, sig_args: list[Any]) -> dict[str, Any]:
366
+ return {
367
+ "event_type": "SignalReceived",
368
+ "payload": {
369
+ "signal_name": name,
370
+ "value": serializer.envelope(sig_args),
371
+ "payload_codec": serializer.AVRO_CODEC,
372
+ },
373
+ }
374
+
375
+
376
+ def _continued_as_new_event(cmd: ContinueAsNew) -> dict[str, Any]:
377
+ payload: dict[str, Any] = {
378
+ "arguments": serializer.envelope(cmd.arguments),
379
+ "payload_codec": serializer.AVRO_CODEC,
380
+ }
381
+ if cmd.workflow_type is not None:
382
+ payload["workflow_type"] = cmd.workflow_type
383
+ if cmd.task_queue is not None:
384
+ payload["queue"] = cmd.task_queue
385
+ return {"event_type": "WorkflowContinuedAsNew", "payload": payload}
386
+
387
+
388
+ # Sentinel marking "no terminal command seen this iteration".
389
+ _SENTINEL = object()
390
+
391
+
392
+ def replay_history(
393
+ workflow_cls: type,
394
+ history_events: Iterable[dict[str, Any]],
395
+ start_input: list[Any] | None = None,
396
+ *,
397
+ run_id: str = "",
398
+ payload_codec: str | None = None,
399
+ ) -> ReplayOutcome:
400
+ """Replay a production history against current workflow code.
401
+
402
+ Hands the durable history directly to the worker's replayer. Raises any
403
+ exception the workflow would raise during replay — for example a
404
+ non-determinism failure when ``run`` yields a different command sequence
405
+ from the one recorded in history.
406
+
407
+ This is the supported way to regression-test a workflow change against
408
+ real production traffic: dump the history from ``Client.get_history``,
409
+ save the JSON, and replay it on every PR.
410
+ """
411
+ return replay(
412
+ workflow_cls,
413
+ history_events,
414
+ list(start_input or []),
415
+ run_id=run_id,
416
+ payload_codec=payload_codec,
417
+ )
418
+
419
+
420
+ def replay_history_file(
421
+ workflow_cls: type,
422
+ path: str | Path,
423
+ start_input: list[Any] | None = None,
424
+ *,
425
+ run_id: str = "",
426
+ payload_codec: str | None = None,
427
+ ) -> ReplayOutcome:
428
+ """Convenience wrapper: load a JSON history file and replay it.
429
+
430
+ Accepts either a list of events at the top level or a dict with an
431
+ ``events`` key (matching the shape of ``Client.get_history``).
432
+ """
433
+ data = json.loads(Path(path).read_text())
434
+ events = data["events"] if isinstance(data, dict) else data
435
+ return replay_history(
436
+ workflow_cls,
437
+ events,
438
+ start_input,
439
+ run_id=run_id,
440
+ payload_codec=payload_codec,
441
+ )
442
+
443
+
444
+ __all__ = [
445
+ "WorkflowEnvironment",
446
+ "WorkflowRunRecord",
447
+ "replay_history",
448
+ "replay_history_file",
449
+ ]
450
+
451
+
452
+ # Re-export terminal exceptions the harness may raise so tests can catch
453
+ # them without hunting for the right import path.
454
+ _TERMINAL_EXCEPTIONS = (WorkflowFailed, WorkflowCancelled, WorkflowTerminated)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Python SDK for the Durable Workflow server (language-neutral HTTP protocol)
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -931,6 +931,34 @@ class TestNamespaces:
931
931
  assert result.external_payload_storage.config["bucket"] == fixture["semantic_body"]["bucket"]
932
932
  assert result.external_payload_storage.prefix == fixture["semantic_body"]["prefix"]
933
933
 
934
+ @pytest.mark.asyncio
935
+ async def test_set_namespace_external_storage_accepts_deprecated_namespace_alias(
936
+ self, client: Client
937
+ ) -> None:
938
+ resp = _mock_response(200, {"name": "billing"})
939
+
940
+ with (
941
+ patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp) as mock,
942
+ pytest.warns(DeprecationWarning, match="namespace.*deprecated"),
943
+ ):
944
+ await client.set_namespace_external_storage(namespace="billing", driver="local")
945
+
946
+ assert mock.call_args.args[:2] == ("PUT", "/api/namespaces/billing/external-storage")
947
+
948
+ @pytest.mark.asyncio
949
+ async def test_set_namespace_external_storage_rejects_both_name_and_alias(
950
+ self, client: Client
951
+ ) -> None:
952
+ with pytest.raises(TypeError, match="both 'name' and the deprecated alias"):
953
+ await client.set_namespace_external_storage(
954
+ "billing", namespace="other", driver="local"
955
+ )
956
+
957
+ @pytest.mark.asyncio
958
+ async def test_set_namespace_external_storage_requires_name(self, client: Client) -> None:
959
+ with pytest.raises(TypeError, match="missing required argument: 'name'"):
960
+ await client.set_namespace_external_storage(driver="local")
961
+
934
962
  @pytest.mark.asyncio
935
963
  async def test_namespace_name_is_url_encoded(self, client: Client) -> None:
936
964
  resp = _mock_response(200, {"name": "billing reports"})