durable-workflow 2.5.3__tar.gz → 2.5.4__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 (127) hide show
  1. {durable_workflow-2.5.3/src/durable_workflow.egg-info → durable_workflow-2.5.4}/PKG-INFO +1 -1
  2. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/pyproject.toml +3 -3
  3. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/__init__.py +2 -0
  4. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/errors.py +24 -0
  5. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/testing.py +5 -1
  6. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/worker.py +13 -2
  7. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/workflow.py +29 -23
  8. {durable_workflow-2.5.3 → durable_workflow-2.5.4/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  9. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow.egg-info/SOURCES.txt +1 -0
  10. durable_workflow-2.5.4/tests/test_registration_ambiguity.py +193 -0
  11. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_replay_regression_corpus.py +11 -1
  12. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/LICENSE +0 -0
  13. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/README.md +0 -0
  14. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/setup.cfg +0 -0
  15. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/_activity_process.py +0 -0
  16. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/_avro.py +0 -0
  17. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/_cancellation_scope.py +0 -0
  18. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  19. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  20. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/_prepared_local_activity.py +0 -0
  21. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  22. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/activity.py +0 -0
  23. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/auth_composition.py +0 -0
  24. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/cancellation.py +0 -0
  25. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/client.py +0 -0
  26. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/external_storage.py +0 -0
  27. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/external_task_input.py +0 -0
  28. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/external_task_result.py +0 -0
  29. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/history_bundle_verify.py +0 -0
  30. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/interceptors.py +0 -0
  31. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/invocable.py +0 -0
  32. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/metrics.py +0 -0
  33. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/nexus.py +0 -0
  34. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/py.typed +0 -0
  35. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/python_conformance.py +0 -0
  36. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/replay_conformance.py +0 -0
  37. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/replay_verify.py +0 -0
  38. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/retry_policy.py +0 -0
  39. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/serializer.py +0 -0
  40. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/sync.py +0 -0
  41. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/worker_session.py +0 -0
  42. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  43. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  44. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  45. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow.egg-info/requires.txt +0 -0
  46. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/src/durable_workflow.egg-info/top_level.txt +0 -0
  47. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_activity_cancellation_policies.py +0 -0
  48. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_activity_context.py +0 -0
  49. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_activity_process.py +0 -0
  50. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_auth_composition.py +0 -0
  51. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_avro_value_benchmark.py +0 -0
  52. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_avro_value_protocol.py +0 -0
  53. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_cancellation_context.py +0 -0
  54. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_cancellation_remaining_time.py +0 -0
  55. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_cancellation_scope_admission.py +0 -0
  56. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_cancellation_scope_authoring.py +0 -0
  57. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_cancellation_scope_boundary.py +0 -0
  58. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_cancellation_scope_opening.py +0 -0
  59. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_child_workflow_policies.py +0 -0
  60. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_ci_checkout.py +0 -0
  61. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_ci_integration_endpoint.py +0 -0
  62. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_client.py +0 -0
  63. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_client_timeouts.py +0 -0
  64. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_codec_regression_corpus.py +0 -0
  65. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  66. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_committed_cancellation_scope_groups.py +0 -0
  67. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_committed_cancellation_scope_history.py +0 -0
  68. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_committed_cancellation_scope_replay.py +0 -0
  69. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_condition_replay_drift.py +0 -0
  70. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_control_plane_parity_fixtures.py +0 -0
  71. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_cooperative_cancellation.py +0 -0
  72. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_cooperative_cancellation_client.py +0 -0
  73. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_cooperative_cancellation_worker.py +0 -0
  74. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_cooperative_remote_worker.py +0 -0
  75. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_docstring_cross_references.py +0 -0
  76. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_durable_selection.py +0 -0
  77. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_errors.py +0 -0
  78. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_external_storage.py +0 -0
  79. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_external_task_input.py +0 -0
  80. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_external_task_result.py +0 -0
  81. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_golden_history_replay.py +0 -0
  82. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_history_bundle_verify.py +0 -0
  83. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_history_event_contract.py +0 -0
  84. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_invocable.py +0 -0
  85. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_message_streams.py +0 -0
  86. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_metrics.py +0 -0
  87. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_order_processing_example.py +0 -0
  88. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_parallel_completion_order_replay.py +0 -0
  89. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_parallel_saga_authoring.py +0 -0
  90. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_patch_history.py +0 -0
  91. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_pending_operation_replay.py +0 -0
  92. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_prepared_local_activity_client.py +0 -0
  93. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_prepared_local_activity_groups.py +0 -0
  94. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_prepared_local_activity_policies.py +0 -0
  95. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_prepared_local_activity_worker.py +0 -0
  96. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_public_boundary_scanner.py +0 -0
  97. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_pypi_project_surface.py +0 -0
  98. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_python_conformance.py +0 -0
  99. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_queries.py +0 -0
  100. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_readme_quickstart.py +0 -0
  101. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_redrive_replay.py +0 -0
  102. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_release_metadata.py +0 -0
  103. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_replay.py +0 -0
  104. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_replay_conformance.py +0 -0
  105. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_replay_verify.py +0 -0
  106. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_retry_policy.py +0 -0
  107. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_run_cancellation_scope_replay.py +0 -0
  108. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_runtime_external_payload_transport.py +0 -0
  109. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_schedules.py +0 -0
  110. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_scoped_run_cancellation_context.py +0 -0
  111. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_serializer.py +0 -0
  112. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_signals.py +0 -0
  113. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_sleep.py +0 -0
  114. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_standalone_activity_client.py +0 -0
  115. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_sticky_execution.py +0 -0
  116. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_storage_admission.py +0 -0
  117. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_sync.py +0 -0
  118. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_testing_harness.py +0 -0
  119. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_update_signal_condition_replay.py +0 -0
  120. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_updates.py +0 -0
  121. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_wait_condition.py +0 -0
  122. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_worker.py +0 -0
  123. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_worker_session.py +0 -0
  124. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_workflow_entrypoint_validation.py +0 -0
  125. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_workflow_result_timeout.py +0 -0
  126. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/tests/test_workflow_streams.py +0 -0
  127. {durable_workflow-2.5.3 → durable_workflow-2.5.4}/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.5.3
3
+ Version: 2.5.4
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.5.3"
7
+ version = "2.5.4"
8
8
  description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -71,8 +71,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
71
71
  durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
72
72
 
73
73
  [tool.durable-workflow]
74
- product-train = "2.5.3"
75
- registry-version = "2.5.3"
74
+ product-train = "2.5.4"
75
+ registry-version = "2.5.4"
76
76
  supported-server-versions = "2.5.16"
77
77
  worker-protocol-version = "1.19"
78
78
  control-plane-version = "2"
@@ -70,6 +70,7 @@ from .errors import (
70
70
  ChildWorkflowCancelled,
71
71
  ChildWorkflowFailed,
72
72
  ChildWorkflowTerminated,
73
+ DuplicateRegistrationError,
73
74
  DurableOperationCancelled,
74
75
  DurableWorkflowError,
75
76
  ExternalPayloadError,
@@ -370,6 +371,7 @@ __all__ = [
370
371
  "NexusOperationResult",
371
372
  "NexusServiceCall",
372
373
  "NonDeterministicReplayError",
374
+ "DuplicateRegistrationError",
373
375
  "InvalidWorkflowDefinition",
374
376
  "NoopMetrics",
375
377
  "QueryFailed",
@@ -31,6 +31,30 @@ class InvalidWorkflowDefinition(DurableWorkflowError, ValueError):
31
31
  """Workflow authoring uses an unsupported entry point or result."""
32
32
 
33
33
 
34
+ class DuplicateRegistrationError(DurableWorkflowError, ValueError):
35
+ """One registration scope assigns a wire name to multiple definitions."""
36
+
37
+ def __init__(
38
+ self, registration_kind: str, wire_name: str,
39
+ first_definition: object, second_definition: object,
40
+ ) -> None:
41
+ def label(definition: object) -> str:
42
+ if isinstance(definition, str):
43
+ return definition
44
+ module = getattr(definition, "__module__", type(definition).__module__)
45
+ name = getattr(definition, "__qualname__", type(definition).__qualname__)
46
+ return f"{module}.{name}"
47
+
48
+ self.registration_kind = registration_kind
49
+ self.wire_name = wire_name
50
+ self.first_definition = label(first_definition)
51
+ self.second_definition = label(second_definition)
52
+ super().__init__(
53
+ f"duplicate {registration_kind} name {wire_name!r}: "
54
+ f"{self.first_definition} conflicts with {self.second_definition}"
55
+ )
56
+
57
+
34
58
  class DurableOperationCancelled(DurableWorkflowError):
35
59
  """A durable selection member was explicitly cancelled."""
36
60
 
@@ -39,7 +39,7 @@ from pathlib import Path
39
39
  from typing import Any
40
40
 
41
41
  from . import serializer
42
- from .errors import WorkflowCancelled, WorkflowFailed, WorkflowTerminated
42
+ from .errors import DuplicateRegistrationError, WorkflowCancelled, WorkflowFailed, WorkflowTerminated
43
43
  from .workflow import (
44
44
  CompleteWorkflow,
45
45
  ContinueAsNew,
@@ -52,6 +52,7 @@ from .workflow import (
52
52
  StartTimer,
53
53
  UpsertSearchAttributes,
54
54
  _validate_workflow_entrypoint,
55
+ _workflow_handler_registries,
55
56
  registry,
56
57
  replay,
57
58
  )
@@ -136,6 +137,9 @@ class WorkflowEnvironment:
136
137
  "decorate it with @workflow.defn(name=...) first."
137
138
  )
138
139
  _validate_workflow_entrypoint(workflow_cls)
140
+ _workflow_handler_registries(workflow_cls)
141
+ if name in self._registered_workflows and self._registered_workflows[name] is not workflow_cls:
142
+ raise DuplicateRegistrationError("workflow", name, self._registered_workflows[name], workflow_cls)
139
143
  self._registered_workflows[name] = workflow_cls
140
144
 
141
145
  def signal(
@@ -66,6 +66,7 @@ from .errors import (
66
66
  ActivityCancelled,
67
67
  ActivityFailed,
68
68
  AvroNotInstalledError,
69
+ DuplicateRegistrationError,
69
70
  DurableWorkflowError,
70
71
  ExternalPayloadError,
71
72
  InvalidArgument,
@@ -106,6 +107,7 @@ from .workflow import (
106
107
  StartChildWorkflow,
107
108
  UpsertMemo,
108
109
  _validate_workflow_entrypoint,
110
+ _workflow_handler_registries,
109
111
  apply_update,
110
112
  commands_to_server_commands,
111
113
  query_state,
@@ -1028,7 +1030,11 @@ class Worker:
1028
1030
  self.workflows: dict[str, type] = {}
1029
1031
  for definition in workflows:
1030
1032
  _validate_workflow_entrypoint(definition)
1031
- self.workflows[_workflow_name(definition)] = definition
1033
+ _workflow_handler_registries(definition)
1034
+ name = _workflow_name(definition)
1035
+ if name in self.workflows:
1036
+ raise DuplicateRegistrationError("workflow", name, self.workflows[name], definition)
1037
+ self.workflows[name] = definition
1032
1038
  self.workflow_definition_fingerprints = {
1033
1039
  workflow_type: _workflow_definition_fingerprint(workflow_cls)
1034
1040
  for workflow_type, workflow_cls in self.workflows.items()
@@ -1037,7 +1043,12 @@ class Worker:
1037
1043
  workflow_type: _workflow_command_contract(workflow_cls)
1038
1044
  for workflow_type, workflow_cls in self.workflows.items()
1039
1045
  }
1040
- self.activities = {_activity_name(a): a for a in activities}
1046
+ self.activities: dict[str, Callable[..., Any]] = {}
1047
+ for activity_definition in activities:
1048
+ name = _activity_name(activity_definition)
1049
+ if name in self.activities:
1050
+ raise DuplicateRegistrationError("activity", name, self.activities[name], activity_definition)
1051
+ self.activities[name] = activity_definition
1041
1052
  self.capabilities = tuple(dict.fromkeys(capability.strip() for capability in capabilities))
1042
1053
  self._sticky_cache = StickyWorkflowCache(
1043
1054
  sticky_cache_capacity, sticky_cache_max_bytes, sticky_cache_ttl_seconds,
@@ -58,6 +58,7 @@ from .errors import (
58
58
  ChildWorkflowCancelled,
59
59
  ChildWorkflowFailed,
60
60
  ChildWorkflowTerminated,
61
+ DuplicateRegistrationError,
61
62
  DurableOperationCancelled,
62
63
  InvalidWorkflowDefinition,
63
64
  NexusOperationFailed,
@@ -124,6 +125,29 @@ def _validate_workflow_result(value: Any, cls: type) -> None:
124
125
  )
125
126
 
126
127
 
128
+ def _workflow_handler_registries(
129
+ cls: type,
130
+ ) -> tuple[dict[str, str], dict[str, str], dict[str, str], dict[str, str]]:
131
+ handlers: dict[str, dict[str, str]] = {kind: {} for kind in ("signal", "query", "update", "update_validator")}
132
+ for attr in dir(cls):
133
+ if attr.startswith("_"):
134
+ continue
135
+ member = getattr(cls, attr, None)
136
+ for kind, registry in handlers.items():
137
+ wire_name = getattr(member, f"__{kind}_name__", None)
138
+ if not isinstance(wire_name, str) or not wire_name:
139
+ continue
140
+ if kind == "signal" and wire_name == MESSAGE_STREAM_SIGNAL:
141
+ raise ValueError(f"signal name {wire_name!r} is reserved by the workflow runtime")
142
+ if wire_name in registry:
143
+ owner = f"{cls.__module__}.{cls.__qualname__}"
144
+ raise DuplicateRegistrationError(
145
+ kind, wire_name, f"{owner}.{registry[wire_name]}", f"{owner}.{attr}",
146
+ )
147
+ registry[wire_name] = attr
148
+ return handlers["signal"], handlers["query"], handlers["update"], handlers["update_validator"]
149
+
150
+
127
151
  def defn(*, name: str) -> Callable[[type[_WorkflowT]], type[_WorkflowT]]:
128
152
  """Register a class as a workflow type under a language-neutral name.
129
153
 
@@ -136,28 +160,7 @@ def defn(*, name: str) -> Callable[[type[_WorkflowT]], type[_WorkflowT]]:
136
160
  def wrap(cls: type[_WorkflowT]) -> type[_WorkflowT]:
137
161
  cls.__workflow_name__ = name # type: ignore[attr-defined]
138
162
  _validate_workflow_entrypoint(cls)
139
- signals: dict[str, str] = {}
140
- queries: dict[str, str] = {}
141
- updates: dict[str, str] = {}
142
- update_validators: dict[str, str] = {}
143
- for attr in dir(cls):
144
- if attr.startswith("_"):
145
- continue
146
- member = getattr(cls, attr, None)
147
- signal_name = getattr(member, "__signal_name__", None)
148
- if isinstance(signal_name, str) and signal_name:
149
- if signal_name == MESSAGE_STREAM_SIGNAL:
150
- raise ValueError(f"signal name {signal_name!r} is reserved by the workflow runtime")
151
- signals[signal_name] = attr
152
- query_name = getattr(member, "__query_name__", None)
153
- if isinstance(query_name, str) and query_name:
154
- queries[query_name] = attr
155
- update_name = getattr(member, "__update_name__", None)
156
- if isinstance(update_name, str) and update_name:
157
- updates[update_name] = attr
158
- update_validator_name = getattr(member, "__update_validator_name__", None)
159
- if isinstance(update_validator_name, str) and update_validator_name:
160
- update_validators[update_validator_name] = attr
163
+ signals, queries, updates, update_validators = _workflow_handler_registries(cls)
161
164
  cls.__workflow_signals__ = signals # type: ignore[attr-defined]
162
165
  cls.__workflow_queries__ = queries # type: ignore[attr-defined]
163
166
  cls.__workflow_updates__ = updates # type: ignore[attr-defined]
@@ -2578,9 +2581,12 @@ class Replayer:
2578
2581
  def __init__(self, *, workflows: Iterable[type]) -> None:
2579
2582
  self._workflows: dict[str, type] = {}
2580
2583
  for workflow_cls in workflows:
2584
+ _workflow_handler_registries(workflow_cls)
2581
2585
  workflow_type = str(getattr(workflow_cls, "__workflow_name__", workflow_cls.__name__))
2582
2586
  if workflow_type in self._workflows:
2583
- raise ValueError(f"duplicate workflow type registered with Replayer: {workflow_type!r}")
2587
+ raise DuplicateRegistrationError(
2588
+ "workflow", workflow_type, self._workflows[workflow_type], workflow_cls,
2589
+ )
2584
2590
  self._workflows[workflow_type] = workflow_cls
2585
2591
  if not self._workflows:
2586
2592
  raise ValueError("Replayer requires at least one workflow class")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.5.3
3
+ Version: 2.5.4
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
@@ -95,6 +95,7 @@ tests/test_python_conformance.py
95
95
  tests/test_queries.py
96
96
  tests/test_readme_quickstart.py
97
97
  tests/test_redrive_replay.py
98
+ tests/test_registration_ambiguity.py
98
99
  tests/test_release_metadata.py
99
100
  tests/test_replay.py
100
101
  tests/test_replay_conformance.py
@@ -0,0 +1,193 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+ from unittest.mock import AsyncMock
6
+
7
+ import pytest
8
+
9
+ from durable_workflow import Client, DuplicateRegistrationError, DurableWorkflowError, Worker, activity, workflow
10
+ from durable_workflow.testing import WorkflowEnvironment
11
+
12
+
13
+ def run(self: Any, ctx: Any) -> int:
14
+ return 1
15
+
16
+
17
+ def first(self: Any, *args: Any) -> None:
18
+ pass
19
+
20
+
21
+ def second(self: Any, *args: Any) -> None:
22
+ pass
23
+
24
+
25
+ def definition(name: str, cls_name: str, members: dict[str, Any] | None = None, bases: tuple[type, ...] = ()) -> type:
26
+ return workflow.defn(name=name)(type(cls_name, bases, {"run": run, **(members or {})}))
27
+
28
+
29
+ @pytest.mark.parametrize("consumer", ["worker", "replayer", "environment"])
30
+ def test_conflicting_workflow_names_report_both_definitions(consumer: str) -> None:
31
+ first_cls = definition("shared-workflow", "FirstWorkflow")
32
+ second_cls = definition("shared-workflow", "SecondWorkflow")
33
+ client = AsyncMock(spec=Client)
34
+ with pytest.raises(ValueError) as captured:
35
+ if consumer == "worker":
36
+ Worker(client, task_queue="registration", workflows=iter([first_cls, second_cls]))
37
+ elif consumer == "replayer":
38
+ workflow.Replayer(workflows=iter([first_cls, second_cls]))
39
+ else:
40
+ env = WorkflowEnvironment()
41
+ env.register_workflow(first_cls)
42
+ env.register_workflow(second_cls)
43
+ assert captured.value.registration_kind == "workflow" # type: ignore[attr-defined]
44
+ assert captured.value.wire_name == "shared-workflow" # type: ignore[attr-defined]
45
+ assert "FirstWorkflow" in str(captured.value)
46
+ assert "SecondWorkflow" in str(captured.value)
47
+ assert client.mock_calls == []
48
+
49
+
50
+ @pytest.mark.parametrize("same_definition", [False, True])
51
+ def test_worker_rejects_duplicate_activities_before_network(same_definition: bool) -> None:
52
+ first_activity = activity.defn(name="shared-activity")(first)
53
+ second_activity = first_activity if same_definition else activity.defn(name="shared-activity")(second)
54
+ client = AsyncMock(spec=Client)
55
+ with pytest.raises(ValueError) as captured:
56
+ Worker(client, task_queue="registration", activities=iter([first_activity, second_activity]))
57
+ assert captured.value.registration_kind == "activity" # type: ignore[attr-defined]
58
+ assert captured.value.wire_name == "shared-activity" # type: ignore[attr-defined]
59
+ assert "first" in str(captured.value)
60
+ assert ("first" if same_definition else "second") in str(captured.value)
61
+ assert client.mock_calls == []
62
+
63
+
64
+ @pytest.mark.parametrize("consumer", ["worker", "replayer"])
65
+ def test_duplicate_list_entry_is_rejected(consumer: str) -> None:
66
+ cls = definition("same-entry", "RepeatedWorkflow")
67
+ with pytest.raises(ValueError, match="same-entry"):
68
+ if consumer == "worker":
69
+ Worker(AsyncMock(spec=Client), task_queue="registration", workflows=[cls, cls])
70
+ else:
71
+ workflow.Replayer(workflows=[cls, cls])
72
+
73
+
74
+ @pytest.mark.parametrize("kind", ["signal", "query", "update", "update_validator"])
75
+ @pytest.mark.parametrize("inherited", [False, True])
76
+ def test_conflicting_handler_names_are_rejected_at_decoration(kind: str, inherited: bool) -> None:
77
+ decorate: Callable[..., Any] = getattr(workflow, kind)
78
+ # Use distinct functions so metadata from one handler kind cannot bleed
79
+ # into the next parameterized definition.
80
+ def first_handler(self: Any, *args: Any) -> None:
81
+ pass
82
+
83
+ def second_handler(self: Any, *args: Any) -> None:
84
+ pass
85
+
86
+ bases = ()
87
+ members = {"first_handler": decorate("approve")(first_handler)}
88
+ if inherited:
89
+ base = definition(f"base-{kind}", "BaseWorkflow", members)
90
+ bases = (base,)
91
+ members = {}
92
+ members["second_handler"] = decorate("approve")(second_handler)
93
+ with pytest.raises(ValueError) as captured:
94
+ definition(f"ambiguous-{kind}", "AmbiguousWorkflow", members, bases)
95
+ assert captured.value.registration_kind == kind # type: ignore[attr-defined]
96
+ assert captured.value.wire_name == "approve" # type: ignore[attr-defined]
97
+ assert "first_handler" in str(captured.value)
98
+ assert "second_handler" in str(captured.value)
99
+
100
+
101
+ @pytest.mark.parametrize("kind", ["signal", "query", "update", "update_validator"])
102
+ def test_overriding_the_same_python_attribute_is_unambiguous(kind: str) -> None:
103
+ decorate: Callable[..., Any] = getattr(workflow, kind)
104
+ def original(self: Any) -> str:
105
+ return "original"
106
+
107
+ def replacement(self: Any) -> str:
108
+ return "replacement"
109
+
110
+ base = definition(f"override-base-{kind}", "OverrideBase", {"handler": decorate("approve")(original)})
111
+ child = definition(
112
+ f"override-child-{kind}", "OverrideChild", {"handler": decorate("approve")(replacement)}, (base,),
113
+ )
114
+ assert child().handler() == "replacement"
115
+
116
+
117
+ @pytest.mark.parametrize("kind", ["signal", "query", "update", "update_validator"])
118
+ @pytest.mark.parametrize("consumer", ["worker", "replayer", "environment"])
119
+ def test_programmatic_workflow_admission_cannot_bypass_handler_validation(kind: str, consumer: str) -> None:
120
+ decorate: Callable[..., Any] = getattr(workflow, kind)
121
+ def first_handler(self: Any) -> None:
122
+ pass
123
+
124
+ def second_handler(self: Any) -> None:
125
+ pass
126
+
127
+ cls = type("ProgrammaticWorkflow", (), {
128
+ "__workflow_name__": "programmatic-workflow", "run": run,
129
+ "first_handler": decorate("approve")(first_handler),
130
+ "second_handler": decorate("approve")(second_handler),
131
+ })
132
+ client = AsyncMock(spec=Client)
133
+ with pytest.raises(DuplicateRegistrationError) as captured:
134
+ if consumer == "worker":
135
+ Worker(client, task_queue="registration", workflows=[cls])
136
+ elif consumer == "replayer":
137
+ workflow.Replayer(workflows=[cls])
138
+ else:
139
+ WorkflowEnvironment().register_workflow(cls)
140
+ assert captured.value.registration_kind == kind
141
+ assert captured.value.wire_name == "approve"
142
+ assert client.mock_calls == []
143
+
144
+
145
+ def test_handler_names_are_scoped_by_kind() -> None:
146
+ cls = definition("scoped-handlers", "ScopedHandlers", {
147
+ "signal": workflow.signal("approve")(lambda self: None),
148
+ "query": workflow.query("approve")(lambda self: None),
149
+ "update": workflow.update("approve")(lambda self: None),
150
+ "validator": workflow.update_validator("approve")(lambda self: None),
151
+ })
152
+ assert cls.__workflow_signals__ == {"approve": "signal"}
153
+ assert cls.__workflow_queries__ == {"approve": "query"}
154
+ assert cls.__workflow_updates__ == {"approve": "update"}
155
+ assert cls.__workflow_update_validators__ == {"approve": "validator"}
156
+
157
+
158
+ def test_distinct_names_and_separate_workers_remain_supported() -> None:
159
+ first_cls = definition("one", "FirstDistinct")
160
+ second_cls = definition("two", "SecondDistinct")
161
+ alternative = definition("one", "AlternativeDefinition")
162
+ client = AsyncMock(spec=Client)
163
+ worker = Worker(client, task_queue="registration", workflows=iter([first_cls, second_cls]))
164
+ assert worker.workflows == {"one": first_cls, "two": second_cls}
165
+ assert Worker(client, task_queue="other", workflows=[alternative]).workflows == {"one": alternative}
166
+ assert workflow.Replayer(workflows=[first_cls, second_cls]).replay([], workflow_type="one").commands[0].result == 1
167
+ assert client.mock_calls == []
168
+
169
+
170
+ def test_environment_registration_is_idempotent_for_automatic_execution() -> None:
171
+ cls = definition("environment-idempotent", "EnvironmentWorkflow")
172
+ env = WorkflowEnvironment()
173
+ env.register_workflow(cls)
174
+ env.register_workflow(cls)
175
+ assert env.execute_workflow(cls) == 1
176
+ assert env.execute_workflow(cls) == 1
177
+
178
+
179
+ def test_mock_replacement_is_an_explicit_test_setting() -> None:
180
+ env = WorkflowEnvironment()
181
+ env.register_activity("mock", lambda: "first")
182
+ env.register_activity("mock", lambda: "second")
183
+ assert env._activity_fns["mock"]() == "second"
184
+
185
+
186
+ def test_duplicate_error_is_a_public_structured_sdk_error() -> None:
187
+ error = DuplicateRegistrationError("workflow", "duplicate", run, first)
188
+ assert isinstance(error, DurableWorkflowError)
189
+ assert isinstance(error, ValueError)
190
+ assert error.registration_kind == "workflow"
191
+ assert error.wire_name == "duplicate"
192
+ assert error.first_definition.endswith(".run")
193
+ assert error.second_definition.endswith(".first")
@@ -368,8 +368,18 @@ def _execute_fixture(fixture: dict[str, Any]) -> list[dict[str, Any]]:
368
368
 
369
369
  query = workflow.get("query")
370
370
  query_result: Any = None
371
+ registered_workflows = [WORKFLOW_TYPES[workflow_type]]
372
+ worker_registrations = workflow.get("worker_registrations")
373
+ if worker_registrations is not None:
374
+ assert isinstance(worker_registrations, list) and worker_registrations
375
+ assert all(isinstance(name, str) and name in WORKFLOW_TYPES for name in worker_registrations)
376
+ worker = Worker(
377
+ AsyncMock(spec=Client), task_queue="regression-corpus",
378
+ workflows=[WORKFLOW_TYPES[name] for name in worker_registrations],
379
+ )
380
+ registered_workflows = list(worker.workflows.values())
371
381
  if query is None:
372
- outcome = Replayer(workflows=[WORKFLOW_TYPES[workflow_type]]).replay(
382
+ outcome = Replayer(workflows=registered_workflows).replay(
373
383
  history,
374
384
  start_input,
375
385
  workflow_type=workflow_type,