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.
- {durable_workflow-2.13.2/src/durable_workflow.egg-info → durable_workflow-2.14.1}/PKG-INFO +1 -1
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/pyproject.toml +3 -3
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/__init__.py +2 -0
- durable_workflow-2.14.1/src/durable_workflow/_condition_fingerprint.py +229 -0
- durable_workflow-2.14.1/src/durable_workflow/_typed_values.py +311 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/activity.py +17 -2
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/errors.py +20 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/worker.py +5 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/workflow.py +206 -59
- {durable_workflow-2.13.2 → durable_workflow-2.14.1/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow.egg-info/SOURCES.txt +4 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_client_timeouts.py +5 -0
- durable_workflow-2.14.1/tests/test_condition_fingerprint.py +282 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_replay_regression_corpus.py +12 -1
- durable_workflow-2.14.1/tests/test_typed_values.py +464 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_wait_condition.py +277 -23
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_worker.py +14 -5
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/LICENSE +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/README.md +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/setup.cfg +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_activity_lease.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_activity_process.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_cancellation_scope_history.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_cooperative_cancellation.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_failure_reporting.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_metric_routes.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_poll_transport.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_query_task_authority.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/client.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/server_compatibility.json +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/telemetry.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/telemetry_carrier.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/worker_status.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_activity_heartbeat_authority.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_activity_lease_authority.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_activity_lease_renewal.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_activity_lease_worker.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_activity_retry_info.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_cancellation_remaining_time.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_cancellation_scope_opening.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_client.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_client_telemetry.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_committed_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_condition_replay_drift.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_cooperative_cancellation_client.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_cooperative_cancellation_worker.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_cooperative_remote_worker.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_empty_callback_failure.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_errors.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_failure_reporting.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_generator_lifecycle.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_history_bundle_pruned.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_history_bundle_schema2.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_invocable.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_local_failure_reporting.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_metric_routes.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_metrics.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_nexus_durable_wait.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_nexus_namespace.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_nexus_wire.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_patch_history.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_pending_operation_replay.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_poll_failure_backoff.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_poll_transport.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_prepared_local_activity_client.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_prepared_local_activity_worker.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_publish_workflow_isolation.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_queries.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_query_task_authority.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_query_timeout_retry.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_redrive_successor_evidence.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_registration_ambiguity.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_replay.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_replay_clock.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_schedules.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_sdk_metrics_contract.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_search_attribute_replay_identity.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_serializer.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_signals.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_sleep.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_sticky_execution.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_storage_admission.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_sync.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_sync_activities.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_telemetry.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_telemetry_carrier.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_testing_failures.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_testing_nexus.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_threaded_heartbeat_example.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_tls_trust.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_transport_diagnostics.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_updates.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_worker_deregistration.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_worker_observation.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_worker_status.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_worker_telemetry_wire.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_workflow_entry_arguments.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_workflow_entrypoint_validation.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_workflow_handler_validation.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_workflow_identity.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.13.2 → durable_workflow-2.14.1}/tests/test_workflow_updates_conformance.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "durable-workflow"
|
|
7
|
-
version = "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.
|
|
83
|
-
registry-version = "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(
|