durable-workflow 2.13.2__tar.gz → 2.14.1__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 (180) hide show
  1. {durable_workflow-2.13.2/src/durable_workflow.egg-info → durable_workflow-2.14.1}/PKG-INFO +1 -1
  2. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/pyproject.toml +3 -3
  3. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/__init__.py +2 -0
  4. durable_workflow-2.14.1/src/durable_workflow/_condition_fingerprint.py +229 -0
  5. durable_workflow-2.14.1/src/durable_workflow/_typed_values.py +311 -0
  6. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/activity.py +17 -2
  7. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/errors.py +20 -0
  8. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/worker.py +5 -0
  9. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/workflow.py +206 -59
  10. {durable_workflow-2.13.2 → durable_workflow-2.14.1/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  11. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow.egg-info/SOURCES.txt +4 -0
  12. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_client_timeouts.py +5 -0
  13. durable_workflow-2.14.1/tests/test_condition_fingerprint.py +282 -0
  14. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_replay_regression_corpus.py +12 -1
  15. durable_workflow-2.14.1/tests/test_typed_values.py +464 -0
  16. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_wait_condition.py +277 -23
  17. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_worker.py +14 -5
  18. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/LICENSE +0 -0
  19. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/README.md +0 -0
  20. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/setup.cfg +0 -0
  21. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_activity_lease.py +0 -0
  22. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_activity_process.py +0 -0
  23. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_avro.py +0 -0
  24. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_cancellation_scope.py +0 -0
  25. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  26. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  27. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_failure_reporting.py +0 -0
  28. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_metric_routes.py +0 -0
  29. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_poll_transport.py +0 -0
  30. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_prepared_local_activity.py +0 -0
  31. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_query_task_authority.py +0 -0
  32. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  33. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/auth_composition.py +0 -0
  34. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/cancellation.py +0 -0
  35. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/client.py +0 -0
  36. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/external_storage.py +0 -0
  37. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/external_task_input.py +0 -0
  38. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/external_task_result.py +0 -0
  39. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/history_bundle_verify.py +0 -0
  40. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/interceptors.py +0 -0
  41. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/invocable.py +0 -0
  42. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/metrics.py +0 -0
  43. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/nexus.py +0 -0
  44. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/py.typed +0 -0
  45. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/python_conformance.py +0 -0
  46. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/replay_conformance.py +0 -0
  47. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/replay_verify.py +0 -0
  48. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/retry_policy.py +0 -0
  49. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/serializer.py +0 -0
  50. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/server_compatibility.json +0 -0
  51. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/sync.py +0 -0
  52. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/telemetry.py +0 -0
  53. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/telemetry_carrier.py +0 -0
  54. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/testing.py +0 -0
  55. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/worker_session.py +0 -0
  56. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/worker_status.py +0 -0
  57. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  58. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  59. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  60. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow.egg-info/requires.txt +0 -0
  61. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow.egg-info/top_level.txt +0 -0
  62. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_activity_cancellation_policies.py +0 -0
  63. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_activity_context.py +0 -0
  64. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_activity_heartbeat_authority.py +0 -0
  65. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_activity_lease_authority.py +0 -0
  66. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_activity_lease_renewal.py +0 -0
  67. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_activity_lease_worker.py +0 -0
  68. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_activity_process.py +0 -0
  69. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_activity_retry_info.py +0 -0
  70. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_auth_composition.py +0 -0
  71. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_avro_value_benchmark.py +0 -0
  72. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_avro_value_protocol.py +0 -0
  73. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_cancellation_context.py +0 -0
  74. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_cancellation_remaining_time.py +0 -0
  75. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_cancellation_scope_admission.py +0 -0
  76. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_cancellation_scope_authoring.py +0 -0
  77. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_cancellation_scope_boundary.py +0 -0
  78. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_cancellation_scope_opening.py +0 -0
  79. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_child_workflow_policies.py +0 -0
  80. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_ci_checkout.py +0 -0
  81. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_ci_integration_endpoint.py +0 -0
  82. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_client.py +0 -0
  83. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_client_telemetry.py +0 -0
  84. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_codec_regression_corpus.py +0 -0
  85. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  86. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_committed_cancellation_scope_groups.py +0 -0
  87. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_committed_cancellation_scope_history.py +0 -0
  88. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_committed_cancellation_scope_replay.py +0 -0
  89. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_condition_replay_drift.py +0 -0
  90. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_control_plane_parity_fixtures.py +0 -0
  91. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_cooperative_cancellation.py +0 -0
  92. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_cooperative_cancellation_client.py +0 -0
  93. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_cooperative_cancellation_worker.py +0 -0
  94. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_cooperative_remote_worker.py +0 -0
  95. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_docstring_cross_references.py +0 -0
  96. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_durable_selection.py +0 -0
  97. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_empty_callback_failure.py +0 -0
  98. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_errors.py +0 -0
  99. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_external_storage.py +0 -0
  100. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_external_task_input.py +0 -0
  101. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_external_task_result.py +0 -0
  102. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_failure_reporting.py +0 -0
  103. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_generator_lifecycle.py +0 -0
  104. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_golden_history_replay.py +0 -0
  105. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_history_bundle_pruned.py +0 -0
  106. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_history_bundle_schema2.py +0 -0
  107. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_history_bundle_verify.py +0 -0
  108. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_history_event_contract.py +0 -0
  109. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_invocable.py +0 -0
  110. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_local_failure_reporting.py +0 -0
  111. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_message_streams.py +0 -0
  112. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_metric_routes.py +0 -0
  113. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_metrics.py +0 -0
  114. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_nexus_durable_wait.py +0 -0
  115. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_nexus_namespace.py +0 -0
  116. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_nexus_wire.py +0 -0
  117. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_order_processing_example.py +0 -0
  118. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_parallel_completion_order_replay.py +0 -0
  119. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_parallel_saga_authoring.py +0 -0
  120. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_patch_history.py +0 -0
  121. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_pending_operation_replay.py +0 -0
  122. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_poll_failure_backoff.py +0 -0
  123. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_poll_transport.py +0 -0
  124. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_prepared_local_activity_client.py +0 -0
  125. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_prepared_local_activity_groups.py +0 -0
  126. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_prepared_local_activity_policies.py +0 -0
  127. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_prepared_local_activity_worker.py +0 -0
  128. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_public_boundary_scanner.py +0 -0
  129. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_publish_workflow_isolation.py +0 -0
  130. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_pypi_project_surface.py +0 -0
  131. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_python_conformance.py +0 -0
  132. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_queries.py +0 -0
  133. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_query_task_authority.py +0 -0
  134. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_query_timeout_retry.py +0 -0
  135. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_readme_quickstart.py +0 -0
  136. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_redrive_replay.py +0 -0
  137. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_redrive_successor_evidence.py +0 -0
  138. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_registration_ambiguity.py +0 -0
  139. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_release_metadata.py +0 -0
  140. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_replay.py +0 -0
  141. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_replay_clock.py +0 -0
  142. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_replay_conformance.py +0 -0
  143. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_replay_verify.py +0 -0
  144. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_retry_policy.py +0 -0
  145. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_run_cancellation_scope_replay.py +0 -0
  146. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_runtime_external_payload_transport.py +0 -0
  147. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_schedules.py +0 -0
  148. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_scoped_run_cancellation_context.py +0 -0
  149. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_sdk_metrics_contract.py +0 -0
  150. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_search_attribute_replay_identity.py +0 -0
  151. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_serializer.py +0 -0
  152. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_signals.py +0 -0
  153. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_sleep.py +0 -0
  154. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_standalone_activity_client.py +0 -0
  155. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_sticky_execution.py +0 -0
  156. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_storage_admission.py +0 -0
  157. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_sync.py +0 -0
  158. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_sync_activities.py +0 -0
  159. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_telemetry.py +0 -0
  160. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_telemetry_carrier.py +0 -0
  161. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_testing_failures.py +0 -0
  162. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_testing_harness.py +0 -0
  163. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_testing_nexus.py +0 -0
  164. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_threaded_heartbeat_example.py +0 -0
  165. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_tls_trust.py +0 -0
  166. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_transport_diagnostics.py +0 -0
  167. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_update_signal_condition_replay.py +0 -0
  168. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_updates.py +0 -0
  169. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_worker_deregistration.py +0 -0
  170. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_worker_observation.py +0 -0
  171. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_worker_session.py +0 -0
  172. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_worker_status.py +0 -0
  173. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_worker_telemetry_wire.py +0 -0
  174. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_workflow_entry_arguments.py +0 -0
  175. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_workflow_entrypoint_validation.py +0 -0
  176. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_workflow_handler_validation.py +0 -0
  177. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_workflow_identity.py +0 -0
  178. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_workflow_result_timeout.py +0 -0
  179. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_workflow_streams.py +0 -0
  180. {durable_workflow-2.13.2 → durable_workflow-2.14.1}/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.2
3
+ Version: 2.14.1
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.2"
7
+ version = "2.14.1"
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.2"
83
- registry-version = "2.13.2"
82
+ product-train = "2.14.1"
83
+ registry-version = "2.14.1"
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",
@@ -0,0 +1,229 @@
1
+ """Stable identity for ``wait_condition`` predicates (#258).
2
+
3
+ The Server records the fingerprint a worker sends with ``open_condition_wait``
4
+ and replay compares it, so the value must be the same on every Python version
5
+ and in every process. Version 2 hashes the predicate's module, qualified name
6
+ and a canonical form of its source AST. It never hashes bytecode, constant
7
+ reprs, memory addresses, file paths or line numbers.
8
+
9
+ Version 1 (every release through 2.14.0) hashed bytecode and ``repr`` of code
10
+ constants, which change across Python versions and, for nested functions,
11
+ across processes. Recorded v1 values cannot be recomputed portably, so replay
12
+ treats a v1 value that differs from this process's v1 as unverifiable rather
13
+ than as a changed predicate (see :func:`fingerprint_verdict`).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import ast
19
+ import hashlib
20
+ import inspect
21
+ import linecache
22
+ import re
23
+ from collections.abc import Callable
24
+ from typing import Any, Literal
25
+
26
+ V2_PREFIX = "dwpy2:sha256:"
27
+ _LEGACY = re.compile(r"sha256:[0-9a-f]{64}\Z")
28
+ # Fields that carry no predicate meaning or differ between Python versions.
29
+ _SKIPPED_FIELDS = frozenset({"ctx", "kind", "type_comment", "type_params", "decorator_list", "returns"})
30
+ _parsed: dict[tuple[str, int], ast.Module | None] = {}
31
+ _nodes: dict[Any, str | None] = {}
32
+ # Python 3.12 inlines list, dict and set comprehensions (PEP 709), which drops
33
+ # their segment from a nested function's __qualname__.
34
+ _INLINED_COMPREHENSION = re.compile(r"\.<(?:listcomp|dictcomp|setcomp)>")
35
+
36
+ Verdict = Literal["match", "mismatch", "unverifiable"]
37
+
38
+
39
+ def legacy_fingerprint(predicate: Callable[[], bool]) -> str:
40
+ """The v1 fingerprint, kept only to recognise histories recorded by 2.14.0 and earlier."""
41
+ h = hashlib.sha256()
42
+ h.update(b"durable-workflow-python.wait-condition.v1\0")
43
+ h.update(f"{getattr(predicate, '__module__', '')}\0".encode())
44
+ h.update(f"{getattr(predicate, '__qualname__', '')}\0".encode())
45
+ original = getattr(predicate, "__typed_workflow_method__", None)
46
+ code = getattr(original if original is not None else predicate, "__code__", None)
47
+ if code is None:
48
+ h.update(repr(predicate).encode())
49
+ else:
50
+ h.update(repr((
51
+ code.co_argcount, code.co_posonlyargcount, code.co_kwonlyargcount, code.co_code,
52
+ code.co_consts, code.co_names, code.co_varnames, code.co_freevars,
53
+ )).encode())
54
+ return f"sha256:{h.hexdigest()}"
55
+
56
+
57
+ def is_legacy(value: Any) -> bool:
58
+ return isinstance(value, str) and _LEGACY.match(value) is not None
59
+
60
+
61
+ def fingerprint(predicate: Callable[[], bool]) -> str | None:
62
+ """Return the v2 fingerprint, or None when the predicate's source is unavailable.
63
+
64
+ Without a fingerprint the wait is identified by its key and call order,
65
+ as in sdk-php.
66
+ """
67
+ function = _authored_function(predicate)
68
+ if function is None:
69
+ return None
70
+ code = function.__code__
71
+ if code not in _nodes:
72
+ _nodes[code] = _predicate_node(function)
73
+ node = _nodes[code]
74
+ if node is None:
75
+ return None
76
+ qualname = _INLINED_COMPREHENSION.sub("", getattr(function, "__qualname__", ""))
77
+ h = hashlib.sha256()
78
+ h.update(b"durable-workflow-python.wait-condition.v2\0")
79
+ h.update(f"{getattr(function, '__module__', '')}\0".encode())
80
+ h.update(f"{qualname}\0".encode())
81
+ h.update(node.encode())
82
+ return f"{V2_PREFIX}{h.hexdigest()}"
83
+
84
+
85
+ def fingerprint_verdict(recorded: Any, authored: str | None, predicate: Callable[[], bool]) -> Verdict:
86
+ """Compare a recorded fingerprint with the authored predicate.
87
+
88
+ - No recorded value: nothing to compare.
89
+ - An identity supplied explicitly in another format (not v2, not None)
90
+ is compared exactly, as before.
91
+ - A v2 value is compared exactly; without source it cannot be checked.
92
+ - A v1 value equal to this process's v1 matches. Any other v1 value is
93
+ unverifiable, because v1 differs across Python versions and processes.
94
+ - Any other string is compared exactly, as before.
95
+ """
96
+ if not isinstance(recorded, str):
97
+ return "match"
98
+ if authored is not None and not authored.startswith(V2_PREFIX):
99
+ return "match" if recorded == authored else "mismatch"
100
+ if recorded.startswith(V2_PREFIX):
101
+ if authored is None:
102
+ return "unverifiable"
103
+ return "match" if recorded == authored else "mismatch"
104
+ if is_legacy(recorded):
105
+ return "match" if recorded == legacy_fingerprint(predicate) else "unverifiable"
106
+ return "match" if recorded == authored else "mismatch"
107
+
108
+
109
+ def _authored_function(predicate: Any) -> Any:
110
+ target = getattr(predicate, "__typed_workflow_method__", None) or predicate
111
+ if inspect.ismethod(target):
112
+ target = target.__func__
113
+ target = getattr(target, "__typed_workflow_method__", None) or target
114
+ return target if inspect.isfunction(target) else None
115
+
116
+
117
+ def _module_tree(function: Any) -> ast.Module | None:
118
+ try:
119
+ filename = inspect.getsourcefile(function) or inspect.getfile(function)
120
+ except (OSError, TypeError):
121
+ return None
122
+ lines = linecache.getlines(filename, function.__globals__)
123
+ if not lines:
124
+ return None
125
+ source = "".join(lines)
126
+ key = (filename, hash(source))
127
+ if key not in _parsed:
128
+ try:
129
+ _parsed[key] = ast.parse(source)
130
+ except (SyntaxError, ValueError):
131
+ _parsed[key] = None
132
+ return _parsed[key]
133
+
134
+
135
+ def _predicate_node(function: Any) -> str | None:
136
+ tree = _module_tree(function)
137
+ if tree is None:
138
+ return None
139
+ code = function.__code__
140
+ first_line = code.co_firstlineno
141
+ if function.__name__ == "<lambda>":
142
+ lambdas = [node for node in ast.walk(tree) if isinstance(node, ast.Lambda) and node.lineno == first_line]
143
+ lambdas.sort(key=lambda node: node.col_offset)
144
+ # PEP 709 moves comprehension instructions into the enclosing lambda.
145
+ # Candidates indistinguishable before 3.12 can become distinct after
146
+ # inlining, so keep the combined AST on every version in this case.
147
+ has_inlined_comprehension = any(
148
+ isinstance(child, ast.ListComp | ast.DictComp | ast.SetComp)
149
+ for node in lambdas for child in ast.walk(node)
150
+ )
151
+ if len(lambdas) > 1 and not has_inlined_comprehension:
152
+ # Names and constants are stable across versions; bytecode only
153
+ # breaks a tie, and is compared within this interpreter.
154
+ matching = [node for node in lambdas if _compiles_to(node, code, compare_bytecode=False)]
155
+ if len(matching) > 1:
156
+ matching = [node for node in matching if _compiles_to(node, code, compare_bytecode=True)]
157
+ if len(matching) == 1:
158
+ lambdas = matching
159
+ if not lambdas:
160
+ return None
161
+ # Lambdas that start on the same line and compile identically share
162
+ # one fingerprint covering all of them.
163
+ return "|".join(_canonical(node) for node in lambdas)
164
+ definitions = [
165
+ node for node in ast.walk(tree)
166
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and node.name == function.__name__
167
+ and min([node.lineno, *(decorator.lineno for decorator in node.decorator_list)]) == first_line
168
+ ]
169
+ if len(definitions) != 1:
170
+ return None
171
+ return _canonical(definitions[0])
172
+
173
+
174
+ def _compiles_to(node: ast.Lambda, code: Any, *, compare_bytecode: bool) -> bool:
175
+ """Whether *node* compiles to *code* on this interpreter.
176
+
177
+ The candidate is compiled inside a function that binds the live code's
178
+ free variables, so names resolve the same way. Only the choice of node
179
+ uses bytecode, which is valid within one interpreter; the fingerprint
180
+ hashes the node's AST, so it stays portable.
181
+ """
182
+ free = list(code.co_freevars)
183
+ lines = ["def __dw_outer__():"]
184
+ if free:
185
+ lines.append(f" {' = '.join(free)} = None")
186
+ lines.append(f" return ({ast.unparse(node)})")
187
+ try:
188
+ outer = compile("\n".join(lines), "<condition-fingerprint>", "exec")
189
+ except SyntaxError:
190
+ return False
191
+ function = next((const for const in outer.co_consts if hasattr(const, "co_code")), None)
192
+ candidate = next(
193
+ (const for const in getattr(function, "co_consts", ()) if getattr(const, "co_name", None) == "<lambda>"),
194
+ None,
195
+ )
196
+ return candidate is not None and (
197
+ (not compare_bytecode or candidate.co_code == code.co_code)
198
+ and candidate.co_argcount == code.co_argcount
199
+ and candidate.co_names == code.co_names
200
+ and candidate.co_varnames == code.co_varnames
201
+ and candidate.co_freevars == code.co_freevars
202
+ and _plain_constants(candidate) == _plain_constants(code)
203
+ )
204
+
205
+
206
+ def _plain_constants(code: Any) -> list[Any]:
207
+ """Constants other than nested code and the qualnames Python 3.10 stores for them."""
208
+ return [
209
+ const for const in code.co_consts
210
+ if not hasattr(const, "co_code") and not (isinstance(const, str) and "<locals>" in const)
211
+ ]
212
+
213
+
214
+ def _canonical(value: Any) -> str:
215
+ if isinstance(value, ast.AST):
216
+ parts = [type(value).__name__]
217
+ for name in value._fields:
218
+ if name in _SKIPPED_FIELDS:
219
+ continue
220
+ item = getattr(value, name, None)
221
+ if item is None or item == []:
222
+ continue
223
+ parts.append(f"{name}={_canonical(item)}")
224
+ return "(" + " ".join(parts) + ")"
225
+ if isinstance(value, list):
226
+ return "[" + ",".join(_canonical(item) for item in value) + "]"
227
+ if value is None or value is Ellipsis or isinstance(value, str | bytes | int | float | complex | bool):
228
+ return f"{type(value).__name__}:{value!r}"
229
+ return type(value).__name__
@@ -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
 
@@ -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(