durable-workflow 0.4.97__tar.gz → 0.4.99__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 (70) hide show
  1. {durable_workflow-0.4.97/src/durable_workflow.egg-info → durable_workflow-0.4.99}/PKG-INFO +31 -1
  2. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/README.md +30 -0
  3. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/pyproject.toml +1 -1
  4. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/__init__.py +2 -0
  5. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/client.py +46 -3
  6. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/errors.py +23 -0
  7. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/replay_verify.py +6 -1
  8. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/sync.py +18 -2
  9. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/worker.py +4 -1
  10. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/workflow.py +1 -1
  11. {durable_workflow-0.4.97 → durable_workflow-0.4.99/src/durable_workflow.egg-info}/PKG-INFO +31 -1
  12. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_replay.py +28 -0
  13. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_replay_verify.py +1 -0
  14. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_schedules.py +88 -0
  15. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_sync.py +22 -0
  16. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/LICENSE +0 -0
  17. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/setup.cfg +0 -0
  18. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/_avro.py +0 -0
  19. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/activity.py +0 -0
  20. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/auth_composition.py +0 -0
  21. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/external_storage.py +0 -0
  22. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/external_task_input.py +0 -0
  23. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/external_task_result.py +0 -0
  24. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/history_bundle_verify.py +0 -0
  25. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/interceptors.py +0 -0
  26. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/invocable.py +0 -0
  27. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/metrics.py +0 -0
  28. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/nexus.py +0 -0
  29. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/py.typed +0 -0
  30. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/python_conformance.py +0 -0
  31. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/replay_conformance.py +0 -0
  32. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/retry_policy.py +0 -0
  33. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/serializer.py +0 -0
  34. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/testing.py +0 -0
  35. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  36. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
  37. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  38. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  39. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow.egg-info/requires.txt +0 -0
  40. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/src/durable_workflow.egg-info/top_level.txt +0 -0
  41. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_activity_context.py +0 -0
  42. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_auth_composition.py +0 -0
  43. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_client.py +0 -0
  44. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_control_plane_parity_fixtures.py +0 -0
  45. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_errors.py +0 -0
  46. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_external_storage.py +0 -0
  47. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_external_task_input.py +0 -0
  48. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_external_task_result.py +0 -0
  49. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_golden_history_replay.py +0 -0
  50. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_history_bundle_verify.py +0 -0
  51. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_history_event_contract.py +0 -0
  52. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_invocable.py +0 -0
  53. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_metrics.py +0 -0
  54. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_order_processing_example.py +0 -0
  55. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_public_boundary_scanner.py +0 -0
  56. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_python_conformance.py +0 -0
  57. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_queries.py +0 -0
  58. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_readme_quickstart.py +0 -0
  59. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_release_docs_audit_workflow.py +0 -0
  60. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_replay_conformance.py +0 -0
  61. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_retry_policy.py +0 -0
  62. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_serializer.py +0 -0
  63. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_signals.py +0 -0
  64. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_sleep.py +0 -0
  65. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_standalone_activity_client.py +0 -0
  66. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_testing_harness.py +0 -0
  67. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_updates.py +0 -0
  68. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_wait_condition.py +0 -0
  69. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_worker.py +0 -0
  70. {durable_workflow-0.4.97 → durable_workflow-0.4.99}/tests/test_workflow_updates_conformance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 0.4.97
3
+ Version: 0.4.99
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
@@ -95,6 +95,36 @@ For a fuller deployable example, see
95
95
  [`examples/order_processing`](examples/order_processing), which runs a
96
96
  multi-activity order workflow against a local server with Docker Compose.
97
97
 
98
+ ## Schedule visibility and paging
99
+
100
+ `list_schedules()` returns one typed `ScheduleList` page. Status and workflow
101
+ type are exact server-side filters; the visibility query uses the server's
102
+ documented equality-predicate grammar. All filters combine with AND semantics.
103
+
104
+ ```python
105
+ page = await client.list_schedules(
106
+ status="active",
107
+ workflow_type="orders.rollup",
108
+ query='Region = "eu" AND Priority = 2',
109
+ page_size=25,
110
+ )
111
+
112
+ while page.next_page_token is not None:
113
+ page = await client.list_schedules(
114
+ status="active",
115
+ workflow_type="orders.rollup",
116
+ query='Region = "eu" AND Priority = 2',
117
+ page_size=25,
118
+ next_page_token=page.next_page_token,
119
+ )
120
+ ```
121
+
122
+ Continuation tokens are opaque. Reuse them unchanged with the same namespace,
123
+ status, workflow type, and query; `None` terminates traversal. Invalid filters
124
+ and malformed, mismatched, cross-namespace, or stale tokens raise
125
+ `ScheduleListError`, which retains `status`, `reason()`, `field`, `errors`,
126
+ `last_safe_cursor`, and the complete server response in `body`.
127
+
98
128
  ## Retry policy scopes
99
129
 
100
130
  Retry and timeout settings are scoped to the layer where you configure them:
@@ -59,6 +59,36 @@ For a fuller deployable example, see
59
59
  [`examples/order_processing`](examples/order_processing), which runs a
60
60
  multi-activity order workflow against a local server with Docker Compose.
61
61
 
62
+ ## Schedule visibility and paging
63
+
64
+ `list_schedules()` returns one typed `ScheduleList` page. Status and workflow
65
+ type are exact server-side filters; the visibility query uses the server's
66
+ documented equality-predicate grammar. All filters combine with AND semantics.
67
+
68
+ ```python
69
+ page = await client.list_schedules(
70
+ status="active",
71
+ workflow_type="orders.rollup",
72
+ query='Region = "eu" AND Priority = 2',
73
+ page_size=25,
74
+ )
75
+
76
+ while page.next_page_token is not None:
77
+ page = await client.list_schedules(
78
+ status="active",
79
+ workflow_type="orders.rollup",
80
+ query='Region = "eu" AND Priority = 2',
81
+ page_size=25,
82
+ next_page_token=page.next_page_token,
83
+ )
84
+ ```
85
+
86
+ Continuation tokens are opaque. Reuse them unchanged with the same namespace,
87
+ status, workflow type, and query; `None` terminates traversal. Invalid filters
88
+ and malformed, mismatched, cross-namespace, or stale tokens raise
89
+ `ScheduleListError`, which retains `status`, `reason()`, `field`, `errors`,
90
+ `last_safe_cursor`, and the complete server response in `body`.
91
+
62
92
  ## Retry policy scopes
63
93
 
64
94
  Retry and timeout settings are scoped to the layer where you configure them:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "0.4.97"
7
+ version = "0.4.99"
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"
@@ -65,6 +65,7 @@ from .errors import (
65
65
  NonRetryableError,
66
66
  QueryFailed,
67
67
  ScheduleAlreadyExists,
68
+ ScheduleListError,
68
69
  ScheduleNotFound,
69
70
  ServerError,
70
71
  SignalFailed,
@@ -224,6 +225,7 @@ __all__ = [
224
225
  "ScheduleHistoryEvent",
225
226
  "ScheduleHistoryPage",
226
227
  "ScheduleList",
228
+ "ScheduleListError",
227
229
  "ScheduleNotFound",
228
230
  "ScheduleSpec",
229
231
  "ScheduleTriggerResult",
@@ -2973,9 +2973,37 @@ class Client:
2973
2973
  sid = data.get("schedule_id", schedule_id or "")
2974
2974
  return ScheduleHandle(self, schedule_id=sid)
2975
2975
 
2976
- async def list_schedules(self) -> ScheduleList:
2977
- """Return all schedules in the current namespace."""
2978
- data = await self._request("GET", "/schedules")
2976
+ async def list_schedules(
2977
+ self,
2978
+ *,
2979
+ status: str | None = None,
2980
+ workflow_type: str | None = None,
2981
+ query: str | None = None,
2982
+ page_size: int | None = None,
2983
+ next_page_token: str | None = None,
2984
+ ) -> ScheduleList:
2985
+ """Return one server-filtered schedule visibility page.
2986
+
2987
+ Status, workflow type, and visibility-query filters combine with AND
2988
+ semantics. Continuation tokens are opaque: pass a non-null token back
2989
+ unchanged with the same namespace and filters. ``None`` terminates the
2990
+ traversal. The server validates page sizes, predicates, and cursors.
2991
+ """
2992
+ params: dict[str, str] = {}
2993
+ if status is not None:
2994
+ params["status"] = status
2995
+ if workflow_type is not None:
2996
+ params["workflow_type"] = workflow_type
2997
+ if query is not None:
2998
+ params["query"] = query
2999
+ if page_size is not None:
3000
+ params["page_size"] = str(page_size)
3001
+ if next_page_token is not None:
3002
+ params["next_page_token"] = next_page_token
3003
+
3004
+ qs = urlencode(params)
3005
+ path = f"/schedules?{qs}" if qs else "/schedules"
3006
+ data = await self._request("GET", path, context="schedule.list")
2979
3007
  items = data.get("schedules", [])
2980
3008
  schedules = [
2981
3009
  ScheduleDescription(
@@ -2985,9 +3013,24 @@ class Client:
2985
3013
  action=item.get("action"),
2986
3014
  overlap_policy=item.get("overlap_policy"),
2987
3015
  note=item.get("note"),
3016
+ memo=item.get("memo") if isinstance(item.get("memo"), dict) else None,
3017
+ search_attributes=(
3018
+ item.get("search_attributes")
3019
+ if isinstance(item.get("search_attributes"), dict)
3020
+ else None
3021
+ ),
3022
+ jitter_seconds=item.get("jitter_seconds"),
3023
+ max_runs=item.get("max_runs"),
3024
+ remaining_actions=item.get("remaining_actions"),
2988
3025
  fires_count=item.get("fires_count", 0),
3026
+ failures_count=item.get("failures_count", 0),
2989
3027
  next_fire_at=item.get("next_fire_at"),
2990
3028
  last_fired_at=item.get("last_fired_at"),
3029
+ latest_workflow_instance_id=item.get("latest_workflow_instance_id"),
3030
+ paused_at=item.get("paused_at"),
3031
+ created_at=item.get("created_at"),
3032
+ updated_at=item.get("updated_at"),
3033
+ info=item.get("info") if isinstance(item.get("info"), dict) else None,
2991
3034
  )
2992
3035
  for item in items
2993
3036
  ]
@@ -152,6 +152,26 @@ class ScheduleAlreadyExists(DurableWorkflowError):
152
152
  self.schedule_id = schedule_id
153
153
 
154
154
 
155
+ class ScheduleListError(ServerError):
156
+ """A schedule visibility filter or continuation cursor was refused.
157
+
158
+ The complete parsed response remains available on :attr:`body`. Convenience
159
+ attributes expose the rejected field, structured field errors, and the
160
+ server's last safe keyset cursor without discarding status or reason.
161
+ """
162
+
163
+ def __init__(self, status: int, body: object) -> None:
164
+ super().__init__(status, body)
165
+ self.field = body.get("field") if isinstance(body, dict) else None
166
+ errors = None
167
+ last_safe_cursor = None
168
+ if isinstance(body, dict):
169
+ errors = body.get("validation_errors") or body.get("errors")
170
+ last_safe_cursor = body.get("last_safe_cursor")
171
+ self.errors = errors if isinstance(errors, dict) else None
172
+ self.last_safe_cursor = last_safe_cursor if isinstance(last_safe_cursor, dict) else None
173
+
174
+
155
175
  class QueryFailed(DurableWorkflowError):
156
176
  """A workflow query was rejected or the workflow raised while handling it."""
157
177
 
@@ -447,6 +467,9 @@ def _raise_for_status(status: int, body: object, *, context: str = "") -> None:
447
467
  if status == 401:
448
468
  raise Unauthorized(message or "unauthorized")
449
469
 
470
+ if context == "schedule.list" and status in (400, 403, 409, 422):
471
+ raise ScheduleListError(status, body)
472
+
450
473
  def query_failed(default: str) -> QueryFailed:
451
474
  return QueryFailed(
452
475
  message or default,
@@ -954,7 +954,12 @@ def _cli(argv: Sequence[str] | None = None) -> int:
954
954
  parser.error("--workflows is required unless --simulate-bundles is set")
955
955
 
956
956
  workflows = _resolve_workflow_loader(args.workflows)
957
- report = verify_golden_history(args.fixture_dir, workflows)
957
+ required_families = REQUIRED_FAMILIES if args.strict_missing_families else ()
958
+ report = verify_golden_history(
959
+ args.fixture_dir,
960
+ workflows,
961
+ required_families=required_families,
962
+ )
958
963
 
959
964
  payload = report.to_dict()
960
965
  text = json.dumps(payload, indent=2, sort_keys=True)
@@ -629,8 +629,24 @@ class Client:
629
629
  )
630
630
  return SyncScheduleHandle(handle)
631
631
 
632
- def list_schedules(self) -> ScheduleList:
633
- result: ScheduleList = _run(self._async.list_schedules())
632
+ def list_schedules(
633
+ self,
634
+ *,
635
+ status: str | None = None,
636
+ workflow_type: str | None = None,
637
+ query: str | None = None,
638
+ page_size: int | None = None,
639
+ next_page_token: str | None = None,
640
+ ) -> ScheduleList:
641
+ result: ScheduleList = _run(
642
+ self._async.list_schedules(
643
+ status=status,
644
+ workflow_type=workflow_type,
645
+ query=query,
646
+ page_size=page_size,
647
+ next_page_token=next_page_token,
648
+ )
649
+ )
634
650
  return result
635
651
 
636
652
  def describe_schedule(self, schedule_id: str) -> ScheduleDescription:
@@ -1823,8 +1823,11 @@ class Worker:
1823
1823
  self._act_semaphore.release()
1824
1824
 
1825
1825
  async def _poll_query_tasks(self, *, client: Client | None = None, track_tasks: bool = True) -> None:
1826
+ query_thread_stop = self._query_thread_stop if client is not None else None
1826
1827
  client = client or self.client
1827
- while not self._stop.is_set() and not self._query_thread_stop.is_set():
1828
+ while not self._stop.is_set() and (
1829
+ query_thread_stop is None or not query_thread_stop.is_set()
1830
+ ):
1828
1831
  try:
1829
1832
  poll_start = time.perf_counter()
1830
1833
  task = await client.poll_query_task(
@@ -2299,7 +2299,7 @@ def _child_workflow_failed_from_payload(
2299
2299
  return ChildWorkflowFailed(
2300
2300
  message or "child workflow failed",
2301
2301
  exception_class,
2302
- failure_kind=_optional_str(payload.get("failure_category")) or "child_workflow",
2302
+ failure_kind="child_workflow",
2303
2303
  child_workflow_run_id=child_workflow_run_id,
2304
2304
  child_workflow_type=child_workflow_type,
2305
2305
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 0.4.97
3
+ Version: 0.4.99
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
@@ -95,6 +95,36 @@ For a fuller deployable example, see
95
95
  [`examples/order_processing`](examples/order_processing), which runs a
96
96
  multi-activity order workflow against a local server with Docker Compose.
97
97
 
98
+ ## Schedule visibility and paging
99
+
100
+ `list_schedules()` returns one typed `ScheduleList` page. Status and workflow
101
+ type are exact server-side filters; the visibility query uses the server's
102
+ documented equality-predicate grammar. All filters combine with AND semantics.
103
+
104
+ ```python
105
+ page = await client.list_schedules(
106
+ status="active",
107
+ workflow_type="orders.rollup",
108
+ query='Region = "eu" AND Priority = 2',
109
+ page_size=25,
110
+ )
111
+
112
+ while page.next_page_token is not None:
113
+ page = await client.list_schedules(
114
+ status="active",
115
+ workflow_type="orders.rollup",
116
+ query='Region = "eu" AND Priority = 2',
117
+ page_size=25,
118
+ next_page_token=page.next_page_token,
119
+ )
120
+ ```
121
+
122
+ Continuation tokens are opaque. Reuse them unchanged with the same namespace,
123
+ status, workflow type, and query; `None` terminates traversal. Invalid filters
124
+ and malformed, mismatched, cross-namespace, or stale tokens raise
125
+ `ScheduleListError`, which retains `status`, `reason()`, `field`, `errors`,
126
+ `last_safe_cursor`, and the complete server response in `body`.
127
+
98
128
  ## Retry policy scopes
99
129
 
100
130
  Retry and timeout settings are scoped to the layer where you configure them:
@@ -1393,6 +1393,34 @@ class TestChildWorkflow:
1393
1393
  "child_workflow_type": "sub-workflow",
1394
1394
  }
1395
1395
 
1396
+ def test_child_failed_reports_parent_failure_kind_for_application_child(self) -> None:
1397
+ history = [
1398
+ {
1399
+ "event_type": "ChildRunFailed",
1400
+ "payload": {
1401
+ "message": "planned typed child failure: typed-marker",
1402
+ "exception_type": "PlannedChildFailure",
1403
+ "exception_class": "smoke.PlannedChildFailure",
1404
+ "failure_category": "application",
1405
+ "child_workflow_run_id": "child-run-1",
1406
+ "child_workflow_type": "sub-workflow",
1407
+ },
1408
+ },
1409
+ ]
1410
+
1411
+ outcome = replay(ChildWorkflowFailureDetailsWf, history, [])
1412
+
1413
+ assert len(outcome.commands) == 1
1414
+ cmd = outcome.commands[0]
1415
+ assert isinstance(cmd, CompleteWorkflow)
1416
+ assert cmd.result == {
1417
+ "message": "planned typed child failure: typed-marker",
1418
+ "exception_class": "smoke.PlannedChildFailure",
1419
+ "failure_kind": "child_workflow",
1420
+ "child_workflow_run_id": "child-run-1",
1421
+ "child_workflow_type": "sub-workflow",
1422
+ }
1423
+
1396
1424
  def test_child_failed_prefers_nested_exception_class_over_type(self) -> None:
1397
1425
  history = [
1398
1426
  {
@@ -17,6 +17,7 @@ from durable_workflow.replay_verify import (
17
17
  REASON_BUNDLE_INVALID,
18
18
  REASON_EXPECTATION_MISMATCH,
19
19
  REASON_NONE,
20
+ REASON_SHAPE_MISMATCH,
20
21
  REPORT_SCHEMA,
21
22
  REPORT_SCHEMA_VERSION,
22
23
  SIMULATION_REPORT_SCHEMA,
@@ -23,6 +23,7 @@ from durable_workflow.client import (
23
23
  from durable_workflow.errors import (
24
24
  InvalidArgument,
25
25
  ScheduleAlreadyExists,
26
+ ScheduleListError,
26
27
  ScheduleNotFound,
27
28
  )
28
29
 
@@ -240,6 +241,93 @@ class TestListSchedules:
240
241
  result = await client.list_schedules()
241
242
  assert result.schedules == []
242
243
 
244
+ @pytest.mark.asyncio
245
+ async def test_filters_and_continuation_token_are_forwarded_and_mapped(self, client: Client) -> None:
246
+ resp = _mock_response(200, {
247
+ "schedules": [{
248
+ "schedule_id": "reports-eu",
249
+ "status": "paused",
250
+ "search_attributes": {"Region": "eu west"},
251
+ "created_at": "2026-07-14T12:00:00Z",
252
+ "updated_at": "2026-07-14T12:01:00Z",
253
+ }],
254
+ "next_page_token": "opaque+/= token",
255
+ })
256
+ with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp) as mock:
257
+ result = await client.list_schedules(
258
+ status="paused",
259
+ workflow_type="reports.rollup",
260
+ query='Region = "eu west"',
261
+ page_size=1,
262
+ next_page_token="page+/= one",
263
+ )
264
+
265
+ assert mock.call_args.args[:2] == (
266
+ "GET",
267
+ "/api/schedules?status=paused&workflow_type=reports.rollup"
268
+ "&query=Region+%3D+%22eu+west%22&page_size=1"
269
+ "&next_page_token=page%2B%2F%3D+one",
270
+ )
271
+ assert result.next_page_token == "opaque+/= token"
272
+ assert result.schedules[0].search_attributes == {"Region": "eu west"}
273
+ assert result.schedules[0].created_at == "2026-07-14T12:00:00Z"
274
+
275
+ @pytest.mark.asyncio
276
+ @pytest.mark.parametrize(
277
+ ("status", "body", "expected_cursor"),
278
+ [
279
+ (422, {
280
+ "message": "Unsupported predicate.",
281
+ "reason": "unsupported_schedule_visibility_predicate",
282
+ "field": "query",
283
+ "errors": {"query": ["Only equality predicates are supported."]},
284
+ "last_safe_cursor": None,
285
+ }, None),
286
+ (400, {
287
+ "message": "Malformed token.",
288
+ "reason": "malformed_schedule_page_token",
289
+ "field": "next_page_token",
290
+ "errors": {"next_page_token": ["Malformed token."]},
291
+ "last_safe_cursor": None,
292
+ }, None),
293
+ (403, {
294
+ "message": "Wrong namespace.",
295
+ "reason": "schedule_page_token_namespace_mismatch",
296
+ "field": "next_page_token",
297
+ "errors": {"next_page_token": ["Wrong namespace."]},
298
+ "last_safe_cursor": {"created_at": "2026-07-14T12:00:00Z", "schedule_id": "s1"},
299
+ }, {"created_at": "2026-07-14T12:00:00Z", "schedule_id": "s1"}),
300
+ (409, {
301
+ "message": "Stale token.",
302
+ "reason": "stale_schedule_page_token",
303
+ "field": "next_page_token",
304
+ "errors": {"next_page_token": ["Stale token."]},
305
+ "last_safe_cursor": {"created_at": "2026-07-14T12:00:00Z", "schedule_id": "s1"},
306
+ }, {"created_at": "2026-07-14T12:00:00Z", "schedule_id": "s1"}),
307
+ ],
308
+ )
309
+ async def test_typed_filter_and_cursor_errors_preserve_server_evidence(
310
+ self,
311
+ client: Client,
312
+ status: int,
313
+ body: dict,
314
+ expected_cursor: dict | None,
315
+ ) -> None:
316
+ resp = _mock_response(status, body)
317
+ with (
318
+ patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp),
319
+ pytest.raises(ScheduleListError) as exc_info,
320
+ ):
321
+ await client.list_schedules(query='Region STARTS_WITH "e"')
322
+
323
+ error = exc_info.value
324
+ assert error.status == status
325
+ assert error.reason() == body["reason"]
326
+ assert error.field == body["field"]
327
+ assert error.errors == body["errors"]
328
+ assert error.last_safe_cursor == expected_cursor
329
+ assert error.body == body
330
+
243
331
 
244
332
  class TestDescribeSchedule:
245
333
  @pytest.mark.asyncio
@@ -727,6 +727,28 @@ class TestSyncClientContextManager:
727
727
  assert client is not None
728
728
 
729
729
 
730
+ class TestSyncScheduleList:
731
+ def test_schedule_filters_and_cursor_are_forwarded(self) -> None:
732
+ client = Client("http://localhost:8080")
733
+ resp = _mock_response(200, {"schedules": [], "next_page_token": "next"})
734
+
735
+ with patch.object(client._async._http, "request", new_callable=AsyncMock, return_value=resp) as mock:
736
+ page = client.list_schedules(
737
+ status="active",
738
+ workflow_type="orders.process",
739
+ query='Region = "eu"',
740
+ page_size=25,
741
+ next_page_token="cursor",
742
+ )
743
+
744
+ assert page.next_page_token == "next"
745
+ assert mock.call_args.args[:2] == (
746
+ "GET",
747
+ "/api/schedules?status=active&workflow_type=orders.process"
748
+ "&query=Region+%3D+%22eu%22&page_size=25&next_page_token=cursor",
749
+ )
750
+
751
+
730
752
  class TestSyncRunInsideLoop:
731
753
  @pytest.mark.asyncio
732
754
  async def test_raises_inside_loop(self) -> None: