durable-workflow 2.5.3__tar.gz → 2.5.5__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 (129) hide show
  1. {durable_workflow-2.5.3/src/durable_workflow.egg-info → durable_workflow-2.5.5}/PKG-INFO +1 -1
  2. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/pyproject.toml +3 -3
  3. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/__init__.py +2 -0
  4. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/client.py +42 -1
  5. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/errors.py +24 -0
  6. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/nexus.py +15 -2
  7. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/testing.py +5 -1
  8. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/worker.py +14 -2
  9. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/workflow.py +47 -23
  10. {durable_workflow-2.5.3 → durable_workflow-2.5.5/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  11. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow.egg-info/SOURCES.txt +3 -0
  12. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_client.py +3 -2
  13. durable_workflow-2.5.5/tests/test_nexus_namespace.py +160 -0
  14. durable_workflow-2.5.5/tests/test_nexus_wire.py +38 -0
  15. durable_workflow-2.5.5/tests/test_registration_ambiguity.py +193 -0
  16. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_replay_regression_corpus.py +22 -1
  17. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_runtime_external_payload_transport.py +46 -0
  18. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_worker.py +7 -3
  19. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/LICENSE +0 -0
  20. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/README.md +0 -0
  21. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/setup.cfg +0 -0
  22. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/_activity_process.py +0 -0
  23. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/_avro.py +0 -0
  24. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/_cancellation_scope.py +0 -0
  25. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  26. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  27. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/_prepared_local_activity.py +0 -0
  28. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  29. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/activity.py +0 -0
  30. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/auth_composition.py +0 -0
  31. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/cancellation.py +0 -0
  32. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/external_storage.py +0 -0
  33. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/external_task_input.py +0 -0
  34. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/external_task_result.py +0 -0
  35. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/history_bundle_verify.py +0 -0
  36. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/interceptors.py +0 -0
  37. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/invocable.py +0 -0
  38. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/metrics.py +0 -0
  39. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/py.typed +0 -0
  40. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/python_conformance.py +0 -0
  41. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/replay_conformance.py +0 -0
  42. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/replay_verify.py +0 -0
  43. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/retry_policy.py +0 -0
  44. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/serializer.py +0 -0
  45. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/sync.py +0 -0
  46. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/worker_session.py +0 -0
  47. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  48. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  49. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  50. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow.egg-info/requires.txt +0 -0
  51. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/src/durable_workflow.egg-info/top_level.txt +0 -0
  52. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_activity_cancellation_policies.py +0 -0
  53. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_activity_context.py +0 -0
  54. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_activity_process.py +0 -0
  55. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_auth_composition.py +0 -0
  56. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_avro_value_benchmark.py +0 -0
  57. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_avro_value_protocol.py +0 -0
  58. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_cancellation_context.py +0 -0
  59. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_cancellation_remaining_time.py +0 -0
  60. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_cancellation_scope_admission.py +0 -0
  61. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_cancellation_scope_authoring.py +0 -0
  62. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_cancellation_scope_boundary.py +0 -0
  63. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_cancellation_scope_opening.py +0 -0
  64. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_child_workflow_policies.py +0 -0
  65. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_ci_checkout.py +0 -0
  66. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_ci_integration_endpoint.py +0 -0
  67. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_client_timeouts.py +0 -0
  68. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_codec_regression_corpus.py +0 -0
  69. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  70. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_committed_cancellation_scope_groups.py +0 -0
  71. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_committed_cancellation_scope_history.py +0 -0
  72. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_committed_cancellation_scope_replay.py +0 -0
  73. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_condition_replay_drift.py +0 -0
  74. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_control_plane_parity_fixtures.py +0 -0
  75. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_cooperative_cancellation.py +0 -0
  76. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_cooperative_cancellation_client.py +0 -0
  77. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_cooperative_cancellation_worker.py +0 -0
  78. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_cooperative_remote_worker.py +0 -0
  79. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_docstring_cross_references.py +0 -0
  80. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_durable_selection.py +0 -0
  81. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_errors.py +0 -0
  82. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_external_storage.py +0 -0
  83. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_external_task_input.py +0 -0
  84. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_external_task_result.py +0 -0
  85. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_golden_history_replay.py +0 -0
  86. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_history_bundle_verify.py +0 -0
  87. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_history_event_contract.py +0 -0
  88. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_invocable.py +0 -0
  89. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_message_streams.py +0 -0
  90. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_metrics.py +0 -0
  91. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_order_processing_example.py +0 -0
  92. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_parallel_completion_order_replay.py +0 -0
  93. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_parallel_saga_authoring.py +0 -0
  94. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_patch_history.py +0 -0
  95. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_pending_operation_replay.py +0 -0
  96. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_prepared_local_activity_client.py +0 -0
  97. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_prepared_local_activity_groups.py +0 -0
  98. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_prepared_local_activity_policies.py +0 -0
  99. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_prepared_local_activity_worker.py +0 -0
  100. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_public_boundary_scanner.py +0 -0
  101. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_pypi_project_surface.py +0 -0
  102. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_python_conformance.py +0 -0
  103. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_queries.py +0 -0
  104. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_readme_quickstart.py +0 -0
  105. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_redrive_replay.py +0 -0
  106. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_release_metadata.py +0 -0
  107. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_replay.py +0 -0
  108. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_replay_conformance.py +0 -0
  109. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_replay_verify.py +0 -0
  110. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_retry_policy.py +0 -0
  111. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_run_cancellation_scope_replay.py +0 -0
  112. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_schedules.py +0 -0
  113. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_scoped_run_cancellation_context.py +0 -0
  114. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_serializer.py +0 -0
  115. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_signals.py +0 -0
  116. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_sleep.py +0 -0
  117. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_standalone_activity_client.py +0 -0
  118. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_sticky_execution.py +0 -0
  119. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_storage_admission.py +0 -0
  120. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_sync.py +0 -0
  121. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_testing_harness.py +0 -0
  122. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_update_signal_condition_replay.py +0 -0
  123. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_updates.py +0 -0
  124. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_wait_condition.py +0 -0
  125. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_worker_session.py +0 -0
  126. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_workflow_entrypoint_validation.py +0 -0
  127. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_workflow_result_timeout.py +0 -0
  128. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/tests/test_workflow_streams.py +0 -0
  129. {durable_workflow-2.5.3 → durable_workflow-2.5.5}/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.5
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.5"
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.5"
75
+ registry-version = "2.5.5"
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",
@@ -19,6 +19,7 @@ methods without repeating the id on every call.
19
19
  from __future__ import annotations
20
20
 
21
21
  import asyncio
22
+ import copy
22
23
  import hashlib
23
24
  import json as json_module
24
25
  import math
@@ -2672,6 +2673,26 @@ class Client:
2672
2673
  """
2673
2674
  return WorkflowHandle(self, workflow_id=workflow_id, run_id=run_id, workflow_type=workflow_type)
2674
2675
 
2676
+ def _nexus_target_client(self, target_namespace: str | None) -> Client:
2677
+ if target_namespace is not None and not target_namespace.strip():
2678
+ raise ValueError("target_namespace must be nonempty")
2679
+ if target_namespace is None or target_namespace == self.namespace:
2680
+ return self
2681
+ self._auth_token(worker=False)
2682
+ scoped = copy.copy(self)
2683
+ scoped.namespace = target_namespace
2684
+ # Share the HTTP pool, never the caller's namespace discovery or
2685
+ # provider credentials. The worker keeps using the original client.
2686
+ scoped.worker_token = None
2687
+ scoped._cluster_info = None
2688
+ scoped._cluster_info_lock = asyncio.Lock()
2689
+ scoped._runtime_external_payload_transport_cache = None
2690
+ scoped._runtime_external_payload_transport_resolved = False
2691
+ scoped.external_storage = None
2692
+ scoped.external_storage_threshold_bytes = None
2693
+ scoped.external_storage_cache = ExternalPayloadCache()
2694
+ return scoped
2695
+
2675
2696
  async def execute_nexus_operation(
2676
2697
  self,
2677
2698
  endpoint_name: str,
@@ -2685,6 +2706,7 @@ class Client:
2685
2706
  idempotency_key: str | None = None,
2686
2707
  payload_codec: str | None = None,
2687
2708
  caller_namespace: str | None = None,
2709
+ target_namespace: str | None = None,
2688
2710
  caller_workflow_instance_id: str | None = None,
2689
2711
  caller_workflow_run_id: str | None = None,
2690
2712
  service_sdk_language: str | None = None,
@@ -2708,7 +2730,11 @@ class Client:
2708
2730
  the worker uses this client method to make the single durable
2709
2731
  execute request and records the returned service-call surface into
2710
2732
  workflow history.
2733
+
2734
+ ``target_namespace`` selects the service catalog and its payload
2735
+ storage. The caller identity and worker namespace are preserved.
2711
2736
  """
2737
+ execution_client = self._nexus_target_client(target_namespace)
2712
2738
  request_body = nexus_request_payload(
2713
2739
  arguments=list(arguments) if arguments is not None else [],
2714
2740
  payload_codec=payload_codec,
@@ -2736,8 +2762,21 @@ class Client:
2736
2762
  )
2737
2763
  context = f"{endpoint_name}/{service_name}/{operation_name}"
2738
2764
 
2765
+ wire_body = request_body
2766
+ transport = await execution_client._runtime_external_payload_transport()
2767
+ if transport is not None:
2768
+ arguments_envelope = await execution_client._externalize_runtime_payloads(
2769
+ {"codec": request_body["payload_codec"], "blob": request_body["arguments"]},
2770
+ worker=False,
2771
+ transport=transport,
2772
+ uploaded={},
2773
+ slot=("arguments",),
2774
+ )
2775
+ if "external_payload" in arguments_envelope:
2776
+ wire_body = {**request_body, "arguments": arguments_envelope}
2777
+
2739
2778
  try:
2740
- data = await self._request("POST", path, json=request_body, context=context)
2779
+ data = await execution_client._request("POST", path, json=wire_body, context=context)
2741
2780
  except ServerError as exc:
2742
2781
  if exc.status != 409 or not isinstance(exc.body, dict):
2743
2782
  raise
@@ -2747,6 +2786,7 @@ class Client:
2747
2786
  service_name=service_name,
2748
2787
  operation_name=operation_name,
2749
2788
  request_payload=request_body,
2789
+ target_namespace=target_namespace,
2750
2790
  service_sdk_language=service_sdk_language,
2751
2791
  artifact_tuple=artifact_tuple,
2752
2792
  published_artifact_worker_execution=published_artifact_worker_execution,
@@ -2770,6 +2810,7 @@ class Client:
2770
2810
  service_name=service_name,
2771
2811
  operation_name=operation_name,
2772
2812
  request_payload=request_body,
2813
+ target_namespace=target_namespace,
2773
2814
  service_sdk_language=service_sdk_language,
2774
2815
  artifact_tuple=artifact_tuple,
2775
2816
  published_artifact_worker_execution=published_artifact_worker_execution,
@@ -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
 
@@ -8,6 +8,7 @@ from collections.abc import Mapping, Sequence
8
8
  from dataclasses import dataclass, field
9
9
  from typing import Any
10
10
 
11
+ from . import serializer
11
12
  from .errors import NexusOperationFailed
12
13
 
13
14
  NEXUS_OPERATION_RESULT_SCHEMA = "durable-workflow.v2.sdk-python.nexus-operation-result"
@@ -49,6 +50,7 @@ def stable_nexus_idempotency_key(
49
50
  service_name: str,
50
51
  operation_name: str,
51
52
  arguments: Sequence[Any],
53
+ target_namespace: str | None = None,
52
54
  ) -> str:
53
55
  """Return a deterministic idempotency key for one workflow-side Nexus call."""
54
56
  source = {
@@ -61,6 +63,8 @@ def stable_nexus_idempotency_key(
61
63
  "operation_name": operation_name,
62
64
  "arguments": list(arguments),
63
65
  }
66
+ if target_namespace is not None:
67
+ source["target_namespace"] = target_namespace
64
68
  encoded = json.dumps(source, sort_keys=True, separators=(",", ":"), default=_json_default)
65
69
  return f"dw-py-nexus-{hashlib.sha256(encoded.encode()).hexdigest()}"
66
70
 
@@ -87,9 +91,10 @@ def nexus_request_payload(
87
91
  duplicate_start_policy: str | None = None,
88
92
  ) -> dict[str, Any]:
89
93
  """Build the server execute-operation request body."""
94
+ codec = payload_codec or serializer.AVRO_CODEC
90
95
  return _clean_dict({
91
- "arguments": list(arguments),
92
- "payload_codec": payload_codec,
96
+ "arguments": serializer.encode(list(arguments), codec=codec),
97
+ "payload_codec": codec,
93
98
  "mode_override": mode,
94
99
  "wait_for": wait_for,
95
100
  "wait_timeout_seconds": wait_timeout_seconds,
@@ -134,6 +139,7 @@ class NexusOperationResult:
134
139
  caller_observed_error_type: str | None = None
135
140
  typed_error_message: str | None = None
136
141
  result: Any = None
142
+ target_namespace: str | None = None
137
143
 
138
144
  @classmethod
139
145
  def from_response(
@@ -147,6 +153,7 @@ class NexusOperationResult:
147
153
  service_sdk_language: str | None = None,
148
154
  artifact_tuple: Mapping[str, Any] | None = None,
149
155
  published_artifact_worker_execution: bool | None = None,
156
+ target_namespace: str | None = None,
150
157
  ) -> NexusOperationResult:
151
158
  call = data.get("service_call")
152
159
  source = call if isinstance(call, Mapping) else data
@@ -188,6 +195,7 @@ class NexusOperationResult:
188
195
  typed_error_message=_optional_str(source.get("typed_error_message"))
189
196
  or _optional_str(data.get("typed_error_message")),
190
197
  result=response_value,
198
+ target_namespace=target_namespace,
191
199
  )
192
200
 
193
201
  @classmethod
@@ -199,6 +207,9 @@ class NexusOperationResult:
199
207
  if value.get("version") != NEXUS_OPERATION_RESULT_VERSION:
200
208
  raise ValueError("recorded Nexus operation result version is not supported")
201
209
 
210
+ if value.get("target_namespace") != expected.get("target_namespace"):
211
+ raise ValueError("recorded Nexus target namespace does not match the current workflow command")
212
+
202
213
  for key in ("endpoint_name", "service_name", "operation_name", "idempotency_key"):
203
214
  expected_value = expected.get(key)
204
215
  recorded_value = value.get(key)
@@ -235,6 +246,7 @@ class NexusOperationResult:
235
246
  caller_observed_error_type=_optional_str(value.get("caller_observed_error_type")),
236
247
  typed_error_message=_optional_str(value.get("typed_error_message")),
237
248
  result=value.get("result"),
249
+ target_namespace=_optional_str(value.get("target_namespace")),
238
250
  )
239
251
  if result.is_failure:
240
252
  raise result.to_failure()
@@ -287,6 +299,7 @@ class NexusOperationResult:
287
299
  "endpoint_name": self.endpoint_name,
288
300
  "service_name": self.service_name,
289
301
  "operation_name": self.operation_name,
302
+ "target_namespace": self.target_namespace,
290
303
  "idempotency_key": self.request_payload.get("idempotency_key"),
291
304
  "caller_workflow_instance_id": self.caller_workflow_instance_id,
292
305
  "caller_workflow_run_id": self.caller_workflow_run_id,
@@ -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,
@@ -1451,6 +1462,7 @@ class Worker:
1451
1462
  idempotency_key=command.idempotency_key,
1452
1463
  payload_codec=command.payload_codec,
1453
1464
  caller_namespace=command.caller_namespace,
1465
+ target_namespace=command.target_namespace,
1454
1466
  caller_workflow_instance_id=_string_or_none(task.get("workflow_id")),
1455
1467
  caller_workflow_run_id=_string_or_none(task.get("run_id")),
1456
1468
  service_sdk_language=command.service_sdk_language,
@@ -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]
@@ -1193,6 +1196,7 @@ class NexusServiceCall:
1193
1196
  memo: dict[str, Any] | None = None
1194
1197
  search_attributes: dict[str, Any] | None = None
1195
1198
  duplicate_start_policy: str | None = None
1199
+ target_namespace: str | None = None
1196
1200
 
1197
1201
  def to_server_command(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
1198
1202
  raise RuntimeError(
@@ -1206,6 +1210,7 @@ class NexusServiceCall:
1206
1210
  "service_name": self.service_name,
1207
1211
  "operation_name": self.operation_name,
1208
1212
  "idempotency_key": self.idempotency_key,
1213
+ "target_namespace": self.target_namespace,
1209
1214
  }
1210
1215
 
1211
1216
  def recorded_result(self, value: Any) -> NexusOperationResult:
@@ -2399,6 +2404,7 @@ class WorkflowContext:
2399
2404
  wait_for: str | None = "completed",
2400
2405
  wait_timeout_seconds: int | None = None,
2401
2406
  caller_namespace: str | None = None,
2407
+ target_namespace: str | None = None,
2402
2408
  service_sdk_language: str | None = None,
2403
2409
  artifact_tuple: Mapping[str, Any] | None = None,
2404
2410
  published_artifact_worker_execution: bool | None = None,
@@ -2417,7 +2423,12 @@ class WorkflowContext:
2417
2423
  If the service reports a typed failure, replay raises
2418
2424
  :class:`~durable_workflow.errors.NexusOperationFailed` at the yield
2419
2425
  point so workflow code can compensate or let the workflow fail.
2426
+
2427
+ ``target_namespace`` addresses a service in another namespace and
2428
+ participates in the durable call identity and replay checks.
2420
2429
  """
2430
+ if target_namespace is not None and not target_namespace.strip():
2431
+ raise ValueError("target_namespace must be nonempty")
2421
2432
  args = list(arguments) if arguments is not None else []
2422
2433
  if idempotency_key is None:
2423
2434
  idempotency_key = stable_nexus_idempotency_key(
@@ -2428,6 +2439,7 @@ class WorkflowContext:
2428
2439
  service_name=service_name,
2429
2440
  operation_name=operation_name,
2430
2441
  arguments=args,
2442
+ target_namespace=target_namespace,
2431
2443
  )
2432
2444
  self._nexus_call_counter += 1
2433
2445
  return NexusServiceCall(
@@ -2441,6 +2453,7 @@ class WorkflowContext:
2441
2453
  wait_for=wait_for,
2442
2454
  wait_timeout_seconds=wait_timeout_seconds,
2443
2455
  caller_namespace=caller_namespace,
2456
+ target_namespace=target_namespace,
2444
2457
  service_sdk_language=service_sdk_language,
2445
2458
  artifact_tuple=dict(artifact_tuple) if artifact_tuple is not None else None,
2446
2459
  published_artifact_worker_execution=published_artifact_worker_execution,
@@ -2578,9 +2591,12 @@ class Replayer:
2578
2591
  def __init__(self, *, workflows: Iterable[type]) -> None:
2579
2592
  self._workflows: dict[str, type] = {}
2580
2593
  for workflow_cls in workflows:
2594
+ _workflow_handler_registries(workflow_cls)
2581
2595
  workflow_type = str(getattr(workflow_cls, "__workflow_name__", workflow_cls.__name__))
2582
2596
  if workflow_type in self._workflows:
2583
- raise ValueError(f"duplicate workflow type registered with Replayer: {workflow_type!r}")
2597
+ raise DuplicateRegistrationError(
2598
+ "workflow", workflow_type, self._workflows[workflow_type], workflow_cls,
2599
+ )
2584
2600
  self._workflows[workflow_type] = workflow_cls
2585
2601
  if not self._workflows:
2586
2602
  raise ValueError("Replayer requires at least one workflow class")
@@ -6583,6 +6599,14 @@ def _replay_state(
6583
6599
  if result_cursor < len(resolved_results):
6584
6600
  _assert_next_step_matches(cmd)
6585
6601
  recorded_value = resolved_results[result_cursor]
6602
+ if (
6603
+ isinstance(recorded_value, Mapping)
6604
+ and recorded_value.get("target_namespace") != cmd.target_namespace
6605
+ ):
6606
+ raise NonDeterministicReplayError(
6607
+ current_call_sequence, "NexusServiceCall", ["SideEffectRecorded"],
6608
+ detail="nexus_target_namespace_changed: recorded target differs from the workflow command",
6609
+ )
6586
6610
  result_cursor += 1
6587
6611
  try:
6588
6612
  next_value = cmd.recorded_result(recorded_value)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.5.3
3
+ Version: 2.5.5
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
@@ -80,6 +80,8 @@ tests/test_history_event_contract.py
80
80
  tests/test_invocable.py
81
81
  tests/test_message_streams.py
82
82
  tests/test_metrics.py
83
+ tests/test_nexus_namespace.py
84
+ tests/test_nexus_wire.py
83
85
  tests/test_order_processing_example.py
84
86
  tests/test_parallel_completion_order_replay.py
85
87
  tests/test_parallel_saga_authoring.py
@@ -95,6 +97,7 @@ tests/test_python_conformance.py
95
97
  tests/test_queries.py
96
98
  tests/test_readme_quickstart.py
97
99
  tests/test_redrive_replay.py
100
+ tests/test_registration_ambiguity.py
98
101
  tests/test_release_metadata.py
99
102
  tests/test_replay.py
100
103
  tests/test_replay_conformance.py
@@ -289,7 +289,8 @@ class TestNexusOperations:
289
289
  "/api/service-endpoints/greeter/services/shared/operations/greet/execute",
290
290
  )
291
291
  body = mock.call_args.kwargs["json"]
292
- assert body["arguments"] == ["Ada"]
292
+ assert body["payload_codec"] == "avro"
293
+ assert serializer.decode(body["arguments"], codec=body["payload_codec"]) == ["Ada"]
293
294
  assert body["mode_override"] == "sync"
294
295
  assert body["wait_for"] == "completed"
295
296
  assert body["idempotency_key"] == "idem-1"
@@ -303,7 +304,7 @@ class TestNexusOperations:
303
304
  assert recorded["caller_sdk_language"] == "sdk-python"
304
305
  assert recorded["service_sdk_language"] == "workflow-php"
305
306
  assert recorded["operation_name"] == "greet"
306
- assert recorded["request_payload"]["arguments"] == ["Ada"]
307
+ assert serializer.decode(recorded["request_payload"]["arguments"], codec="avro") == ["Ada"]
307
308
  assert recorded["response_or_failure_surface"]["result"] == {"greeting": "hello Ada"}
308
309
  assert recorded["service_call_id"] == "svc-call-1"
309
310
  assert recorded["artifact_tuple"]["sdk-python"] == "0.4.95"