stepfunction 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.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.
- stepfunction/__init__.py +0 -0
- stepfunction/constants/visualizer.py +5 -0
- stepfunction/core/serializer/__init__.py +3 -0
- stepfunction/core/serializer/serializer.py +170 -0
- stepfunction/core/step_function/step_function.py +368 -87
- stepfunction/core/visualizer/visualizer.py +2 -3
- stepfunction/exceptions/step_errors.py +36 -0
- stepfunction/hooks/__init__.py +4 -0
- stepfunction/hooks/events.py +73 -0
- stepfunction/hooks/step_function_hooks.py +51 -0
- stepfunction/registry/__init__.py +0 -0
- stepfunction/registry/step_registry.py +97 -0
- stepfunction/utils/utils.py +6 -0
- {stepfunction-0.1.0.dist-info → stepfunction-0.2.0.dist-info}/METADATA +34 -1
- {stepfunction-0.1.0.dist-info → stepfunction-0.2.0.dist-info}/RECORD +18 -10
- {stepfunction-0.1.0.dist-info → stepfunction-0.2.0.dist-info}/WHEEL +1 -1
- {stepfunction-0.1.0.dist-info → stepfunction-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {stepfunction-0.1.0.dist-info → stepfunction-0.2.0.dist-info}/top_level.txt +0 -0
stepfunction/__init__.py
ADDED
|
File without changes
|
|
@@ -46,3 +46,8 @@ DEFAULT_VISUALIZER_SUB_STEP_FUNCTION_CLASS_STYLE = (
|
|
|
46
46
|
"fill:#f5f5f5,stroke:#333,stroke-dasharray: 5 5"
|
|
47
47
|
)
|
|
48
48
|
"""str: The Mermaid classDef style applied to sub-step function nodes."""
|
|
49
|
+
|
|
50
|
+
# Node ID sanitization
|
|
51
|
+
|
|
52
|
+
VISUALIZER_INVALID_NODE_ID_CHARS = r"[^0-9A-Za-z_]"
|
|
53
|
+
"""str: Regex matching characters not allowed in a Mermaid node ID; matches are replaced with "_"."""
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Encode/decode logic for declarative (JSON-able dict) StepFunction specs.
|
|
2
|
+
|
|
3
|
+
Author: Vineeth Penugonda
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any, Callable, Dict, Optional, cast
|
|
7
|
+
|
|
8
|
+
from stepfunction.core.step_function.step_function import StepFunction
|
|
9
|
+
from stepfunction.exceptions.step_errors import (
|
|
10
|
+
UnregisteredFunctionError,
|
|
11
|
+
UnserializableStepError,
|
|
12
|
+
)
|
|
13
|
+
from stepfunction.registry.step_registry import StepRegistry
|
|
14
|
+
from stepfunction.registry.step_registry import registry as default_registry
|
|
15
|
+
from stepfunction.types.step_types import StepParams
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _name_for_or_raise(
|
|
19
|
+
step_registry: StepRegistry, func: Callable[[Any], Any], step_name: str
|
|
20
|
+
) -> str:
|
|
21
|
+
name = step_registry.name_for(func)
|
|
22
|
+
if name is None:
|
|
23
|
+
raise UnregisteredFunctionError(
|
|
24
|
+
f"The function used in step '{step_name}' is not registered in the "
|
|
25
|
+
"given registry, so its name can't be determined for export. "
|
|
26
|
+
"Register it with stepfunction.registry.step_registry.register_step() first."
|
|
27
|
+
)
|
|
28
|
+
return name
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _encode_step(
|
|
32
|
+
step_name: str, step: StepParams, step_registry: StepRegistry
|
|
33
|
+
) -> Dict[str, Any]:
|
|
34
|
+
if step["step_type"] is not None:
|
|
35
|
+
raise UnserializableStepError(step_name, step["step_type"])
|
|
36
|
+
|
|
37
|
+
encoded: Dict[str, Any] = {
|
|
38
|
+
"next_step": step["next_step"],
|
|
39
|
+
"on_failure": step["on_failure"],
|
|
40
|
+
"parallel": step["parallel"],
|
|
41
|
+
"stop_on_failure": step["stop_on_failure"],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if step["is_sub_step_function"]:
|
|
45
|
+
sub_step_function = cast(StepFunction, step["sub_step_function"])
|
|
46
|
+
encoded["sub_step_function"] = encode_step_function(
|
|
47
|
+
sub_step_function, step_registry
|
|
48
|
+
)
|
|
49
|
+
return encoded
|
|
50
|
+
|
|
51
|
+
if step["parallel"]:
|
|
52
|
+
func_map = cast(Dict[str, Callable[[Any], Any]], step["func"])
|
|
53
|
+
encoded["func"] = {
|
|
54
|
+
slot: _name_for_or_raise(step_registry, fn, step_name)
|
|
55
|
+
for slot, fn in func_map.items()
|
|
56
|
+
}
|
|
57
|
+
else:
|
|
58
|
+
func = cast(Callable[[Any], Any], step["func"])
|
|
59
|
+
encoded["func"] = _name_for_or_raise(step_registry, func, step_name)
|
|
60
|
+
|
|
61
|
+
branch = step["branch"]
|
|
62
|
+
if branch is not None:
|
|
63
|
+
if callable(branch):
|
|
64
|
+
encoded["branch"] = _name_for_or_raise(step_registry, branch, step_name)
|
|
65
|
+
else:
|
|
66
|
+
encoded["branch"] = {str(key): value for key, value in branch.items()}
|
|
67
|
+
|
|
68
|
+
return encoded
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def encode_step_function(
|
|
72
|
+
sf: StepFunction, step_registry: Optional[StepRegistry] = None
|
|
73
|
+
) -> Dict[str, Any]:
|
|
74
|
+
"""Export ``sf`` as a JSON-able dict.
|
|
75
|
+
|
|
76
|
+
Recurses into nested sub-step-functions by calling itself again, so
|
|
77
|
+
arbitrary nesting depth is handled without special-casing.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
UnserializableStepError: If any step was built from a BaseStep
|
|
81
|
+
instance (RetryStep, TimeoutStep, WaitStep, or a custom
|
|
82
|
+
BaseStep subclass) — not yet supported.
|
|
83
|
+
UnregisteredFunctionError: If a step or branch function used in
|
|
84
|
+
``sf`` has no registered name in ``step_registry``.
|
|
85
|
+
"""
|
|
86
|
+
step_registry = step_registry or default_registry
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
"name": sf.name,
|
|
90
|
+
"start_step": sf.current_step,
|
|
91
|
+
"steps": {
|
|
92
|
+
step_name: _encode_step(step_name, step, step_registry)
|
|
93
|
+
for step_name, step in sf.steps.items()
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def decode_step_function(
|
|
99
|
+
data: Dict[str, Any],
|
|
100
|
+
step_registry: Optional[StepRegistry] = None,
|
|
101
|
+
_validate: bool = True,
|
|
102
|
+
) -> StepFunction:
|
|
103
|
+
"""Reconstruct a StepFunction from a dict produced by ``encode_step_function``.
|
|
104
|
+
|
|
105
|
+
Rebuilds the workflow purely through ``add_step``/``add_sub_step_function``/
|
|
106
|
+
``set_start_step`` — the same public API a user would call by hand — and
|
|
107
|
+
recurses into nested "sub_step_function" entries by calling itself again.
|
|
108
|
+
Validates exactly once, at the very end, at the outermost level only:
|
|
109
|
+
``StepFunction.validate()`` already recurses into sub-step functions and
|
|
110
|
+
reports a readable breadcrumb across nesting levels, so a malformed
|
|
111
|
+
nested spec still fails fast without the decoder needing its own
|
|
112
|
+
recursive validation pass.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
UnregisteredFunctionError: If a referenced function name isn't
|
|
116
|
+
registered in ``step_registry`` (defaults to the package's
|
|
117
|
+
default singleton registry if not given).
|
|
118
|
+
ValueError: If the reconstructed workflow fails validate().
|
|
119
|
+
"""
|
|
120
|
+
step_registry = step_registry or default_registry
|
|
121
|
+
|
|
122
|
+
sf = StepFunction(data["name"])
|
|
123
|
+
|
|
124
|
+
for step_name, step_data in data["steps"].items():
|
|
125
|
+
if "sub_step_function" in step_data:
|
|
126
|
+
sub_step_function = decode_step_function(
|
|
127
|
+
step_data["sub_step_function"], step_registry, _validate=False
|
|
128
|
+
)
|
|
129
|
+
sf.add_sub_step_function(
|
|
130
|
+
step_name,
|
|
131
|
+
sub_step_function=sub_step_function,
|
|
132
|
+
next_step=step_data.get("next_step"),
|
|
133
|
+
on_failure=step_data.get("on_failure"),
|
|
134
|
+
)
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
func_spec = step_data["func"]
|
|
138
|
+
if isinstance(func_spec, dict):
|
|
139
|
+
func: Any = {
|
|
140
|
+
slot: step_registry.get(ref) for slot, ref in func_spec.items()
|
|
141
|
+
}
|
|
142
|
+
else:
|
|
143
|
+
func = step_registry.get(func_spec)
|
|
144
|
+
|
|
145
|
+
branch_spec = step_data.get("branch")
|
|
146
|
+
if branch_spec is None:
|
|
147
|
+
branch: Any = None
|
|
148
|
+
elif isinstance(branch_spec, dict):
|
|
149
|
+
branch = dict(branch_spec)
|
|
150
|
+
else:
|
|
151
|
+
branch = step_registry.get(branch_spec)
|
|
152
|
+
|
|
153
|
+
sf.add_step(
|
|
154
|
+
step_name,
|
|
155
|
+
func,
|
|
156
|
+
next_step=step_data.get("next_step"),
|
|
157
|
+
on_failure=step_data.get("on_failure"),
|
|
158
|
+
branch=branch,
|
|
159
|
+
parallel=step_data.get("parallel", False),
|
|
160
|
+
stop_on_failure=step_data.get("stop_on_failure", False),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
start_step = data.get("start_step")
|
|
164
|
+
if start_step is not None:
|
|
165
|
+
sf.set_start_step(start_step)
|
|
166
|
+
|
|
167
|
+
if _validate:
|
|
168
|
+
sf.validate()
|
|
169
|
+
|
|
170
|
+
return sf
|
|
@@ -3,18 +3,24 @@
|
|
|
3
3
|
Author: Vineeth Penugonda
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
-
from asyncio import gather, get_running_loop
|
|
7
|
-
from inspect import iscoroutinefunction
|
|
8
|
-
from
|
|
6
|
+
from asyncio import CancelledError, gather, get_running_loop
|
|
7
|
+
from inspect import isawaitable, iscoroutinefunction
|
|
8
|
+
from time import monotonic
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union, cast
|
|
9
10
|
|
|
10
11
|
from stepfunction.constants.enums import StepFunctionStatus
|
|
11
12
|
from stepfunction.exceptions.step_errors import (
|
|
12
13
|
ParallelStepExecutionError,
|
|
13
14
|
StepExecutionError,
|
|
14
15
|
)
|
|
16
|
+
from stepfunction.hooks import StepEvent, StepFunctionHooks, WorkflowEvent
|
|
15
17
|
from stepfunction.steps.base import BaseStep
|
|
16
18
|
from stepfunction.types.step_types import StepParams
|
|
17
19
|
from stepfunction.utils.logger import setup_logger
|
|
20
|
+
from stepfunction.utils.utils import utc_now
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from stepfunction.registry.step_registry import StepRegistry
|
|
18
24
|
|
|
19
25
|
|
|
20
26
|
class StepFunction:
|
|
@@ -33,6 +39,7 @@ class StepFunction:
|
|
|
33
39
|
|
|
34
40
|
Properties:
|
|
35
41
|
name (str): The name of the step function.
|
|
42
|
+
hooks (Optional[StepFunctionHooks]): The lifecycle hooks set on this step function, if any.
|
|
36
43
|
steps (Dict[str, StepParams]): A dictionary containing the steps of the workflow.
|
|
37
44
|
last_result (Any): The result of the last step.
|
|
38
45
|
context (Dict[str, Any]): Stores step names and results, including exceptions if any occur.
|
|
@@ -49,6 +56,9 @@ class StepFunction:
|
|
|
49
56
|
set_start_step(name):
|
|
50
57
|
Set the start step of the workflow.
|
|
51
58
|
|
|
59
|
+
set_hooks(hooks):
|
|
60
|
+
Set the lifecycle hooks (a StepFunctionHooks) notified as steps start, succeed and fail.
|
|
61
|
+
|
|
52
62
|
add_sub_step_function(name, sub_step_function, next_step=None, on_failure=None):
|
|
53
63
|
Add a sub-step function to be executed as a step.
|
|
54
64
|
|
|
@@ -66,6 +76,14 @@ class StepFunction:
|
|
|
66
76
|
visualize_to_string():
|
|
67
77
|
Return a string representation of the workflow for visualization.
|
|
68
78
|
|
|
79
|
+
to_dict(step_registry=None) / to_json(indent=2, step_registry=None):
|
|
80
|
+
Export the workflow as a JSON-able dict / JSON string, resolving step and
|
|
81
|
+
branch functions to their registered name via stepfunction.registry.step_registry.
|
|
82
|
+
|
|
83
|
+
from_dict(data, step_registry=None) / from_json(json_str, step_registry=None):
|
|
84
|
+
Classmethods that reconstruct a StepFunction from a dict/JSON string
|
|
85
|
+
produced by to_dict()/to_json(), resolving function names via the registry.
|
|
86
|
+
|
|
69
87
|
Protected Methods:
|
|
70
88
|
_execute_step(func, input_value):
|
|
71
89
|
Execute a single step, handling asynchronous and synchronous functions appropriately.
|
|
@@ -108,11 +126,25 @@ class StepFunction:
|
|
|
108
126
|
|
|
109
127
|
Status Example:
|
|
110
128
|
status = step_function.status # Will be StepFunctionStatus.INITIALIZED, StepFunctionStatus.RUNNING, StepFunctionStatus.COMPLETED, or StepFunctionStatus.FAILED.
|
|
129
|
+
|
|
130
|
+
Hooks Example:
|
|
131
|
+
class RunRecorder(StepFunctionHooks):
|
|
132
|
+
async def on_step_success(self, event):
|
|
133
|
+
print(event.step, event.duration, event.next_step)
|
|
134
|
+
|
|
135
|
+
step_function = StepFunction("MyStepFunction", hooks=RunRecorder())
|
|
111
136
|
"""
|
|
112
137
|
|
|
113
|
-
def __init__(self, name: str):
|
|
138
|
+
def __init__(self, name: str, hooks: Optional[StepFunctionHooks] = None):
|
|
114
139
|
self.__name = name # Name of the step function
|
|
115
140
|
|
|
141
|
+
self.__hooks = hooks # Lifecycle hooks set on this step function
|
|
142
|
+
|
|
143
|
+
# Hooks and workflow path for the current run; a sub-step function
|
|
144
|
+
# without hooks of its own inherits its parent's
|
|
145
|
+
self.__run_hooks: Optional[StepFunctionHooks] = None
|
|
146
|
+
self.__run_path: List[str] = [name]
|
|
147
|
+
|
|
116
148
|
self.__steps: Dict[str, StepParams] = {} # Steps of the workflow
|
|
117
149
|
self.__current_step = None # The current step being executed
|
|
118
150
|
|
|
@@ -181,7 +213,13 @@ class StepFunction:
|
|
|
181
213
|
raise ValueError(f"Step '{name}' already exists in steps")
|
|
182
214
|
|
|
183
215
|
async def sub_func(last_result):
|
|
184
|
-
await sub_step_function.
|
|
216
|
+
await sub_step_function._execute(
|
|
217
|
+
initial_input=last_result,
|
|
218
|
+
hooks=sub_step_function.hooks
|
|
219
|
+
if sub_step_function.hooks is not None
|
|
220
|
+
else self.__run_hooks,
|
|
221
|
+
path=self.__run_path + [_sub_path_label(name, sub_step_function)],
|
|
222
|
+
)
|
|
185
223
|
self.__context.update(sub_step_function.context)
|
|
186
224
|
|
|
187
225
|
if sub_step_function.status == StepFunctionStatus.FAILED:
|
|
@@ -208,6 +246,10 @@ class StepFunction:
|
|
|
208
246
|
|
|
209
247
|
self.__current_step = name
|
|
210
248
|
|
|
249
|
+
def set_hooks(self, hooks: Optional[StepFunctionHooks]):
|
|
250
|
+
"""Set the lifecycle hooks notified as the workflow runs (None removes them)."""
|
|
251
|
+
self.__hooks = hooks
|
|
252
|
+
|
|
211
253
|
def validate(self):
|
|
212
254
|
"""Validate the workflow configuration before execution."""
|
|
213
255
|
self._validate(path=[self.__name])
|
|
@@ -246,16 +288,33 @@ class StepFunction:
|
|
|
246
288
|
if step["is_sub_step_function"]:
|
|
247
289
|
sub_step_function = cast("StepFunction", step["sub_step_function"])
|
|
248
290
|
sub_step_function._validate(
|
|
249
|
-
path=path + [
|
|
250
|
-
if step_name != sub_step_function.name
|
|
251
|
-
else path + [step_name]
|
|
291
|
+
path=path + [_sub_path_label(step_name, sub_step_function)]
|
|
252
292
|
)
|
|
253
293
|
|
|
254
294
|
async def execute(self, initial_input: Any = None):
|
|
255
295
|
"""Execute the workflow."""
|
|
256
296
|
|
|
297
|
+
await self._execute(initial_input, hooks=self.__hooks, path=[self.__name])
|
|
298
|
+
|
|
299
|
+
async def _execute(
|
|
300
|
+
self,
|
|
301
|
+
initial_input: Any,
|
|
302
|
+
hooks: Optional[StepFunctionHooks],
|
|
303
|
+
path: List[str],
|
|
304
|
+
):
|
|
305
|
+
"""Execute the workflow, reporting to ``hooks`` under the workflow ``path``.
|
|
306
|
+
|
|
307
|
+
``path`` is the chain of step-function names from the outermost
|
|
308
|
+
workflow down to this one, as in _validate(); sub-step functions are
|
|
309
|
+
run through here so their events carry the full path.
|
|
310
|
+
"""
|
|
311
|
+
|
|
257
312
|
self.validate()
|
|
258
313
|
|
|
314
|
+
self.__run_hooks = hooks
|
|
315
|
+
self.__run_path = path
|
|
316
|
+
workflow = " -> ".join(path)
|
|
317
|
+
|
|
259
318
|
self.__status = StepFunctionStatus.RUNNING
|
|
260
319
|
|
|
261
320
|
self.__logger.debug(
|
|
@@ -264,96 +323,174 @@ class StepFunction:
|
|
|
264
323
|
|
|
265
324
|
self.__last_result = initial_input
|
|
266
325
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
326
|
+
run_started_at = utc_now()
|
|
327
|
+
run_started = monotonic()
|
|
328
|
+
await self._emit(
|
|
329
|
+
"on_workflow_start",
|
|
330
|
+
WorkflowEvent(
|
|
331
|
+
workflow=workflow,
|
|
332
|
+
status=self.__status,
|
|
333
|
+
input=initial_input,
|
|
334
|
+
started_at=run_started_at,
|
|
335
|
+
),
|
|
336
|
+
)
|
|
278
337
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
338
|
+
run_error: Optional[BaseException] = None
|
|
339
|
+
try:
|
|
340
|
+
while self.__current_step:
|
|
341
|
+
await self._execute_current_step(workflow)
|
|
342
|
+
except BaseException as exc:
|
|
343
|
+
run_error = exc
|
|
344
|
+
self.__status = StepFunctionStatus.FAILED
|
|
345
|
+
raise
|
|
346
|
+
finally:
|
|
347
|
+
await self._emit(
|
|
348
|
+
"on_workflow_end",
|
|
349
|
+
WorkflowEvent(
|
|
350
|
+
workflow=workflow,
|
|
351
|
+
status=self.__status,
|
|
352
|
+
input=initial_input,
|
|
353
|
+
started_at=run_started_at,
|
|
354
|
+
output=self.__last_result,
|
|
355
|
+
error=run_error,
|
|
356
|
+
finished_at=utc_now(),
|
|
357
|
+
duration=monotonic() - run_started,
|
|
358
|
+
),
|
|
359
|
+
)
|
|
286
360
|
|
|
287
|
-
|
|
288
|
-
|
|
361
|
+
async def _execute_current_step(self, workflow: str):
|
|
362
|
+
"""Execute the current step, route to the next one and report both to the hooks."""
|
|
363
|
+
|
|
364
|
+
step_name = cast(str, self.__current_step)
|
|
365
|
+
step = self.__steps[step_name]
|
|
366
|
+
step_input = self.__last_result
|
|
367
|
+
|
|
368
|
+
started_at = utc_now()
|
|
369
|
+
started = monotonic()
|
|
370
|
+
await self._emit(
|
|
371
|
+
"on_step_start",
|
|
372
|
+
StepEvent(
|
|
373
|
+
workflow=workflow,
|
|
374
|
+
step=step_name,
|
|
375
|
+
task=None,
|
|
376
|
+
input=step_input,
|
|
377
|
+
started_at=started_at,
|
|
378
|
+
),
|
|
379
|
+
)
|
|
289
380
|
|
|
290
|
-
|
|
381
|
+
def _ended(**fields: Any) -> StepEvent:
|
|
382
|
+
return StepEvent(
|
|
383
|
+
workflow=workflow,
|
|
384
|
+
step=step_name,
|
|
385
|
+
task=None,
|
|
386
|
+
input=step_input,
|
|
387
|
+
started_at=started_at,
|
|
388
|
+
finished_at=utc_now(),
|
|
389
|
+
duration=monotonic() - started,
|
|
390
|
+
**fields,
|
|
391
|
+
)
|
|
291
392
|
|
|
292
|
-
|
|
393
|
+
try:
|
|
394
|
+
if step["parallel"]:
|
|
395
|
+
results = await self._execute_parallel(
|
|
396
|
+
cast(Dict[str, Callable[[Any], Any]], step["func"]),
|
|
397
|
+
step["stop_on_failure"],
|
|
398
|
+
workflow=workflow,
|
|
399
|
+
step_name=step_name,
|
|
400
|
+
)
|
|
293
401
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
next_step = step["branch"](self.__last_result)
|
|
297
|
-
else:
|
|
298
|
-
next_step = step["branch"].get(self.__last_result)
|
|
402
|
+
self.__last_result = results
|
|
403
|
+
self.__context[step_name] = results
|
|
299
404
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
f"Step '{self.__current_step}': branch resolved to '{next_step}' "
|
|
308
|
-
"which does not exist in steps. This will cause a failure."
|
|
309
|
-
)
|
|
405
|
+
self.__logger.info(
|
|
406
|
+
f"Parallel step '{step_name}' succeeded with results: {results}"
|
|
407
|
+
)
|
|
408
|
+
else:
|
|
409
|
+
result = await self._execute_step(
|
|
410
|
+
cast(Callable[[Any], Any], step["func"]), self.__last_result
|
|
411
|
+
)
|
|
310
412
|
|
|
311
|
-
self.
|
|
413
|
+
self.__last_result = result
|
|
414
|
+
self.__context[step_name] = result
|
|
312
415
|
|
|
313
|
-
|
|
314
|
-
self.__logger.exception(
|
|
315
|
-
f"Step '{self.__current_step}' failed. Exception: {exc}"
|
|
316
|
-
)
|
|
416
|
+
self.__logger.info(f"Step '{step_name}' succeeded")
|
|
317
417
|
|
|
318
|
-
|
|
418
|
+
next_step = None
|
|
319
419
|
|
|
320
|
-
|
|
420
|
+
if step["branch"]:
|
|
421
|
+
if callable(step["branch"]):
|
|
422
|
+
next_step = step["branch"](self.__last_result)
|
|
423
|
+
else:
|
|
424
|
+
next_step = step["branch"].get(self.__last_result)
|
|
321
425
|
|
|
322
|
-
if
|
|
323
|
-
self.__logger.
|
|
324
|
-
f"
|
|
426
|
+
if next_step is None:
|
|
427
|
+
self.__logger.warning(
|
|
428
|
+
f"Step '{step_name}': branch did not resolve to a next step "
|
|
429
|
+
f"for result '{self.__last_result}'. Workflow will end."
|
|
430
|
+
)
|
|
431
|
+
elif next_step not in self.__steps:
|
|
432
|
+
self.__logger.warning(
|
|
433
|
+
f"Step '{step_name}': branch resolved to '{next_step}' "
|
|
434
|
+
"which does not exist in steps. This will cause a failure."
|
|
325
435
|
)
|
|
326
436
|
|
|
327
|
-
|
|
328
|
-
self.__last_result = exc_value
|
|
437
|
+
self.__current_step = next_step or step["next_step"]
|
|
329
438
|
|
|
330
|
-
|
|
439
|
+
except CancelledError as exc:
|
|
440
|
+
self.__logger.warning(f"Step '{step_name}' was cancelled")
|
|
331
441
|
|
|
332
|
-
|
|
333
|
-
f"StepFunction - {self.__name} - Status - {self.__status.value}"
|
|
334
|
-
)
|
|
335
|
-
else:
|
|
336
|
-
self.__logger.exception(
|
|
337
|
-
f"No failure step defined for '{self.__current_step}'. Raising Exception."
|
|
338
|
-
)
|
|
442
|
+
self.__status = StepFunctionStatus.FAILED
|
|
339
443
|
|
|
340
|
-
|
|
444
|
+
await self._emit("on_step_failure", _ended(error=exc))
|
|
341
445
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
446
|
+
raise
|
|
447
|
+
|
|
448
|
+
except Exception as exc:
|
|
449
|
+
self.__logger.exception(f"Step '{step_name}' failed. Exception: {exc}")
|
|
450
|
+
|
|
451
|
+
exc_value = exc.args[0] if exc.args else exc
|
|
452
|
+
|
|
453
|
+
self.__context[step_name] = exc_value
|
|
454
|
+
|
|
455
|
+
self.__status = StepFunctionStatus.FAILED
|
|
345
456
|
|
|
346
|
-
|
|
457
|
+
self.__logger.debug(
|
|
458
|
+
f"StepFunction - {self.__name} - Status - {self.__status.value}"
|
|
459
|
+
)
|
|
347
460
|
|
|
348
|
-
|
|
349
|
-
|
|
461
|
+
await self._emit(
|
|
462
|
+
"on_step_failure", _ended(error=exc, next_step=step["on_failure"])
|
|
463
|
+
)
|
|
350
464
|
|
|
351
|
-
|
|
465
|
+
if step["on_failure"]:
|
|
466
|
+
self.__logger.exception(
|
|
467
|
+
f"Executing failure step: {step['on_failure']} for '{step_name}'"
|
|
468
|
+
)
|
|
352
469
|
|
|
353
|
-
self.
|
|
354
|
-
|
|
470
|
+
self.__current_step = step["on_failure"]
|
|
471
|
+
self.__last_result = exc_value
|
|
472
|
+
else:
|
|
473
|
+
self.__logger.exception(
|
|
474
|
+
f"No failure step defined for '{step_name}'. Raising Exception."
|
|
355
475
|
)
|
|
356
476
|
|
|
477
|
+
raise StepExecutionError(exc)
|
|
478
|
+
|
|
479
|
+
else:
|
|
480
|
+
await self._emit(
|
|
481
|
+
"on_step_success",
|
|
482
|
+
_ended(output=self.__last_result, next_step=self.__current_step),
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
if not self.__current_step and self.__status != StepFunctionStatus.FAILED:
|
|
486
|
+
# If no more steps, mark as COMPLETED
|
|
487
|
+
|
|
488
|
+
self.__status = StepFunctionStatus.COMPLETED
|
|
489
|
+
|
|
490
|
+
self.__logger.debug(
|
|
491
|
+
f"StepFunction - {self.__name} - Status - {self.__status.value}"
|
|
492
|
+
)
|
|
493
|
+
|
|
357
494
|
async def _execute_step(self, func: Callable, input_value: Any):
|
|
358
495
|
"""Execute a single step, handling async functions."""
|
|
359
496
|
if iscoroutinefunction(func):
|
|
@@ -362,30 +499,72 @@ class StepFunction:
|
|
|
362
499
|
return func(input_value)
|
|
363
500
|
|
|
364
501
|
async def _execute_parallel(
|
|
365
|
-
self,
|
|
502
|
+
self,
|
|
503
|
+
func_dict: Dict[str, Callable[[Any], Any]],
|
|
504
|
+
stop_on_failure: bool = False,
|
|
505
|
+
workflow: Optional[str] = None,
|
|
506
|
+
step_name: Optional[str] = None,
|
|
366
507
|
):
|
|
367
|
-
"""Execute the steps in parallel without blocking the event loop.
|
|
508
|
+
"""Execute the steps in parallel without blocking the event loop.
|
|
509
|
+
|
|
510
|
+
Each task is reported to the hooks as its own step event, with
|
|
511
|
+
``task`` set to its name.
|
|
512
|
+
"""
|
|
368
513
|
loop = get_running_loop()
|
|
369
514
|
results = {}
|
|
370
515
|
errors = []
|
|
516
|
+
step_input = self.__last_result
|
|
517
|
+
|
|
518
|
+
async def _run_one(task: str, func: Callable[[Any], Any]) -> Any:
|
|
519
|
+
started_at = utc_now()
|
|
520
|
+
started = monotonic()
|
|
521
|
+
await self._emit(
|
|
522
|
+
"on_step_start",
|
|
523
|
+
StepEvent(
|
|
524
|
+
workflow=cast(str, workflow),
|
|
525
|
+
step=cast(str, step_name),
|
|
526
|
+
task=task,
|
|
527
|
+
input=step_input,
|
|
528
|
+
started_at=started_at,
|
|
529
|
+
),
|
|
530
|
+
)
|
|
371
531
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
532
|
+
def _ended(**fields: Any) -> StepEvent:
|
|
533
|
+
return StepEvent(
|
|
534
|
+
workflow=cast(str, workflow),
|
|
535
|
+
step=cast(str, step_name),
|
|
536
|
+
task=task,
|
|
537
|
+
input=step_input,
|
|
538
|
+
started_at=started_at,
|
|
539
|
+
finished_at=utc_now(),
|
|
540
|
+
duration=monotonic() - started,
|
|
541
|
+
**fields,
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
try:
|
|
545
|
+
if iscoroutinefunction(func):
|
|
546
|
+
result = await func(step_input)
|
|
547
|
+
else:
|
|
548
|
+
result = await loop.run_in_executor(None, func, step_input)
|
|
549
|
+
except BaseException as exc:
|
|
550
|
+
await self._emit("on_step_failure", _ended(error=exc))
|
|
551
|
+
raise
|
|
552
|
+
|
|
553
|
+
await self._emit("on_step_success", _ended(output=result))
|
|
554
|
+
return result
|
|
376
555
|
|
|
377
556
|
task_results = await gather(
|
|
378
|
-
*[_run_one(func) for func in func_dict.
|
|
557
|
+
*[_run_one(task, func) for task, func in func_dict.items()],
|
|
379
558
|
return_exceptions=True,
|
|
380
559
|
)
|
|
381
560
|
|
|
382
|
-
for
|
|
561
|
+
for task, result in zip(func_dict.keys(), task_results):
|
|
383
562
|
if isinstance(result, Exception):
|
|
384
|
-
self.__logger.exception(f"Parallel task '{
|
|
385
|
-
results[
|
|
386
|
-
errors.append((
|
|
563
|
+
self.__logger.exception(f"Parallel task '{task}' failed: {result}")
|
|
564
|
+
results[task] = result.args[0] if result.args else result
|
|
565
|
+
errors.append((task, result))
|
|
387
566
|
else:
|
|
388
|
-
results[
|
|
567
|
+
results[task] = result
|
|
389
568
|
|
|
390
569
|
if errors:
|
|
391
570
|
self.__logger.error(f"Some parallel tasks failed: {errors}")
|
|
@@ -396,6 +575,25 @@ class StepFunction:
|
|
|
396
575
|
|
|
397
576
|
return results
|
|
398
577
|
|
|
578
|
+
async def _emit(self, hook_name: str, event: Union[StepEvent, WorkflowEvent]):
|
|
579
|
+
"""Call a hook for this run, awaiting it if it's a coroutine.
|
|
580
|
+
|
|
581
|
+
A hook that raises is logged and ignored: hooks observe the workflow,
|
|
582
|
+
they never change its routing or status.
|
|
583
|
+
"""
|
|
584
|
+
hooks = self.__run_hooks
|
|
585
|
+
if hooks is None:
|
|
586
|
+
return
|
|
587
|
+
|
|
588
|
+
try:
|
|
589
|
+
result = getattr(hooks, hook_name)(event)
|
|
590
|
+
if isawaitable(result):
|
|
591
|
+
await result
|
|
592
|
+
except Exception:
|
|
593
|
+
self.__logger.exception(
|
|
594
|
+
f"StepFunction - {self.__name} - Hook '{hook_name}' raised; ignoring"
|
|
595
|
+
)
|
|
596
|
+
|
|
399
597
|
def visualize(self):
|
|
400
598
|
"""Visualize the workflow."""
|
|
401
599
|
from stepfunction.core.visualizer import Visualizer
|
|
@@ -430,11 +628,80 @@ class StepFunction:
|
|
|
430
628
|
|
|
431
629
|
return visualizer.render_step_function_to_string()
|
|
432
630
|
|
|
631
|
+
def to_dict(self, step_registry: Optional["StepRegistry"] = None) -> Dict[str, Any]:
|
|
632
|
+
"""Export this workflow as a JSON-able dict.
|
|
633
|
+
|
|
634
|
+
Function references (step funcs, parallel-step funcs, and branch
|
|
635
|
+
callables) are encoded as their registered string name — see
|
|
636
|
+
``stepfunction.registry.step_registry``. Nested sub-step-functions are
|
|
637
|
+
encoded recursively.
|
|
638
|
+
|
|
639
|
+
Call this before execute(): if the workflow has already run,
|
|
640
|
+
"start_step" reflects wherever the execution cursor ended up
|
|
641
|
+
(often None after a completed run), not the original start step.
|
|
642
|
+
|
|
643
|
+
Raises:
|
|
644
|
+
UnserializableStepError: If any step was built from a BaseStep
|
|
645
|
+
instance (RetryStep, TimeoutStep, WaitStep, or a custom
|
|
646
|
+
BaseStep subclass) — not yet supported.
|
|
647
|
+
UnregisteredFunctionError: If a step or branch function used in
|
|
648
|
+
this workflow has no registered name in ``step_registry``.
|
|
649
|
+
"""
|
|
650
|
+
from stepfunction.core.serializer import encode_step_function
|
|
651
|
+
|
|
652
|
+
return encode_step_function(self, step_registry)
|
|
653
|
+
|
|
654
|
+
def to_json(
|
|
655
|
+
self, indent: Optional[int] = 2, step_registry: Optional["StepRegistry"] = None
|
|
656
|
+
) -> str:
|
|
657
|
+
"""Export this workflow as a JSON string. See to_dict() for details
|
|
658
|
+
and caveats."""
|
|
659
|
+
from json import dumps
|
|
660
|
+
|
|
661
|
+
return dumps(self.to_dict(step_registry), indent=indent)
|
|
662
|
+
|
|
663
|
+
@classmethod
|
|
664
|
+
def from_dict(
|
|
665
|
+
cls, data: Dict[str, Any], step_registry: Optional["StepRegistry"] = None
|
|
666
|
+
) -> "StepFunction":
|
|
667
|
+
"""Reconstruct a StepFunction from a dict produced by to_dict().
|
|
668
|
+
|
|
669
|
+
Function name references in ``data`` are resolved against
|
|
670
|
+
``step_registry`` (defaults to the package's default registry,
|
|
671
|
+
``stepfunction.registry.step_registry.registry``, if not given). Validates the
|
|
672
|
+
reconstructed workflow before returning, so a malformed spec fails
|
|
673
|
+
fast with a readable breadcrumb error rather than only failing
|
|
674
|
+
later at execute() time.
|
|
675
|
+
|
|
676
|
+
Raises:
|
|
677
|
+
UnregisteredFunctionError: If a referenced function name isn't
|
|
678
|
+
registered.
|
|
679
|
+
ValueError: If the reconstructed workflow fails validate().
|
|
680
|
+
"""
|
|
681
|
+
from stepfunction.core.serializer import decode_step_function
|
|
682
|
+
|
|
683
|
+
return decode_step_function(data, step_registry)
|
|
684
|
+
|
|
685
|
+
@classmethod
|
|
686
|
+
def from_json(
|
|
687
|
+
cls, json_str: str, step_registry: Optional["StepRegistry"] = None
|
|
688
|
+
) -> "StepFunction":
|
|
689
|
+
"""Reconstruct a StepFunction from a JSON string produced by to_json().
|
|
690
|
+
See from_dict() for details."""
|
|
691
|
+
from json import loads
|
|
692
|
+
|
|
693
|
+
return cls.from_dict(loads(json_str), step_registry)
|
|
694
|
+
|
|
433
695
|
@property
|
|
434
696
|
def name(self):
|
|
435
697
|
"""Returns the name of the step function."""
|
|
436
698
|
return self.__name
|
|
437
699
|
|
|
700
|
+
@property
|
|
701
|
+
def hooks(self) -> Optional[StepFunctionHooks]:
|
|
702
|
+
"""Returns the lifecycle hooks set on the step function, if any."""
|
|
703
|
+
return self.__hooks
|
|
704
|
+
|
|
438
705
|
@property
|
|
439
706
|
def steps(self):
|
|
440
707
|
"""Returns the steps of the step function."""
|
|
@@ -455,8 +722,22 @@ class StepFunction:
|
|
|
455
722
|
"""Returns the status of the step function."""
|
|
456
723
|
return self.__status
|
|
457
724
|
|
|
725
|
+
@property
|
|
726
|
+
def current_step(self):
|
|
727
|
+
"""Returns the step set by set_start_step(), advanced by execute()
|
|
728
|
+
as the workflow progresses."""
|
|
729
|
+
return self.__current_step
|
|
730
|
+
|
|
458
731
|
def __str__(self):
|
|
459
732
|
return f"StepFunction(name={self.__name}, NoOfSteps={len(self.__steps)}, CurrentStep={self.__current_step}, Status={self.__status})"
|
|
460
733
|
|
|
461
734
|
def __repr__(self):
|
|
462
735
|
return str(self)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _sub_path_label(step_name: str, sub_step_function: StepFunction) -> str:
|
|
739
|
+
"""How a sub-step function appears in a workflow path: its step name, plus
|
|
740
|
+
its own name when the two differ (e.g. "SubStep (SUB_FLOW)")."""
|
|
741
|
+
if step_name != sub_step_function.name:
|
|
742
|
+
return f"{step_name} ({sub_step_function.name})"
|
|
743
|
+
return step_name
|
|
@@ -36,11 +36,10 @@ from stepfunction.constants.visualizer import (
|
|
|
36
36
|
DEFAULT_VISUALIZER_SUB_STEP_FUNCTION_CLASS,
|
|
37
37
|
DEFAULT_VISUALIZER_SUB_STEP_FUNCTION_CLASS_STYLE,
|
|
38
38
|
DEFAULT_VISUALIZER_SUCCESS_EDGE_LABEL,
|
|
39
|
+
VISUALIZER_INVALID_NODE_ID_CHARS,
|
|
39
40
|
)
|
|
40
41
|
from stepfunction.types.step_types import StepParams
|
|
41
42
|
|
|
42
|
-
_INVALID_NODE_ID_CHARS = r"[^0-9A-Za-z_]"
|
|
43
|
-
|
|
44
43
|
|
|
45
44
|
def _quote(label: str) -> str:
|
|
46
45
|
"""Escape and quote a string for use as a Mermaid edge label."""
|
|
@@ -261,7 +260,7 @@ class Visualizer:
|
|
|
261
260
|
if step_name in self.__node_ids:
|
|
262
261
|
return self.__node_ids[step_name]
|
|
263
262
|
|
|
264
|
-
sanitized = re_sub(
|
|
263
|
+
sanitized = re_sub(VISUALIZER_INVALID_NODE_ID_CHARS, "_", step_name) or "step"
|
|
265
264
|
if sanitized[0].isdigit():
|
|
266
265
|
sanitized = f"_{sanitized}"
|
|
267
266
|
|
|
@@ -12,3 +12,39 @@ class ParallelStepExecutionError(Exception):
|
|
|
12
12
|
def __init__(self, exc: list[tuple[str, Exception]]):
|
|
13
13
|
self.message = f"Parallel step generated an exception: {exc}"
|
|
14
14
|
super().__init__(self.message)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DuplicateRegistrationError(Exception):
|
|
18
|
+
"""Error raised when a function is registered under a name that is already taken."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, name: str):
|
|
21
|
+
self.message = f"A function is already registered under the name '{name}'."
|
|
22
|
+
super().__init__(self.message)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UnregisteredFunctionError(Exception):
|
|
26
|
+
"""Error raised when a step spec references a function that has no
|
|
27
|
+
registered name, in either direction: decoding a name that was never
|
|
28
|
+
registered, or encoding a function that was never given a name."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str):
|
|
31
|
+
self.message = message
|
|
32
|
+
super().__init__(self.message)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class UnserializableStepError(Exception):
|
|
36
|
+
"""Error raised when a step cannot be represented in a declarative spec.
|
|
37
|
+
|
|
38
|
+
Currently applies to any step built from a BaseStep instance (e.g.
|
|
39
|
+
RetryStep, TimeoutStep, WaitStep) — only the built closure survives
|
|
40
|
+
add_step(), not the original instance/config needed to reconstruct it.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, step_name: str, step_type: object):
|
|
44
|
+
self.message = (
|
|
45
|
+
f"Step '{step_name}' cannot be serialized: it was built from a "
|
|
46
|
+
f"BaseStep instance (step_type={step_type}). Serializing "
|
|
47
|
+
"BaseStep-derived steps (RetryStep, TimeoutStep, WaitStep, or "
|
|
48
|
+
"custom BaseStep subclasses) is not yet supported."
|
|
49
|
+
)
|
|
50
|
+
super().__init__(self.message)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Events passed to lifecycle hooks.
|
|
2
|
+
|
|
3
|
+
Author: Vineeth Penugonda
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from stepfunction.constants.enums import StepFunctionStatus
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class StepEvent:
|
|
15
|
+
"""Describes one step (or one task of a parallel step) as it starts or ends.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
workflow (str): Path of the step function running the step, from the
|
|
19
|
+
outermost workflow down, e.g. "FLOW -> SubStep (SUB_FLOW)".
|
|
20
|
+
step (str): Name of the step.
|
|
21
|
+
task (Optional[str]): Name of the task within a parallel step; None for
|
|
22
|
+
ordinary steps and for the parallel step itself.
|
|
23
|
+
input (Any): The value the step was called with.
|
|
24
|
+
started_at (datetime): When the step started (UTC).
|
|
25
|
+
output (Any): The step's result. Set on success only.
|
|
26
|
+
error (Optional[BaseException]): The exception the step raised. Set on
|
|
27
|
+
failure only — the original exception, not the value stored in context.
|
|
28
|
+
next_step (Optional[str]): Where the workflow goes next: the branch or
|
|
29
|
+
next_step target on success, the on_failure target on failure, or
|
|
30
|
+
None if the workflow ends (or raises) here.
|
|
31
|
+
finished_at (Optional[datetime]): When the step ended (UTC). None on start.
|
|
32
|
+
duration (Optional[float]): Seconds the step took, from a monotonic
|
|
33
|
+
clock. None on start.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
workflow: str
|
|
37
|
+
step: str
|
|
38
|
+
task: Optional[str]
|
|
39
|
+
input: Any
|
|
40
|
+
started_at: datetime
|
|
41
|
+
output: Any = None
|
|
42
|
+
error: Optional[BaseException] = None
|
|
43
|
+
next_step: Optional[str] = None
|
|
44
|
+
finished_at: Optional[datetime] = None
|
|
45
|
+
duration: Optional[float] = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class WorkflowEvent:
|
|
50
|
+
"""Describes a step function run as it starts or ends.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
workflow (str): Path of the step function, from the outermost workflow down.
|
|
54
|
+
status (StepFunctionStatus): RUNNING on start; COMPLETED or FAILED on end.
|
|
55
|
+
input (Any): The initial input the run was called with.
|
|
56
|
+
started_at (datetime): When the run started (UTC).
|
|
57
|
+
output (Any): The last step's result. Set on end only.
|
|
58
|
+
error (Optional[BaseException]): The exception execute() raised (e.g. a
|
|
59
|
+
StepExecutionError wrapping the step's error — the step's own failure
|
|
60
|
+
event has the original). None when the run completed, or failed into
|
|
61
|
+
an on_failure step.
|
|
62
|
+
finished_at (Optional[datetime]): When the run ended (UTC). None on start.
|
|
63
|
+
duration (Optional[float]): Seconds the run took. None on start.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
workflow: str
|
|
67
|
+
status: StepFunctionStatus
|
|
68
|
+
input: Any
|
|
69
|
+
started_at: datetime
|
|
70
|
+
output: Any = None
|
|
71
|
+
error: Optional[BaseException] = None
|
|
72
|
+
finished_at: Optional[datetime] = None
|
|
73
|
+
duration: Optional[float] = None
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Base class for observing a step function's lifecycle.
|
|
2
|
+
|
|
3
|
+
Author: Vineeth Penugonda
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from stepfunction.hooks.events import StepEvent, WorkflowEvent
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StepFunctionHooks:
|
|
10
|
+
"""Receives events as a step function runs.
|
|
11
|
+
|
|
12
|
+
Subclass it and override the methods you need; the rest do nothing. Each
|
|
13
|
+
method may be a regular function or a coroutine function. Pass an instance
|
|
14
|
+
to ``StepFunction(name, hooks=...)`` or ``set_hooks()``.
|
|
15
|
+
|
|
16
|
+
Hooks observe; they don't steer. An exception raised by a hook is logged
|
|
17
|
+
and ignored, so it never changes the workflow's routing or status. A
|
|
18
|
+
sub-step function without hooks of its own uses its parent's.
|
|
19
|
+
|
|
20
|
+
Order of events for one run:
|
|
21
|
+
on_workflow_start
|
|
22
|
+
for each step: on_step_start, then on_step_success or on_step_failure
|
|
23
|
+
(a parallel step also reports each task between its own start and end,
|
|
24
|
+
with ``event.task`` set)
|
|
25
|
+
on_workflow_end
|
|
26
|
+
|
|
27
|
+
If the run is cancelled, the running step and the workflow end with
|
|
28
|
+
``error`` set to the ``asyncio.CancelledError``, which is then re-raised.
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
class PrintHooks(StepFunctionHooks):
|
|
32
|
+
async def on_step_success(self, event: StepEvent):
|
|
33
|
+
print(f"{event.step} took {event.duration:.3f}s -> {event.next_step}")
|
|
34
|
+
|
|
35
|
+
sf = StepFunction("MyStepFunction", hooks=PrintHooks())
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def on_workflow_start(self, event: WorkflowEvent):
|
|
39
|
+
"""Called once the workflow is validated, before its first step."""
|
|
40
|
+
|
|
41
|
+
def on_workflow_end(self, event: WorkflowEvent):
|
|
42
|
+
"""Called when the workflow completes, fails, raises or is cancelled."""
|
|
43
|
+
|
|
44
|
+
def on_step_start(self, event: StepEvent):
|
|
45
|
+
"""Called before a step (or a parallel task) runs."""
|
|
46
|
+
|
|
47
|
+
def on_step_success(self, event: StepEvent):
|
|
48
|
+
"""Called after a step (or a parallel task) returns and its next step is resolved."""
|
|
49
|
+
|
|
50
|
+
def on_step_failure(self, event: StepEvent):
|
|
51
|
+
"""Called after a step (or a parallel task) raises."""
|
|
File without changes
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Function registry for declarative (JSON) workflow loading.
|
|
2
|
+
|
|
3
|
+
Author: Vineeth Penugonda
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any, Callable, Dict, Optional
|
|
7
|
+
|
|
8
|
+
from stepfunction.exceptions.step_errors import (
|
|
9
|
+
DuplicateRegistrationError,
|
|
10
|
+
UnregisteredFunctionError,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StepRegistry:
|
|
15
|
+
"""A name -> callable registry used to resolve string function references
|
|
16
|
+
in a declarative (JSON) workflow spec back into real Python callables.
|
|
17
|
+
|
|
18
|
+
Step functions and branch-router functions share one namespace, since
|
|
19
|
+
both are simply ``Callable[[Any], Any]`` — the calling context
|
|
20
|
+
(``add_step`` vs. ``branch=``), not the function itself, determines the
|
|
21
|
+
role a registered callable plays in a given workflow.
|
|
22
|
+
|
|
23
|
+
A module-level default instance (``registry``) covers the common case of
|
|
24
|
+
a single process-wide registry. Construct your own ``StepRegistry()`` for
|
|
25
|
+
isolated namespaces (e.g. per-tenant, or test isolation).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
self.__functions: Dict[str, Callable[[Any], Any]] = {}
|
|
30
|
+
|
|
31
|
+
def register(
|
|
32
|
+
self, name: str, func: Optional[Callable[[Any], Any]] = None
|
|
33
|
+
) -> Callable[[Any], Any]:
|
|
34
|
+
"""Register ``func`` under ``name``.
|
|
35
|
+
|
|
36
|
+
Usable as a direct call (``registry.register("my_step", my_step)``)
|
|
37
|
+
or as a decorator (``@registry.register("my_step")``).
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
DuplicateRegistrationError: If ``name`` is already registered.
|
|
41
|
+
"""
|
|
42
|
+
if func is not None:
|
|
43
|
+
self.__add(name, func)
|
|
44
|
+
return func
|
|
45
|
+
|
|
46
|
+
def decorator(fn: Callable[[Any], Any]) -> Callable[[Any], Any]:
|
|
47
|
+
self.__add(name, fn)
|
|
48
|
+
return fn
|
|
49
|
+
|
|
50
|
+
return decorator
|
|
51
|
+
|
|
52
|
+
def __add(self, name: str, func: Callable[[Any], Any]) -> None:
|
|
53
|
+
if name in self.__functions:
|
|
54
|
+
raise DuplicateRegistrationError(name)
|
|
55
|
+
self.__functions[name] = func
|
|
56
|
+
|
|
57
|
+
def unregister(self, name: str) -> None:
|
|
58
|
+
"""Remove a registration if present. No error if ``name`` isn't registered."""
|
|
59
|
+
self.__functions.pop(name, None)
|
|
60
|
+
|
|
61
|
+
def get(self, name: str) -> Callable[[Any], Any]:
|
|
62
|
+
"""Look up a registered callable by name.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
UnregisteredFunctionError: If ``name`` has not been registered.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
return self.__functions[name]
|
|
69
|
+
except KeyError:
|
|
70
|
+
raise UnregisteredFunctionError(
|
|
71
|
+
f"No function is registered under the name '{name}'. Register it "
|
|
72
|
+
"with stepfunction.registry.step_registry.register_step() (or your "
|
|
73
|
+
"own StepRegistry instance) before loading this workflow spec."
|
|
74
|
+
) from None
|
|
75
|
+
|
|
76
|
+
def name_for(self, func: Callable[[Any], Any]) -> Optional[str]:
|
|
77
|
+
"""Reverse lookup: the registered name for ``func``, or ``None`` if it
|
|
78
|
+
isn't registered under this registry."""
|
|
79
|
+
for registered_name, registered_func in self.__functions.items():
|
|
80
|
+
if registered_func is func:
|
|
81
|
+
return registered_name
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
def __contains__(self, name: str) -> bool:
|
|
85
|
+
return name in self.__functions
|
|
86
|
+
|
|
87
|
+
def clear(self) -> None:
|
|
88
|
+
"""Remove all registrations. Primarily useful for test isolation."""
|
|
89
|
+
self.__functions.clear()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Module-level default singleton, covering the common case of one
|
|
93
|
+
# process-wide registry.
|
|
94
|
+
registry = StepRegistry()
|
|
95
|
+
|
|
96
|
+
register_step = registry.register
|
|
97
|
+
get_step = registry.get
|
stepfunction/utils/utils.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""This module contains utility functions for the stepfunction package."""
|
|
2
2
|
|
|
3
|
+
from datetime import datetime, timezone
|
|
3
4
|
from os import getenv
|
|
4
5
|
from typing import Optional
|
|
5
6
|
|
|
@@ -16,3 +17,8 @@ def get_environment_variable(name: str, default: Optional[str] = None) -> Option
|
|
|
16
17
|
str: The value of the environment variable.
|
|
17
18
|
"""
|
|
18
19
|
return getenv(name, default)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def utc_now() -> datetime:
|
|
23
|
+
"""Returns the current time as a timezone-aware UTC datetime."""
|
|
24
|
+
return datetime.now(timezone.utc)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stepfunction
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Step Function Workflow Orchestration Library
|
|
5
5
|
Author: Vineeth Penugonda
|
|
6
6
|
License-Expression: MIT
|
|
@@ -33,6 +33,39 @@ Dynamic: license-file
|
|
|
33
33
|
- **Branching Logic**: Direct workflows based on conditions.
|
|
34
34
|
- **Sub-Step Functions**: Modularize workflows by embedding sub-step functions.
|
|
35
35
|
- **Visualization**: Integrated support for visualizing the workflow graph as a Mermaid flowchart.
|
|
36
|
+
- **Lifecycle Hooks**: Get notified as the workflow and each step start, succeed or fail — with inputs, outputs, errors, routing and timings — to record run history, metrics or traces.
|
|
37
|
+
|
|
38
|
+
## Lifecycle Hooks
|
|
39
|
+
|
|
40
|
+
Subclass `StepFunctionHooks`, override the events you need (sync or async), and pass an instance to the step function:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from stepfunction.core.step_function import StepFunction
|
|
44
|
+
from stepfunction.hooks import StepEvent, StepFunctionHooks
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RunRecorder(StepFunctionHooks):
|
|
48
|
+
async def on_step_start(self, event: StepEvent):
|
|
49
|
+
print(f"{event.workflow}: {event.step} started with {event.input!r}")
|
|
50
|
+
|
|
51
|
+
async def on_step_success(self, event: StepEvent):
|
|
52
|
+
print(f"{event.step} -> {event.output!r} in {event.duration:.3f}s, next: {event.next_step}")
|
|
53
|
+
|
|
54
|
+
async def on_step_failure(self, event: StepEvent):
|
|
55
|
+
print(f"{event.step} failed: {event.error!r}, next: {event.next_step}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
sf = StepFunction("MyStepFunction", hooks=RunRecorder())
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Events: `on_workflow_start`, `on_step_start`, `on_step_success`, `on_step_failure`, `on_workflow_end`.
|
|
62
|
+
|
|
63
|
+
- `StepEvent` carries the workflow path, step name, parallel `task` name (if any), `input`, `output`, the original `error`, the resolved `next_step`, UTC `started_at` / `finished_at` and `duration` in seconds.
|
|
64
|
+
- `WorkflowEvent` carries the workflow path, `status`, initial `input`, final `output`, the `error` that ended the run (if `execute()` raised) and timings.
|
|
65
|
+
- Each task of a parallel step is reported as its own step event, with `task` set.
|
|
66
|
+
- A sub-step function without hooks of its own reports to its parent's hooks; its events carry the nested path, e.g. `"FLOW -> SubStep (SUB_FLOW)"`.
|
|
67
|
+
- Hooks observe; they don't steer. An exception raised by a hook is logged and ignored.
|
|
68
|
+
- If the run is cancelled, the running step and the workflow are reported with the `CancelledError`, which is then re-raised.
|
|
36
69
|
|
|
37
70
|
## Installation
|
|
38
71
|
|
|
@@ -1,12 +1,20 @@
|
|
|
1
|
+
stepfunction/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
1
2
|
stepfunction/constants/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
3
|
stepfunction/constants/enums.py,sha256=K4_xKGa0NApw6Ai4VkfGW1Sc5fEqwFPpuyezy6YexzY,240
|
|
3
|
-
stepfunction/constants/visualizer.py,sha256=
|
|
4
|
+
stepfunction/constants/visualizer.py,sha256=MHi7MafiXGr6wb2ug7-czRG-CiM-xO5wniN6EFZBRzA,1994
|
|
5
|
+
stepfunction/core/serializer/__init__.py,sha256=PvxFmVDK8EnFBqZXgYBELO1uobT5vkFFntRzlGx7cJo,127
|
|
6
|
+
stepfunction/core/serializer/serializer.py,sha256=k5Uu881Amoy0Kv2mPwiv7kcNOnydtfOEQeBRu3aloj4,6006
|
|
4
7
|
stepfunction/core/step_function/__init__.py,sha256=4L5ufP13A7hXBDBYrD45pfEsV9bsa6zbSkSnHpi-tyM,68
|
|
5
|
-
stepfunction/core/step_function/step_function.py,sha256=
|
|
8
|
+
stepfunction/core/step_function/step_function.py,sha256=whoKdcjayIHnu8wSEnB06ufhhXLnQNrmvb_7I2qXcT4,27809
|
|
6
9
|
stepfunction/core/visualizer/__init__.py,sha256=pgNI6LPnY9Pm9e_YhMn-5CVmUzp8mvoytbDZ_b3McSw,61
|
|
7
|
-
stepfunction/core/visualizer/visualizer.py,sha256=
|
|
10
|
+
stepfunction/core/visualizer/visualizer.py,sha256=hhgZt5y98KiKLFtxhc_W7U0NwQ48F6WmAmN_N2_0GKc,12897
|
|
8
11
|
stepfunction/exceptions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
-
stepfunction/exceptions/step_errors.py,sha256=
|
|
12
|
+
stepfunction/exceptions/step_errors.py,sha256=jN71QXMtuekNy9YDMrwkcOxY9RR9Wssbk-zKeGVW0I4,1923
|
|
13
|
+
stepfunction/hooks/__init__.py,sha256=omNFwha6-07ggGZHnDTZCbZNEM26qvffIxtRvdGp8RU,159
|
|
14
|
+
stepfunction/hooks/events.py,sha256=gIqaEJqQRro7VPyqfDEgdLQNK627iX3E6fOYz2Cf9lg,2886
|
|
15
|
+
stepfunction/hooks/step_function_hooks.py,sha256=JK6wLb_uns3DoECt6tQJGCiSNMhbgZ71HEcNADq4pig,2011
|
|
16
|
+
stepfunction/registry/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
stepfunction/registry/step_registry.py,sha256=znpqXkgJQnOwCg7K3h6BmHdZrrDIFhsnyOYzdW8iVQE,3430
|
|
10
18
|
stepfunction/steps/__init__.py,sha256=DvzYMJiMY-5O2tWhCa82LSbqIa80VXfUuLgFYyAOdi8,256
|
|
11
19
|
stepfunction/steps/retry_step.py,sha256=Y5cIWi4MqVSeXUVxHKXW0TkYPBl7bOcGGL1cxH_26wY,2855
|
|
12
20
|
stepfunction/steps/timeout_step.py,sha256=0_wwAAZcwC61fKSkT4TWRef-mUOT3A2T_-Y1pTXuhd8,2379
|
|
@@ -20,9 +28,9 @@ stepfunction/types/step_types.py,sha256=pMA8xY_a3yNmpDul8BBqa-0_Fpp3QNIMFpxAgn_t
|
|
|
20
28
|
stepfunction/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
29
|
stepfunction/utils/constants.py,sha256=VZefE-OhNXOyg28wPu62VZPvReublyqWS515-TbYWo0,391
|
|
22
30
|
stepfunction/utils/logger.py,sha256=0L8F8oO5HiYNqcuV8G8UD4cTdCpmObQYrkvwvrF1S-8,1789
|
|
23
|
-
stepfunction/utils/utils.py,sha256=
|
|
24
|
-
stepfunction-0.
|
|
25
|
-
stepfunction-0.
|
|
26
|
-
stepfunction-0.
|
|
27
|
-
stepfunction-0.
|
|
28
|
-
stepfunction-0.
|
|
31
|
+
stepfunction/utils/utils.py,sha256=CrJuiWQrVs3re569wlJFODkmZ2fZcQaUS7Vxm56188c,739
|
|
32
|
+
stepfunction-0.2.0.dist-info/licenses/LICENSE,sha256=sLQZUABreUtT56S4w592eAmWiFONtLKZG-KZWf-_dvg,1074
|
|
33
|
+
stepfunction-0.2.0.dist-info/METADATA,sha256=ojVNltqxMd6afHuj6RpORW05ldaJ-AB6SI_fO59Kc_8,4737
|
|
34
|
+
stepfunction-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
35
|
+
stepfunction-0.2.0.dist-info/top_level.txt,sha256=XqLJSUeYSmzTNMiPj-IESxp1om-LdZjefT4Un2orQD4,13
|
|
36
|
+
stepfunction-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|