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.
@@ -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>"