durable-workflow 0.4.2__tar.gz → 0.4.4__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 (56) hide show
  1. {durable_workflow-0.4.2/src/durable_workflow.egg-info → durable_workflow-0.4.4}/PKG-INFO +1 -1
  2. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/pyproject.toml +1 -1
  3. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow/__init__.py +4 -0
  4. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow/client.py +159 -1
  5. {durable_workflow-0.4.2 → durable_workflow-0.4.4/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  6. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_schedules.py +186 -0
  7. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/LICENSE +0 -0
  8. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/README.md +0 -0
  9. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/setup.cfg +0 -0
  10. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow/_avro.py +0 -0
  11. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow/activity.py +0 -0
  12. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow/auth_composition.py +0 -0
  13. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow/errors.py +0 -0
  14. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow/external_storage.py +0 -0
  15. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow/external_task_input.py +0 -0
  16. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow/external_task_result.py +0 -0
  17. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow/interceptors.py +0 -0
  18. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow/invocable.py +0 -0
  19. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow/metrics.py +0 -0
  20. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow/py.typed +0 -0
  21. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow/retry_policy.py +0 -0
  22. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow/serializer.py +0 -0
  23. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow/sync.py +0 -0
  24. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow/testing.py +0 -0
  25. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow/worker.py +0 -0
  26. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow/workflow.py +0 -0
  27. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
  28. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  29. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow.egg-info/requires.txt +0 -0
  30. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/src/durable_workflow.egg-info/top_level.txt +0 -0
  31. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_activity_context.py +0 -0
  32. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_auth_composition.py +0 -0
  33. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_client.py +0 -0
  34. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_control_plane_parity_fixtures.py +0 -0
  35. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_errors.py +0 -0
  36. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_external_storage.py +0 -0
  37. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_external_task_input.py +0 -0
  38. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_external_task_result.py +0 -0
  39. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_golden_history_replay.py +0 -0
  40. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_history_event_contract.py +0 -0
  41. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_invocable.py +0 -0
  42. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_metrics.py +0 -0
  43. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_order_processing_example.py +0 -0
  44. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_public_boundary_scanner.py +0 -0
  45. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_queries.py +0 -0
  46. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_readme_quickstart.py +0 -0
  47. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_replay.py +0 -0
  48. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_retry_policy.py +0 -0
  49. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_serializer.py +0 -0
  50. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_signals.py +0 -0
  51. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_sleep.py +0 -0
  52. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_sync.py +0 -0
  53. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_testing_harness.py +0 -0
  54. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_updates.py +0 -0
  55. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/tests/test_wait_condition.py +0 -0
  56. {durable_workflow-0.4.2 → durable_workflow-0.4.4}/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.2
3
+ Version: 0.4.4
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.2"
7
+ version = "0.4.4"
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"
@@ -25,6 +25,8 @@ from .client import (
25
25
  ScheduleBackfillResult,
26
26
  ScheduleDescription,
27
27
  ScheduleHandle,
28
+ ScheduleHistoryEvent,
29
+ ScheduleHistoryPage,
28
30
  ScheduleList,
29
31
  ScheduleSpec,
30
32
  ScheduleTriggerResult,
@@ -171,6 +173,8 @@ __all__ = [
171
173
  "ScheduleBackfillResult",
172
174
  "ScheduleDescription",
173
175
  "ScheduleHandle",
176
+ "ScheduleHistoryEvent",
177
+ "ScheduleHistoryPage",
174
178
  "ScheduleList",
175
179
  "ScheduleNotFound",
176
180
  "ScheduleSpec",
@@ -21,7 +21,7 @@ from __future__ import annotations
21
21
  import asyncio
22
22
  import time
23
23
  import warnings
24
- from collections.abc import Callable
24
+ from collections.abc import AsyncIterator, Callable
25
25
  from dataclasses import dataclass
26
26
  from importlib.metadata import PackageNotFoundError
27
27
  from importlib.metadata import version as _pkg_version
@@ -745,6 +745,42 @@ class ScheduleBackfillResult:
745
745
  results: list[dict[str, Any]] | None = None
746
746
 
747
747
 
748
+ @dataclass
749
+ class ScheduleHistoryEvent:
750
+ """One entry in a schedule's audit history stream.
751
+
752
+ Each event corresponds to a lifecycle transition recorded by the
753
+ server (ScheduleCreated, SchedulePaused, ScheduleResumed,
754
+ ScheduleUpdated, ScheduleTriggered, ScheduleTriggerSkipped, or
755
+ ScheduleDeleted). The ``payload`` mirrors what the workflow engine
756
+ recorded, including command-context attribution when the transition
757
+ came from a mutating API call.
758
+ """
759
+
760
+ sequence: int
761
+ event_type: str | None = None
762
+ recorded_at: str | None = None
763
+ workflow_instance_id: str | None = None
764
+ workflow_run_id: str | None = None
765
+ payload: dict[str, Any] | None = None
766
+ id: str | None = None
767
+
768
+
769
+ @dataclass
770
+ class ScheduleHistoryPage:
771
+ """One page of a schedule's audit history stream.
772
+
773
+ ``next_cursor`` is the ``after_sequence`` value to request the next
774
+ page when ``has_more`` is ``True``; it is ``None`` on the final page.
775
+ """
776
+
777
+ schedule_id: str
778
+ events: list[ScheduleHistoryEvent]
779
+ has_more: bool = False
780
+ next_cursor: int | None = None
781
+ namespace: str | None = None
782
+
783
+
748
784
  @dataclass
749
785
  class BridgeAdapterOutcome:
750
786
  """Machine-readable result returned by a bridge adapter event."""
@@ -934,6 +970,32 @@ class ScheduleHandle:
934
970
  self.schedule_id, start_time=start_time, end_time=end_time, overlap_policy=overlap_policy,
935
971
  )
936
972
 
973
+ async def history(
974
+ self,
975
+ *,
976
+ limit: int | None = None,
977
+ after_sequence: int | None = None,
978
+ ) -> ScheduleHistoryPage:
979
+ """Return one page of this schedule's audit history. See :meth:`Client.get_schedule_history`."""
980
+ return await self._client.get_schedule_history(
981
+ self.schedule_id,
982
+ limit=limit,
983
+ after_sequence=after_sequence,
984
+ )
985
+
986
+ def iter_history(
987
+ self,
988
+ *,
989
+ limit: int | None = None,
990
+ after_sequence: int | None = None,
991
+ ) -> AsyncIterator[ScheduleHistoryEvent]:
992
+ """Iterate every audit event for this schedule. See :meth:`Client.iter_schedule_history`."""
993
+ return self._client.iter_schedule_history(
994
+ self.schedule_id,
995
+ limit=limit,
996
+ after_sequence=after_sequence,
997
+ )
998
+
937
999
 
938
1000
  class Client:
939
1001
  """Async HTTP client for Durable Workflow control-plane and worker APIs.
@@ -2409,6 +2471,102 @@ class Client:
2409
2471
  results=data.get("results"),
2410
2472
  )
2411
2473
 
2474
+ async def get_schedule_history(
2475
+ self,
2476
+ schedule_id: str,
2477
+ *,
2478
+ limit: int | None = None,
2479
+ after_sequence: int | None = None,
2480
+ ) -> ScheduleHistoryPage:
2481
+ """Return one page of the audit history stream for a schedule.
2482
+
2483
+ The page is ordered by ``sequence`` ascending. Use
2484
+ ``after_sequence=page.next_cursor`` to request the next page while
2485
+ ``page.has_more`` is ``True``, or call :meth:`iter_schedule_history`
2486
+ to walk every remaining event with paging hidden.
2487
+
2488
+ History is available for deleted schedules: the audit stream
2489
+ records ``ScheduleDeleted`` and survives the schedule's removal
2490
+ exactly so operators can review what happened.
2491
+
2492
+ ``limit`` is clamped by the server between 1 and 500 (default
2493
+ 100). ``after_sequence`` must be a non-negative integer; invalid
2494
+ values raise :class:`~durable_workflow.errors.InvalidArgument`
2495
+ through the shared 4xx mapping.
2496
+ """
2497
+ if limit is not None and limit < 1:
2498
+ raise ValueError("limit must be >= 1")
2499
+ if after_sequence is not None and after_sequence < 0:
2500
+ raise ValueError("after_sequence must be >= 0")
2501
+
2502
+ params: dict[str, str] = {}
2503
+ if limit is not None:
2504
+ params["limit"] = str(limit)
2505
+ if after_sequence is not None:
2506
+ params["after_sequence"] = str(after_sequence)
2507
+
2508
+ path = f"/schedules/{schedule_id}/history"
2509
+ if params:
2510
+ path = f"{path}?{urlencode(params)}"
2511
+
2512
+ data = await self._request("GET", path, context=schedule_id)
2513
+ raw_events = data.get("events") or []
2514
+ events = [
2515
+ ScheduleHistoryEvent(
2516
+ sequence=int(item.get("sequence", 0)),
2517
+ event_type=item.get("event_type"),
2518
+ recorded_at=item.get("recorded_at"),
2519
+ workflow_instance_id=item.get("workflow_instance_id"),
2520
+ workflow_run_id=item.get("workflow_run_id"),
2521
+ payload=item.get("payload") if isinstance(item.get("payload"), dict) else None,
2522
+ id=item.get("id"),
2523
+ )
2524
+ for item in raw_events
2525
+ ]
2526
+
2527
+ raw_cursor = data.get("next_cursor")
2528
+ next_cursor: int | None
2529
+ if raw_cursor is None:
2530
+ next_cursor = None
2531
+ else:
2532
+ try:
2533
+ next_cursor = int(raw_cursor)
2534
+ except (TypeError, ValueError):
2535
+ next_cursor = None
2536
+
2537
+ return ScheduleHistoryPage(
2538
+ schedule_id=data.get("schedule_id", schedule_id),
2539
+ events=events,
2540
+ has_more=bool(data.get("has_more", False)),
2541
+ next_cursor=next_cursor,
2542
+ namespace=data.get("namespace"),
2543
+ )
2544
+
2545
+ async def iter_schedule_history(
2546
+ self,
2547
+ schedule_id: str,
2548
+ *,
2549
+ limit: int | None = None,
2550
+ after_sequence: int | None = None,
2551
+ ) -> AsyncIterator[ScheduleHistoryEvent]:
2552
+ """Yield every audit event for a schedule, paging under the hood.
2553
+
2554
+ Each element is a :class:`ScheduleHistoryEvent`. Paging stops once
2555
+ the server reports ``has_more=False``.
2556
+ """
2557
+ cursor = after_sequence
2558
+ while True:
2559
+ page = await self.get_schedule_history(
2560
+ schedule_id,
2561
+ limit=limit,
2562
+ after_sequence=cursor,
2563
+ )
2564
+ for event in page.events:
2565
+ yield event
2566
+ if not page.has_more or page.next_cursor is None:
2567
+ return
2568
+ cursor = page.next_cursor
2569
+
2412
2570
  # ── Worker protocol ────────────────────────────────────────────────
2413
2571
  async def register_worker(
2414
2572
  self,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 0.4.2
3
+ Version: 0.4.4
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
@@ -14,6 +14,8 @@ from durable_workflow.client import (
14
14
  ScheduleBackfillResult,
15
15
  ScheduleDescription,
16
16
  ScheduleHandle,
17
+ ScheduleHistoryEvent,
18
+ ScheduleHistoryPage,
17
19
  ScheduleList,
18
20
  ScheduleSpec,
19
21
  ScheduleTriggerResult,
@@ -538,3 +540,187 @@ class TestScheduleHandle:
538
540
  end_time="2026-04-14T01:00:00Z",
539
541
  )
540
542
  assert result.fires_attempted == 1
543
+
544
+
545
+ class TestScheduleHistory:
546
+ @pytest.mark.asyncio
547
+ async def test_parses_events_and_pagination_cursor(self, client: Client) -> None:
548
+ resp = _mock_response(200, {
549
+ "schedule_id": "sched-1",
550
+ "namespace": "ns1",
551
+ "events": [
552
+ {
553
+ "id": "evt-1",
554
+ "sequence": 1,
555
+ "event_type": "ScheduleCreated",
556
+ "recorded_at": "2026-04-01T00:00:00+00:00",
557
+ "workflow_instance_id": None,
558
+ "workflow_run_id": None,
559
+ "payload": {},
560
+ },
561
+ {
562
+ "id": "evt-2",
563
+ "sequence": 2,
564
+ "event_type": "ScheduleTriggered",
565
+ "recorded_at": "2026-04-02T00:00:00+00:00",
566
+ "workflow_instance_id": "wf-abc",
567
+ "workflow_run_id": "run-abc",
568
+ "payload": {"outcome": "triggered", "trigger_number": 1},
569
+ },
570
+ ],
571
+ "has_more": True,
572
+ "next_cursor": 2,
573
+ })
574
+
575
+ with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp) as mock_req:
576
+ page = await client.get_schedule_history("sched-1")
577
+
578
+ assert isinstance(page, ScheduleHistoryPage)
579
+ assert page.schedule_id == "sched-1"
580
+ assert page.namespace == "ns1"
581
+ assert page.has_more is True
582
+ assert page.next_cursor == 2
583
+ assert len(page.events) == 2
584
+ assert isinstance(page.events[0], ScheduleHistoryEvent)
585
+ assert page.events[0].sequence == 1
586
+ assert page.events[0].event_type == "ScheduleCreated"
587
+ assert page.events[1].workflow_instance_id == "wf-abc"
588
+ assert page.events[1].payload == {"outcome": "triggered", "trigger_number": 1}
589
+
590
+ call = mock_req.call_args
591
+ assert call.args[0] == "GET"
592
+ assert call.args[1] == "/api/schedules/sched-1/history"
593
+
594
+ @pytest.mark.asyncio
595
+ async def test_forwards_limit_and_after_sequence_in_query(self, client: Client) -> None:
596
+ resp = _mock_response(200, {
597
+ "schedule_id": "sched-1",
598
+ "namespace": "ns1",
599
+ "events": [],
600
+ "has_more": False,
601
+ "next_cursor": None,
602
+ })
603
+
604
+ with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp) as mock_req:
605
+ await client.get_schedule_history("sched-1", limit=50, after_sequence=7)
606
+
607
+ call = mock_req.call_args
608
+ path = call.args[1]
609
+ assert path.startswith("/api/schedules/sched-1/history?")
610
+ assert "limit=50" in path
611
+ assert "after_sequence=7" in path
612
+
613
+ @pytest.mark.asyncio
614
+ async def test_rejects_invalid_limit(self, client: Client) -> None:
615
+ with pytest.raises(ValueError):
616
+ await client.get_schedule_history("sched-1", limit=0)
617
+
618
+ @pytest.mark.asyncio
619
+ async def test_rejects_negative_after_sequence(self, client: Client) -> None:
620
+ with pytest.raises(ValueError):
621
+ await client.get_schedule_history("sched-1", after_sequence=-1)
622
+
623
+ @pytest.mark.asyncio
624
+ async def test_not_found_maps_to_schedule_not_found(self, client: Client) -> None:
625
+ resp = _mock_response(404, {"reason": "schedule_not_found", "message": "not found"})
626
+ with (
627
+ patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp),
628
+ pytest.raises(ScheduleNotFound),
629
+ ):
630
+ await client.get_schedule_history("missing")
631
+
632
+ @pytest.mark.asyncio
633
+ async def test_iter_pages_across_multiple_server_responses(self, client: Client) -> None:
634
+ responses = [
635
+ _mock_response(200, {
636
+ "schedule_id": "sched-1",
637
+ "namespace": "ns1",
638
+ "events": [
639
+ {
640
+ "sequence": 1,
641
+ "event_type": "ScheduleCreated",
642
+ "recorded_at": "2026-04-01T00:00:00+00:00",
643
+ "payload": {},
644
+ },
645
+ {
646
+ "sequence": 2,
647
+ "event_type": "SchedulePaused",
648
+ "recorded_at": "2026-04-02T00:00:00+00:00",
649
+ "payload": {},
650
+ },
651
+ ],
652
+ "has_more": True,
653
+ "next_cursor": 2,
654
+ }),
655
+ _mock_response(200, {
656
+ "schedule_id": "sched-1",
657
+ "namespace": "ns1",
658
+ "events": [
659
+ {
660
+ "sequence": 3,
661
+ "event_type": "ScheduleResumed",
662
+ "recorded_at": "2026-04-03T00:00:00+00:00",
663
+ "payload": {},
664
+ },
665
+ ],
666
+ "has_more": False,
667
+ "next_cursor": None,
668
+ }),
669
+ ]
670
+
671
+ mock = AsyncMock(side_effect=responses)
672
+ with patch.object(client._http, "request", new=mock):
673
+ sequences = [event.sequence async for event in client.iter_schedule_history("sched-1")]
674
+
675
+ assert sequences == [1, 2, 3]
676
+ assert mock.call_count == 2
677
+ second_call_path = mock.call_args_list[1].args[1]
678
+ assert "after_sequence=2" in second_call_path
679
+
680
+ @pytest.mark.asyncio
681
+ async def test_handle_history_delegates_to_client(self, client: Client) -> None:
682
+ resp = _mock_response(200, {
683
+ "schedule_id": "sched-1",
684
+ "namespace": "ns1",
685
+ "events": [
686
+ {
687
+ "sequence": 1,
688
+ "event_type": "ScheduleCreated",
689
+ "recorded_at": "2026-04-01T00:00:00+00:00",
690
+ "payload": {},
691
+ },
692
+ ],
693
+ "has_more": False,
694
+ "next_cursor": None,
695
+ })
696
+
697
+ handle = client.get_schedule_handle("sched-1")
698
+ with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp):
699
+ page = await handle.history(limit=10)
700
+
701
+ assert isinstance(page, ScheduleHistoryPage)
702
+ assert page.events[0].event_type == "ScheduleCreated"
703
+
704
+ @pytest.mark.asyncio
705
+ async def test_handle_iter_history_delegates_to_client(self, client: Client) -> None:
706
+ resp = _mock_response(200, {
707
+ "schedule_id": "sched-1",
708
+ "namespace": "ns1",
709
+ "events": [
710
+ {
711
+ "sequence": 1,
712
+ "event_type": "ScheduleCreated",
713
+ "recorded_at": "2026-04-01T00:00:00+00:00",
714
+ "payload": {},
715
+ },
716
+ ],
717
+ "has_more": False,
718
+ "next_cursor": None,
719
+ })
720
+
721
+ handle = client.get_schedule_handle("sched-1")
722
+ with patch.object(client._http, "request", new_callable=AsyncMock, return_value=resp):
723
+ events = [event async for event in handle.iter_history()]
724
+
725
+ assert len(events) == 1
726
+ assert events[0].event_type == "ScheduleCreated"