durable-workflow 2.13.1__tar.gz → 2.14.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 (179) hide show
  1. {durable_workflow-2.13.1/src/durable_workflow.egg-info → durable_workflow-2.14.0}/PKG-INFO +1 -1
  2. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/pyproject.toml +3 -3
  3. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/__init__.py +2 -0
  4. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/_cancellation_scope_history.py +6 -1
  5. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/_cooperative_cancellation.py +3 -1
  6. durable_workflow-2.14.0/src/durable_workflow/_typed_values.py +311 -0
  7. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/activity.py +17 -2
  8. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/errors.py +20 -0
  9. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/testing.py +12 -1
  10. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/worker.py +5 -0
  11. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/workflow.py +508 -20
  12. {durable_workflow-2.13.1 → durable_workflow-2.14.0/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  13. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow.egg-info/SOURCES.txt +5 -0
  14. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_cancellation_remaining_time.py +42 -11
  15. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_committed_cancellation_scope_replay.py +96 -0
  16. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_history_event_contract.py +16 -3
  17. durable_workflow-2.14.0/tests/test_publish_workflow_isolation.py +149 -0
  18. durable_workflow-2.14.0/tests/test_redrive_successor_evidence.py +613 -0
  19. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_replay.py +6 -3
  20. durable_workflow-2.14.0/tests/test_replay_clock.py +998 -0
  21. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_replay_regression_corpus.py +28 -2
  22. durable_workflow-2.14.0/tests/test_search_attribute_replay_identity.py +675 -0
  23. durable_workflow-2.14.0/tests/test_typed_values.py +464 -0
  24. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_wait_condition.py +374 -22
  25. durable_workflow-2.13.1/tests/test_replay_clock.py +0 -133
  26. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/LICENSE +0 -0
  27. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/README.md +0 -0
  28. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/setup.cfg +0 -0
  29. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/_activity_lease.py +0 -0
  30. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/_activity_process.py +0 -0
  31. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/_avro.py +0 -0
  32. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/_cancellation_scope.py +0 -0
  33. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/_failure_reporting.py +0 -0
  34. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/_metric_routes.py +0 -0
  35. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/_poll_transport.py +0 -0
  36. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/_prepared_local_activity.py +0 -0
  37. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/_query_task_authority.py +0 -0
  38. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  39. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/auth_composition.py +0 -0
  40. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/cancellation.py +0 -0
  41. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/client.py +0 -0
  42. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/external_storage.py +0 -0
  43. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/external_task_input.py +0 -0
  44. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/external_task_result.py +0 -0
  45. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/history_bundle_verify.py +0 -0
  46. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/interceptors.py +0 -0
  47. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/invocable.py +0 -0
  48. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/metrics.py +0 -0
  49. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/nexus.py +0 -0
  50. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/py.typed +0 -0
  51. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/python_conformance.py +0 -0
  52. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/replay_conformance.py +0 -0
  53. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/replay_verify.py +0 -0
  54. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/retry_policy.py +0 -0
  55. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/serializer.py +0 -0
  56. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/server_compatibility.json +0 -0
  57. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/sync.py +0 -0
  58. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/telemetry.py +0 -0
  59. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/telemetry_carrier.py +0 -0
  60. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/worker_session.py +0 -0
  61. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/worker_status.py +0 -0
  62. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  63. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  64. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  65. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow.egg-info/requires.txt +0 -0
  66. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
  67. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_activity_cancellation_policies.py +0 -0
  68. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_activity_context.py +0 -0
  69. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_activity_heartbeat_authority.py +0 -0
  70. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_activity_lease_authority.py +0 -0
  71. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_activity_lease_renewal.py +0 -0
  72. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_activity_lease_worker.py +0 -0
  73. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_activity_process.py +0 -0
  74. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_activity_retry_info.py +0 -0
  75. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_auth_composition.py +0 -0
  76. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_avro_value_benchmark.py +0 -0
  77. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_avro_value_protocol.py +0 -0
  78. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_cancellation_context.py +0 -0
  79. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_cancellation_scope_admission.py +0 -0
  80. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_cancellation_scope_authoring.py +0 -0
  81. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_cancellation_scope_boundary.py +0 -0
  82. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_cancellation_scope_opening.py +0 -0
  83. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_child_workflow_policies.py +0 -0
  84. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_ci_checkout.py +0 -0
  85. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_ci_integration_endpoint.py +0 -0
  86. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_client.py +0 -0
  87. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_client_telemetry.py +0 -0
  88. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_client_timeouts.py +0 -0
  89. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_codec_regression_corpus.py +0 -0
  90. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  91. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_committed_cancellation_scope_groups.py +0 -0
  92. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_committed_cancellation_scope_history.py +0 -0
  93. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_condition_replay_drift.py +0 -0
  94. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_control_plane_parity_fixtures.py +0 -0
  95. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_cooperative_cancellation.py +0 -0
  96. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_cooperative_cancellation_client.py +0 -0
  97. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_cooperative_cancellation_worker.py +0 -0
  98. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_cooperative_remote_worker.py +0 -0
  99. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_docstring_cross_references.py +0 -0
  100. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_durable_selection.py +0 -0
  101. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_empty_callback_failure.py +0 -0
  102. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_errors.py +0 -0
  103. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_external_storage.py +0 -0
  104. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_external_task_input.py +0 -0
  105. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_external_task_result.py +0 -0
  106. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_failure_reporting.py +0 -0
  107. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_generator_lifecycle.py +0 -0
  108. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_golden_history_replay.py +0 -0
  109. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_history_bundle_pruned.py +0 -0
  110. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_history_bundle_schema2.py +0 -0
  111. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_history_bundle_verify.py +0 -0
  112. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_invocable.py +0 -0
  113. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_local_failure_reporting.py +0 -0
  114. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_message_streams.py +0 -0
  115. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_metric_routes.py +0 -0
  116. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_metrics.py +0 -0
  117. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_nexus_durable_wait.py +0 -0
  118. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_nexus_namespace.py +0 -0
  119. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_nexus_wire.py +0 -0
  120. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_order_processing_example.py +0 -0
  121. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_parallel_completion_order_replay.py +0 -0
  122. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_parallel_saga_authoring.py +0 -0
  123. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_patch_history.py +0 -0
  124. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_pending_operation_replay.py +0 -0
  125. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_poll_failure_backoff.py +0 -0
  126. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_poll_transport.py +0 -0
  127. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_prepared_local_activity_client.py +0 -0
  128. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_prepared_local_activity_groups.py +0 -0
  129. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_prepared_local_activity_policies.py +0 -0
  130. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_prepared_local_activity_worker.py +0 -0
  131. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_public_boundary_scanner.py +0 -0
  132. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_pypi_project_surface.py +0 -0
  133. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_python_conformance.py +0 -0
  134. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_queries.py +0 -0
  135. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_query_task_authority.py +0 -0
  136. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_query_timeout_retry.py +0 -0
  137. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_readme_quickstart.py +0 -0
  138. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_redrive_replay.py +0 -0
  139. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_registration_ambiguity.py +0 -0
  140. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_release_metadata.py +0 -0
  141. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_replay_conformance.py +0 -0
  142. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_replay_verify.py +0 -0
  143. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_retry_policy.py +0 -0
  144. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_run_cancellation_scope_replay.py +0 -0
  145. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_runtime_external_payload_transport.py +0 -0
  146. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_schedules.py +0 -0
  147. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_scoped_run_cancellation_context.py +0 -0
  148. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_sdk_metrics_contract.py +0 -0
  149. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_serializer.py +0 -0
  150. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_signals.py +0 -0
  151. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_sleep.py +0 -0
  152. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_standalone_activity_client.py +0 -0
  153. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_sticky_execution.py +0 -0
  154. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_storage_admission.py +0 -0
  155. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_sync.py +0 -0
  156. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_sync_activities.py +0 -0
  157. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_telemetry.py +0 -0
  158. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_telemetry_carrier.py +0 -0
  159. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_testing_failures.py +0 -0
  160. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_testing_harness.py +0 -0
  161. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_testing_nexus.py +0 -0
  162. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_threaded_heartbeat_example.py +0 -0
  163. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_tls_trust.py +0 -0
  164. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_transport_diagnostics.py +0 -0
  165. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_update_signal_condition_replay.py +0 -0
  166. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_updates.py +0 -0
  167. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_worker.py +0 -0
  168. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_worker_deregistration.py +0 -0
  169. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_worker_observation.py +0 -0
  170. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_worker_session.py +0 -0
  171. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_worker_status.py +0 -0
  172. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_worker_telemetry_wire.py +0 -0
  173. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_workflow_entry_arguments.py +0 -0
  174. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_workflow_entrypoint_validation.py +0 -0
  175. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_workflow_handler_validation.py +0 -0
  176. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_workflow_identity.py +0 -0
  177. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_workflow_result_timeout.py +0 -0
  178. {durable_workflow-2.13.1 → durable_workflow-2.14.0}/tests/test_workflow_streams.py +0 -0
  179. {durable_workflow-2.13.1 → durable_workflow-2.14.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.13.1
3
+ Version: 2.14.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.13.1"
7
+ version = "2.14.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"
@@ -79,8 +79,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
79
79
  durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
80
80
 
81
81
  [tool.durable-workflow]
82
- product-train = "2.13.1"
83
- registry-version = "2.13.1"
82
+ product-train = "2.14.0"
83
+ registry-version = "2.14.0"
84
84
  supported-server-versions = "2.8.0"
85
85
  supported-server-release-baselines = { "2" = "2.8.0", "3" = "3.0.0" }
86
86
  worker-protocol-version = "1.20"
@@ -98,6 +98,7 @@ from .errors import (
98
98
  ScheduleNotFound,
99
99
  ServerError,
100
100
  SignalFailed,
101
+ TypedValueError,
101
102
  Unauthorized,
102
103
  UpdateFailed,
103
104
  UpdateRejected,
@@ -280,6 +281,7 @@ __all__ = [
280
281
  "AuthCompositionContract",
281
282
  "AuthCompositionContractError",
282
283
  "NonRetryableError",
284
+ "TypedValueError",
283
285
  "ReplayOutcome",
284
286
  "Replayer",
285
287
  "Saga",
@@ -122,10 +122,15 @@ def _cleanup_operation(event: dict[str, Any], prefix: Sequence[dict[str, Any]],
122
122
  "authority_deadline_at": _canonical_time(authority),
123
123
  }
124
124
  sequence = payload.get("sequence")
125
+ # Only an explicit null reads as one call, as in Core; any other value
126
+ # that is not a positive integer is refused, never narrowed.
127
+ span = 1 if recorded.get("sequence_span") is None else recorded["sequence_span"]
128
+ if not _positive(span) or not _positive(recorded.get("sequence")):
129
+ raise _invalid(operation + " follows a delivery without a canonical sequence span")
125
130
  deadline = scope_timestamp(expected["authority_deadline_at"])
126
131
  timestamp = scope_timestamp(event.get("timestamp", event.get("recorded_at")))
127
132
  if (snapshot != expected or scope_id != context.scope_id
128
- or not _positive(sequence) or sequence < recorded["sequence"] + recorded["sequence_span"]
133
+ or not _positive(sequence) or sequence < recorded["sequence"] + span
129
134
  or not _positive(event.get("sequence")) or delivery["sequence"] >= event["sequence"]
130
135
  or timestamp < scope_timestamp(delivery.get("timestamp", delivery.get("recorded_at")))
131
136
  or timestamp >= deadline or not local and (
@@ -112,7 +112,9 @@ class CancellationDelivery:
112
112
  request_id = _text(payload.get("workflow_command_id"), "workflow_command_id")
113
113
  sequence = _positive(payload.get("sequence"), "sequence")
114
114
  kind = _text(payload.get("call_kind"), "call_kind")
115
- span = _positive(payload.get("sequence_span", 1), "sequence_span", maximum=1000)
115
+ # Absent and null both mean one call, as Core's WorkflowStepHistory reads it.
116
+ recorded_span = payload.get("sequence_span")
117
+ span = _positive(1 if recorded_span is None else recorded_span, "sequence_span", maximum=1000)
116
118
  operation = payload.get("operation_sequence")
117
119
  operation_span = _positive(payload.get("operation_sequence_span", 1), "operation_sequence_span", maximum=1000)
118
120
  if kind not in _CALL_KINDS or (kind != "parallel" and span != 1):
@@ -0,0 +1,311 @@
1
+ """Opt-in conversion between neutral payload values and annotated Python types.
2
+
3
+ The wire format stays the neutral Avro Value: a dataclass travels as the
4
+ string-keyed map of its fields and an ``Enum`` as its value, exactly as
5
+ ``dataclasses.asdict()`` and ``.value`` would produce by hand. Decoding is
6
+ driven only by the receiver's own annotations, so no class name, tag or
7
+ pickled object ever enters durable history.
8
+
9
+ Decoding follows payload-evolution rules for rolling deploys: keys a target
10
+ dataclass does not declare are ignored, and a missing field takes its
11
+ default. A value that cannot be converted raises
12
+ :class:`~durable_workflow.errors.TypedValueError`, which names the field path
13
+ and the expected type but never the value.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import dataclasses
19
+ import functools
20
+ import inspect
21
+ import types
22
+ from collections.abc import Callable
23
+ from enum import Enum
24
+ from typing import Any, Union, cast, get_args, get_origin, get_type_hints
25
+
26
+ from .errors import TypedValueError
27
+
28
+ _SCALARS: tuple[type, ...] = (str, int, float, bool, bytes)
29
+ _PASS_THROUGH = (Any, object, inspect.Parameter.empty)
30
+
31
+
32
+ def _type_name(annotation: Any) -> str:
33
+ """A short, module-free name for diagnostics, such as ``list[Item]``."""
34
+ if annotation is type(None) or annotation is None:
35
+ return "None"
36
+ if _is_union(annotation):
37
+ options = get_args(annotation)
38
+ concrete = [option for option in options if option is not type(None)]
39
+ if len(concrete) == 1 and len(options) == 2:
40
+ return f"Optional[{_type_name(concrete[0])}]"
41
+ return " | ".join(_type_name(option) for option in options)
42
+ origin = get_origin(annotation)
43
+ if origin is not None:
44
+ arguments = ", ".join(_type_name(argument) for argument in get_args(annotation))
45
+ name = getattr(origin, "__name__", repr(origin).replace("typing.", ""))
46
+ return f"{name}[{arguments}]" if arguments else name
47
+ if isinstance(annotation, type):
48
+ return annotation.__qualname__
49
+ return repr(annotation).replace("typing.", "")
50
+
51
+
52
+ def _json_kind(value: Any) -> str:
53
+ """The neutral kind of a received value, never the value itself."""
54
+ if value is None:
55
+ return "null"
56
+ if isinstance(value, bool):
57
+ return "boolean"
58
+ if isinstance(value, int):
59
+ return "integer"
60
+ if isinstance(value, float):
61
+ return "number"
62
+ if isinstance(value, str):
63
+ return "string"
64
+ if isinstance(value, bytes):
65
+ return "bytes"
66
+ if isinstance(value, list):
67
+ return "array"
68
+ if isinstance(value, dict):
69
+ return "object"
70
+ return type(value).__name__
71
+
72
+
73
+ def _optional_member(annotation: Any) -> Any:
74
+ """Return T for Optional[T]; refuse every other union as ambiguous."""
75
+ options = get_args(annotation)
76
+ concrete = [option for option in options if option is not type(None)]
77
+ if len(concrete) != 1 or len(concrete) == len(options):
78
+ raise TypeError(
79
+ f"typed values support only Optional[T] unions; {_type_name(annotation)} is ambiguous"
80
+ )
81
+ return concrete[0]
82
+
83
+
84
+ def _is_union(annotation: Any) -> bool:
85
+ return get_origin(annotation) in (Union, types.UnionType)
86
+
87
+
88
+ def _dataclass_hints(cls: type) -> dict[str, Any]:
89
+ try:
90
+ return get_type_hints(cls)
91
+ except (NameError, TypeError) as error:
92
+ raise TypeError(f"typed values cannot resolve the annotations of {cls.__qualname__}: {error}") from None
93
+
94
+
95
+ def validate_annotation(annotation: Any, where: str, _seen: frozenset[type] = frozenset()) -> None:
96
+ """Refuse an annotation this contract cannot decode unambiguously.
97
+
98
+ Called when a typed activity is declared or a ``result_type`` is given, so
99
+ an unsupported type fails at definition time instead of passing a plain
100
+ value through silently.
101
+ """
102
+ if annotation in _PASS_THROUGH or annotation is None or annotation is type(None):
103
+ return
104
+ if _is_union(annotation):
105
+ validate_annotation(_optional_member(annotation), where, _seen)
106
+ return
107
+ origin = get_origin(annotation)
108
+ if annotation is list or origin is list:
109
+ for item in get_args(annotation):
110
+ validate_annotation(item, where, _seen)
111
+ return
112
+ if annotation is dict or origin is dict:
113
+ arguments = get_args(annotation)
114
+ if arguments:
115
+ if arguments[0] is not str:
116
+ raise TypeError(f"{where}: typed maps need str keys, not {_type_name(arguments[0])}")
117
+ validate_annotation(arguments[1], where, _seen)
118
+ return
119
+ if isinstance(annotation, type) and issubclass(annotation, Enum):
120
+ for member in annotation:
121
+ if not isinstance(member.value, str | int | float):
122
+ raise TypeError(
123
+ f"{where}: {annotation.__qualname__} members must have str, int or float values"
124
+ )
125
+ return
126
+ if isinstance(annotation, type) and dataclasses.is_dataclass(annotation):
127
+ if annotation in _seen:
128
+ return
129
+ hints = _dataclass_hints(annotation)
130
+ for item in dataclasses.fields(annotation):
131
+ if item.init:
132
+ validate_annotation(hints.get(item.name, Any), f"{where}.{item.name}", _seen | {annotation})
133
+ return
134
+ if isinstance(annotation, type) and annotation in _SCALARS:
135
+ return
136
+ raise TypeError(f"{where}: typed values do not support {_type_name(annotation)}")
137
+
138
+
139
+ def decode_payload_value(value: Any, annotation: Any, path: str = "value") -> Any:
140
+ """Decode an untrusted payload value; every refusal is a TypedValueError."""
141
+ try:
142
+ return from_payload_value(value, annotation, path)
143
+ except RecursionError:
144
+ raise TypedValueError(path, _type_name(annotation), "the value is nested too deeply") from None
145
+
146
+
147
+ def from_payload_value(value: Any, annotation: Any, path: str = "value") -> Any:
148
+ """Return *value* converted to *annotation*, which must be validated."""
149
+ if annotation in _PASS_THROUGH:
150
+ return value
151
+ if annotation is None or annotation is type(None):
152
+ if value is not None:
153
+ raise TypedValueError(path, "None", f"got {_json_kind(value)}")
154
+ return None
155
+ if _is_union(annotation):
156
+ if value is None:
157
+ return None
158
+ return from_payload_value(value, _optional_member(annotation), path)
159
+ if value is None:
160
+ raise TypedValueError(path, _type_name(annotation), "got null")
161
+
162
+ origin = get_origin(annotation)
163
+ if annotation is list or origin is list:
164
+ if not isinstance(value, list):
165
+ raise TypedValueError(path, _type_name(annotation), f"got {_json_kind(value)}")
166
+ (item_type,) = get_args(annotation) or (Any,)
167
+ return [from_payload_value(item, item_type, f"{path}[{index}]") for index, item in enumerate(value)]
168
+
169
+ if annotation is dict or origin is dict:
170
+ if not isinstance(value, dict):
171
+ raise TypedValueError(path, _type_name(annotation), f"got {_json_kind(value)}")
172
+ _, item_type = get_args(annotation) or (str, Any)
173
+ # Map keys are user data (for example email addresses), so a path names
174
+ # an entry by its position, never by its key.
175
+ return {
176
+ key: from_payload_value(item, item_type, f"{path}[entry {index}]")
177
+ for index, (key, item) in enumerate(value.items())
178
+ }
179
+
180
+ if isinstance(annotation, type) and issubclass(annotation, Enum):
181
+ return _enum_from_payload(value, annotation, path)
182
+
183
+ if isinstance(annotation, type) and dataclasses.is_dataclass(annotation):
184
+ return _dataclass_from_payload(value, annotation, path)
185
+
186
+ if annotation is float and isinstance(value, int) and not isinstance(value, bool):
187
+ return float(value)
188
+ if annotation is int and isinstance(value, bool):
189
+ raise TypedValueError(path, "int", "got boolean")
190
+ if isinstance(annotation, type) and annotation in _SCALARS:
191
+ if not isinstance(value, annotation):
192
+ raise TypedValueError(path, annotation.__qualname__, f"got {_json_kind(value)}")
193
+ return value
194
+ raise TypeError(f"{path}: typed values do not support {_type_name(annotation)}")
195
+
196
+
197
+ def _enum_from_payload(value: Any, cls: type[Enum], path: str) -> Enum:
198
+ if isinstance(value, cls):
199
+ return value
200
+ for member in cls:
201
+ expected = member.value
202
+ same_kind = type(expected) is type(value) or (type(expected) is float and type(value) is int)
203
+ if same_kind and expected == value:
204
+ return member
205
+ # The diagnostic must not echo the received value.
206
+ raise TypedValueError(path, cls.__qualname__, f"the {_json_kind(value)} is not a member")
207
+
208
+
209
+ def _dataclass_from_payload(value: Any, cls: type, path: str) -> Any:
210
+ if isinstance(value, cls):
211
+ return value
212
+ if not isinstance(value, dict):
213
+ raise TypedValueError(path, cls.__qualname__, f"got {_json_kind(value)}")
214
+ hints = _dataclass_hints(cls)
215
+ arguments: dict[str, Any] = {}
216
+ for item in dataclasses.fields(cls):
217
+ if not item.init:
218
+ # Computed fields are derived again by the dataclass itself.
219
+ continue
220
+ field_path = f"{path}.{item.name}"
221
+ annotation = hints.get(item.name, Any)
222
+ if item.name in value:
223
+ arguments[item.name] = from_payload_value(value[item.name], annotation, field_path)
224
+ elif item.default is dataclasses.MISSING and item.default_factory is dataclasses.MISSING:
225
+ raise TypedValueError(field_path, _type_name(annotation), "the field is missing")
226
+ # Keys the receiver does not declare are ignored for rolling deploys.
227
+ try:
228
+ return cls(**arguments)
229
+ except ValueError:
230
+ # A __post_init__ check refused the decoded fields. Its message can
231
+ # carry the values, so it is not repeated here.
232
+ raise TypedValueError(path, cls.__qualname__, "the dataclass refused the decoded fields") from None
233
+
234
+
235
+ def to_payload_value(value: Any) -> Any:
236
+ """Return the neutral value for dataclass and ``Enum`` values, recursively.
237
+
238
+ Every other value is returned unchanged, so the codec keeps refusing what
239
+ it refuses today (for example datetimes and tuples). The result encodes
240
+ to the same bytes as ``dataclasses.asdict()`` with each ``Enum`` replaced
241
+ by its ``.value``.
242
+ """
243
+ if isinstance(value, Enum):
244
+ return to_payload_value(value.value)
245
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
246
+ return {item.name: to_payload_value(getattr(value, item.name)) for item in dataclasses.fields(value)}
247
+ if isinstance(value, list):
248
+ return [to_payload_value(item) for item in value]
249
+ if isinstance(value, dict):
250
+ return {key: to_payload_value(item) for key, item in value.items()}
251
+ return value
252
+
253
+
254
+ def to_payload_values(values: list[Any]) -> list[Any]:
255
+ return [to_payload_value(value) for value in values]
256
+
257
+
258
+ def typed_callable(fn: Callable[..., Any]) -> Callable[..., Any]:
259
+ """Wrap *fn* so positional arguments decode to its annotations.
260
+
261
+ Annotations are validated here, so an unsupported type fails when the
262
+ activity is declared. The wrapper keeps *fn*'s module and qualified name:
263
+ the decorator binds that name to the wrapper, so supervised callbacks that
264
+ pickle by reference resolve to it.
265
+ """
266
+ signature = inspect.signature(fn)
267
+ try:
268
+ hints = get_type_hints(fn)
269
+ except (NameError, TypeError) as error:
270
+ raise TypeError(f"typed activity {fn.__qualname__} has unresolvable annotations: {error}") from None
271
+ positional: list[tuple[str, Any]] = []
272
+ variadic: tuple[str, Any] | None = None
273
+ for parameter in signature.parameters.values():
274
+ annotation = hints.get(parameter.name, Any)
275
+ if parameter.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD):
276
+ validate_annotation(annotation, parameter.name)
277
+ positional.append((parameter.name, annotation))
278
+ elif parameter.kind is inspect.Parameter.VAR_POSITIONAL:
279
+ validate_annotation(annotation, parameter.name)
280
+ variadic = (parameter.name, annotation)
281
+
282
+ def convert(args: tuple[Any, ...]) -> list[Any]:
283
+ converted: list[Any] = []
284
+ for index, value in enumerate(args):
285
+ if index < len(positional):
286
+ name, annotation = positional[index]
287
+ converted.append(decode_payload_value(value, annotation, name))
288
+ elif variadic is not None:
289
+ name, annotation = variadic
290
+ converted.append(decode_payload_value(value, annotation, f"{name}[{index - len(positional)}]"))
291
+ else:
292
+ converted.append(value)
293
+ return converted
294
+
295
+ wrapper: Any
296
+ if inspect.iscoroutinefunction(fn):
297
+
298
+ @functools.wraps(fn)
299
+ async def typed_async(*args: Any, **kwargs: Any) -> Any:
300
+ return to_payload_value(await fn(*convert(args), **kwargs))
301
+
302
+ wrapper = typed_async
303
+ else:
304
+
305
+ @functools.wraps(fn)
306
+ def typed_sync(*args: Any, **kwargs: Any) -> Any:
307
+ return to_payload_value(fn(*convert(args), **kwargs))
308
+
309
+ wrapper = typed_sync
310
+ wrapper.__typed_activity__ = fn
311
+ return cast(Callable[..., Any], wrapper)
@@ -206,10 +206,25 @@ def _set_context(ctx: ActivityContext | None) -> contextvars.Token[ActivityConte
206
206
  return _current_context.set(ctx)
207
207
 
208
208
 
209
- def defn(*, name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
210
- """Register a callable as an activity type under a language-neutral name."""
209
+ def defn(*, name: str, typed: bool = False) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
210
+ """Register a callable as an activity type under a language-neutral name.
211
+
212
+ With ``typed=True``, positional arguments are decoded to the callable's
213
+ parameter annotations: dataclasses, ``Enum`` (including ``IntEnum`` and
214
+ ``StrEnum``), ``Optional[T]``, ``list[T]``, ``dict[str, T]`` and the
215
+ scalars ``str``, ``int``, ``float``, ``bool`` and ``bytes``. Keys a
216
+ dataclass does not declare are ignored and missing fields take their
217
+ defaults. A value that does not match fails the activity without retries
218
+ with :class:`~durable_workflow.errors.TypedValueError`. An annotation
219
+ outside this list raises ``TypeError`` here, when the activity is
220
+ declared. Untyped activities keep receiving plain values.
221
+ """
211
222
 
212
223
  def wrap(fn: Callable[..., Any]) -> Callable[..., Any]:
224
+ if typed:
225
+ from ._typed_values import typed_callable
226
+
227
+ fn = typed_callable(fn)
213
228
  fn.__activity_name__ = name # type: ignore[attr-defined]
214
229
  _REGISTRY[name] = fn
215
230
  return fn
@@ -881,6 +881,26 @@ class NonRetryableError(DurableWorkflowError):
881
881
  self.__cause__ = cause
882
882
 
883
883
 
884
+ class TypedValueError(NonRetryableError, TypeError):
885
+ """A payload value does not match the receiver's declared type.
886
+
887
+ Raised by opt-in typed values: a ``typed=True`` activity's arguments, or an
888
+ activity result decoded to ``result_type``. ``path`` names the field (for
889
+ example ``order.items[2].sku``) and ``expected`` the declared type. The
890
+ message never contains the received value. An activity whose arguments do
891
+ not match fails without retries, because a retry cannot change the payload.
892
+ """
893
+
894
+ def __init__(self, path: str, expected: str, detail: str) -> None:
895
+ self.path = path
896
+ self.expected = expected
897
+ self.detail = detail
898
+ super().__init__(f"{path}: expected {expected}; {detail}")
899
+
900
+ def __reduce__(self) -> tuple[Any, ...]:
901
+ return (type(self), (self.path, self.expected, self.detail))
902
+
903
+
884
904
  class AvroNotInstalledError(DurableWorkflowError, ImportError):
885
905
  """Raised when the core ``avro`` runtime dependency is unavailable."""
886
906
 
@@ -400,8 +400,19 @@ class WorkflowEnvironment:
400
400
  }
401
401
  )
402
402
  elif isinstance(cmd, UpsertSearchAttributes):
403
+ # Record the JSON wire form, as the Server does; replay
404
+ # checks the workflow's update against it.
403
405
  history.append(
404
- {"event_type": "SearchAttributesUpserted", "payload": {}}
406
+ {
407
+ "event_type": "SearchAttributesUpserted",
408
+ "payload": {
409
+ # Every recorded event but a signal is a step.
410
+ "sequence": 1 + sum(
411
+ event["event_type"] != "SignalReceived" for event in history
412
+ ),
413
+ "attributes": json.loads(json.dumps(cmd.attributes)),
414
+ },
415
+ }
405
416
  )
406
417
  elif isinstance(cmd, RecordVersionMarker):
407
418
  history.append(
@@ -49,6 +49,7 @@ from ._failure_reporting import REQUEST_BYTES, exception_message, normalize_loca
49
49
  from ._prepared_local_activity import PreparedAttempt, PreparedCancellationObserved, PreparedLocalRunner
50
50
  from ._query_task_authority import QueryTaskAuthority, current_query_authority
51
51
  from ._sticky_workflow_cache import CacheKey, StickyWorkflowCache, complete_history
52
+ from ._typed_values import to_payload_value
52
53
  from .activity import ActivityContext, ActivityInfo, _set_context
53
54
  from .auth_composition import (
54
55
  AUTH_COMPOSITION_CONTRACT_SCHEMA,
@@ -2505,6 +2506,9 @@ class Worker:
2505
2506
  "duration_ms": max(0, round((time.monotonic() - attempt_started_at) * 1000)),
2506
2507
  "heartbeats": heartbeats,
2507
2508
  })
2509
+ # The workflow receives the same neutral value on this first
2510
+ # run as on replay of the recorded result.
2511
+ result = to_payload_value(result)
2508
2512
  try:
2509
2513
  inline_result = serializer.envelope(result, codec=payload_codec)
2510
2514
  except Exception:
@@ -3270,6 +3274,7 @@ class Worker:
3270
3274
  finally:
3271
3275
  _set_context(None)
3272
3276
 
3277
+ result = to_payload_value(result)
3273
3278
  log.info("completing activity task %s (%s) -> %r", task_id, activity_type, result)
3274
3279
  try:
3275
3280
  await self.client.complete_activity_task(