dex-python-sdk 0.0.2__cp311-abi3-win_amd64.whl
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.
- dex/__init__.py +146 -0
- dex/_grpc_errors.py +73 -0
- dex/_invocation_context.py +269 -0
- dex/_native.pyd +0 -0
- dex/_native.pyi +20 -0
- dex/_utils.py +18 -0
- dex/_value_hydrator.py +223 -0
- dex/_value_mapper.py +174 -0
- dex/_worker_dispatcher.py +451 -0
- dex/_worker_service.py +60 -0
- dex/attribute.py +87 -0
- dex/blob_cache.py +73 -0
- dex/channel.py +165 -0
- dex/client.py +715 -0
- dex/client_options.py +19 -0
- dex/codec.py +286 -0
- dex/command_request.py +120 -0
- dex/command_results.py +107 -0
- dex/communication.py +136 -0
- dex/communication_schema.py +54 -0
- dex/condition.py +74 -0
- dex/context.py +86 -0
- dex/data_attributes.py +70 -0
- dex/dexpb/__init__.py +1 -0
- dex/dexpb/dex_pb2.py +381 -0
- dex/dexpb/dex_pb2.pyi +1734 -0
- dex/dexpb/dex_pb2_grpc.py +1298 -0
- dex/errors.py +109 -0
- dex/flow.py +456 -0
- dex/flow_config.py +29 -0
- dex/flow_info.py +51 -0
- dex/flow_options.py +122 -0
- dex/object_encoder.py +799 -0
- dex/persistence.py +89 -0
- dex/persistence_options.py +12 -0
- dex/persistence_schema.py +51 -0
- dex/py.typed +1 -0
- dex/registry.py +204 -0
- dex/reset_workflow_type_and_options.py +67 -0
- dex/rpc.py +93 -0
- dex/runtime_errors.py +81 -0
- dex/search_attributes.py +184 -0
- dex/state_decision.py +153 -0
- dex/state_execution_locals.py +66 -0
- dex/state_movement.py +115 -0
- dex/state_schema.py +48 -0
- dex/step.py +194 -0
- dex/step_execution.py +42 -0
- dex/stop_workflow_options.py +18 -0
- dex/tests/__init__.py +80 -0
- dex/tests/dex-service-env/.env +7 -0
- dex/tests/dex-service-env/docker-compose-init.sh +44 -0
- dex/tests/dex-service-env/docker-compose.yml +97 -0
- dex/tests/dex-service-env/dynamicconfig/README.md +39 -0
- dex/tests/dex-service-env/dynamicconfig/development-sql.yaml +9 -0
- dex/tests/dex-service-env/dynamicconfig/docker.yaml +2 -0
- dex/tests/test_abnormal_exit_workflow.py +43 -0
- dex/tests/test_basic_workflow.py +70 -0
- dex/tests/test_conditional_complete.py +50 -0
- dex/tests/test_describe_workflow.py +40 -0
- dex/tests/test_empty_data_decodes_properly.py +74 -0
- dex/tests/test_internal_channel.py +28 -0
- dex/tests/test_internal_channel_with_no_prefix_channel.py +41 -0
- dex/tests/test_persistence_data_attributes.py +62 -0
- dex/tests/test_persistence_search_attributes.py +127 -0
- dex/tests/test_persistence_state_execution_locals.py +38 -0
- dex/tests/test_rpc.py +64 -0
- dex/tests/test_rpc_with_memo.py +195 -0
- dex/tests/test_rpc_with_memo_duplicate_java_tests.py +117 -0
- dex/tests/test_signal.py +51 -0
- dex/tests/test_skip_wait_until.py +76 -0
- dex/tests/test_state_failure_recovery.py +28 -0
- dex/tests/test_timer.py +35 -0
- dex/tests/test_wait_for_state_execution_completion.py +53 -0
- dex/tests/test_workflow_errors.py +87 -0
- dex/tests/test_workflow_state_options.py +118 -0
- dex/tests/test_workflow_state_options_override.py +44 -0
- dex/tests/worker_server.py +64 -0
- dex/tests/workflows/abnormal_exit_workflow.py +42 -0
- dex/tests/workflows/basic_workflow.py +62 -0
- dex/tests/workflows/conditional_complete_workflow.py +95 -0
- dex/tests/workflows/describe_workflow.py +46 -0
- dex/tests/workflows/empty_data_workflow.py +45 -0
- dex/tests/workflows/internal_channel_workflow.py +129 -0
- dex/tests/workflows/internal_channel_workflow_with_no_prefix_channel.py +100 -0
- dex/tests/workflows/java_duplicate_rpc_memo_workflow.py +276 -0
- dex/tests/workflows/persistence_data_attributes_workflow.py +98 -0
- dex/tests/workflows/persistence_search_attributes_workflow.py +159 -0
- dex/tests/workflows/persistence_state_execution_local_workflow.py +63 -0
- dex/tests/workflows/recovery_workflow.py +82 -0
- dex/tests/workflows/rpc_memo_workflow.py +231 -0
- dex/tests/workflows/rpc_workflow.py +117 -0
- dex/tests/workflows/state_options_override_workflow.py +93 -0
- dex/tests/workflows/state_options_workflow.py +84 -0
- dex/tests/workflows/timer_workflow.py +46 -0
- dex/tests/workflows/wait_for_state_with_state_execution_id_workflow.py +70 -0
- dex/tests/workflows/wait_for_state_with_wait_for_key_workflow.py +71 -0
- dex/tests/workflows/wait_internal_channel_workflow.py +47 -0
- dex/tests/workflows/wait_signal_workflow.py +147 -0
- dex/timer.py +21 -0
- dex/type_store.py +99 -0
- dex/unregistered_client.py +585 -0
- dex/utils/__init__.py +3 -0
- dex/utils/dex_typing.py +25 -0
- dex/utils/persistence_utils.py +32 -0
- dex/wait.py +49 -0
- dex/worker.py +121 -0
- dex/worker_options.py +22 -0
- dex/worker_service.py +432 -0
- dex/workflow.py +79 -0
- dex/workflow_context.py +44 -0
- dex/workflow_info.py +16 -0
- dex/workflow_options.py +74 -0
- dex/workflow_state.py +123 -0
- dex/workflow_state_options.py +154 -0
- dex_python_sdk-0.0.2.dist-info/METADATA +202 -0
- dex_python_sdk-0.0.2.dist-info/RECORD +121 -0
- dex_python_sdk-0.0.2.dist-info/WHEEL +4 -0
- dex_python_sdk-0.0.2.dist-info/licenses/LEGACY_NOTICES.md +61 -0
- dex_python_sdk-0.0.2.dist-info/licenses/LICENSE +192 -0
- dex_python_sdk-0.0.2.dist-info/sboms/dex-blob-cache-python.cyclonedx.json +2406 -0
dex/errors.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Legacy Materials in this file remain under their original licenses.
|
|
2
|
+
# See LEGACY_NOTICES.md.
|
|
3
|
+
|
|
4
|
+
# Modifications Copyright (c) 2026 Super Durable, Inc.
|
|
5
|
+
#
|
|
6
|
+
# Modifications after the Legacy Cutoff are licensed under the
|
|
7
|
+
# Super Durable Source License 1.0.
|
|
8
|
+
# Legacy Materials remain under their original licenses.
|
|
9
|
+
# See LICENSE and LEGACY_NOTICES.md.
|
|
10
|
+
|
|
11
|
+
import json as jsonlib
|
|
12
|
+
from httpx import Response
|
|
13
|
+
|
|
14
|
+
from dex.dex_api.models import (
|
|
15
|
+
ErrorResponse,
|
|
16
|
+
ErrorSubStatus,
|
|
17
|
+
WorkflowGetResponse,
|
|
18
|
+
WorkflowStatus,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
class WorkflowDefinitionError(Exception):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
class InvalidArgumentError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
class NotRegisteredError(Exception):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
class HttpError(RuntimeError):
|
|
31
|
+
def __init__(self, status: int, err_resp: ErrorResponse):
|
|
32
|
+
super().__init__(err_resp.detail)
|
|
33
|
+
self.sub_status = err_resp.sub_status
|
|
34
|
+
self.error_resp = err_resp
|
|
35
|
+
self.status = status
|
|
36
|
+
|
|
37
|
+
class ClientSideError(HttpError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
class ServerSideError(HttpError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
class WorkflowStillRunningError(ClientSideError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
class WorkflowRPCExecutionError(ClientSideError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
class WorkflowRPCAcquiringLockFailure(ClientSideError):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
class WorkflowAlreadyStartedError(ClientSideError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
class WorkflowNotExistsError(ClientSideError):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
def process_http_error(status: int, err_resp: ErrorResponse) -> HttpError:
|
|
59
|
+
if 400 <= status < 500:
|
|
60
|
+
if err_resp.sub_status == ErrorSubStatus.WORKFLOW_ALREADY_STARTED_SUB_STATUS:
|
|
61
|
+
return WorkflowAlreadyStartedError(status, err_resp)
|
|
62
|
+
elif err_resp.sub_status == ErrorSubStatus.WORKFLOW_NOT_EXISTS_SUB_STATUS:
|
|
63
|
+
return WorkflowNotExistsError(status, err_resp)
|
|
64
|
+
else:
|
|
65
|
+
return ClientSideError(status, err_resp)
|
|
66
|
+
else:
|
|
67
|
+
return ServerSideError(status, err_resp)
|
|
68
|
+
|
|
69
|
+
class WorkflowAbnormalExitError(RuntimeError):
|
|
70
|
+
def __init__(self, get_response: WorkflowGetResponse):
|
|
71
|
+
self.run_id = get_response.workflow_run_id
|
|
72
|
+
self.workflow_status = get_response.workflow_status
|
|
73
|
+
self.error_type = get_response.error_type
|
|
74
|
+
self.error_message = get_response.error_message
|
|
75
|
+
# TODO add methods to decode the state results into objects
|
|
76
|
+
self._state_results = get_response.results
|
|
77
|
+
|
|
78
|
+
class WorkflowFailed(WorkflowAbnormalExitError):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
class WorkflowTimeout(WorkflowAbnormalExitError):
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
class WorkflowTerminated(WorkflowAbnormalExitError):
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
class WorkflowCanceled(WorkflowAbnormalExitError):
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
def process_workflow_abnormal_exit_error(
|
|
91
|
+
get_response: WorkflowGetResponse,
|
|
92
|
+
) -> WorkflowAbnormalExitError:
|
|
93
|
+
status = get_response.workflow_status
|
|
94
|
+
if status == WorkflowStatus.CANCELED:
|
|
95
|
+
return WorkflowCanceled(get_response)
|
|
96
|
+
elif status == WorkflowStatus.FAILED:
|
|
97
|
+
return WorkflowFailed(get_response)
|
|
98
|
+
elif status == WorkflowStatus.TERMINATED:
|
|
99
|
+
return WorkflowTerminated(get_response)
|
|
100
|
+
elif status == WorkflowStatus.TIMEOUT:
|
|
101
|
+
return WorkflowTimeout(get_response)
|
|
102
|
+
return WorkflowAbnormalExitError(get_response)
|
|
103
|
+
|
|
104
|
+
def parse_unexpected_error(err) -> ErrorResponse:
|
|
105
|
+
try:
|
|
106
|
+
response = Response(err.status_code, content=err.content)
|
|
107
|
+
return ErrorResponse.from_dict(response.json())
|
|
108
|
+
except Exception:
|
|
109
|
+
return ErrorResponse.from_dict(jsonlib.loads(err.content))
|
dex/flow.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
# Legacy Materials in this file remain under their original licenses.
|
|
2
|
+
# See LEGACY_NOTICES.md.
|
|
3
|
+
|
|
4
|
+
# Modifications Copyright (c) 2026 Super Durable, Inc.
|
|
5
|
+
#
|
|
6
|
+
# Modifications after the Legacy Cutoff are licensed under the
|
|
7
|
+
# Super Durable Source License 1.0.
|
|
8
|
+
# Legacy Materials remain under their original licenses.
|
|
9
|
+
# See LICENSE and LEGACY_NOTICES.md.
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from abc import ABC
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import timedelta
|
|
16
|
+
from inspect import iscoroutinefunction, signature
|
|
17
|
+
from types import MappingProxyType
|
|
18
|
+
from typing import (
|
|
19
|
+
Any,
|
|
20
|
+
Callable,
|
|
21
|
+
Generic,
|
|
22
|
+
Sequence,
|
|
23
|
+
TypeVar,
|
|
24
|
+
get_args,
|
|
25
|
+
get_origin,
|
|
26
|
+
get_type_hints,
|
|
27
|
+
overload,
|
|
28
|
+
)
|
|
29
|
+
from urllib.parse import quote
|
|
30
|
+
|
|
31
|
+
from dex._utils import require_name
|
|
32
|
+
from dex.attribute import Attribute, AttributeLock, AttributeMap
|
|
33
|
+
from dex.channel import Channel, ChannelMap
|
|
34
|
+
from dex.codec import Codec, CodecRegistry
|
|
35
|
+
from dex.context import Context
|
|
36
|
+
from dex.step import Step, StepDecision, StepList, StepMovement, _StepDef
|
|
37
|
+
from dex.wait import Wait
|
|
38
|
+
|
|
39
|
+
OutputT = TypeVar("OutputT")
|
|
40
|
+
StartT = TypeVar("StartT")
|
|
41
|
+
CallableT = TypeVar("CallableT", bound=Callable[..., Any])
|
|
42
|
+
_PersistenceDefinition = (
|
|
43
|
+
Attribute[Any] | AttributeMap[Any] | Channel[Any] | ChannelMap[Any]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class _RPCOptions:
|
|
49
|
+
name: str | None
|
|
50
|
+
timeout: timedelta | None
|
|
51
|
+
lock_attributes: tuple[AttributeLock, ...]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@overload
|
|
55
|
+
def rpc(handler: CallableT) -> CallableT: ...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@overload
|
|
59
|
+
def rpc(
|
|
60
|
+
*,
|
|
61
|
+
name: str | None = None,
|
|
62
|
+
timeout: timedelta | None = None,
|
|
63
|
+
lock_attributes: Sequence[AttributeLock] = (),
|
|
64
|
+
) -> Callable[[CallableT], CallableT]: ...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def rpc(
|
|
68
|
+
handler: CallableT | None = None,
|
|
69
|
+
*,
|
|
70
|
+
name: str | None = None,
|
|
71
|
+
timeout: timedelta | None = None,
|
|
72
|
+
lock_attributes: Sequence[AttributeLock] = (),
|
|
73
|
+
) -> CallableT | Callable[[CallableT], CallableT]:
|
|
74
|
+
if name is not None:
|
|
75
|
+
require_name(name)
|
|
76
|
+
if timeout is not None and timeout < timedelta(0):
|
|
77
|
+
raise ValueError("RPC timeout must not be negative")
|
|
78
|
+
|
|
79
|
+
def decorate(handler: CallableT) -> CallableT:
|
|
80
|
+
setattr(
|
|
81
|
+
handler,
|
|
82
|
+
"__dex_rpc_options__",
|
|
83
|
+
_RPCOptions(name, timeout, tuple(lock_attributes)),
|
|
84
|
+
)
|
|
85
|
+
return handler
|
|
86
|
+
|
|
87
|
+
if handler is not None:
|
|
88
|
+
return decorate(handler)
|
|
89
|
+
return decorate
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class RPCResult(Generic[OutputT]):
|
|
94
|
+
output: OutputT
|
|
95
|
+
next_steps: tuple[StepMovement[Any], ...] = ()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class PersistenceSchema:
|
|
100
|
+
attributes: tuple[Attribute[Any] | AttributeMap[Any], ...] = ()
|
|
101
|
+
channels: tuple[Channel[Any] | ChannelMap[Any], ...] = ()
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def of(*definitions: _PersistenceDefinition) -> PersistenceSchema:
|
|
105
|
+
attributes: list[Attribute[Any] | AttributeMap[Any]] = []
|
|
106
|
+
channels: list[Channel[Any] | ChannelMap[Any]] = []
|
|
107
|
+
for definition in definitions:
|
|
108
|
+
if isinstance(definition, (Attribute, AttributeMap)):
|
|
109
|
+
attributes.append(definition)
|
|
110
|
+
elif isinstance(definition, (Channel, ChannelMap)):
|
|
111
|
+
channels.append(definition)
|
|
112
|
+
else:
|
|
113
|
+
raise TypeError("unsupported persistence definition")
|
|
114
|
+
return PersistenceSchema(tuple(attributes), tuple(channels))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Flow(Generic[StartT], ABC):
|
|
118
|
+
def get_flow_type(self) -> str:
|
|
119
|
+
return type(self).__name__
|
|
120
|
+
|
|
121
|
+
def get_steps(self) -> StepList[StartT]:
|
|
122
|
+
return StepList.empty()
|
|
123
|
+
|
|
124
|
+
def get_persistence_schema(self) -> PersistenceSchema:
|
|
125
|
+
return PersistenceSchema.of()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class _RegisteredStep:
|
|
130
|
+
name: str
|
|
131
|
+
step: Step[Any]
|
|
132
|
+
input_codec: Codec[Any]
|
|
133
|
+
starting: bool
|
|
134
|
+
skips_wait_for: bool
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class _RegisteredRPC:
|
|
139
|
+
name: str
|
|
140
|
+
method: Callable[..., Any]
|
|
141
|
+
options: _RPCOptions
|
|
142
|
+
input_codec: Codec[Any] | None
|
|
143
|
+
output_codec: Codec[Any] | None
|
|
144
|
+
locks: tuple[str, ...]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class _RegisteredFlow:
|
|
149
|
+
name: str
|
|
150
|
+
flow: Flow[Any]
|
|
151
|
+
steps: MappingProxyType[str, _RegisteredStep]
|
|
152
|
+
start_step: _RegisteredStep | None
|
|
153
|
+
rpcs: MappingProxyType[str, _RegisteredRPC]
|
|
154
|
+
persistence: MappingProxyType[str, _PersistenceDefinition]
|
|
155
|
+
|
|
156
|
+
def step(self, name: str) -> _RegisteredStep:
|
|
157
|
+
try:
|
|
158
|
+
return self.steps[name]
|
|
159
|
+
except KeyError as error:
|
|
160
|
+
raise ValueError(f"Step is not registered: {name}") from error
|
|
161
|
+
|
|
162
|
+
def rpc(self, name: str) -> _RegisteredRPC:
|
|
163
|
+
try:
|
|
164
|
+
return self.rpcs[name]
|
|
165
|
+
except KeyError as error:
|
|
166
|
+
raise ValueError(f"RPC is not registered: {name}") from error
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True)
|
|
170
|
+
class Registry:
|
|
171
|
+
flows: tuple[Flow[Any], ...]
|
|
172
|
+
codec_registry: CodecRegistry
|
|
173
|
+
_steps: tuple[_RegisteredStep, ...]
|
|
174
|
+
_rpcs: tuple[_RegisteredRPC, ...]
|
|
175
|
+
_registered_flows: MappingProxyType[str, _RegisteredFlow]
|
|
176
|
+
|
|
177
|
+
def __init__(
|
|
178
|
+
self,
|
|
179
|
+
flows: Sequence[Flow[Any]],
|
|
180
|
+
codec_registry: CodecRegistry | None = None,
|
|
181
|
+
) -> None:
|
|
182
|
+
immutable_flows = tuple(flows)
|
|
183
|
+
resolved_codecs = codec_registry or CodecRegistry()
|
|
184
|
+
registered_flows = self._assemble(immutable_flows, resolved_codecs)
|
|
185
|
+
registered_steps = tuple(
|
|
186
|
+
step
|
|
187
|
+
for registered_flow in registered_flows.values()
|
|
188
|
+
for step in registered_flow.steps.values()
|
|
189
|
+
)
|
|
190
|
+
registered_rpcs = tuple(
|
|
191
|
+
registered_rpc
|
|
192
|
+
for registered_flow in registered_flows.values()
|
|
193
|
+
for registered_rpc in registered_flow.rpcs.values()
|
|
194
|
+
)
|
|
195
|
+
object.__setattr__(self, "flows", immutable_flows)
|
|
196
|
+
object.__setattr__(self, "codec_registry", resolved_codecs)
|
|
197
|
+
object.__setattr__(self, "_steps", registered_steps)
|
|
198
|
+
object.__setattr__(self, "_rpcs", registered_rpcs)
|
|
199
|
+
object.__setattr__(
|
|
200
|
+
self,
|
|
201
|
+
"_registered_flows",
|
|
202
|
+
MappingProxyType(registered_flows),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
@staticmethod
|
|
206
|
+
def _assemble(
|
|
207
|
+
flows: tuple[Flow[Any], ...], codec_registry: CodecRegistry
|
|
208
|
+
) -> dict[str, _RegisteredFlow]:
|
|
209
|
+
registered_flows: dict[str, _RegisteredFlow] = {}
|
|
210
|
+
for flow in flows:
|
|
211
|
+
if not isinstance(flow, Flow):
|
|
212
|
+
raise TypeError("Flow definition is invalid")
|
|
213
|
+
flow_name = flow.get_flow_type()
|
|
214
|
+
require_name(flow_name)
|
|
215
|
+
if flow_name in registered_flows:
|
|
216
|
+
raise ValueError(f"duplicate Flow {flow_name}")
|
|
217
|
+
registered_flows[flow_name] = Registry._assemble_flow(
|
|
218
|
+
flow_name,
|
|
219
|
+
flow,
|
|
220
|
+
codec_registry,
|
|
221
|
+
)
|
|
222
|
+
return registered_flows
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def _assemble_flow(
|
|
226
|
+
flow_name: str,
|
|
227
|
+
flow: Flow[Any],
|
|
228
|
+
codec_registry: CodecRegistry,
|
|
229
|
+
) -> _RegisteredFlow:
|
|
230
|
+
definitions = flow.get_steps()
|
|
231
|
+
if not isinstance(definitions, StepList):
|
|
232
|
+
raise TypeError("Flow steps must be a StepList")
|
|
233
|
+
registered_steps: dict[str, _RegisteredStep] = {}
|
|
234
|
+
start_step: _RegisteredStep | None = None
|
|
235
|
+
for definition in definitions:
|
|
236
|
+
if not isinstance(definition, _StepDef):
|
|
237
|
+
raise TypeError("Flow StepList contains an invalid definition")
|
|
238
|
+
if definition.is_start_step:
|
|
239
|
+
if start_step is not None:
|
|
240
|
+
raise ValueError("Flow must not have multiple start Steps")
|
|
241
|
+
step = definition.step
|
|
242
|
+
step_name = step.get_step_type()
|
|
243
|
+
require_name(step_name)
|
|
244
|
+
if step_name in registered_steps:
|
|
245
|
+
raise ValueError(f"duplicate Step {step_name}")
|
|
246
|
+
registered_step = _RegisteredStep(
|
|
247
|
+
step_name,
|
|
248
|
+
step,
|
|
249
|
+
Registry._step_input_codec(step, codec_registry),
|
|
250
|
+
definition.is_start_step,
|
|
251
|
+
type(step).wait_for is Step.wait_for,
|
|
252
|
+
)
|
|
253
|
+
registered_steps[step_name] = registered_step
|
|
254
|
+
if definition.is_start_step:
|
|
255
|
+
start_step = registered_step
|
|
256
|
+
|
|
257
|
+
schema = flow.get_persistence_schema()
|
|
258
|
+
if not isinstance(schema, PersistenceSchema):
|
|
259
|
+
raise TypeError("Flow persistence schema must be a PersistenceSchema")
|
|
260
|
+
persistence = Registry._assemble_persistence(schema)
|
|
261
|
+
registered_rpcs: dict[str, _RegisteredRPC] = {}
|
|
262
|
+
for attribute_name in dir(flow):
|
|
263
|
+
method = getattr(flow, attribute_name)
|
|
264
|
+
function = getattr(method, "__func__", method)
|
|
265
|
+
options = getattr(function, "__dex_rpc_options__", None)
|
|
266
|
+
if not isinstance(options, _RPCOptions):
|
|
267
|
+
continue
|
|
268
|
+
rpc_name = options.name or attribute_name
|
|
269
|
+
require_name(rpc_name)
|
|
270
|
+
if rpc_name in registered_rpcs:
|
|
271
|
+
raise ValueError(f"duplicate RPC {rpc_name}")
|
|
272
|
+
Registry._validate_rpc_locks(rpc_name, options, schema)
|
|
273
|
+
input_codec, output_codec = Registry._rpc_codecs(method, codec_registry)
|
|
274
|
+
registered_rpcs[rpc_name] = _RegisteredRPC(
|
|
275
|
+
rpc_name,
|
|
276
|
+
method,
|
|
277
|
+
options,
|
|
278
|
+
input_codec,
|
|
279
|
+
output_codec,
|
|
280
|
+
tuple(
|
|
281
|
+
Registry._physical_lock(lock) for lock in options.lock_attributes
|
|
282
|
+
),
|
|
283
|
+
)
|
|
284
|
+
return _RegisteredFlow(
|
|
285
|
+
flow_name,
|
|
286
|
+
flow,
|
|
287
|
+
MappingProxyType(registered_steps),
|
|
288
|
+
start_step,
|
|
289
|
+
MappingProxyType(registered_rpcs),
|
|
290
|
+
MappingProxyType(persistence),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
@staticmethod
|
|
294
|
+
def _assemble_persistence(
|
|
295
|
+
schema: PersistenceSchema,
|
|
296
|
+
) -> dict[str, _PersistenceDefinition]:
|
|
297
|
+
persistence: dict[str, _PersistenceDefinition] = {}
|
|
298
|
+
for definition in (*schema.attributes, *schema.channels):
|
|
299
|
+
if definition.name in persistence:
|
|
300
|
+
raise ValueError(f"duplicate persistence definition {definition.name}")
|
|
301
|
+
persistence[definition.name] = definition
|
|
302
|
+
return persistence
|
|
303
|
+
|
|
304
|
+
@staticmethod
|
|
305
|
+
def _validate_rpc_locks(
|
|
306
|
+
rpc_name: str,
|
|
307
|
+
options: _RPCOptions,
|
|
308
|
+
schema: PersistenceSchema,
|
|
309
|
+
) -> None:
|
|
310
|
+
lock_identities: set[tuple[int, str | None]] = set()
|
|
311
|
+
for lock in options.lock_attributes:
|
|
312
|
+
if not isinstance(lock, AttributeLock):
|
|
313
|
+
raise TypeError(f"RPC {rpc_name} has an invalid attribute lock")
|
|
314
|
+
if all(lock.attribute is not attribute for attribute in schema.attributes):
|
|
315
|
+
raise ValueError(f"RPC {rpc_name} locks an unregistered attribute")
|
|
316
|
+
if isinstance(lock.attribute, AttributeMap):
|
|
317
|
+
if lock.instance is None:
|
|
318
|
+
raise ValueError(
|
|
319
|
+
f"RPC {rpc_name} attribute-map lock needs an instance"
|
|
320
|
+
)
|
|
321
|
+
require_name(lock.instance)
|
|
322
|
+
elif lock.instance is not None:
|
|
323
|
+
raise ValueError(
|
|
324
|
+
f"RPC {rpc_name} attribute lock cannot have an instance"
|
|
325
|
+
)
|
|
326
|
+
identity = (id(lock.attribute), lock.instance)
|
|
327
|
+
if identity in lock_identities:
|
|
328
|
+
raise ValueError(f"RPC {rpc_name} has a duplicate attribute lock")
|
|
329
|
+
lock_identities.add(identity)
|
|
330
|
+
|
|
331
|
+
@staticmethod
|
|
332
|
+
def _step_input_codec(step: Step[Any], codec_registry: CodecRegistry) -> Codec[Any]:
|
|
333
|
+
input_type = Registry._step_handler_input_type(
|
|
334
|
+
step,
|
|
335
|
+
"execute",
|
|
336
|
+
step.execute,
|
|
337
|
+
StepDecision,
|
|
338
|
+
)
|
|
339
|
+
if type(step).wait_for is not Step.wait_for:
|
|
340
|
+
wait_input_type = Registry._step_handler_input_type(
|
|
341
|
+
step,
|
|
342
|
+
"wait_for",
|
|
343
|
+
step.wait_for,
|
|
344
|
+
Wait,
|
|
345
|
+
)
|
|
346
|
+
if wait_input_type != input_type:
|
|
347
|
+
raise TypeError(
|
|
348
|
+
f"Step {step.get_step_type()} handlers must use the same input type"
|
|
349
|
+
)
|
|
350
|
+
return codec_registry.resolve(input_type)
|
|
351
|
+
|
|
352
|
+
@staticmethod
|
|
353
|
+
def _step_handler_input_type(
|
|
354
|
+
step: Step[Any],
|
|
355
|
+
handler_name: str,
|
|
356
|
+
handler: Callable[..., Any],
|
|
357
|
+
return_type: type[Any],
|
|
358
|
+
) -> Any:
|
|
359
|
+
if iscoroutinefunction(handler):
|
|
360
|
+
raise TypeError(
|
|
361
|
+
f"Step {step.get_step_type()} {handler_name} must be synchronous"
|
|
362
|
+
)
|
|
363
|
+
parameters = tuple(signature(handler).parameters.values())
|
|
364
|
+
hints = get_type_hints(handler)
|
|
365
|
+
if len(parameters) != 2:
|
|
366
|
+
raise TypeError(
|
|
367
|
+
f"Step {step.get_step_type()} {handler_name} must accept context and input"
|
|
368
|
+
)
|
|
369
|
+
context_parameter, input_parameter = parameters
|
|
370
|
+
if hints.get(context_parameter.name) is not Context:
|
|
371
|
+
raise TypeError(
|
|
372
|
+
f"Step {step.get_step_type()} {handler_name} context must be Context"
|
|
373
|
+
)
|
|
374
|
+
input_type = hints.get(input_parameter.name)
|
|
375
|
+
if input_type is None:
|
|
376
|
+
raise TypeError(
|
|
377
|
+
f"Step {step.get_step_type()} {handler_name} input must be annotated"
|
|
378
|
+
)
|
|
379
|
+
if hints.get("return") is not return_type:
|
|
380
|
+
raise TypeError(
|
|
381
|
+
f"Step {step.get_step_type()} {handler_name} must return "
|
|
382
|
+
f"{return_type.__name__}"
|
|
383
|
+
)
|
|
384
|
+
return input_type
|
|
385
|
+
|
|
386
|
+
@staticmethod
|
|
387
|
+
def _rpc_codecs(
|
|
388
|
+
method: Callable[..., Any], codec_registry: CodecRegistry
|
|
389
|
+
) -> tuple[Codec[Any] | None, Codec[Any] | None]:
|
|
390
|
+
if iscoroutinefunction(method):
|
|
391
|
+
raise TypeError("RPC must be synchronous")
|
|
392
|
+
parameters = tuple(signature(method).parameters.values())
|
|
393
|
+
hints = get_type_hints(method)
|
|
394
|
+
if len(parameters) not in (1, 2) or "return" not in hints:
|
|
395
|
+
raise TypeError("RPC must annotate Context, optional input, and return")
|
|
396
|
+
if hints.get(parameters[0].name) is not Context:
|
|
397
|
+
raise TypeError("RPC context must be Context")
|
|
398
|
+
if len(parameters) == 2 and parameters[1].name not in hints:
|
|
399
|
+
raise TypeError("RPC input must be annotated")
|
|
400
|
+
input_codec = (
|
|
401
|
+
codec_registry.resolve(hints[parameters[1].name])
|
|
402
|
+
if len(parameters) == 2
|
|
403
|
+
else None
|
|
404
|
+
)
|
|
405
|
+
return_type = hints["return"]
|
|
406
|
+
output_codec = None
|
|
407
|
+
if get_origin(return_type) is RPCResult:
|
|
408
|
+
arguments = get_args(return_type)
|
|
409
|
+
if len(arguments) != 1:
|
|
410
|
+
raise TypeError("RPCResult must declare one output type")
|
|
411
|
+
output_codec = codec_registry.resolve(arguments[0])
|
|
412
|
+
elif return_type not in (None, type(None)):
|
|
413
|
+
raise TypeError("RPC must return RPCResult[O] or None")
|
|
414
|
+
return input_codec, output_codec
|
|
415
|
+
|
|
416
|
+
@staticmethod
|
|
417
|
+
def physical_name(name: str, instance: str) -> str:
|
|
418
|
+
require_name(instance)
|
|
419
|
+
return f"{name}/{quote(instance, safe='')}"
|
|
420
|
+
|
|
421
|
+
@staticmethod
|
|
422
|
+
def _physical_lock(lock: AttributeLock) -> str:
|
|
423
|
+
if lock.instance is None:
|
|
424
|
+
return lock.attribute.name
|
|
425
|
+
return Registry.physical_name(lock.attribute.name, lock.instance)
|
|
426
|
+
|
|
427
|
+
def _flow_by_type(self, flow_type: str) -> _RegisteredFlow:
|
|
428
|
+
try:
|
|
429
|
+
return self._registered_flows[flow_type]
|
|
430
|
+
except KeyError as error:
|
|
431
|
+
raise ValueError(f"Flow is not registered: {flow_type}") from error
|
|
432
|
+
|
|
433
|
+
def _flow_for_instance(self, flow: Flow[Any]) -> _RegisteredFlow:
|
|
434
|
+
registered = self._flow_by_type(flow.get_flow_type())
|
|
435
|
+
if registered.flow is not flow:
|
|
436
|
+
raise ValueError("Flow instance is not registered")
|
|
437
|
+
return registered
|
|
438
|
+
|
|
439
|
+
def _rpc_for_method(
|
|
440
|
+
self,
|
|
441
|
+
method: Callable[..., Any],
|
|
442
|
+
) -> tuple[_RegisteredFlow, _RegisteredRPC]:
|
|
443
|
+
receiver = getattr(method, "__self__", None)
|
|
444
|
+
function = getattr(method, "__func__", method)
|
|
445
|
+
for flow in self._registered_flows.values():
|
|
446
|
+
if receiver is not flow.flow:
|
|
447
|
+
continue
|
|
448
|
+
for registered_rpc in flow.rpcs.values():
|
|
449
|
+
registered_function = getattr(
|
|
450
|
+
registered_rpc.method,
|
|
451
|
+
"__func__",
|
|
452
|
+
registered_rpc.method,
|
|
453
|
+
)
|
|
454
|
+
if registered_function is function:
|
|
455
|
+
return flow, registered_rpc
|
|
456
|
+
raise ValueError("RPC method is not registered")
|
dex/flow_config.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Super Durable Source License 1.0.
|
|
4
|
+
# You may not use this file except in compliance with the License.
|
|
5
|
+
# See the LICENSE file in the repository root.
|
|
6
|
+
#
|
|
7
|
+
# SPDX-License-Identifier: LicenseRef-Super-Durable-1.0
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
12
|
+
from dex.step import StepDurability
|
|
13
|
+
from dex.worker_options import WorkerTarget
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ActiveStepSearchMode(Enum):
|
|
17
|
+
DEFAULT = "default"
|
|
18
|
+
ALL = "all"
|
|
19
|
+
WITH_WAIT_FOR = "with_wait_for"
|
|
20
|
+
DISABLED = "disabled"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class FlowConfig:
|
|
25
|
+
active_step_search_mode: ActiveStepSearchMode | None = None
|
|
26
|
+
continue_as_new_threshold: int | None = None
|
|
27
|
+
continue_as_new_page_size_bytes: int | None = None
|
|
28
|
+
step_durability: StepDurability | None = None
|
|
29
|
+
worker_target: WorkerTarget | None = None
|
dex/flow_info.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Copyright (c) 2026 Super Durable, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Super Durable Source License 1.0.
|
|
4
|
+
# You may not use this file except in compliance with the License.
|
|
5
|
+
# See the LICENSE file in the repository root.
|
|
6
|
+
#
|
|
7
|
+
# SPDX-License-Identifier: LicenseRef-Super-Durable-1.0
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Mapping
|
|
13
|
+
|
|
14
|
+
from dex.codec import Value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FlowStatus(Enum):
|
|
18
|
+
RUNNING = "running"
|
|
19
|
+
COMPLETED = "completed"
|
|
20
|
+
FAILED = "failed"
|
|
21
|
+
CANCELED = "canceled"
|
|
22
|
+
TERMINATED = "terminated"
|
|
23
|
+
TIMED_OUT = "timed_out"
|
|
24
|
+
CONTINUED_AS_NEW = "continued_as_new"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class FlowInfo:
|
|
29
|
+
flow_id: str
|
|
30
|
+
run_id: str
|
|
31
|
+
flow_type: str
|
|
32
|
+
status: FlowStatus
|
|
33
|
+
started_at: datetime
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class HealthInfo:
|
|
38
|
+
condition: str
|
|
39
|
+
hostname: str
|
|
40
|
+
duration_seconds: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class SearchFlowEntry:
|
|
45
|
+
flow_id: str
|
|
46
|
+
run_id: str
|
|
47
|
+
flow_type: str
|
|
48
|
+
status: str
|
|
49
|
+
started_at: datetime
|
|
50
|
+
closed_at: datetime | None
|
|
51
|
+
attributes: Mapping[str, Value]
|