durable-workflow 2.1.2__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. {durable_workflow-2.1.2/src/durable_workflow.egg-info → durable_workflow-2.2.0}/PKG-INFO +1 -1
  2. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/pyproject.toml +3 -3
  3. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/activity.py +10 -1
  4. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/client.py +31 -10
  5. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/errors.py +5 -3
  6. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/external_storage.py +12 -2
  7. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/worker.py +358 -26
  8. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/workflow.py +175 -8
  9. {durable_workflow-2.1.2 → durable_workflow-2.2.0/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  10. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_client.py +24 -0
  11. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_external_storage.py +219 -1
  12. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_replay.py +81 -0
  13. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_replay_regression_corpus.py +7 -0
  14. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_worker.py +255 -3
  15. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/LICENSE +0 -0
  16. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/README.md +0 -0
  17. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/setup.cfg +0 -0
  18. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/__init__.py +0 -0
  19. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/_avro.py +0 -0
  20. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/auth_composition.py +0 -0
  21. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/external_task_input.py +0 -0
  22. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/external_task_result.py +0 -0
  23. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/history_bundle_verify.py +0 -0
  24. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/interceptors.py +0 -0
  25. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/invocable.py +0 -0
  26. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/metrics.py +0 -0
  27. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/nexus.py +0 -0
  28. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/py.typed +0 -0
  29. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/python_conformance.py +0 -0
  30. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/replay_conformance.py +0 -0
  31. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/replay_verify.py +0 -0
  32. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/retry_policy.py +0 -0
  33. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/serializer.py +0 -0
  34. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/sync.py +0 -0
  35. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/testing.py +0 -0
  36. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  37. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow.egg-info/SOURCES.txt +0 -0
  38. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  39. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  40. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow.egg-info/requires.txt +0 -0
  41. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
  42. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_activity_context.py +0 -0
  43. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_auth_composition.py +0 -0
  44. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_avro_value_benchmark.py +0 -0
  45. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_avro_value_protocol.py +0 -0
  46. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_ci_checkout.py +0 -0
  47. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_ci_integration_endpoint.py +0 -0
  48. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_codec_regression_corpus.py +0 -0
  49. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_control_plane_parity_fixtures.py +0 -0
  50. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_docstring_cross_references.py +0 -0
  51. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_durable_selection.py +0 -0
  52. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_errors.py +0 -0
  53. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_external_task_input.py +0 -0
  54. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_external_task_result.py +0 -0
  55. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_golden_history_replay.py +0 -0
  56. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_history_bundle_verify.py +0 -0
  57. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_history_event_contract.py +0 -0
  58. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_invocable.py +0 -0
  59. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_message_streams.py +0 -0
  60. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_metrics.py +0 -0
  61. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_order_processing_example.py +0 -0
  62. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_parallel_completion_order_replay.py +0 -0
  63. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_parallel_saga_authoring.py +0 -0
  64. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_public_boundary_scanner.py +0 -0
  65. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_pypi_project_surface.py +0 -0
  66. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_python_conformance.py +0 -0
  67. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_queries.py +0 -0
  68. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_readme_quickstart.py +0 -0
  69. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_redrive_replay.py +0 -0
  70. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_release_metadata.py +0 -0
  71. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_replay_conformance.py +0 -0
  72. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_replay_verify.py +0 -0
  73. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_retry_policy.py +0 -0
  74. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_runtime_external_payload_transport.py +0 -0
  75. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_schedules.py +0 -0
  76. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_serializer.py +0 -0
  77. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_signals.py +0 -0
  78. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_sleep.py +0 -0
  79. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_standalone_activity_client.py +0 -0
  80. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_storage_admission.py +0 -0
  81. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_sync.py +0 -0
  82. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_testing_harness.py +0 -0
  83. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_update_signal_condition_replay.py +0 -0
  84. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_updates.py +0 -0
  85. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_wait_condition.py +0 -0
  86. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_workflow_result_timeout.py +0 -0
  87. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/tests/test_workflow_streams.py +0 -0
  88. {durable_workflow-2.1.2 → durable_workflow-2.2.0}/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: 2.1.2
3
+ Version: 2.2.0
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
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 = "2.1.2"
7
+ version = "2.2.0"
8
8
  description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -71,8 +71,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
71
71
  durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
72
72
 
73
73
  [tool.durable-workflow]
74
- product-train = "2.1.2"
75
- registry-version = "2.1.2"
74
+ product-train = "2.2.0"
75
+ registry-version = "2.2.0"
76
76
  supported-server-versions = "2.4.0"
77
77
  worker-protocol-version = "1.19"
78
78
  control-plane-version = "2"
@@ -2,7 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import contextvars
5
- from collections.abc import Callable
5
+ from collections.abc import Awaitable, Callable
6
6
  from dataclasses import dataclass
7
7
  from typing import TYPE_CHECKING, Any
8
8
 
@@ -38,9 +38,11 @@ class ActivityContext:
38
38
  *,
39
39
  info: ActivityInfo,
40
40
  client: Client,
41
+ heartbeat_callback: Callable[[dict[str, Any] | None], Awaitable[None]] | None = None,
41
42
  ) -> None:
42
43
  self._info = info
43
44
  self._client = client
45
+ self._heartbeat_callback = heartbeat_callback
44
46
  self._cancel_requested = False
45
47
 
46
48
  @property
@@ -65,6 +67,13 @@ class ActivityContext:
65
67
  owning workflow has requested cancellation, so the activity can exit
66
68
  cleanly at its next natural break point.
67
69
  """
70
+ if self._heartbeat_callback is not None:
71
+ try:
72
+ await self._heartbeat_callback(details)
73
+ except ActivityCancelled:
74
+ self._cancel_requested = True
75
+ raise
76
+ return
68
77
  resp = await self._client.heartbeat_activity_task(
69
78
  task_id=self._info.task_id,
70
79
  activity_attempt_id=self._info.activity_attempt_id,
@@ -66,9 +66,9 @@ PROTOCOL_VERSION = "1.19"
66
66
  CONTROL_PLANE_VERSION = "2"
67
67
  PORTABLE_WORKER_AFFINITY_CAPABILITY_MANIFEST: dict[str, dict[str, str | bool]] = {
68
68
  "local_activities": {
69
- "supported": False,
69
+ "supported": True,
70
70
  "minimum_protocol_version": "1.18",
71
- "reason": "python_worker_does_not_execute_record_local_activity",
71
+ "implementation": "record_local_activity",
72
72
  },
73
73
  "worker_sessions": {
74
74
  "supported": False,
@@ -1316,7 +1316,7 @@ class WorkflowHandle:
1316
1316
  return await self._client.query_workflow(self.workflow_id, query_name, args=args)
1317
1317
 
1318
1318
  async def cancel(self, *, reason: str | None = None) -> None:
1319
- """Request graceful cancellation of this workflow. See :meth:`Client.cancel_workflow`."""
1319
+ """Close this workflow's current run as cancelled. See :meth:`Client.cancel_workflow`."""
1320
1320
  await self._client.cancel_workflow(self.workflow_id, reason=reason)
1321
1321
 
1322
1322
  async def terminate(self, *, reason: str | None = None) -> None:
@@ -4114,11 +4114,13 @@ class Client:
4114
4114
  )
4115
4115
 
4116
4116
  async def cancel_workflow(self, workflow_id: str, *, reason: str | None = None) -> None:
4117
- """Request graceful cancellation of a workflow's current run.
4117
+ """Close the current run as cancelled immediately.
4118
4118
 
4119
- Cancellation is cooperative: the server delivers a cancellation signal
4120
- that the workflow can observe and handle (e.g. to roll back via a
4121
- saga). Compare with :meth:`terminate_workflow`, which is forceful.
4119
+ Server cancels open tasks and timers; it does not resume workflow code
4120
+ to run saga or ``finally`` cleanup. :meth:`terminate_workflow` also
4121
+ closes immediately, with a distinct terminal outcome. Embedded
4122
+ Laravel's cooperative ``requestCancellation()`` is not yet available
4123
+ through this service-mode API.
4122
4124
  """
4123
4125
  body: dict[str, Any] = {}
4124
4126
  if reason is not None:
@@ -4126,10 +4128,11 @@ class Client:
4126
4128
  await self._request("POST", f"/workflows/{workflow_id}/cancel", json=body, context=workflow_id)
4127
4129
 
4128
4130
  async def terminate_workflow(self, workflow_id: str, *, reason: str | None = None) -> None:
4129
- """Forcefully stop a workflow without giving it a chance to clean up.
4131
+ """Close the current run as terminated immediately.
4130
4132
 
4131
- Prefer :meth:`cancel_workflow` when the workflow code can implement
4132
- graceful shutdown. Termination is an operator escape hatch.
4133
+ Like :meth:`cancel_workflow`, this does not resume workflow code for
4134
+ cleanup. Use the distinct terminal outcome when termination is the
4135
+ appropriate operator action.
4133
4136
  """
4134
4137
  body: dict[str, Any] = {}
4135
4138
  if reason is not None:
@@ -4964,6 +4967,24 @@ class Client:
4964
4967
  "POST", f"/worker/workflow-tasks/{task_id}/fail", worker=True, json=body
4965
4968
  )
4966
4969
 
4970
+ async def heartbeat_workflow_task(
4971
+ self,
4972
+ *,
4973
+ task_id: str,
4974
+ lease_owner: str,
4975
+ workflow_task_attempt: int,
4976
+ ) -> Any:
4977
+ """Renew a leased workflow task while in-process work is running."""
4978
+ return await self._request(
4979
+ "POST",
4980
+ f"/worker/workflow-tasks/{task_id}/heartbeat",
4981
+ worker=True,
4982
+ json={
4983
+ "lease_owner": lease_owner,
4984
+ "workflow_task_attempt": workflow_task_attempt,
4985
+ },
4986
+ )
4987
+
4967
4988
  async def workflow_task_history(
4968
4989
  self,
4969
4990
  *,
@@ -581,7 +581,8 @@ class ActivityFailed(DurableWorkflowError):
581
581
  class WorkflowTerminated(DurableWorkflowError):
582
582
  """A workflow was terminated by operator action.
583
583
 
584
- Termination is non-gracious and skips normal cleanup, unlike cancellation.
584
+ Like terminal cancellation, termination does not resume workflow cleanup.
585
+ It records a distinct terminal outcome.
585
586
  """
586
587
 
587
588
  def __init__(self, message: str = "workflow was terminated") -> None:
@@ -643,8 +644,9 @@ class ActivityCancelled(BaseException):
643
644
  """An in-flight activity was cancelled.
644
645
 
645
646
  Raised inside :meth:`durable_workflow.ActivityContext.heartbeat` when the
646
- server reports that the owning workflow has asked for cancellation, so the
647
- activity can exit cleanly on its next heartbeat.
647
+ server reports that the task was revoked or its run was cancelled, so the
648
+ activity can exit on its next heartbeat. This permits local activity cleanup,
649
+ not durable workflow compensation after the run closes.
648
650
 
649
651
  Inherits from :class:`BaseException` — not :class:`Exception` — so that a
650
652
  user ``except Exception:`` block inside the activity function cannot
@@ -12,7 +12,12 @@ from pathlib import Path
12
12
  from typing import Any, Protocol
13
13
  from urllib.parse import quote, unquote, urlparse
14
14
 
15
- from .errors import ExternalPayloadIntegrityMismatch, ExternalPayloadUnsupported
15
+ from .errors import (
16
+ ExternalPayloadError,
17
+ ExternalPayloadIntegrityMismatch,
18
+ ExternalPayloadUnavailable,
19
+ ExternalPayloadUnsupported,
20
+ )
16
21
 
17
22
  EXTERNAL_PAYLOAD_REFERENCE_SCHEMA = "durable-workflow.v2.external-payload-reference.v1"
18
23
  RUNTIME_EXTERNAL_PAYLOAD_REFERENCE_SCHEMA = (
@@ -556,7 +561,12 @@ def store_external_payload(
556
561
  if expires_at is not None:
557
562
  _validate_rfc3339(expires_at)
558
563
  sha256 = hashlib.sha256(data).hexdigest()
559
- uri = driver.put(data, sha256=sha256, codec=codec)
564
+ try:
565
+ uri = driver.put(data, sha256=sha256, codec=codec)
566
+ except ExternalPayloadError:
567
+ raise
568
+ except Exception as exc:
569
+ raise ExternalPayloadUnavailable("external payload storage upload failed") from exc
560
570
  return ExternalPayloadReference(
561
571
  uri=uri,
562
572
  sha256=sha256,