kailash 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.
Files changed (69) hide show
  1. kailash/__init__.py +31 -0
  2. kailash/__main__.py +11 -0
  3. kailash/cli/__init__.py +5 -0
  4. kailash/cli/commands.py +563 -0
  5. kailash/manifest.py +778 -0
  6. kailash/nodes/__init__.py +23 -0
  7. kailash/nodes/ai/__init__.py +26 -0
  8. kailash/nodes/ai/agents.py +417 -0
  9. kailash/nodes/ai/models.py +488 -0
  10. kailash/nodes/api/__init__.py +52 -0
  11. kailash/nodes/api/auth.py +567 -0
  12. kailash/nodes/api/graphql.py +480 -0
  13. kailash/nodes/api/http.py +598 -0
  14. kailash/nodes/api/rate_limiting.py +572 -0
  15. kailash/nodes/api/rest.py +665 -0
  16. kailash/nodes/base.py +1032 -0
  17. kailash/nodes/base_async.py +128 -0
  18. kailash/nodes/code/__init__.py +32 -0
  19. kailash/nodes/code/python.py +1021 -0
  20. kailash/nodes/data/__init__.py +125 -0
  21. kailash/nodes/data/readers.py +496 -0
  22. kailash/nodes/data/sharepoint_graph.py +623 -0
  23. kailash/nodes/data/sql.py +380 -0
  24. kailash/nodes/data/streaming.py +1168 -0
  25. kailash/nodes/data/vector_db.py +964 -0
  26. kailash/nodes/data/writers.py +529 -0
  27. kailash/nodes/logic/__init__.py +6 -0
  28. kailash/nodes/logic/async_operations.py +702 -0
  29. kailash/nodes/logic/operations.py +551 -0
  30. kailash/nodes/transform/__init__.py +5 -0
  31. kailash/nodes/transform/processors.py +379 -0
  32. kailash/runtime/__init__.py +6 -0
  33. kailash/runtime/async_local.py +356 -0
  34. kailash/runtime/docker.py +697 -0
  35. kailash/runtime/local.py +434 -0
  36. kailash/runtime/parallel.py +557 -0
  37. kailash/runtime/runner.py +110 -0
  38. kailash/runtime/testing.py +347 -0
  39. kailash/sdk_exceptions.py +307 -0
  40. kailash/tracking/__init__.py +7 -0
  41. kailash/tracking/manager.py +885 -0
  42. kailash/tracking/metrics_collector.py +342 -0
  43. kailash/tracking/models.py +535 -0
  44. kailash/tracking/storage/__init__.py +0 -0
  45. kailash/tracking/storage/base.py +113 -0
  46. kailash/tracking/storage/database.py +619 -0
  47. kailash/tracking/storage/filesystem.py +543 -0
  48. kailash/utils/__init__.py +0 -0
  49. kailash/utils/export.py +924 -0
  50. kailash/utils/templates.py +680 -0
  51. kailash/visualization/__init__.py +62 -0
  52. kailash/visualization/api.py +732 -0
  53. kailash/visualization/dashboard.py +951 -0
  54. kailash/visualization/performance.py +808 -0
  55. kailash/visualization/reports.py +1471 -0
  56. kailash/workflow/__init__.py +15 -0
  57. kailash/workflow/builder.py +245 -0
  58. kailash/workflow/graph.py +827 -0
  59. kailash/workflow/mermaid_visualizer.py +628 -0
  60. kailash/workflow/mock_registry.py +63 -0
  61. kailash/workflow/runner.py +302 -0
  62. kailash/workflow/state.py +238 -0
  63. kailash/workflow/visualization.py +588 -0
  64. kailash-0.1.0.dist-info/METADATA +710 -0
  65. kailash-0.1.0.dist-info/RECORD +69 -0
  66. kailash-0.1.0.dist-info/WHEEL +5 -0
  67. kailash-0.1.0.dist-info/entry_points.txt +2 -0
  68. kailash-0.1.0.dist-info/licenses/LICENSE +21 -0
  69. kailash-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,588 @@
1
+ """Workflow visualization utilities."""
2
+
3
+ import matplotlib
4
+
5
+ matplotlib.use("Agg") # Use non-interactive backend
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional, Tuple
8
+
9
+ import matplotlib.pyplot as plt
10
+ import networkx as nx
11
+
12
+ from kailash.tracking.manager import TaskManager
13
+ from kailash.workflow.graph import Workflow
14
+
15
+
16
+ class WorkflowVisualizer:
17
+ """Visualize workflows as graphs."""
18
+
19
+ def __init__(
20
+ self,
21
+ workflow: Workflow,
22
+ node_colors: Optional[Dict[str, str]] = None,
23
+ edge_colors: Optional[Dict[str, str]] = None,
24
+ layout: str = "hierarchical",
25
+ ):
26
+ """Initialize visualizer.
27
+
28
+ Args:
29
+ workflow: Workflow to visualize
30
+ node_colors: Custom node color map
31
+ edge_colors: Custom edge color map
32
+ layout: Layout algorithm to use
33
+ """
34
+ self.workflow = workflow
35
+ self.node_colors = node_colors or self._default_node_colors()
36
+ self.edge_colors = edge_colors or self._default_edge_colors()
37
+ self.layout = layout
38
+
39
+ def _default_node_colors(self) -> Dict[str, str]:
40
+ """Get default node color map."""
41
+ return {
42
+ "data": "lightblue",
43
+ "transform": "lightyellow",
44
+ "logic": "lightcoral",
45
+ "ai": "lightpink",
46
+ "default": "lightgray",
47
+ }
48
+
49
+ def _default_edge_colors(self) -> Dict[str, str]:
50
+ """Get default edge color map."""
51
+ return {"default": "gray", "error": "red", "conditional": "orange"}
52
+
53
+ def _get_node_color(self, node_type: str) -> str:
54
+ """Get color for node based on type."""
55
+ if "Reader" in node_type or "Writer" in node_type:
56
+ return self.node_colors["data"]
57
+ elif (
58
+ "Transform" in node_type
59
+ or "Filter" in node_type
60
+ or "Processor" in node_type
61
+ ):
62
+ return self.node_colors["transform"]
63
+ elif "Logic" in node_type or "Merge" in node_type or "Conditional" in node_type:
64
+ return self.node_colors["logic"]
65
+ elif "AI" in node_type or "Model" in node_type:
66
+ return self.node_colors["ai"]
67
+ return self.node_colors["default"]
68
+
69
+ def _get_node_colors(self) -> List[str]:
70
+ """Get colors for all nodes in workflow."""
71
+ colors = []
72
+ for node_id in self.workflow.graph.nodes():
73
+ node_instance = self.workflow.nodes[node_id]
74
+ node_type = node_instance.node_type
75
+ colors.append(self._get_node_color(node_type))
76
+ return colors
77
+
78
+ def _get_node_labels(self) -> Dict[str, str]:
79
+ """Get labels for nodes in workflow."""
80
+ labels = {}
81
+ for node_id in self.workflow.graph.nodes():
82
+ # Try to get name from node instance
83
+ node = self.workflow.get_node(node_id)
84
+ if node and hasattr(node, "name") and node.name:
85
+ # For test compatibility - test expects just the name, not node_id
86
+ labels[node_id] = node.name
87
+ else:
88
+ # Fallback to node type from metadata
89
+ node_instance = self.workflow.nodes.get(node_id)
90
+ if node_instance:
91
+ labels[node_id] = f"{node_id} ({node_instance.node_type})"
92
+ else:
93
+ labels[node_id] = node_id
94
+ return labels
95
+
96
+ def _get_edge_labels(self) -> Dict[Tuple[str, str], str]:
97
+ """Get labels for edges in workflow."""
98
+ edge_labels = {}
99
+
100
+ for edge in self.workflow.graph.edges(data=True):
101
+ source, target, data = edge
102
+
103
+ # Try both edge data formats for compatibility
104
+ from_output = data.get("from_output")
105
+ to_input = data.get("to_input")
106
+
107
+ if from_output and to_input:
108
+ edge_labels[(source, target)] = f"{from_output}→{to_input}"
109
+ else:
110
+ # Fallback to mapping format
111
+ mapping = data.get("mapping", {})
112
+ if mapping:
113
+ # Create label from mapping
114
+ label_parts = []
115
+ for src, dst in mapping.items():
116
+ label_parts.append(f"{src}→{dst}")
117
+ label = "\n".join(label_parts)
118
+ edge_labels[(source, target)] = label
119
+
120
+ return edge_labels
121
+
122
+ def _calculate_layout(self) -> Dict[str, Tuple[float, float]]:
123
+ """Calculate node positions for visualization."""
124
+ # Try to use stored positions first
125
+ pos = {}
126
+ for node_id, node_instance in self.workflow.nodes.items():
127
+ if node_instance.position != (0, 0):
128
+ pos[node_id] = node_instance.position
129
+
130
+ # If no positions stored, calculate them
131
+ if not pos:
132
+ if self.layout == "hierarchical":
133
+ # Use hierarchical layout for DAGs
134
+ try:
135
+ # Create layers based on topological order
136
+ layers = self._create_layers()
137
+ pos = self._hierarchical_layout(layers)
138
+ except Exception:
139
+ # Fallback to spring layout
140
+ pos = nx.spring_layout(self.workflow.graph, k=3, iterations=50)
141
+ elif self.layout == "circular":
142
+ pos = nx.circular_layout(self.workflow.graph)
143
+ elif self.layout == "spring":
144
+ pos = nx.spring_layout(self.workflow.graph, k=2, iterations=100)
145
+ else:
146
+ # Default to spring layout
147
+ pos = nx.spring_layout(self.workflow.graph)
148
+
149
+ return pos
150
+
151
+ def _create_layers(self) -> Dict[int, list]:
152
+ """Create layers of nodes for hierarchical layout."""
153
+ layers = {}
154
+ remaining = set(self.workflow.graph.nodes())
155
+ layer = 0
156
+
157
+ while remaining:
158
+ # Find nodes with no dependencies in remaining set
159
+ current_layer = []
160
+ for node in remaining:
161
+ predecessors = set(self.workflow.graph.predecessors(node))
162
+ if not predecessors.intersection(remaining):
163
+ current_layer.append(node)
164
+
165
+ if not current_layer:
166
+ # Circular dependency, break and use all remaining
167
+ current_layer = list(remaining)
168
+
169
+ layers[layer] = current_layer
170
+ remaining -= set(current_layer)
171
+ layer += 1
172
+
173
+ return layers
174
+
175
+ def _hierarchical_layout(
176
+ self, layers: Dict[int, list]
177
+ ) -> Dict[str, Tuple[float, float]]:
178
+ """Create hierarchical layout from layers."""
179
+ pos = {}
180
+ layer_height = 2.0
181
+
182
+ for layer, nodes in layers.items():
183
+ y = layer * layer_height
184
+ if len(nodes) == 1:
185
+ x_positions = [0]
186
+ else:
187
+ width = max(2.0, len(nodes) - 1)
188
+ x_positions = [
189
+ -width / 2 + i * width / (len(nodes) - 1) for i in range(len(nodes))
190
+ ]
191
+
192
+ for i, node in enumerate(nodes):
193
+ pos[node] = (x_positions[i], -y) # Negative y to flow top to bottom
194
+
195
+ return pos
196
+
197
+ def _draw_graph(
198
+ self,
199
+ pos: Dict[str, Tuple[float, float]],
200
+ node_colors: List[str],
201
+ show_labels: bool,
202
+ show_connections: bool,
203
+ ) -> None:
204
+ """Draw the graph with given positions and options."""
205
+ # Draw nodes
206
+ nx.draw_networkx_nodes(
207
+ self.workflow.graph, pos, node_color=node_colors, node_size=3000, alpha=0.9
208
+ )
209
+
210
+ # Draw edges
211
+ nx.draw_networkx_edges(
212
+ self.workflow.graph,
213
+ pos,
214
+ edge_color=self.edge_colors["default"],
215
+ width=2,
216
+ alpha=0.6,
217
+ arrows=True,
218
+ arrowsize=20,
219
+ arrowstyle="->",
220
+ )
221
+
222
+ # Draw labels
223
+ if show_labels:
224
+ labels = self._get_node_labels()
225
+ nx.draw_networkx_labels(
226
+ self.workflow.graph, pos, labels, font_size=10, font_weight="bold"
227
+ )
228
+
229
+ # Draw connection labels
230
+ if show_connections:
231
+ edge_labels = self._get_edge_labels()
232
+ nx.draw_networkx_edge_labels(
233
+ self.workflow.graph, pos, edge_labels, font_size=8
234
+ )
235
+
236
+ def visualize(
237
+ self,
238
+ output_path: Optional[str] = None,
239
+ figsize: Tuple[int, int] = (12, 8),
240
+ title: Optional[str] = None,
241
+ show_labels: bool = True,
242
+ show_connections: bool = True,
243
+ dpi: int = 300,
244
+ **kwargs,
245
+ ) -> None:
246
+ """Create a visualization of the workflow.
247
+
248
+ Args:
249
+ output_path: Path to save the image (if None, shows interactive plot)
250
+ figsize: Figure size (width, height)
251
+ title: Optional title for the graph
252
+ show_labels: Whether to show node labels
253
+ show_connections: Whether to show connection labels
254
+ dpi: Resolution (dots per inch) for saved images
255
+ **kwargs: Additional options passed to plt.savefig
256
+ """
257
+ try:
258
+ plt.figure(figsize=figsize)
259
+
260
+ # Calculate node positions
261
+ pos = self._calculate_layout()
262
+
263
+ # Handle empty workflow case
264
+ if not self.workflow.graph.nodes():
265
+ pos = {}
266
+ node_colors = []
267
+ else:
268
+ # Draw the graph with colors
269
+ node_colors = self._get_node_colors()
270
+
271
+ # Draw the graph components
272
+ if pos and node_colors:
273
+ self._draw_graph(pos, node_colors, show_labels, show_connections)
274
+
275
+ # Set title
276
+ if title is None:
277
+ title = f"Workflow: {self.workflow.name}"
278
+ plt.title(title, fontsize=16, fontweight="bold")
279
+
280
+ # Remove axes
281
+ plt.axis("off")
282
+ plt.tight_layout()
283
+
284
+ # Show or save
285
+ if output_path:
286
+ plt.savefig(output_path, dpi=dpi, bbox_inches="tight", **kwargs)
287
+ plt.close()
288
+ else:
289
+ plt.show()
290
+ except Exception as e:
291
+ plt.close()
292
+ raise e
293
+
294
+ def save(self, output_path: str, dpi: int = 300, **kwargs) -> None:
295
+ """Save visualization to file.
296
+
297
+ Args:
298
+ output_path: Path to save the image
299
+ dpi: Resolution (dots per inch)
300
+ **kwargs: Additional options for plt.savefig
301
+ """
302
+ kwargs["dpi"] = dpi
303
+ self.visualize(output_path=output_path, **kwargs)
304
+
305
+ def create_execution_graph(
306
+ self, run_id: str, task_manager: Any, output_path: Optional[str] = None
307
+ ) -> str:
308
+ """Create a Mermaid visualization showing execution status.
309
+
310
+ Args:
311
+ run_id: Run ID to visualize
312
+ task_manager: Task manager instance
313
+ output_path: Optional path to save the markdown file. If not provided,
314
+ saves to 'workflow_executions/execution_{run_id}.md'
315
+
316
+ Returns:
317
+ Path to the created markdown file
318
+ """
319
+ # Import here to avoid circular dependency
320
+ from kailash.tracking import TaskStatus
321
+ from kailash.workflow.mermaid_visualizer import MermaidVisualizer
322
+
323
+ # Get tasks for this run
324
+ tasks = task_manager.list_tasks(run_id)
325
+
326
+ # Create status emoji mapping
327
+ status_emojis = {
328
+ TaskStatus.PENDING: "⏳",
329
+ TaskStatus.RUNNING: "🔄",
330
+ TaskStatus.COMPLETED: "✅",
331
+ TaskStatus.FAILED: "❌",
332
+ TaskStatus.SKIPPED: "⏭️",
333
+ }
334
+
335
+ # Map node IDs to statuses
336
+ node_status = {}
337
+ for task in tasks:
338
+ node_status[task.node_id] = task.status
339
+
340
+ # Create Mermaid visualizer
341
+ visualizer = MermaidVisualizer(self.workflow)
342
+
343
+ # Generate base Mermaid diagram
344
+ mermaid_code = visualizer.generate()
345
+
346
+ # Add status information to the diagram
347
+ lines = mermaid_code.split("\n")
348
+ new_lines = []
349
+
350
+ for line in lines:
351
+ new_lines.append(line)
352
+ # Add status emoji to node labels
353
+ for node_id, status in node_status.items():
354
+ sanitized_id = visualizer._sanitize_node_id(node_id)
355
+ if sanitized_id in line and '["' in line:
356
+ # Add emoji to the end of the label
357
+ emoji = status_emojis.get(status, "")
358
+ if emoji:
359
+ new_lines[-1] = line.replace('"]', f' {emoji}"]')
360
+
361
+ # Create markdown content
362
+ newline_joined = "\n".join(new_lines)
363
+ markdown_content = f"""# Workflow Execution Status
364
+
365
+ **Run ID**: `{run_id}`
366
+ **Workflow**: {self.workflow.name}
367
+ **Timestamp**: {task_manager.get_run(run_id).started_at if hasattr(task_manager, 'get_run') else 'N/A'}
368
+
369
+ ## Execution Diagram
370
+
371
+ ```mermaid
372
+ {newline_joined}
373
+ ```
374
+
375
+ ## Status Legend
376
+
377
+ | Status | Symbol | Description |
378
+ |--------|--------|-------------|
379
+ | Pending | ⏳ | Task is waiting to be executed |
380
+ | Running | 🔄 | Task is currently executing |
381
+ | Completed | ✅ | Task completed successfully |
382
+ | Failed | ❌ | Task failed during execution |
383
+ | Skipped | ⏭️ | Task was skipped |
384
+
385
+ ## Task Details
386
+
387
+ | Node ID | Status | Start Time | End Time | Duration |
388
+ |---------|--------|------------|----------|----------|
389
+ """
390
+
391
+ # Add task details
392
+ for task in tasks:
393
+ status_emoji = status_emojis.get(task.status, "")
394
+ start_time = task.started_at or "N/A"
395
+ end_time = task.ended_at or "N/A"
396
+ duration = f"{task.duration:.2f}s" if task.duration else "N/A"
397
+
398
+ markdown_content += f"| {task.node_id} | {task.status.value} {status_emoji} | {start_time} | {end_time} | {duration} |\n"
399
+
400
+ # Determine output path
401
+ if output_path is None:
402
+ # Create default directory if it doesn't exist
403
+ output_dir = Path.cwd() / "outputs" / "workflow_executions"
404
+ output_dir.mkdir(parents=True, exist_ok=True)
405
+ output_path = output_dir / f"execution_{run_id}.md"
406
+ else:
407
+ output_path = Path(output_path)
408
+ # Create parent directory if needed
409
+ output_path.parent.mkdir(parents=True, exist_ok=True)
410
+
411
+ # Write markdown file
412
+ with open(output_path, "w") as f:
413
+ f.write(markdown_content)
414
+
415
+ return str(output_path)
416
+
417
+ def create_performance_dashboard(
418
+ self, run_id: str, task_manager: TaskManager, output_dir: Optional[Path] = None
419
+ ) -> Dict[str, Path]:
420
+ """Create integrated performance dashboard with workflow visualization.
421
+
422
+ Args:
423
+ run_id: Run ID to visualize
424
+ task_manager: Task manager with execution data
425
+ output_dir: Directory for output files
426
+
427
+ Returns:
428
+ Dictionary of output file paths
429
+ """
430
+ from kailash.visualization.performance import PerformanceVisualizer
431
+
432
+ if output_dir is None:
433
+ output_dir = Path.cwd() / "outputs" / "performance" / run_id
434
+ output_dir.mkdir(parents=True, exist_ok=True)
435
+
436
+ outputs = {}
437
+
438
+ # Create workflow execution graph
439
+ outputs["workflow_graph"] = self.create_execution_graph(
440
+ run_id, task_manager, str(output_dir / "workflow_execution.md")
441
+ )
442
+
443
+ # Create performance visualizations
444
+ perf_viz = PerformanceVisualizer(task_manager)
445
+ perf_outputs = perf_viz.create_run_performance_summary(run_id, output_dir)
446
+ outputs.update(perf_outputs)
447
+
448
+ # Create integrated dashboard HTML
449
+ dashboard_path = output_dir / "dashboard.html"
450
+ self._create_dashboard_html(run_id, outputs, dashboard_path)
451
+ outputs["dashboard"] = dashboard_path
452
+
453
+ return outputs
454
+
455
+ def _create_dashboard_html(
456
+ self, run_id: str, outputs: Dict[str, Path], dashboard_path: Path
457
+ ) -> None:
458
+ """Create HTML dashboard integrating all visualizations."""
459
+ html_content = f"""
460
+ <!DOCTYPE html>
461
+ <html>
462
+ <head>
463
+ <title>Performance Dashboard - Run {run_id}</title>
464
+ <style>
465
+ body {{
466
+ font-family: Arial, sans-serif;
467
+ margin: 20px;
468
+ background-color: #f5f5f5;
469
+ }}
470
+ .container {{
471
+ max-width: 1200px;
472
+ margin: 0 auto;
473
+ background-color: white;
474
+ padding: 20px;
475
+ border-radius: 8px;
476
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
477
+ }}
478
+ h1, h2 {{
479
+ color: #333;
480
+ }}
481
+ .section {{
482
+ margin: 20px 0;
483
+ padding: 20px;
484
+ border: 1px solid #ddd;
485
+ border-radius: 4px;
486
+ }}
487
+ .image-grid {{
488
+ display: grid;
489
+ grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
490
+ gap: 20px;
491
+ margin: 20px 0;
492
+ }}
493
+ img {{
494
+ max-width: 100%;
495
+ height: auto;
496
+ border: 1px solid #ddd;
497
+ border-radius: 4px;
498
+ }}
499
+ .report-link {{
500
+ display: inline-block;
501
+ margin: 10px 0;
502
+ padding: 10px 20px;
503
+ background-color: #007bff;
504
+ color: white;
505
+ text-decoration: none;
506
+ border-radius: 4px;
507
+ }}
508
+ .report-link:hover {{
509
+ background-color: #0056b3;
510
+ }}
511
+ </style>
512
+ </head>
513
+ <body>
514
+ <div class="container">
515
+ <h1>Performance Dashboard - Run {run_id}</h1>
516
+
517
+ <div class="section">
518
+ <h2>Workflow Execution</h2>
519
+ <p>View the workflow execution graph and task status:</p>
520
+ <a href="{outputs.get('workflow_graph', '#')}" class="report-link">
521
+ View Workflow Graph
522
+ </a>
523
+ </div>
524
+
525
+ <div class="section">
526
+ <h2>Performance Metrics</h2>
527
+ <div class="image-grid">
528
+ """
529
+
530
+ # Add performance images
531
+ image_keys = [
532
+ "execution_timeline",
533
+ "resource_usage",
534
+ "performance_comparison",
535
+ "io_analysis",
536
+ "performance_heatmap",
537
+ ]
538
+
539
+ for key in image_keys:
540
+ if key in outputs:
541
+ title = key.replace("_", " ").title()
542
+ html_content += f"""
543
+ <div>
544
+ <h3>{title}</h3>
545
+ <img src="{outputs[key].name}" alt="{title}">
546
+ </div>
547
+ """
548
+
549
+ html_content += """
550
+ </div>
551
+ </div>
552
+
553
+ <div class="section">
554
+ <h2>Detailed Report</h2>
555
+ <p>View the comprehensive performance analysis report:</p>
556
+ <a href="{outputs.get('report', '#')}" class="report-link">
557
+ View Performance Report
558
+ </a>
559
+ </div>
560
+ </div>
561
+ </body>
562
+ </html>
563
+ """
564
+
565
+ with open(dashboard_path, "w") as f:
566
+ f.write(html_content)
567
+
568
+
569
+ # Add visualization method to Workflow class
570
+ def add_visualization_to_workflow():
571
+ """Add visualization method to Workflow class."""
572
+
573
+ def visualize(self, output_path: Optional[str] = None, **kwargs) -> None:
574
+ """Visualize the workflow.
575
+
576
+ Args:
577
+ output_path: Path to save the visualization
578
+ **kwargs: Additional arguments for the visualizer
579
+ """
580
+ visualizer = WorkflowVisualizer(self)
581
+ visualizer.visualize(output_path, **kwargs)
582
+
583
+ # Add method to Workflow class
584
+ Workflow.visualize = visualize
585
+
586
+
587
+ # Call this when module is imported
588
+ add_visualization_to_workflow()