edda-framework 0.1.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.
- edda/__init__.py +56 -0
- edda/activity.py +505 -0
- edda/app.py +996 -0
- edda/compensation.py +326 -0
- edda/context.py +489 -0
- edda/events.py +505 -0
- edda/exceptions.py +64 -0
- edda/hooks.py +284 -0
- edda/locking.py +322 -0
- edda/outbox/__init__.py +15 -0
- edda/outbox/relayer.py +274 -0
- edda/outbox/transactional.py +112 -0
- edda/pydantic_utils.py +316 -0
- edda/replay.py +799 -0
- edda/retry.py +207 -0
- edda/serialization/__init__.py +9 -0
- edda/serialization/base.py +83 -0
- edda/serialization/json.py +102 -0
- edda/storage/__init__.py +9 -0
- edda/storage/models.py +194 -0
- edda/storage/protocol.py +737 -0
- edda/storage/sqlalchemy_storage.py +1809 -0
- edda/viewer_ui/__init__.py +20 -0
- edda/viewer_ui/app.py +1399 -0
- edda/viewer_ui/components.py +1105 -0
- edda/viewer_ui/data_service.py +880 -0
- edda/visualizer/__init__.py +11 -0
- edda/visualizer/ast_analyzer.py +383 -0
- edda/visualizer/mermaid_generator.py +355 -0
- edda/workflow.py +218 -0
- edda_framework-0.1.0.dist-info/METADATA +748 -0
- edda_framework-0.1.0.dist-info/RECORD +35 -0
- edda_framework-0.1.0.dist-info/WHEEL +4 -0
- edda_framework-0.1.0.dist-info/entry_points.txt +2 -0
- edda_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Workflow visualization module for Edda framework.
|
|
3
|
+
|
|
4
|
+
This module provides AST-based analysis and visualization of workflow definitions
|
|
5
|
+
in Mermaid and DOT formats.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from edda.visualizer.ast_analyzer import WorkflowAnalyzer
|
|
9
|
+
from edda.visualizer.mermaid_generator import MermaidGenerator
|
|
10
|
+
|
|
11
|
+
__all__ = ["WorkflowAnalyzer", "MermaidGenerator"]
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AST-based workflow analyzer for Edda framework.
|
|
3
|
+
|
|
4
|
+
This module analyzes Python source code to extract workflow definitions
|
|
5
|
+
and their control flow using the Abstract Syntax Tree.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WorkflowAnalyzer(ast.NodeVisitor):
|
|
13
|
+
"""
|
|
14
|
+
AST visitor that analyzes @workflow decorated workflows.
|
|
15
|
+
|
|
16
|
+
This visitor extracts workflow structure including:
|
|
17
|
+
- Activity calls
|
|
18
|
+
- Compensation registrations
|
|
19
|
+
- Event waits
|
|
20
|
+
- Conditional branches
|
|
21
|
+
- Exception handling
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
"""Initialize the workflow analyzer."""
|
|
26
|
+
self.workflows: list[dict[str, Any]] = []
|
|
27
|
+
self.current_workflow: dict[str, Any] | None = None
|
|
28
|
+
|
|
29
|
+
def analyze(self, source_code: str) -> list[dict[str, Any]]:
|
|
30
|
+
"""
|
|
31
|
+
Analyze Python source code and extract workflow definitions.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
source_code: Python source code as string
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
List of workflow dictionaries containing structure information
|
|
38
|
+
"""
|
|
39
|
+
tree = ast.parse(source_code)
|
|
40
|
+
self.visit(tree)
|
|
41
|
+
return self.workflows
|
|
42
|
+
|
|
43
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Visit function definition nodes.
|
|
46
|
+
|
|
47
|
+
Detects @workflow decorated functions and analyzes their body.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
node: Function definition AST node
|
|
51
|
+
"""
|
|
52
|
+
self._process_function(node)
|
|
53
|
+
self.generic_visit(node)
|
|
54
|
+
|
|
55
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
56
|
+
"""
|
|
57
|
+
Visit async function definition nodes.
|
|
58
|
+
|
|
59
|
+
Detects @workflow decorated async functions and analyzes their body.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
node: Async function definition AST node
|
|
63
|
+
"""
|
|
64
|
+
self._process_function(node)
|
|
65
|
+
self.generic_visit(node)
|
|
66
|
+
|
|
67
|
+
def _process_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
68
|
+
"""
|
|
69
|
+
Process function definition (sync or async).
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
node: Function definition node
|
|
73
|
+
"""
|
|
74
|
+
if self._has_workflow_decorator(node):
|
|
75
|
+
steps: list[dict[str, Any]] = []
|
|
76
|
+
workflow = {
|
|
77
|
+
"name": node.name,
|
|
78
|
+
"args": [arg.arg for arg in node.args.args[1:]], # Skip 'ctx'
|
|
79
|
+
"steps": steps,
|
|
80
|
+
"docstring": ast.get_docstring(node),
|
|
81
|
+
}
|
|
82
|
+
self.current_workflow = workflow
|
|
83
|
+
self.workflows.append(workflow)
|
|
84
|
+
|
|
85
|
+
# Analyze function body
|
|
86
|
+
self._analyze_body(node.body, steps)
|
|
87
|
+
self.current_workflow = None
|
|
88
|
+
|
|
89
|
+
def _has_workflow_decorator(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
90
|
+
"""
|
|
91
|
+
Check if function has @workflow decorator.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
node: Function definition node
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
True if function is decorated with @workflow
|
|
98
|
+
"""
|
|
99
|
+
for decorator in node.decorator_list:
|
|
100
|
+
# Check for @workflow (simple decorator)
|
|
101
|
+
if isinstance(decorator, ast.Name) and decorator.id == "workflow":
|
|
102
|
+
return True
|
|
103
|
+
# Check for @workflow(...) (decorator with arguments)
|
|
104
|
+
if (
|
|
105
|
+
isinstance(decorator, ast.Call)
|
|
106
|
+
and isinstance(decorator.func, ast.Name)
|
|
107
|
+
and decorator.func.id == "workflow"
|
|
108
|
+
):
|
|
109
|
+
return True
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
def _analyze_body(self, body: list[ast.stmt], steps: list[dict[str, Any]]) -> None:
|
|
113
|
+
"""
|
|
114
|
+
Analyze function body to extract workflow steps.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
body: List of AST statement nodes
|
|
118
|
+
steps: List to append extracted steps to
|
|
119
|
+
"""
|
|
120
|
+
for stmt in body:
|
|
121
|
+
# Skip docstring
|
|
122
|
+
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant):
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
# await activity() or await function()
|
|
126
|
+
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Await):
|
|
127
|
+
call = stmt.value.value
|
|
128
|
+
if isinstance(call, ast.Call):
|
|
129
|
+
step = self._extract_call_info(call)
|
|
130
|
+
if step:
|
|
131
|
+
steps.append(step)
|
|
132
|
+
|
|
133
|
+
# result = await activity()
|
|
134
|
+
elif isinstance(stmt, ast.Assign) and isinstance(stmt.value, ast.Await):
|
|
135
|
+
call = stmt.value.value
|
|
136
|
+
if isinstance(call, ast.Call):
|
|
137
|
+
step = self._extract_call_info(call)
|
|
138
|
+
if step:
|
|
139
|
+
# Store variable name if assigned
|
|
140
|
+
if stmt.targets and isinstance(stmt.targets[0], ast.Name):
|
|
141
|
+
step["result_var"] = stmt.targets[0].id
|
|
142
|
+
steps.append(step)
|
|
143
|
+
|
|
144
|
+
# if/elif/else conditional
|
|
145
|
+
elif isinstance(stmt, ast.If):
|
|
146
|
+
# Check if this is an if-elif chain (render as multi-branch)
|
|
147
|
+
if self._is_elif_chain(stmt):
|
|
148
|
+
multi_condition = self._flatten_elif_chain(stmt)
|
|
149
|
+
steps.append(multi_condition)
|
|
150
|
+
else:
|
|
151
|
+
# Simple if-else (render as binary condition)
|
|
152
|
+
if_branch: list[dict[str, Any]] = []
|
|
153
|
+
else_branch: list[dict[str, Any]] = []
|
|
154
|
+
condition = {
|
|
155
|
+
"type": "condition",
|
|
156
|
+
"test": self._unparse_safely(stmt.test),
|
|
157
|
+
"if_branch": if_branch,
|
|
158
|
+
"else_branch": else_branch,
|
|
159
|
+
}
|
|
160
|
+
self._analyze_body(stmt.body, if_branch)
|
|
161
|
+
self._analyze_body(stmt.orelse, else_branch)
|
|
162
|
+
steps.append(condition)
|
|
163
|
+
|
|
164
|
+
# try/except exception handling
|
|
165
|
+
elif isinstance(stmt, ast.Try):
|
|
166
|
+
try_body: list[dict[str, Any]] = []
|
|
167
|
+
finally_body: list[dict[str, Any]] = []
|
|
168
|
+
except_handlers: list[dict[str, Any]] = []
|
|
169
|
+
try_block = {
|
|
170
|
+
"type": "try",
|
|
171
|
+
"try_body": try_body,
|
|
172
|
+
"except_handlers": except_handlers,
|
|
173
|
+
"finally_body": finally_body,
|
|
174
|
+
}
|
|
175
|
+
self._analyze_body(stmt.body, try_body)
|
|
176
|
+
|
|
177
|
+
for handler in stmt.handlers:
|
|
178
|
+
except_body: list[dict[str, Any]] = []
|
|
179
|
+
self._analyze_body(handler.body, except_body)
|
|
180
|
+
exception_type = (
|
|
181
|
+
self._unparse_safely(handler.type) if handler.type else "Exception"
|
|
182
|
+
)
|
|
183
|
+
except_handlers.append({"exception": exception_type, "body": except_body})
|
|
184
|
+
|
|
185
|
+
if stmt.finalbody:
|
|
186
|
+
self._analyze_body(stmt.finalbody, finally_body)
|
|
187
|
+
|
|
188
|
+
steps.append(try_block)
|
|
189
|
+
|
|
190
|
+
# for loop (simplified representation)
|
|
191
|
+
elif isinstance(stmt, ast.For):
|
|
192
|
+
loop_body: list[dict[str, Any]] = []
|
|
193
|
+
loop = {
|
|
194
|
+
"type": "loop",
|
|
195
|
+
"loop_type": "for",
|
|
196
|
+
"target": self._unparse_safely(stmt.target),
|
|
197
|
+
"iter": self._unparse_safely(stmt.iter),
|
|
198
|
+
"body": loop_body,
|
|
199
|
+
}
|
|
200
|
+
self._analyze_body(stmt.body, loop_body)
|
|
201
|
+
steps.append(loop)
|
|
202
|
+
|
|
203
|
+
# while loop (simplified representation)
|
|
204
|
+
elif isinstance(stmt, ast.While):
|
|
205
|
+
while_body: list[dict[str, Any]] = []
|
|
206
|
+
loop = {
|
|
207
|
+
"type": "loop",
|
|
208
|
+
"loop_type": "while",
|
|
209
|
+
"test": self._unparse_safely(stmt.test),
|
|
210
|
+
"body": while_body,
|
|
211
|
+
}
|
|
212
|
+
self._analyze_body(stmt.body, while_body)
|
|
213
|
+
steps.append(loop)
|
|
214
|
+
|
|
215
|
+
# match-case statement (Python 3.10+)
|
|
216
|
+
elif isinstance(stmt, ast.Match):
|
|
217
|
+
cases: list[dict[str, Any]] = []
|
|
218
|
+
match_block = {
|
|
219
|
+
"type": "match",
|
|
220
|
+
"subject": self._unparse_safely(stmt.subject),
|
|
221
|
+
"cases": cases,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for case in stmt.cases:
|
|
225
|
+
case_body: list[dict[str, Any]] = []
|
|
226
|
+
self._analyze_body(case.body, case_body)
|
|
227
|
+
|
|
228
|
+
# Extract pattern as string
|
|
229
|
+
pattern = self._unparse_safely(case.pattern)
|
|
230
|
+
|
|
231
|
+
# Extract guard condition (if clause) if present
|
|
232
|
+
guard = None
|
|
233
|
+
if case.guard:
|
|
234
|
+
guard = self._unparse_safely(case.guard)
|
|
235
|
+
|
|
236
|
+
cases.append(
|
|
237
|
+
{
|
|
238
|
+
"pattern": pattern,
|
|
239
|
+
"guard": guard,
|
|
240
|
+
"body": case_body,
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
steps.append(match_block)
|
|
245
|
+
|
|
246
|
+
def _extract_call_info(self, call: ast.Call) -> dict[str, Any] | None:
|
|
247
|
+
"""
|
|
248
|
+
Extract information from function call.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
call: Call AST node
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Dictionary with call information or None
|
|
255
|
+
"""
|
|
256
|
+
if isinstance(call.func, ast.Name):
|
|
257
|
+
func_name = call.func.id
|
|
258
|
+
|
|
259
|
+
# Detect special Edda functions
|
|
260
|
+
if func_name == "register_compensation":
|
|
261
|
+
# await register_compensation(ctx, compensation_func, **kwargs)
|
|
262
|
+
compensation_func = (
|
|
263
|
+
self._get_arg_name(call.args[1]) if len(call.args) > 1 else "unknown"
|
|
264
|
+
)
|
|
265
|
+
return {"type": "compensation", "activity_name": compensation_func}
|
|
266
|
+
|
|
267
|
+
elif func_name == "wait_event":
|
|
268
|
+
# await wait_event(ctx, event_type="...", ...)
|
|
269
|
+
event_type = self._get_keyword_arg(call, "event_type")
|
|
270
|
+
timeout = self._get_keyword_arg(call, "timeout_seconds")
|
|
271
|
+
return {
|
|
272
|
+
"type": "wait_event",
|
|
273
|
+
"event_type": event_type or "unknown",
|
|
274
|
+
"timeout": timeout,
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
else:
|
|
278
|
+
# Regular activity or function call
|
|
279
|
+
return {"type": "activity", "activity_name": func_name}
|
|
280
|
+
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
def _get_arg_name(self, arg: ast.expr) -> str:
|
|
284
|
+
"""
|
|
285
|
+
Get function name from argument.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
arg: Argument expression node
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
Function name as string
|
|
292
|
+
"""
|
|
293
|
+
if isinstance(arg, ast.Name):
|
|
294
|
+
return arg.id
|
|
295
|
+
return self._unparse_safely(arg)
|
|
296
|
+
|
|
297
|
+
def _get_keyword_arg(self, call: ast.Call, key: str) -> str | None:
|
|
298
|
+
"""
|
|
299
|
+
Extract keyword argument value from call.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
call: Call AST node
|
|
303
|
+
key: Keyword argument name
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
Argument value as string or None
|
|
307
|
+
"""
|
|
308
|
+
for keyword in call.keywords:
|
|
309
|
+
if keyword.arg == key:
|
|
310
|
+
if isinstance(keyword.value, ast.Constant):
|
|
311
|
+
return str(keyword.value.value)
|
|
312
|
+
return self._unparse_safely(keyword.value)
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
def _is_elif_chain(self, stmt: ast.If) -> bool:
|
|
316
|
+
"""
|
|
317
|
+
Check if an if statement is part of an if-elif chain.
|
|
318
|
+
|
|
319
|
+
An if-elif chain is detected when the orelse contains another ast.If node.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
stmt: If statement node
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
True if this is an if-elif chain
|
|
326
|
+
"""
|
|
327
|
+
# Check if orelse contains exactly one statement and it's an ast.If
|
|
328
|
+
return bool(len(stmt.orelse) == 1 and isinstance(stmt.orelse[0], ast.If))
|
|
329
|
+
|
|
330
|
+
def _flatten_elif_chain(self, stmt: ast.If) -> dict[str, Any]:
|
|
331
|
+
"""
|
|
332
|
+
Flatten an if-elif-else chain into a multi-branch structure.
|
|
333
|
+
|
|
334
|
+
Converts nested ast.If nodes in orelse into a flat list of branches,
|
|
335
|
+
similar to match-case structure.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
stmt: Root if statement node
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
Dictionary with type "multi_condition" and flat list of branches
|
|
342
|
+
"""
|
|
343
|
+
branches: list[dict[str, Any]] = []
|
|
344
|
+
|
|
345
|
+
# Process the initial if branch
|
|
346
|
+
if_body: list[dict[str, Any]] = []
|
|
347
|
+
self._analyze_body(stmt.body, if_body)
|
|
348
|
+
branches.append({"test": self._unparse_safely(stmt.test), "body": if_body})
|
|
349
|
+
|
|
350
|
+
# Recursively process elif/else branches
|
|
351
|
+
current_orelse = stmt.orelse
|
|
352
|
+
while current_orelse:
|
|
353
|
+
# Check if orelse contains an elif (another ast.If)
|
|
354
|
+
if len(current_orelse) == 1 and isinstance(current_orelse[0], ast.If):
|
|
355
|
+
elif_node = current_orelse[0]
|
|
356
|
+
elif_body: list[dict[str, Any]] = []
|
|
357
|
+
self._analyze_body(elif_node.body, elif_body)
|
|
358
|
+
branches.append({"test": self._unparse_safely(elif_node.test), "body": elif_body})
|
|
359
|
+
current_orelse = elif_node.orelse
|
|
360
|
+
else:
|
|
361
|
+
# This is the final else branch (or no else)
|
|
362
|
+
if current_orelse:
|
|
363
|
+
else_body: list[dict[str, Any]] = []
|
|
364
|
+
self._analyze_body(current_orelse, else_body)
|
|
365
|
+
branches.append({"test": None, "body": else_body})
|
|
366
|
+
break
|
|
367
|
+
|
|
368
|
+
return {"type": "multi_condition", "branches": branches}
|
|
369
|
+
|
|
370
|
+
def _unparse_safely(self, node: ast.AST) -> str:
|
|
371
|
+
"""
|
|
372
|
+
Safely unparse AST node to string.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
node: AST node
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
String representation of the node
|
|
379
|
+
"""
|
|
380
|
+
try:
|
|
381
|
+
return ast.unparse(node)
|
|
382
|
+
except Exception:
|
|
383
|
+
return "<unparseable>"
|