durable-workflow 0.4.94__tar.gz → 0.4.96__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 (70) hide show
  1. {durable_workflow-0.4.94/src/durable_workflow.egg-info → durable_workflow-0.4.96}/PKG-INFO +31 -1
  2. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/README.md +30 -0
  3. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/pyproject.toml +1 -1
  4. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/__init__.py +14 -0
  5. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/client.py +121 -1
  6. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/errors.py +33 -0
  7. durable_workflow-0.4.96/src/durable_workflow/nexus.py +307 -0
  8. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/worker.py +110 -2
  9. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/workflow.py +183 -4
  10. {durable_workflow-0.4.94 → durable_workflow-0.4.96/src/durable_workflow.egg-info}/PKG-INFO +31 -1
  11. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow.egg-info/SOURCES.txt +1 -0
  12. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_client.py +148 -1
  13. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_replay.py +164 -0
  14. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_sync.py +22 -1
  15. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_worker.py +86 -0
  16. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/LICENSE +0 -0
  17. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/setup.cfg +0 -0
  18. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/_avro.py +0 -0
  19. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/activity.py +0 -0
  20. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/auth_composition.py +0 -0
  21. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/external_storage.py +0 -0
  22. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/external_task_input.py +0 -0
  23. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/external_task_result.py +0 -0
  24. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/history_bundle_verify.py +0 -0
  25. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/interceptors.py +0 -0
  26. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/invocable.py +0 -0
  27. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/metrics.py +0 -0
  28. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/py.typed +0 -0
  29. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/python_conformance.py +0 -0
  30. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/replay_conformance.py +0 -0
  31. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/replay_verify.py +0 -0
  32. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/retry_policy.py +0 -0
  33. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/serializer.py +0 -0
  34. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/sync.py +0 -0
  35. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/testing.py +0 -0
  36. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  37. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  38. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  39. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow.egg-info/requires.txt +0 -0
  40. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/src/durable_workflow.egg-info/top_level.txt +0 -0
  41. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_activity_context.py +0 -0
  42. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_auth_composition.py +0 -0
  43. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_control_plane_parity_fixtures.py +0 -0
  44. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_errors.py +0 -0
  45. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_external_storage.py +0 -0
  46. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_external_task_input.py +0 -0
  47. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_external_task_result.py +0 -0
  48. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_golden_history_replay.py +0 -0
  49. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_history_bundle_verify.py +0 -0
  50. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_history_event_contract.py +0 -0
  51. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_invocable.py +0 -0
  52. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_metrics.py +0 -0
  53. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_order_processing_example.py +0 -0
  54. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_public_boundary_scanner.py +0 -0
  55. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_python_conformance.py +0 -0
  56. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_queries.py +0 -0
  57. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_readme_quickstart.py +0 -0
  58. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_release_docs_audit_workflow.py +0 -0
  59. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_replay_conformance.py +0 -0
  60. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_replay_verify.py +0 -0
  61. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_retry_policy.py +0 -0
  62. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_schedules.py +0 -0
  63. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_serializer.py +0 -0
  64. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_signals.py +0 -0
  65. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_sleep.py +0 -0
  66. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_standalone_activity_client.py +0 -0
  67. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_testing_harness.py +0 -0
  68. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_updates.py +0 -0
  69. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/tests/test_wait_condition.py +0 -0
  70. {durable_workflow-0.4.94 → durable_workflow-0.4.96}/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: 0.4.94
3
+ Version: 0.4.96
4
4
  Summary: Python SDK for the Durable Workflow server (language-neutral HTTP protocol)
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -160,6 +160,36 @@ receipt = yield ctx.start_child_workflow(
160
160
  )
161
161
  ```
162
162
 
163
+ ## Nexus service calls
164
+
165
+ Workflow code can call a registered Nexus service operation through
166
+ `WorkflowContext.call_nexus_service(...)`. The worker executes the service
167
+ operation through the service-catalog API, records the response or typed
168
+ failure as a durable side-effect marker, and resumes replay from that marker
169
+ on subsequent workflow tasks.
170
+
171
+ ```python
172
+ from durable_workflow import NexusOperationFailed
173
+
174
+ try:
175
+ result = yield ctx.call_nexus_service(
176
+ "greeter",
177
+ "shared",
178
+ "greet",
179
+ ["Ada"],
180
+ service_sdk_language="workflow-php",
181
+ )
182
+ print(result.service_call_id, result.result)
183
+ except NexusOperationFailed as exc:
184
+ print(exc.service_call_id, exc.service_error_type, exc.typed_error_message)
185
+ ```
186
+
187
+ The SDK assigns a deterministic idempotency key when one is not provided and
188
+ attaches the caller workflow instance id, caller run id, `sdk-python` caller
189
+ language, target service language, operation name, request payload,
190
+ service-call id, response or failure surface, and optional artifact metadata
191
+ to the recorded result.
192
+
163
193
  ## Workflow signals, queries, and updates
164
194
 
165
195
  Signals mutate workflow state during replay:
@@ -124,6 +124,36 @@ receipt = yield ctx.start_child_workflow(
124
124
  )
125
125
  ```
126
126
 
127
+ ## Nexus service calls
128
+
129
+ Workflow code can call a registered Nexus service operation through
130
+ `WorkflowContext.call_nexus_service(...)`. The worker executes the service
131
+ operation through the service-catalog API, records the response or typed
132
+ failure as a durable side-effect marker, and resumes replay from that marker
133
+ on subsequent workflow tasks.
134
+
135
+ ```python
136
+ from durable_workflow import NexusOperationFailed
137
+
138
+ try:
139
+ result = yield ctx.call_nexus_service(
140
+ "greeter",
141
+ "shared",
142
+ "greet",
143
+ ["Ada"],
144
+ service_sdk_language="workflow-php",
145
+ )
146
+ print(result.service_call_id, result.result)
147
+ except NexusOperationFailed as exc:
148
+ print(exc.service_call_id, exc.service_error_type, exc.typed_error_message)
149
+ ```
150
+
151
+ The SDK assigns a deterministic idempotency key when one is not provided and
152
+ attaches the caller workflow instance id, caller run id, `sdk-python` caller
153
+ language, target service language, operation name, request payload,
154
+ service-call id, response or failure surface, and optional artifact metadata
155
+ to the recorded result.
156
+
127
157
  ## Workflow signals, queries, and updates
128
158
 
129
159
  Signals mutate workflow state during replay:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "0.4.94"
7
+ version = "0.4.96"
8
8
  description = "Python SDK for the Durable Workflow server (language-neutral HTTP protocol)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -60,6 +60,7 @@ from .errors import (
60
60
  DurableWorkflowError,
61
61
  InvalidArgument,
62
62
  NamespaceNotFound,
63
+ NexusOperationFailed,
63
64
  NonDeterministicReplayError,
64
65
  NonRetryableError,
65
66
  QueryFailed,
@@ -76,6 +77,12 @@ from .errors import (
76
77
  WorkflowPayloadDecodeError,
77
78
  WorkflowTerminated,
78
79
  )
80
+ from .nexus import (
81
+ NEXUS_CALLER_SDK_LANGUAGE,
82
+ NEXUS_OPERATION_RESULT_SCHEMA,
83
+ NEXUS_OPERATION_RESULT_VERSION,
84
+ NexusOperationResult,
85
+ )
79
86
  from .external_storage import (
80
87
  EXTERNAL_PAYLOAD_REFERENCE_SCHEMA,
81
88
  AzureBlobExternalStorage,
@@ -173,6 +180,7 @@ from .workflow import (
173
180
  ActivityRetryPolicy,
174
181
  ChildWorkflowRetryPolicy,
175
182
  ContinueAsNew,
183
+ NexusServiceCall,
176
184
  Replayer,
177
185
  ReplayOutcome,
178
186
  StartChildWorkflow,
@@ -197,6 +205,9 @@ __all__ = [
197
205
  "ContinueAsNew",
198
206
  "NamespaceDescription",
199
207
  "NamespaceList",
208
+ "NEXUS_CALLER_SDK_LANGUAGE",
209
+ "NEXUS_OPERATION_RESULT_SCHEMA",
210
+ "NEXUS_OPERATION_RESULT_VERSION",
200
211
  "AUTH_COMPOSITION_CONTRACT_SCHEMA",
201
212
  "AUTH_COMPOSITION_CONTRACT_VERSION",
202
213
  "AUTH_COMPOSITION_REQUIRED_EFFECTIVE_CONFIG_FIELDS",
@@ -275,6 +286,9 @@ __all__ = [
275
286
  "LocalFilesystemExternalStorage",
276
287
  "MetricsRecorder",
277
288
  "NamespaceNotFound",
289
+ "NexusOperationFailed",
290
+ "NexusOperationResult",
291
+ "NexusServiceCall",
278
292
  "NonDeterministicReplayError",
279
293
  "NoopMetrics",
280
294
  "QueryFailed",
@@ -24,7 +24,7 @@ import os
24
24
  import time
25
25
  import uuid
26
26
  import warnings
27
- from collections.abc import AsyncIterator, Callable
27
+ from collections.abc import AsyncIterator, Callable, Mapping, Sequence
28
28
  from dataclasses import dataclass
29
29
  from importlib.metadata import PackageNotFoundError
30
30
  from importlib.metadata import version as _pkg_version
@@ -43,6 +43,7 @@ from .errors import (
43
43
  )
44
44
  from .external_storage import ExternalPayloadCache, ExternalPayloadStoragePolicy, ExternalStorageDriver
45
45
  from .metrics import CLIENT_REQUEST_DURATION_SECONDS, CLIENT_REQUESTS, NOOP_METRICS, MetricsRecorder
46
+ from .nexus import NexusOperationResult, nexus_request_payload
46
47
  from .retry_policy import TransportRetryPolicy
47
48
 
48
49
  PROTOCOL_VERSION = "1.1"
@@ -163,6 +164,8 @@ class WorkflowExecution:
163
164
  input: Any = None
164
165
  output: Any = None
165
166
  payload_codec: str | None = None
167
+ memo: dict[str, Any] | None = None
168
+ search_attributes: dict[str, Any] | None = None
166
169
 
167
170
 
168
171
  @dataclass
@@ -1563,6 +1566,112 @@ class Client:
1563
1566
  """
1564
1567
  return WorkflowHandle(self, workflow_id=workflow_id, run_id=run_id, workflow_type=workflow_type)
1565
1568
 
1569
+ async def execute_nexus_operation(
1570
+ self,
1571
+ endpoint_name: str,
1572
+ service_name: str,
1573
+ operation_name: str,
1574
+ arguments: Sequence[Any] | None = None,
1575
+ *,
1576
+ mode: str | None = None,
1577
+ wait_for: str | None = None,
1578
+ wait_timeout_seconds: int | None = None,
1579
+ idempotency_key: str | None = None,
1580
+ payload_codec: str | None = None,
1581
+ caller_namespace: str | None = None,
1582
+ caller_workflow_instance_id: str | None = None,
1583
+ caller_workflow_run_id: str | None = None,
1584
+ service_sdk_language: str | None = None,
1585
+ artifact_tuple: Mapping[str, Any] | None = None,
1586
+ published_artifact_worker_execution: bool | None = None,
1587
+ target_workflow_instance_id: str | None = None,
1588
+ target_workflow_run_id: str | None = None,
1589
+ connection: str | None = None,
1590
+ queue: str | None = None,
1591
+ business_key: str | None = None,
1592
+ labels: Mapping[str, Any] | None = None,
1593
+ memo: Mapping[str, Any] | None = None,
1594
+ search_attributes: Mapping[str, Any] | None = None,
1595
+ duplicate_start_policy: str | None = None,
1596
+ raise_on_failure: bool = True,
1597
+ ) -> NexusOperationResult:
1598
+ """Execute a Nexus service operation through the service-catalog API.
1599
+
1600
+ Workflow code should normally yield
1601
+ :meth:`durable_workflow.workflow.WorkflowContext.call_nexus_service`;
1602
+ the worker uses this client method to make the single durable
1603
+ execute request and records the returned service-call surface into
1604
+ workflow history.
1605
+ """
1606
+ request_body = nexus_request_payload(
1607
+ arguments=list(arguments) if arguments is not None else [],
1608
+ payload_codec=payload_codec,
1609
+ mode=mode,
1610
+ wait_for=wait_for,
1611
+ wait_timeout_seconds=wait_timeout_seconds,
1612
+ idempotency_key=idempotency_key,
1613
+ caller_namespace=caller_namespace or self.namespace,
1614
+ caller_workflow_instance_id=caller_workflow_instance_id,
1615
+ caller_workflow_run_id=caller_workflow_run_id,
1616
+ target_workflow_instance_id=target_workflow_instance_id,
1617
+ target_workflow_run_id=target_workflow_run_id,
1618
+ connection=connection,
1619
+ queue=queue,
1620
+ business_key=business_key,
1621
+ labels=labels,
1622
+ memo=memo,
1623
+ search_attributes=search_attributes,
1624
+ duplicate_start_policy=duplicate_start_policy,
1625
+ )
1626
+ path = (
1627
+ f"/service-endpoints/{quote(endpoint_name, safe='')}"
1628
+ f"/services/{quote(service_name, safe='')}"
1629
+ f"/operations/{quote(operation_name, safe='')}/execute"
1630
+ )
1631
+ context = f"{endpoint_name}/{service_name}/{operation_name}"
1632
+
1633
+ try:
1634
+ data = await self._request("POST", path, json=request_body, context=context)
1635
+ except ServerError as exc:
1636
+ if exc.status != 409 or not isinstance(exc.body, dict):
1637
+ raise
1638
+ result = NexusOperationResult.from_response(
1639
+ exc.body,
1640
+ endpoint_name=endpoint_name,
1641
+ service_name=service_name,
1642
+ operation_name=operation_name,
1643
+ request_payload=request_body,
1644
+ service_sdk_language=service_sdk_language,
1645
+ artifact_tuple=artifact_tuple,
1646
+ published_artifact_worker_execution=published_artifact_worker_execution,
1647
+ )
1648
+ if raise_on_failure:
1649
+ raise result.to_failure() from exc
1650
+ return result
1651
+
1652
+ if not isinstance(data, dict):
1653
+ raise ServerError(
1654
+ 200,
1655
+ {
1656
+ "reason": "invalid_nexus_operation_response",
1657
+ "message": f"expected JSON object, got {type(data).__name__}",
1658
+ },
1659
+ )
1660
+
1661
+ result = NexusOperationResult.from_response(
1662
+ data,
1663
+ endpoint_name=endpoint_name,
1664
+ service_name=service_name,
1665
+ operation_name=operation_name,
1666
+ request_payload=request_body,
1667
+ service_sdk_language=service_sdk_language,
1668
+ artifact_tuple=artifact_tuple,
1669
+ published_artifact_worker_execution=published_artifact_worker_execution,
1670
+ )
1671
+ if raise_on_failure and result.is_failure:
1672
+ raise result.to_failure()
1673
+ return result
1674
+
1566
1675
  # ── Health ─────────────────────────────────────────────────────────
1567
1676
  async def health(self) -> dict[str, Any]:
1568
1677
  """Call the server's ``/health`` endpoint and return the JSON response."""
@@ -2279,6 +2388,10 @@ class Client:
2279
2388
  input=input_val,
2280
2389
  output=output_val,
2281
2390
  payload_codec=data.get("payload_codec"),
2391
+ memo=data.get("memo") if isinstance(data.get("memo"), dict) else None,
2392
+ search_attributes=(
2393
+ data.get("search_attributes") if isinstance(data.get("search_attributes"), dict) else None
2394
+ ),
2282
2395
  )
2283
2396
 
2284
2397
  async def list_workflows(
@@ -2318,6 +2431,13 @@ class Client:
2318
2431
  run_id=item.get("run_id"),
2319
2432
  workflow_type=item.get("workflow_type", ""),
2320
2433
  status=item.get("status"),
2434
+ namespace=item.get("namespace"),
2435
+ task_queue=item.get("task_queue"),
2436
+ payload_codec=item.get("payload_codec"),
2437
+ memo=item.get("memo") if isinstance(item.get("memo"), dict) else None,
2438
+ search_attributes=(
2439
+ item.get("search_attributes") if isinstance(item.get("search_attributes"), dict) else None
2440
+ ),
2321
2441
  )
2322
2442
  for item in items
2323
2443
  ]
@@ -44,6 +44,39 @@ class ServerError(DurableWorkflowError):
44
44
  return None
45
45
 
46
46
 
47
+ class NexusOperationFailed(DurableWorkflowError):
48
+ """A Nexus service operation completed with a typed service failure."""
49
+
50
+ def __init__(
51
+ self,
52
+ message: str,
53
+ *,
54
+ service_call_id: str | None = None,
55
+ endpoint_name: str | None = None,
56
+ service_name: str | None = None,
57
+ operation_name: str | None = None,
58
+ status: str | None = None,
59
+ outcome: str | None = None,
60
+ reason: str | None = None,
61
+ service_error_type: str | None = None,
62
+ caller_observed_error_type: str | None = None,
63
+ typed_error_message: str | None = None,
64
+ body: object | None = None,
65
+ ) -> None:
66
+ super().__init__(message)
67
+ self.service_call_id = service_call_id
68
+ self.endpoint_name = endpoint_name
69
+ self.service_name = service_name
70
+ self.operation_name = operation_name
71
+ self.status = status
72
+ self.outcome = outcome
73
+ self.reason = reason
74
+ self.service_error_type = service_error_type
75
+ self.caller_observed_error_type = caller_observed_error_type
76
+ self.typed_error_message = typed_error_message
77
+ self.body = body
78
+
79
+
47
80
  class WorkflowFailed(DurableWorkflowError):
48
81
  """A workflow finished in the ``failed`` state.
49
82
 
@@ -0,0 +1,307 @@
1
+ """Nexus service-operation request and replay result helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from collections.abc import Mapping, Sequence
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+ from .errors import NexusOperationFailed
12
+
13
+ NEXUS_OPERATION_RESULT_SCHEMA = "durable-workflow.v2.sdk-python.nexus-operation-result"
14
+ NEXUS_OPERATION_RESULT_VERSION = 1
15
+ NEXUS_CALLER_SDK_LANGUAGE = "sdk-python"
16
+
17
+ _FAILURE_STATUSES = {"failed", "cancelled", "canceled", "timed_out", "timeout"}
18
+ _FAILURE_OUTCOMES = {
19
+ "failed",
20
+ "handler_failed",
21
+ "cancelled",
22
+ "canceled",
23
+ "timed_out",
24
+ "timeout",
25
+ "dispatch_failed",
26
+ "handler_unavailable",
27
+ "worker_unavailable",
28
+ }
29
+
30
+
31
+ def _optional_str(value: Any) -> str | None:
32
+ return value if isinstance(value, str) and value else None
33
+
34
+
35
+ def _json_default(value: Any) -> str:
36
+ return str(value)
37
+
38
+
39
+ def _clean_dict(values: Mapping[str, Any]) -> dict[str, Any]:
40
+ return {key: value for key, value in values.items() if value is not None}
41
+
42
+
43
+ def stable_nexus_idempotency_key(
44
+ *,
45
+ workflow_id: str,
46
+ run_id: str,
47
+ ordinal: int,
48
+ endpoint_name: str,
49
+ service_name: str,
50
+ operation_name: str,
51
+ arguments: Sequence[Any],
52
+ ) -> str:
53
+ """Return a deterministic idempotency key for one workflow-side Nexus call."""
54
+ source = {
55
+ "schema": "durable-workflow.v2.sdk-python.nexus-idempotency-key",
56
+ "workflow_id": workflow_id,
57
+ "run_id": run_id,
58
+ "ordinal": ordinal,
59
+ "endpoint_name": endpoint_name,
60
+ "service_name": service_name,
61
+ "operation_name": operation_name,
62
+ "arguments": list(arguments),
63
+ }
64
+ encoded = json.dumps(source, sort_keys=True, separators=(",", ":"), default=_json_default)
65
+ return f"dw-py-nexus-{hashlib.sha256(encoded.encode()).hexdigest()}"
66
+
67
+
68
+ def nexus_request_payload(
69
+ *,
70
+ arguments: Sequence[Any],
71
+ payload_codec: str | None = None,
72
+ mode: str | None = None,
73
+ wait_for: str | None = None,
74
+ wait_timeout_seconds: int | None = None,
75
+ idempotency_key: str | None = None,
76
+ caller_namespace: str | None = None,
77
+ caller_workflow_instance_id: str | None = None,
78
+ caller_workflow_run_id: str | None = None,
79
+ target_workflow_instance_id: str | None = None,
80
+ target_workflow_run_id: str | None = None,
81
+ connection: str | None = None,
82
+ queue: str | None = None,
83
+ business_key: str | None = None,
84
+ labels: Mapping[str, Any] | None = None,
85
+ memo: Mapping[str, Any] | None = None,
86
+ search_attributes: Mapping[str, Any] | None = None,
87
+ duplicate_start_policy: str | None = None,
88
+ ) -> dict[str, Any]:
89
+ """Build the server execute-operation request body."""
90
+ return _clean_dict({
91
+ "arguments": list(arguments),
92
+ "payload_codec": payload_codec,
93
+ "mode_override": mode,
94
+ "wait_for": wait_for,
95
+ "wait_timeout_seconds": wait_timeout_seconds,
96
+ "idempotency_key": idempotency_key,
97
+ "caller_namespace": caller_namespace,
98
+ "caller_workflow_instance_id": caller_workflow_instance_id,
99
+ "caller_workflow_run_id": caller_workflow_run_id,
100
+ "target_workflow_instance_id": target_workflow_instance_id,
101
+ "target_workflow_run_id": target_workflow_run_id,
102
+ "connection": connection,
103
+ "queue": queue,
104
+ "business_key": business_key,
105
+ "labels": dict(labels) if labels is not None else None,
106
+ "memo": dict(memo) if memo is not None else None,
107
+ "search_attributes": dict(search_attributes) if search_attributes is not None else None,
108
+ "duplicate_start_policy": duplicate_start_policy,
109
+ })
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class NexusOperationResult:
114
+ """Durable result recorded for a Nexus service operation."""
115
+
116
+ accepted: bool
117
+ service_call_id: str
118
+ endpoint_name: str
119
+ service_name: str
120
+ operation_name: str
121
+ caller_workflow_instance_id: str | None = None
122
+ caller_workflow_run_id: str | None = None
123
+ caller_sdk_language: str = NEXUS_CALLER_SDK_LANGUAGE
124
+ service_sdk_language: str | None = None
125
+ request_payload: dict[str, Any] = field(default_factory=dict)
126
+ response_or_failure_surface: dict[str, Any] = field(default_factory=dict)
127
+ artifact_tuple: dict[str, Any] | None = None
128
+ published_artifact_worker_execution: bool | None = None
129
+ status: str | None = None
130
+ outcome: str | None = None
131
+ reason: str | None = None
132
+ message: str | None = None
133
+ service_error_type: str | None = None
134
+ caller_observed_error_type: str | None = None
135
+ typed_error_message: str | None = None
136
+ result: Any = None
137
+
138
+ @classmethod
139
+ def from_response(
140
+ cls,
141
+ data: Mapping[str, Any],
142
+ *,
143
+ endpoint_name: str,
144
+ service_name: str,
145
+ operation_name: str,
146
+ request_payload: Mapping[str, Any],
147
+ service_sdk_language: str | None = None,
148
+ artifact_tuple: Mapping[str, Any] | None = None,
149
+ published_artifact_worker_execution: bool | None = None,
150
+ ) -> NexusOperationResult:
151
+ call = data.get("service_call")
152
+ source = call if isinstance(call, Mapping) else data
153
+ service_call_id = (
154
+ _optional_str(source.get("service_call_id"))
155
+ or _optional_str(source.get("id"))
156
+ or _optional_str(data.get("service_call_id"))
157
+ or _optional_str(data.get("id"))
158
+ or ""
159
+ )
160
+ accepted = data.get("accepted", source.get("accepted", True)) is not False
161
+ raw = dict(data)
162
+ response_value = source.get("result", data.get("result"))
163
+ return cls(
164
+ accepted=accepted,
165
+ service_call_id=service_call_id,
166
+ endpoint_name=endpoint_name,
167
+ service_name=service_name,
168
+ operation_name=_optional_str(source.get("operation_name")) or operation_name,
169
+ caller_workflow_instance_id=_optional_str(source.get("caller_workflow_instance_id"))
170
+ or _optional_str(data.get("caller_workflow_instance_id"))
171
+ or _optional_str(request_payload.get("caller_workflow_instance_id")),
172
+ caller_workflow_run_id=_optional_str(source.get("caller_workflow_run_id"))
173
+ or _optional_str(data.get("caller_workflow_run_id"))
174
+ or _optional_str(request_payload.get("caller_workflow_run_id")),
175
+ service_sdk_language=service_sdk_language,
176
+ request_payload=dict(request_payload),
177
+ response_or_failure_surface=raw,
178
+ artifact_tuple=dict(artifact_tuple) if artifact_tuple is not None else None,
179
+ published_artifact_worker_execution=published_artifact_worker_execution,
180
+ status=_optional_str(source.get("status")) or _optional_str(data.get("status")),
181
+ outcome=_optional_str(data.get("outcome")) or _optional_str(source.get("outcome")),
182
+ reason=_optional_str(data.get("reason")) or _optional_str(source.get("reason")),
183
+ message=_optional_str(data.get("message")) or _optional_str(source.get("message")),
184
+ service_error_type=_optional_str(source.get("service_error_type"))
185
+ or _optional_str(data.get("service_error_type")),
186
+ caller_observed_error_type=_optional_str(source.get("caller_observed_error_type"))
187
+ or _optional_str(data.get("caller_observed_error_type")),
188
+ typed_error_message=_optional_str(source.get("typed_error_message"))
189
+ or _optional_str(data.get("typed_error_message")),
190
+ result=response_value,
191
+ )
192
+
193
+ @classmethod
194
+ def from_recorded_payload(cls, value: Any, *, expected: Mapping[str, Any]) -> NexusOperationResult:
195
+ if not isinstance(value, Mapping):
196
+ raise ValueError("recorded Nexus operation result must be a mapping")
197
+ if value.get("schema") != NEXUS_OPERATION_RESULT_SCHEMA:
198
+ raise ValueError("recorded Nexus operation result schema is not recognized")
199
+ if value.get("version") != NEXUS_OPERATION_RESULT_VERSION:
200
+ raise ValueError("recorded Nexus operation result version is not supported")
201
+
202
+ for key in ("endpoint_name", "service_name", "operation_name", "idempotency_key"):
203
+ expected_value = expected.get(key)
204
+ recorded_value = value.get(key)
205
+ if expected_value is not None and recorded_value is not None and expected_value != recorded_value:
206
+ raise ValueError(
207
+ f"recorded Nexus operation {key} {recorded_value!r} does not match current "
208
+ f"workflow command {expected_value!r}"
209
+ )
210
+
211
+ response_surface = value.get("response_or_failure_surface")
212
+ request_payload = value.get("request_payload")
213
+ artifact_tuple = value.get("artifact_tuple")
214
+ result = cls(
215
+ accepted=value.get("accepted") is not False,
216
+ service_call_id=str(value.get("service_call_id") or ""),
217
+ endpoint_name=str(value.get("endpoint_name") or expected.get("endpoint_name") or ""),
218
+ service_name=str(value.get("service_name") or expected.get("service_name") or ""),
219
+ operation_name=str(value.get("operation_name") or expected.get("operation_name") or ""),
220
+ caller_workflow_instance_id=_optional_str(value.get("caller_workflow_instance_id")),
221
+ caller_workflow_run_id=_optional_str(value.get("caller_workflow_run_id")),
222
+ caller_sdk_language=str(value.get("caller_sdk_language") or NEXUS_CALLER_SDK_LANGUAGE),
223
+ service_sdk_language=_optional_str(value.get("service_sdk_language")),
224
+ request_payload=dict(request_payload) if isinstance(request_payload, Mapping) else {},
225
+ response_or_failure_surface=dict(response_surface) if isinstance(response_surface, Mapping) else {},
226
+ artifact_tuple=dict(artifact_tuple) if isinstance(artifact_tuple, Mapping) else None,
227
+ published_artifact_worker_execution=value.get("published_artifact_worker_execution")
228
+ if isinstance(value.get("published_artifact_worker_execution"), bool)
229
+ else None,
230
+ status=_optional_str(value.get("status")),
231
+ outcome=_optional_str(value.get("outcome")),
232
+ reason=_optional_str(value.get("reason")),
233
+ message=_optional_str(value.get("message")),
234
+ service_error_type=_optional_str(value.get("service_error_type")),
235
+ caller_observed_error_type=_optional_str(value.get("caller_observed_error_type")),
236
+ typed_error_message=_optional_str(value.get("typed_error_message")),
237
+ result=value.get("result"),
238
+ )
239
+ if result.is_failure:
240
+ raise result.to_failure()
241
+ return result
242
+
243
+ @property
244
+ def is_failure(self) -> bool:
245
+ status = (self.status or "").lower()
246
+ outcome = (self.outcome or "").lower()
247
+ return (
248
+ not self.accepted
249
+ or status in _FAILURE_STATUSES
250
+ or outcome in _FAILURE_OUTCOMES
251
+ or outcome.startswith("rejected")
252
+ or self.service_error_type is not None
253
+ or self.caller_observed_error_type is not None
254
+ or self.typed_error_message is not None
255
+ )
256
+
257
+ def to_failure(self) -> NexusOperationFailed:
258
+ message = (
259
+ self.typed_error_message
260
+ or self.message
261
+ or self.reason
262
+ or self.outcome
263
+ or self.status
264
+ or "Nexus service operation failed"
265
+ )
266
+ return NexusOperationFailed(
267
+ message,
268
+ service_call_id=self.service_call_id or None,
269
+ endpoint_name=self.endpoint_name,
270
+ service_name=self.service_name,
271
+ operation_name=self.operation_name,
272
+ status=self.status,
273
+ outcome=self.outcome,
274
+ reason=self.reason,
275
+ service_error_type=self.service_error_type,
276
+ caller_observed_error_type=self.caller_observed_error_type,
277
+ typed_error_message=self.typed_error_message,
278
+ body=self.response_or_failure_surface,
279
+ )
280
+
281
+ def to_recorded_payload(self) -> dict[str, Any]:
282
+ return _clean_dict({
283
+ "schema": NEXUS_OPERATION_RESULT_SCHEMA,
284
+ "version": NEXUS_OPERATION_RESULT_VERSION,
285
+ "accepted": self.accepted,
286
+ "service_call_id": self.service_call_id,
287
+ "endpoint_name": self.endpoint_name,
288
+ "service_name": self.service_name,
289
+ "operation_name": self.operation_name,
290
+ "idempotency_key": self.request_payload.get("idempotency_key"),
291
+ "caller_workflow_instance_id": self.caller_workflow_instance_id,
292
+ "caller_workflow_run_id": self.caller_workflow_run_id,
293
+ "caller_sdk_language": self.caller_sdk_language,
294
+ "service_sdk_language": self.service_sdk_language,
295
+ "request_payload": self.request_payload,
296
+ "response_or_failure_surface": self.response_or_failure_surface,
297
+ "artifact_tuple": self.artifact_tuple,
298
+ "published_artifact_worker_execution": self.published_artifact_worker_execution,
299
+ "status": self.status,
300
+ "outcome": self.outcome,
301
+ "reason": self.reason,
302
+ "message": self.message,
303
+ "service_error_type": self.service_error_type,
304
+ "caller_observed_error_type": self.caller_observed_error_type,
305
+ "typed_error_message": self.typed_error_message,
306
+ "result": self.result,
307
+ })