durable-workflow 2.14.0__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.14.0/src/durable_workflow.egg-info → durable_workflow-2.14.1}/PKG-INFO +1 -1
  2. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/pyproject.toml +3 -3
  3. durable_workflow-2.14.1/src/durable_workflow/_condition_fingerprint.py +229 -0
  4. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/workflow.py +80 -40
  5. {durable_workflow-2.14.0 → durable_workflow-2.14.1/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  6. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow.egg-info/SOURCES.txt +2 -0
  7. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_client_timeouts.py +5 -0
  8. durable_workflow-2.14.1/tests/test_condition_fingerprint.py +282 -0
  9. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_replay_regression_corpus.py +4 -0
  10. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_wait_condition.py +2 -1
  11. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_worker.py +14 -5
  12. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/LICENSE +0 -0
  13. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/README.md +0 -0
  14. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/setup.cfg +0 -0
  15. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/__init__.py +0 -0
  16. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/_activity_lease.py +0 -0
  17. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/_activity_process.py +0 -0
  18. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/_avro.py +0 -0
  19. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/_cancellation_scope.py +0 -0
  20. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  21. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  22. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/_failure_reporting.py +0 -0
  23. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/_metric_routes.py +0 -0
  24. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/_poll_transport.py +0 -0
  25. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/_prepared_local_activity.py +0 -0
  26. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/_query_task_authority.py +0 -0
  27. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  28. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/_typed_values.py +0 -0
  29. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/activity.py +0 -0
  30. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/auth_composition.py +0 -0
  31. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/cancellation.py +0 -0
  32. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/client.py +0 -0
  33. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/errors.py +0 -0
  34. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/external_storage.py +0 -0
  35. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/external_task_input.py +0 -0
  36. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/external_task_result.py +0 -0
  37. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/history_bundle_verify.py +0 -0
  38. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/interceptors.py +0 -0
  39. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/invocable.py +0 -0
  40. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/metrics.py +0 -0
  41. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/nexus.py +0 -0
  42. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/py.typed +0 -0
  43. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/python_conformance.py +0 -0
  44. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/replay_conformance.py +0 -0
  45. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/replay_verify.py +0 -0
  46. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/retry_policy.py +0 -0
  47. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/serializer.py +0 -0
  48. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/server_compatibility.json +0 -0
  49. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/sync.py +0 -0
  50. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/telemetry.py +0 -0
  51. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/telemetry_carrier.py +0 -0
  52. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/testing.py +0 -0
  53. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/worker.py +0 -0
  54. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/worker_session.py +0 -0
  55. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/worker_status.py +0 -0
  56. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  57. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  58. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  59. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow.egg-info/requires.txt +0 -0
  60. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/src/durable_workflow.egg-info/top_level.txt +0 -0
  61. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_activity_cancellation_policies.py +0 -0
  62. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_activity_context.py +0 -0
  63. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_activity_heartbeat_authority.py +0 -0
  64. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_activity_lease_authority.py +0 -0
  65. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_activity_lease_renewal.py +0 -0
  66. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_activity_lease_worker.py +0 -0
  67. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_activity_process.py +0 -0
  68. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_activity_retry_info.py +0 -0
  69. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_auth_composition.py +0 -0
  70. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_avro_value_benchmark.py +0 -0
  71. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_avro_value_protocol.py +0 -0
  72. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_cancellation_context.py +0 -0
  73. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_cancellation_remaining_time.py +0 -0
  74. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_cancellation_scope_admission.py +0 -0
  75. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_cancellation_scope_authoring.py +0 -0
  76. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_cancellation_scope_boundary.py +0 -0
  77. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_cancellation_scope_opening.py +0 -0
  78. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_child_workflow_policies.py +0 -0
  79. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_ci_checkout.py +0 -0
  80. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_ci_integration_endpoint.py +0 -0
  81. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_client.py +0 -0
  82. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_client_telemetry.py +0 -0
  83. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_codec_regression_corpus.py +0 -0
  84. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  85. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_committed_cancellation_scope_groups.py +0 -0
  86. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_committed_cancellation_scope_history.py +0 -0
  87. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_committed_cancellation_scope_replay.py +0 -0
  88. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_condition_replay_drift.py +0 -0
  89. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_control_plane_parity_fixtures.py +0 -0
  90. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_cooperative_cancellation.py +0 -0
  91. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_cooperative_cancellation_client.py +0 -0
  92. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_cooperative_cancellation_worker.py +0 -0
  93. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_cooperative_remote_worker.py +0 -0
  94. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_docstring_cross_references.py +0 -0
  95. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_durable_selection.py +0 -0
  96. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_empty_callback_failure.py +0 -0
  97. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_errors.py +0 -0
  98. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_external_storage.py +0 -0
  99. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_external_task_input.py +0 -0
  100. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_external_task_result.py +0 -0
  101. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_failure_reporting.py +0 -0
  102. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_generator_lifecycle.py +0 -0
  103. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_golden_history_replay.py +0 -0
  104. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_history_bundle_pruned.py +0 -0
  105. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_history_bundle_schema2.py +0 -0
  106. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_history_bundle_verify.py +0 -0
  107. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_history_event_contract.py +0 -0
  108. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_invocable.py +0 -0
  109. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_local_failure_reporting.py +0 -0
  110. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_message_streams.py +0 -0
  111. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_metric_routes.py +0 -0
  112. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_metrics.py +0 -0
  113. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_nexus_durable_wait.py +0 -0
  114. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_nexus_namespace.py +0 -0
  115. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_nexus_wire.py +0 -0
  116. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_order_processing_example.py +0 -0
  117. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_parallel_completion_order_replay.py +0 -0
  118. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_parallel_saga_authoring.py +0 -0
  119. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_patch_history.py +0 -0
  120. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_pending_operation_replay.py +0 -0
  121. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_poll_failure_backoff.py +0 -0
  122. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_poll_transport.py +0 -0
  123. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_prepared_local_activity_client.py +0 -0
  124. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_prepared_local_activity_groups.py +0 -0
  125. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_prepared_local_activity_policies.py +0 -0
  126. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_prepared_local_activity_worker.py +0 -0
  127. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_public_boundary_scanner.py +0 -0
  128. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_publish_workflow_isolation.py +0 -0
  129. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_pypi_project_surface.py +0 -0
  130. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_python_conformance.py +0 -0
  131. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_queries.py +0 -0
  132. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_query_task_authority.py +0 -0
  133. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_query_timeout_retry.py +0 -0
  134. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_readme_quickstart.py +0 -0
  135. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_redrive_replay.py +0 -0
  136. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_redrive_successor_evidence.py +0 -0
  137. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_registration_ambiguity.py +0 -0
  138. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_release_metadata.py +0 -0
  139. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_replay.py +0 -0
  140. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_replay_clock.py +0 -0
  141. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_replay_conformance.py +0 -0
  142. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_replay_verify.py +0 -0
  143. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_retry_policy.py +0 -0
  144. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_run_cancellation_scope_replay.py +0 -0
  145. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_runtime_external_payload_transport.py +0 -0
  146. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_schedules.py +0 -0
  147. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_scoped_run_cancellation_context.py +0 -0
  148. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_sdk_metrics_contract.py +0 -0
  149. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_search_attribute_replay_identity.py +0 -0
  150. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_serializer.py +0 -0
  151. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_signals.py +0 -0
  152. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_sleep.py +0 -0
  153. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_standalone_activity_client.py +0 -0
  154. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_sticky_execution.py +0 -0
  155. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_storage_admission.py +0 -0
  156. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_sync.py +0 -0
  157. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_sync_activities.py +0 -0
  158. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_telemetry.py +0 -0
  159. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_telemetry_carrier.py +0 -0
  160. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_testing_failures.py +0 -0
  161. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_testing_harness.py +0 -0
  162. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_testing_nexus.py +0 -0
  163. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_threaded_heartbeat_example.py +0 -0
  164. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_tls_trust.py +0 -0
  165. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_transport_diagnostics.py +0 -0
  166. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_typed_values.py +0 -0
  167. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_update_signal_condition_replay.py +0 -0
  168. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_updates.py +0 -0
  169. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_worker_deregistration.py +0 -0
  170. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_worker_observation.py +0 -0
  171. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_worker_session.py +0 -0
  172. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_worker_status.py +0 -0
  173. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_worker_telemetry_wire.py +0 -0
  174. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_workflow_entry_arguments.py +0 -0
  175. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_workflow_entrypoint_validation.py +0 -0
  176. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_workflow_handler_validation.py +0 -0
  177. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_workflow_identity.py +0 -0
  178. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_workflow_result_timeout.py +0 -0
  179. {durable_workflow-2.14.0 → durable_workflow-2.14.1}/tests/test_workflow_streams.py +0 -0
  180. {durable_workflow-2.14.0 → 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.14.0
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.14.0"
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.14.0"
83
- registry-version = "2.14.0"
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"
@@ -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__
@@ -42,6 +42,8 @@ from ._cancellation_scope_history import (
42
42
  CommittedCancellationScopeHistory,
43
43
  ScopeBoundary,
44
44
  )
45
+ from ._condition_fingerprint import fingerprint as _stable_condition_fingerprint
46
+ from ._condition_fingerprint import fingerprint_verdict, is_legacy
45
47
  from ._cooperative_cancellation import CancellationDelivery, read_cancellation_history
46
48
  from ._failure_reporting import exception_message
47
49
  from ._typed_values import decode_payload_value, to_payload_values, validate_annotation
@@ -1720,28 +1722,9 @@ class CancelDurableOperation:
1720
1722
  }
1721
1723
 
1722
1724
 
1723
- def _condition_predicate_fingerprint(predicate: Callable[[], bool]) -> str:
1724
- h = hashlib.sha256()
1725
- h.update(b"durable-workflow-python.wait-condition.v1\0")
1726
- h.update(f"{getattr(predicate, '__module__', '')}\0".encode())
1727
- h.update(f"{getattr(predicate, '__qualname__', '')}\0".encode())
1728
-
1729
- code = getattr(predicate, "__code__", None)
1730
- if code is None:
1731
- h.update(repr(predicate).encode())
1732
- else:
1733
- h.update(repr((
1734
- code.co_argcount,
1735
- code.co_posonlyargcount,
1736
- code.co_kwonlyargcount,
1737
- code.co_code,
1738
- code.co_consts,
1739
- code.co_names,
1740
- code.co_varnames,
1741
- code.co_freevars,
1742
- )).encode())
1743
-
1744
- return f"sha256:{h.hexdigest()}"
1725
+ def _condition_predicate_fingerprint(predicate: Callable[[], bool]) -> str | None:
1726
+ """The stable v2 predicate fingerprint, or None without source (#258)."""
1727
+ return _stable_condition_fingerprint(predicate)
1745
1728
 
1746
1729
 
1747
1730
  Command = (
@@ -6017,10 +6000,7 @@ def _replay_state(
6017
6000
  )
6018
6001
 
6019
6002
  opened_fingerprint = opened.get("condition_definition_fingerprint")
6020
- if (
6021
- isinstance(opened_fingerprint, str)
6022
- and cmd.condition_definition_fingerprint != opened_fingerprint
6023
- ):
6003
+ if _fingerprint_verdict(opened, cmd) == "mismatch":
6024
6004
  return NonDeterministicReplayError(
6025
6005
  step.workflow_sequence, "condition wait", step.event_types,
6026
6006
  detail=(
@@ -6033,7 +6013,19 @@ def _replay_state(
6033
6013
 
6034
6014
  return None
6035
6015
 
6036
- def _same_logical_condition_wait(opened: Mapping[str, Any], cmd: WaitCondition) -> bool:
6016
+ def _fingerprint_verdict(opened: Mapping[str, Any], cmd: WaitCondition) -> str:
6017
+ return fingerprint_verdict(
6018
+ opened.get("condition_definition_fingerprint"), cmd.condition_definition_fingerprint, cmd.predicate,
6019
+ )
6020
+
6021
+ def _same_logical_condition_wait(
6022
+ opened: Mapping[str, Any],
6023
+ cmd: WaitCondition,
6024
+ *,
6025
+ chain_first: Mapping[str, Any] | None = None,
6026
+ chain_current: Mapping[str, Any] | None = None,
6027
+ next_index: int | None = None,
6028
+ ) -> bool:
6037
6029
  if _condition_wait_mismatch(opened, cmd) is not None:
6038
6030
  return False
6039
6031
 
@@ -6042,10 +6034,33 @@ def _replay_state(
6042
6034
  return True
6043
6035
 
6044
6036
  opened_fingerprint = opened.get("condition_definition_fingerprint")
6037
+ if not isinstance(opened_fingerprint, str) or not opened_fingerprint:
6038
+ # A keyless wait without source has no identity beyond call order.
6039
+ # Within its own chain (both opens recorded without a fingerprint)
6040
+ # it is the same wait, as 2.14.0 linked it by its process-local v1.
6041
+ first_fingerprint = (
6042
+ chain_first.get("condition_definition_fingerprint") if chain_first is not None else "absent"
6043
+ )
6044
+ return (
6045
+ cmd.condition_definition_fingerprint is None and not cmd.condition_key
6046
+ and chain_first is not None and not first_fingerprint
6047
+ )
6048
+ verdict = _fingerprint_verdict(opened, cmd)
6049
+ if verdict == "match":
6050
+ return True
6051
+ if verdict != "unverifiable":
6052
+ return False
6053
+ # A keyless wait whose fingerprint cannot be checked (a v1 value from
6054
+ # another Python version or process, #258) links only within its own
6055
+ # chain: the same recorded v1 value as the chain's first open, or a
6056
+ # duplicate opened while the current wait was still open (#253). It
6057
+ # never binds a recorded wait to another call.
6058
+ first_fingerprint = chain_first.get("condition_definition_fingerprint") if chain_first is not None else None
6059
+ if is_legacy(opened_fingerprint) and opened_fingerprint == first_fingerprint:
6060
+ return True
6045
6061
  return (
6046
- isinstance(opened_fingerprint, str)
6047
- and bool(opened_fingerprint)
6048
- and opened_fingerprint == cmd.condition_definition_fingerprint
6062
+ chain_current is not None and next_index is not None
6063
+ and _opened_before_terminal(next_index, chain_current)
6049
6064
  )
6050
6065
 
6051
6066
  result_cursor = 0
@@ -6111,6 +6126,10 @@ def _replay_state(
6111
6126
  pending: list[Command] = []
6112
6127
  advanced_cmd: Any = None
6113
6128
  terminal_condition_reopen_cmd: WaitCondition | None = None
6129
+ # The first open of the chain a terminal reopen cleanup belongs to (#258).
6130
+ terminal_condition_chain_first: Mapping[str, Any] | None = None
6131
+ # Its terminal position proves whether a different-process duplicate belongs.
6132
+ terminal_condition_chain_current: Mapping[str, Any] | None = None
6114
6133
  version_decisions: dict[str, int] = {}
6115
6134
 
6116
6135
  def _version_error(detail: str, step: _RecordedStep | None = None) -> NonDeterministicReplayError:
@@ -6686,7 +6705,10 @@ def _replay_state(
6686
6705
  return
6687
6706
  while wait_yield_count < len(wait_opened):
6688
6707
  opened = wait_opened[wait_yield_count]
6689
- if not _same_logical_condition_wait(opened, terminal_condition_reopen_cmd):
6708
+ if not _same_logical_condition_wait(
6709
+ opened, terminal_condition_reopen_cmd, chain_first=terminal_condition_chain_first,
6710
+ chain_current=terminal_condition_chain_current, next_index=wait_yield_count,
6711
+ ):
6690
6712
  break
6691
6713
  opened_id = opened.get("condition_wait_id")
6692
6714
  if not isinstance(opened_id, str):
@@ -6928,9 +6950,14 @@ def _replay_state(
6928
6950
  expected = ({"delay_seconds": leaf.delay_seconds} if isinstance(leaf, StartTimer) else {
6929
6951
  "condition_key": leaf.condition_key,
6930
6952
  "timeout_seconds": leaf.timeout_seconds,
6931
- "condition_definition_fingerprint": leaf.condition_definition_fingerprint,
6932
6953
  })
6933
- if any(field in payload and payload[field] != actual for field, actual in expected.items()):
6954
+ fingerprint_changed = isinstance(leaf, WaitCondition) and fingerprint_verdict(
6955
+ payload.get("condition_definition_fingerprint"), leaf.condition_definition_fingerprint,
6956
+ leaf.predicate,
6957
+ ) == "mismatch"
6958
+ if fingerprint_changed or any(
6959
+ field in payload and payload[field] != actual for field, actual in expected.items()
6960
+ ):
6934
6961
  raise NonDeterministicReplayError(
6935
6962
  sequence, "original group member", [event_type],
6936
6963
  detail="scoped group changed its original timer or condition definition",
@@ -7054,7 +7081,7 @@ def _replay_state(
7054
7081
  return terminal is not None and wait_opened_event_indexes[next_index] < terminal
7055
7082
 
7056
7083
  def _reopened_with_remaining_timeout(command: WaitCondition, first_index: int) -> WaitCondition:
7057
- """Reopen a logical wait with the time left before its first deadline.
7084
+ """Reopen a logical wait with its first fingerprint and remaining time.
7058
7085
 
7059
7086
  The deadline is fixed when the wait first opens, as in embedded
7060
7087
  Workflow: the first ConditionWaitOpened time plus the authored
@@ -7063,14 +7090,20 @@ def _replay_state(
7063
7090
  history time, rounded up and clamped to 0..authored (the sdk-php#269
7064
7091
  rule). Without recorded times the authored timeout is kept.
7065
7092
  """
7066
- authored = command.timeout_seconds
7067
- if authored is None or first_index >= len(wait_opened):
7093
+ if first_index >= len(wait_opened):
7068
7094
  return command
7095
+ reopened = copy(command)
7096
+ # A reopen sends the fingerprint its first open recorded, so a wait
7097
+ # first opened under v1 (2.14.0 and earlier) keeps v1 across the
7098
+ # upgrade and links v1 to v1, never v1 to v2 (#258).
7099
+ recorded = wait_opened[first_index].get("condition_definition_fingerprint")
7100
+ if isinstance(recorded, str) and recorded:
7101
+ reopened.condition_definition_fingerprint = recorded
7102
+ authored = command.timeout_seconds
7069
7103
  opened_at = wait_opened_times[first_index]
7070
- if opened_at is None or history_time is None:
7071
- return command
7104
+ if authored is None or opened_at is None or history_time is None:
7105
+ return reopened
7072
7106
  remaining = math.ceil(authored - (history_time - opened_at).total_seconds())
7073
- reopened = copy(command)
7074
7107
  reopened.timeout_seconds = min(authored, max(0, remaining))
7075
7108
  return reopened
7076
7109
 
@@ -7730,7 +7763,10 @@ def _replay_state(
7730
7763
  has_reopened_same_wait = (
7731
7764
  opened is not None
7732
7765
  and next_wait_index < len(wait_opened)
7733
- and _same_logical_condition_wait(wait_opened[next_wait_index], cmd)
7766
+ and _same_logical_condition_wait(
7767
+ wait_opened[next_wait_index], cmd, chain_first=wait_opened[first_open_index],
7768
+ chain_current=opened, next_index=next_wait_index,
7769
+ )
7734
7770
  )
7735
7771
  if resolution == "timed_out":
7736
7772
  if has_reopened_same_wait and _opened_before_terminal(next_wait_index, opened):
@@ -7769,6 +7805,8 @@ def _replay_state(
7769
7805
  if has_reopened_same_wait and consumed_reopen_for_current_wait
7770
7806
  else None
7771
7807
  )
7808
+ terminal_condition_chain_first = wait_opened[first_open_index]
7809
+ terminal_condition_chain_current = opened
7772
7810
  next_value = True
7773
7811
  wait_yield_count += 1
7774
7812
  break
@@ -7778,6 +7816,8 @@ def _replay_state(
7778
7816
  if has_reopened_same_wait and consumed_reopen_for_current_wait
7779
7817
  else None
7780
7818
  )
7819
+ terminal_condition_chain_first = wait_opened[first_open_index]
7820
+ terminal_condition_chain_current = opened
7781
7821
  next_value = True
7782
7822
  wait_yield_count += 1
7783
7823
  break
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.14.0
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
@@ -7,6 +7,7 @@ src/durable_workflow/_activity_process.py
7
7
  src/durable_workflow/_avro.py
8
8
  src/durable_workflow/_cancellation_scope.py
9
9
  src/durable_workflow/_cancellation_scope_history.py
10
+ src/durable_workflow/_condition_fingerprint.py
10
11
  src/durable_workflow/_cooperative_cancellation.py
11
12
  src/durable_workflow/_failure_reporting.py
12
13
  src/durable_workflow/_metric_routes.py
@@ -78,6 +79,7 @@ tests/test_committed_cancellation_scope_descendants.py
78
79
  tests/test_committed_cancellation_scope_groups.py
79
80
  tests/test_committed_cancellation_scope_history.py
80
81
  tests/test_committed_cancellation_scope_replay.py
82
+ tests/test_condition_fingerprint.py
81
83
  tests/test_condition_replay_drift.py
82
84
  tests/test_control_plane_parity_fixtures.py
83
85
  tests/test_cooperative_cancellation.py
@@ -60,10 +60,15 @@ async def response_server(*, delay: float | None) -> AsyncIterator[str]:
60
60
  @pytest.mark.asyncio
61
61
  @pytest.mark.parametrize("operation", ["control", "discovery", "worker"])
62
62
  async def test_configured_timeout_bounds_a_stalled_response(operation: str) -> None:
63
+ async def allow_connection_setup(request: httpx.Request) -> None:
64
+ # Exercise the configured read budget even when cold TCP setup is slow.
65
+ request.extensions["timeout"]["connect"] = 1.0
66
+
63
67
  async with (
64
68
  response_server(delay=None) as endpoint,
65
69
  Client(endpoint, timeout=0.05, retry_policy=TransportRetryPolicy(max_attempts=1)) as client,
66
70
  ):
71
+ client._http.event_hooks["request"].append(allow_connection_setup)
67
72
  if operation == "control":
68
73
  request = client.health()
69
74
  elif operation == "discovery":