durable-workflow 0.4.76__tar.gz → 0.4.77__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 (66) hide show
  1. {durable_workflow-0.4.76/src/durable_workflow.egg-info → durable_workflow-0.4.77}/PKG-INFO +1 -1
  2. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/pyproject.toml +1 -1
  3. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/client.py +58 -1
  4. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/sync.py +10 -0
  5. {durable_workflow-0.4.76 → durable_workflow-0.4.77/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  6. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_client.py +53 -0
  7. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_sync.py +28 -0
  8. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/LICENSE +0 -0
  9. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/README.md +0 -0
  10. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/setup.cfg +0 -0
  11. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/__init__.py +0 -0
  12. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/_avro.py +0 -0
  13. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/activity.py +0 -0
  14. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/auth_composition.py +0 -0
  15. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/errors.py +0 -0
  16. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/external_storage.py +0 -0
  17. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/external_task_input.py +0 -0
  18. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/external_task_result.py +0 -0
  19. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/history_bundle_verify.py +0 -0
  20. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/interceptors.py +0 -0
  21. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/invocable.py +0 -0
  22. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/metrics.py +0 -0
  23. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/py.typed +0 -0
  24. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/python_conformance.py +0 -0
  25. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/replay_conformance.py +0 -0
  26. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/replay_verify.py +0 -0
  27. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/retry_policy.py +0 -0
  28. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/serializer.py +0 -0
  29. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/testing.py +0 -0
  30. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/worker.py +0 -0
  31. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow/workflow.py +0 -0
  32. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
  33. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  34. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  35. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow.egg-info/requires.txt +0 -0
  36. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/src/durable_workflow.egg-info/top_level.txt +0 -0
  37. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_activity_context.py +0 -0
  38. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_auth_composition.py +0 -0
  39. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_control_plane_parity_fixtures.py +0 -0
  40. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_errors.py +0 -0
  41. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_external_storage.py +0 -0
  42. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_external_task_input.py +0 -0
  43. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_external_task_result.py +0 -0
  44. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_golden_history_replay.py +0 -0
  45. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_history_bundle_verify.py +0 -0
  46. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_history_event_contract.py +0 -0
  47. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_invocable.py +0 -0
  48. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_metrics.py +0 -0
  49. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_order_processing_example.py +0 -0
  50. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_public_boundary_scanner.py +0 -0
  51. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_python_conformance.py +0 -0
  52. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_queries.py +0 -0
  53. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_readme_quickstart.py +0 -0
  54. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_replay.py +0 -0
  55. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_replay_conformance.py +0 -0
  56. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_replay_verify.py +0 -0
  57. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_retry_policy.py +0 -0
  58. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_schedules.py +0 -0
  59. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_serializer.py +0 -0
  60. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_signals.py +0 -0
  61. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_sleep.py +0 -0
  62. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_standalone_activity_client.py +0 -0
  63. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_testing_harness.py +0 -0
  64. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_updates.py +0 -0
  65. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/tests/test_wait_condition.py +0 -0
  66. {durable_workflow-0.4.76 → durable_workflow-0.4.77}/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.76
3
+ Version: 0.4.77
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.76"
7
+ version = "0.4.77"
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"
@@ -597,12 +597,29 @@ class TaskQueueBuildIdCohort:
597
597
  sdk_versions: list[str]
598
598
  last_heartbeat_at: str | None = None
599
599
  first_seen_at: str | None = None
600
+ drain_intent: str | None = None
601
+ drained_at: str | None = None
602
+ promoted_at: str | None = None
603
+ rolled_back_at: str | None = None
604
+ new_start_selected: bool = False
605
+ workflow_definition_fingerprint_count: int = 0
606
+ workflow_definition_fingerprint_conflicts: list[dict[str, Any]] | None = None
600
607
  raw: dict[str, Any] | None = None
601
608
 
602
609
  @classmethod
603
610
  def from_dict(cls, data: dict[str, Any]) -> TaskQueueBuildIdCohort:
604
611
  runtimes = data.get("runtimes")
605
612
  sdk_versions = data.get("sdk_versions")
613
+ fingerprint_conflicts_raw = data.get("workflow_definition_fingerprint_conflicts")
614
+ fingerprint_conflicts: list[dict[str, Any]] | None = None
615
+ if isinstance(fingerprint_conflicts_raw, list):
616
+ fingerprint_conflicts = []
617
+ for item in fingerprint_conflicts_raw:
618
+ if not isinstance(item, dict):
619
+ continue
620
+ fingerprint_conflicts.append(
621
+ {str(key): value for key, value in item.items() if isinstance(key, str)}
622
+ )
606
623
  return cls(
607
624
  build_id=data.get("build_id"),
608
625
  rollout_status=str(data.get("rollout_status") or ""),
@@ -614,6 +631,15 @@ class TaskQueueBuildIdCohort:
614
631
  sdk_versions=[v for v in sdk_versions if isinstance(v, str)] if isinstance(sdk_versions, list) else [],
615
632
  last_heartbeat_at=data.get("last_heartbeat_at"),
616
633
  first_seen_at=data.get("first_seen_at"),
634
+ drain_intent=data.get("drain_intent") if isinstance(data.get("drain_intent"), str) else None,
635
+ drained_at=data.get("drained_at") if isinstance(data.get("drained_at"), str) else None,
636
+ promoted_at=data.get("promoted_at") if isinstance(data.get("promoted_at"), str) else None,
637
+ rolled_back_at=data.get("rolled_back_at") if isinstance(data.get("rolled_back_at"), str) else None,
638
+ new_start_selected=bool(data.get("new_start_selected")),
639
+ workflow_definition_fingerprint_count=int(
640
+ data.get("workflow_definition_fingerprint_count") or 0
641
+ ),
642
+ workflow_definition_fingerprint_conflicts=fingerprint_conflicts,
617
643
  raw=data,
618
644
  )
619
645
 
@@ -650,11 +676,14 @@ class TaskQueueBuildIdRollout:
650
676
  class TaskQueueBuildIdRolloutState:
651
677
  """Operator-recorded drain intent for one ``(task_queue, build_id)`` cohort.
652
678
 
653
- Returned by ``drain_task_queue_build_id`` and ``resume_task_queue_build_id``.
679
+ Returned by ``drain_task_queue_build_id``,
680
+ ``promote_task_queue_build_id``, and ``resume_task_queue_build_id``.
654
681
  ``build_id`` is ``None`` for the unversioned cohort (workers registered
655
682
  without a build identifier). ``drain_intent`` is ``"active"`` or
656
683
  ``"draining"``. ``drained_at`` is set only when ``drain_intent`` is
657
684
  ``"draining"``; repeated drains do not shift the timestamp.
685
+ ``promoted_at`` and ``new_start_selected`` identify the cohort currently
686
+ selected for fresh workflow starts.
658
687
  """
659
688
 
660
689
  namespace: str | None
@@ -662,16 +691,26 @@ class TaskQueueBuildIdRolloutState:
662
691
  build_id: str | None
663
692
  drain_intent: str
664
693
  drained_at: str | None
694
+ promoted_at: str | None = None
695
+ rolled_back_at: str | None = None
696
+ new_start_selected: bool = False
697
+ deployment: dict[str, Any] | None = None
665
698
  raw: dict[str, Any] | None = None
666
699
 
667
700
  @classmethod
668
701
  def from_dict(cls, data: dict[str, Any]) -> TaskQueueBuildIdRolloutState:
702
+ deployment_raw = data.get("deployment")
703
+ deployment = dict(deployment_raw) if isinstance(deployment_raw, dict) else None
669
704
  return cls(
670
705
  namespace=data.get("namespace"),
671
706
  task_queue=str(data.get("task_queue") or ""),
672
707
  build_id=data.get("build_id") if isinstance(data.get("build_id"), str) else None,
673
708
  drain_intent=str(data.get("drain_intent") or ""),
674
709
  drained_at=data.get("drained_at") if isinstance(data.get("drained_at"), str) else None,
710
+ promoted_at=data.get("promoted_at") if isinstance(data.get("promoted_at"), str) else None,
711
+ rolled_back_at=data.get("rolled_back_at") if isinstance(data.get("rolled_back_at"), str) else None,
712
+ new_start_selected=bool(data.get("new_start_selected")),
713
+ deployment=deployment,
675
714
  raw=data,
676
715
  )
677
716
 
@@ -1895,6 +1934,24 @@ class Client:
1895
1934
  action="drain",
1896
1935
  )
1897
1936
 
1937
+ async def promote_task_queue_build_id(
1938
+ self,
1939
+ task_queue: str,
1940
+ build_id: str | None,
1941
+ ) -> TaskQueueBuildIdRolloutState:
1942
+ """Select a build-id cohort for fresh workflow starts on a task queue.
1943
+
1944
+ New workflow starts pin to ``build_id`` after promotion. Existing
1945
+ workflow runs keep their stamped compatibility marker and continue
1946
+ routing only to compatible workers. Pass ``None`` to promote the
1947
+ unversioned cohort.
1948
+ """
1949
+ return await self._mutate_task_queue_build_id_rollout(
1950
+ task_queue,
1951
+ build_id,
1952
+ action="promote",
1953
+ )
1954
+
1898
1955
  async def resume_task_queue_build_id(
1899
1956
  self,
1900
1957
  task_queue: str,
@@ -340,6 +340,16 @@ class Client:
340
340
  )
341
341
  return result
342
342
 
343
+ def promote_task_queue_build_id(
344
+ self,
345
+ task_queue: str,
346
+ build_id: str | None,
347
+ ) -> TaskQueueBuildIdRolloutState:
348
+ result: TaskQueueBuildIdRolloutState = _run(
349
+ self._async.promote_task_queue_build_id(task_queue, build_id)
350
+ )
351
+ return result
352
+
343
353
  def resume_task_queue_build_id(
344
354
  self,
345
355
  task_queue: str,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 0.4.76
3
+ Version: 0.4.77
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
@@ -1533,6 +1533,17 @@ class TestTaskQueues:
1533
1533
  "total_worker_count": 3,
1534
1534
  "runtimes": ["worker-runtime"],
1535
1535
  "sdk_versions": ["polyglot-sdk/2.0.0"],
1536
+ "drain_intent": "active",
1537
+ "drained_at": None,
1538
+ "promoted_at": "2026-04-22T10:00:00Z",
1539
+ "new_start_selected": True,
1540
+ "workflow_definition_fingerprint_count": 2,
1541
+ "workflow_definition_fingerprint_conflicts": [
1542
+ {
1543
+ "workflow_type": "orders.Checkout",
1544
+ "fingerprint_count": 2,
1545
+ }
1546
+ ],
1536
1547
  "last_heartbeat_at": "2026-04-22T09:30:00Z",
1537
1548
  "first_seen_at": "2026-04-22T08:00:00Z",
1538
1549
  },
@@ -1571,6 +1582,16 @@ class TestTaskQueues:
1571
1582
  assert alpha.total_worker_count == 3
1572
1583
  assert alpha.runtimes == ["worker-runtime"]
1573
1584
  assert alpha.sdk_versions == ["polyglot-sdk/2.0.0"]
1585
+ assert alpha.drain_intent == "active"
1586
+ assert alpha.promoted_at == "2026-04-22T10:00:00Z"
1587
+ assert alpha.new_start_selected is True
1588
+ assert alpha.workflow_definition_fingerprint_count == 2
1589
+ assert alpha.workflow_definition_fingerprint_conflicts == [
1590
+ {
1591
+ "workflow_type": "orders.Checkout",
1592
+ "fingerprint_count": 2,
1593
+ }
1594
+ ]
1574
1595
  assert unversioned.build_id is None
1575
1596
  assert unversioned.rollout_status == "stale_only"
1576
1597
  assert unversioned.stale_worker_count == 1
@@ -1607,6 +1628,38 @@ class TestTaskQueues:
1607
1628
  assert result.drain_intent == semantic["drain_intent"]
1608
1629
  assert result.drained_at == semantic["drained_at"]
1609
1630
 
1631
+ @pytest.mark.asyncio
1632
+ async def test_promote_task_queue_build_id_matches_polyglot_fixture(self, client: Client) -> None:
1633
+ fixture_path = (
1634
+ Path(__file__).parent
1635
+ / "fixtures"
1636
+ / "control-plane"
1637
+ / "task-queue-build-id-promote-parity.json"
1638
+ )
1639
+ fixture = json.loads(fixture_path.read_text())
1640
+ assert fixture["operation"] == "task_queue.build_id.promote"
1641
+ sdk = fixture["sdk_python"]
1642
+ resp = _mock_response(200, fixture["response_body"])
1643
+
1644
+ with patch.object(
1645
+ client._http, "request", new_callable=AsyncMock, return_value=resp
1646
+ ) as mock:
1647
+ result = await client.promote_task_queue_build_id(**sdk["args"])
1648
+
1649
+ assert mock.call_args.args[0] == fixture["request"]["method"]
1650
+ assert mock.call_args.args[1] == f"/api{fixture['request']['path']}"
1651
+ body = mock.call_args.kwargs.get("json")
1652
+ assert body == fixture["request"]["body"]
1653
+
1654
+ semantic = fixture["semantic_body"]
1655
+ assert result.namespace == semantic["namespace"]
1656
+ assert result.task_queue == semantic["task_queue"]
1657
+ assert result.build_id == semantic["build_id"]
1658
+ assert result.drain_intent == semantic["drain_intent"]
1659
+ assert result.drained_at is None
1660
+ assert result.promoted_at == semantic["promoted_at"]
1661
+ assert result.new_start_selected == semantic["new_start_selected"]
1662
+
1610
1663
  @pytest.mark.asyncio
1611
1664
  async def test_resume_task_queue_build_id_matches_polyglot_fixture(self, client: Client) -> None:
1612
1665
  fixture_path = (
@@ -294,6 +294,34 @@ class TestSyncClientList:
294
294
  )
295
295
  assert mock.call_args.kwargs.get("json") == {"build_id": "build-alpha"}
296
296
 
297
+ def test_promote_task_queue_build_id(self) -> None:
298
+ client = Client("http://localhost:8080")
299
+ resp = _mock_response(
300
+ 200,
301
+ {
302
+ "namespace": "default",
303
+ "task_queue": "orders",
304
+ "build_id": "build-alpha",
305
+ "drain_intent": "active",
306
+ "drained_at": None,
307
+ "promoted_at": "2026-04-22T10:00:00Z",
308
+ "new_start_selected": True,
309
+ },
310
+ )
311
+ with patch.object(
312
+ client._async._http, "request", new_callable=AsyncMock, return_value=resp
313
+ ) as mock:
314
+ result = client.promote_task_queue_build_id("orders", "build-alpha")
315
+ assert result.drain_intent == "active"
316
+ assert result.promoted_at == "2026-04-22T10:00:00Z"
317
+ assert result.new_start_selected is True
318
+ assert result.build_id == "build-alpha"
319
+ assert mock.call_args.args[:2] == (
320
+ "POST",
321
+ "/api/task-queues/orders/build-ids/promote",
322
+ )
323
+ assert mock.call_args.kwargs.get("json") == {"build_id": "build-alpha"}
324
+
297
325
  def test_resume_task_queue_build_id_for_unversioned_cohort(self) -> None:
298
326
  client = Client("http://localhost:8080")
299
327
  resp = _mock_response(