flowyml 1.5.0__py3-none-any.whl → 1.7.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,595 @@
1
+ """Rich display system for pipeline execution with beautiful CLI output."""
2
+
3
+ import time
4
+ from typing import Any
5
+ from collections import defaultdict
6
+
7
+ try:
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+ from rich.panel import Panel
11
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
12
+ from rich.tree import Tree
13
+ from rich.text import Text
14
+ from rich import box
15
+
16
+ RICH_AVAILABLE = True
17
+ except ImportError:
18
+ RICH_AVAILABLE = False
19
+
20
+
21
+ class PipelineDisplay:
22
+ """Beautiful CLI display for pipeline execution."""
23
+
24
+ def __init__(
25
+ self,
26
+ pipeline_name: str,
27
+ steps: list[Any],
28
+ dag: Any,
29
+ verbose: bool = True,
30
+ ui_url: str | None = None,
31
+ run_url: str | None = None,
32
+ ):
33
+ """Initialize display system.
34
+
35
+ Args:
36
+ pipeline_name: Name of the pipeline
37
+ steps: List of step objects
38
+ dag: Pipeline DAG
39
+ verbose: Whether to show detailed output
40
+ ui_url: Optional base URL for the UI dashboard
41
+ run_url: Optional URL to view this specific run in the UI
42
+ """
43
+ self.pipeline_name = pipeline_name
44
+ self.steps = steps
45
+ self.dag = dag
46
+ self.verbose = verbose
47
+ self.ui_url = ui_url
48
+ self.run_url = run_url
49
+ self.console = Console() if RICH_AVAILABLE else None
50
+ self.step_status = {step.name: "pending" for step in steps}
51
+ self.step_durations = {}
52
+ self.step_outputs = {}
53
+ self.step_errors = {}
54
+ self.step_cached = {}
55
+ self.start_time = None
56
+ self.progress = None
57
+ self.progress_tasks = {} # Track progress tasks for each step
58
+ if RICH_AVAILABLE:
59
+ self._init_progress()
60
+
61
+ def _init_progress(self) -> None:
62
+ """Initialize progress display."""
63
+ if not RICH_AVAILABLE:
64
+ return
65
+ self.progress = Progress(
66
+ SpinnerColumn(),
67
+ TextColumn("[progress.description]{task.description}"),
68
+ BarColumn(),
69
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
70
+ TimeElapsedColumn(),
71
+ console=self.console,
72
+ transient=False,
73
+ )
74
+
75
+ def show_header(self) -> None:
76
+ """Display pipeline header with DAG visualization."""
77
+ if not self.verbose:
78
+ return
79
+
80
+ if RICH_AVAILABLE:
81
+ # Rich header
82
+ header = Panel(
83
+ f"[bold cyan]🌊 flowyml Pipeline[/bold cyan]\n" f"[bold]{self.pipeline_name}[/bold]",
84
+ border_style="cyan",
85
+ box=box.ROUNDED,
86
+ )
87
+ self.console.print(header)
88
+ self.console.print()
89
+
90
+ # Show prominent UI URL if available (so users can click to follow execution)
91
+ self._show_ui_url_banner()
92
+
93
+ # DAG visualization
94
+ self._show_dag_rich()
95
+ else:
96
+ # Fallback header
97
+ print("=" * 70)
98
+ print(f"🌊 flowyml Pipeline: {self.pipeline_name}")
99
+ print("=" * 70)
100
+ print()
101
+ # Show UI URL in simple mode too
102
+ self._show_ui_url_simple()
103
+ self._show_dag_simple()
104
+
105
+ def _show_ui_url_banner(self) -> None:
106
+ """Show a prominent UI URL banner with clickable link (Rich mode)."""
107
+ if not RICH_AVAILABLE or not self.console:
108
+ return
109
+
110
+ if self.run_url:
111
+ # Create a prominent banner with the run URL
112
+ url_content = Text()
113
+ url_content.append("🌐 ", style="bold cyan")
114
+ url_content.append("Dashboard: ", style="bold white")
115
+ url_content.append(self.run_url, style="bold cyan underline link " + self.run_url)
116
+ url_content.append("\n", style="")
117
+ url_content.append(" ", style="")
118
+ url_content.append("↑ Click to follow pipeline execution in real-time", style="dim italic")
119
+
120
+ ui_panel = Panel(
121
+ url_content,
122
+ border_style="green",
123
+ box=box.DOUBLE,
124
+ title="[bold green]✨ Live Dashboard[/bold green]",
125
+ title_align="left",
126
+ )
127
+ self.console.print(ui_panel)
128
+ self.console.print()
129
+ elif self.ui_url:
130
+ # Show base UI URL if no specific run URL
131
+ url_content = Text()
132
+ url_content.append("🌐 ", style="bold cyan")
133
+ url_content.append("Dashboard: ", style="bold white")
134
+ url_content.append(self.ui_url, style="bold cyan underline link " + self.ui_url)
135
+
136
+ ui_panel = Panel(
137
+ url_content,
138
+ border_style="cyan",
139
+ box=box.ROUNDED,
140
+ title="[bold cyan]UI Available[/bold cyan]",
141
+ title_align="left",
142
+ )
143
+ self.console.print(ui_panel)
144
+ self.console.print()
145
+
146
+ def _show_ui_url_simple(self) -> None:
147
+ """Show UI URL in simple text mode."""
148
+ if self.run_url:
149
+ print("=" * 70)
150
+ print(f"🌐 Dashboard: {self.run_url}")
151
+ print(" ↑ Open this URL to follow pipeline execution in real-time")
152
+ print("=" * 70)
153
+ print()
154
+ elif self.ui_url:
155
+ print(f"🌐 UI Available: {self.ui_url}")
156
+ print()
157
+
158
+ def _show_dag_rich(self) -> None:
159
+ """Show DAG using rich."""
160
+ if not self.dag:
161
+ return
162
+
163
+ tree = Tree("📊 Pipeline Execution Plan")
164
+
165
+ # Get topological order
166
+ try:
167
+ topo_order = self.dag.topological_sort()
168
+ except Exception:
169
+ topo_order = list(self.dag.nodes.values())
170
+
171
+ # Group steps by execution group
172
+ groups = defaultdict(list)
173
+ for step in self.steps:
174
+ if step.execution_group:
175
+ groups[step.execution_group].append(step)
176
+ else:
177
+ groups[None].append(step)
178
+
179
+ # Build tree
180
+ for i, node in enumerate(topo_order, 1):
181
+ step = next((s for s in self.steps if s.name == node.name), None)
182
+ if not step:
183
+ continue
184
+
185
+ # Check if in a group
186
+ group_name = step.execution_group
187
+ if group_name and group_name in groups:
188
+ # Add to group branch
189
+ if not hasattr(self, "_group_branches"):
190
+ self._group_branches = {}
191
+ if group_name not in self._group_branches:
192
+ group_branch = tree.add(f"📦 [cyan]{group_name}[/cyan]")
193
+ self._group_branches[group_name] = group_branch
194
+ branch = self._group_branches[group_name]
195
+ else:
196
+ branch = tree
197
+
198
+ # Step info
199
+ deps = self.dag.edges.get(node.name, [])
200
+ deps_str = f" → depends on: {', '.join(deps)}" if deps else ""
201
+
202
+ inputs_str = f"Inputs: {', '.join(step.inputs)}" if step.inputs else "No inputs"
203
+ outputs_str = f"Outputs: {', '.join(step.outputs)}" if step.outputs else "No outputs"
204
+
205
+ step_text = f"[bold]{i}. {step.name}[/bold]\n" f" {inputs_str}\n" f" {outputs_str}"
206
+ if deps_str:
207
+ step_text += f"\n [dim]{deps_str}[/dim]"
208
+
209
+ branch.add(step_text)
210
+
211
+ self.console.print(tree)
212
+ self.console.print()
213
+
214
+ def _show_dag_simple(self) -> None:
215
+ """Show DAG using simple text."""
216
+ if not self.dag:
217
+ return
218
+
219
+ print("Pipeline DAG:")
220
+ print("=" * 70)
221
+
222
+ try:
223
+ topo_order = self.dag.topological_sort()
224
+ except Exception:
225
+ topo_order = list(self.dag.nodes.values())
226
+
227
+ for i, node in enumerate(topo_order, 1):
228
+ step = next((s for s in self.steps if s.name == node.name), None)
229
+ if not step:
230
+ continue
231
+
232
+ deps = self.dag.edges.get(node.name, [])
233
+ deps_str = f"Dependencies: {', '.join(deps)}" if deps else "Dependencies: none"
234
+
235
+ inputs_str = f"Inputs: {step.inputs}" if step.inputs else "Inputs: []"
236
+ outputs_str = f"Outputs: {step.outputs}" if step.outputs else "Outputs: []"
237
+
238
+ group_str = f" [Group: {step.execution_group}]" if step.execution_group else ""
239
+
240
+ print(f"{i}. {step.name}{group_str}")
241
+ print(f" {inputs_str}")
242
+ print(f" {outputs_str}")
243
+ print(f" {deps_str}")
244
+ print()
245
+
246
+ def show_execution_start(self) -> None:
247
+ """Show execution start message."""
248
+ self.start_time = time.time()
249
+ if not self.verbose:
250
+ return
251
+
252
+ if RICH_AVAILABLE:
253
+ # Use Text for styled output
254
+ start_text = Text()
255
+ start_text.append("🚀 ", style="bold")
256
+ start_text.append("Starting pipeline execution...", style="bold green")
257
+ self.console.print(start_text)
258
+ self.console.print()
259
+
260
+ # Start progress display if available
261
+ if self.progress:
262
+ self.progress.start()
263
+ else:
264
+ print("🚀 Starting pipeline execution...")
265
+ print()
266
+
267
+ def update_step_status(
268
+ self,
269
+ step_name: str,
270
+ status: str,
271
+ duration: float = None,
272
+ cached: bool = False,
273
+ error: str = None,
274
+ ) -> None:
275
+ """Update and display step status.
276
+
277
+ Args:
278
+ step_name: Name of the step
279
+ status: Status (running, success, failed, cached)
280
+ duration: Execution duration in seconds
281
+ cached: Whether step was cached
282
+ error: Error message if failed
283
+ """
284
+ self.step_status[step_name] = status
285
+ if duration is not None:
286
+ self.step_durations[step_name] = duration
287
+ if cached:
288
+ self.step_cached[step_name] = True
289
+ if error:
290
+ self.step_errors[step_name] = error
291
+
292
+ if not self.verbose:
293
+ return
294
+
295
+ if RICH_AVAILABLE:
296
+ self._update_step_rich(step_name, status, duration, cached, error)
297
+ else:
298
+ self._update_step_simple(step_name, status, duration, cached, error)
299
+
300
+ def _update_step_rich(
301
+ self,
302
+ step_name: str,
303
+ status: str,
304
+ duration: float = None,
305
+ cached: bool = False,
306
+ error: str = None,
307
+ ) -> None:
308
+ """Update step display using rich with progress tracking."""
309
+ if status == "running":
310
+ # Use Text for better formatting
311
+ icon = "⏳"
312
+ color = "yellow"
313
+ text_obj = Text()
314
+ text_obj.append(f"{icon} ", style=color)
315
+ text_obj.append(step_name, style=f"bold {color}")
316
+ text_obj.append(" running...", style="dim")
317
+ self.console.print(text_obj)
318
+
319
+ # Start progress tracking if available
320
+ if self.progress and step_name not in self.progress_tasks:
321
+ task_id = self.progress.add_task(
322
+ f"[yellow]⏳ {step_name}[/yellow]",
323
+ total=100,
324
+ )
325
+ self.progress_tasks[step_name] = task_id
326
+ self.progress.update(task_id, completed=50) # Show progress
327
+
328
+ elif status == "success":
329
+ icon = "✅"
330
+ color = "green"
331
+ text_obj = Text()
332
+ text_obj.append(f"{icon} ", style=color)
333
+ text_obj.append(step_name, style=f"bold {color}")
334
+ if cached:
335
+ text_obj.append(" (cached)", style="dim italic")
336
+ if duration:
337
+ text_obj.append(f" ({duration:.2f}s)", style="dim")
338
+ self.console.print(text_obj)
339
+
340
+ # Complete progress task
341
+ if self.progress and step_name in self.progress_tasks:
342
+ task_id = self.progress_tasks[step_name]
343
+ self.progress.update(task_id, completed=100)
344
+ self.progress.remove_task(task_id)
345
+ del self.progress_tasks[step_name]
346
+
347
+ elif status == "failed":
348
+ icon = "❌"
349
+ color = "red"
350
+ text_obj = Text()
351
+ text_obj.append(f"{icon} ", style=color)
352
+ text_obj.append(step_name, style=f"bold {color}")
353
+ if error:
354
+ text_obj.append(f" - {error[:100]}", style="red dim")
355
+ self.console.print(text_obj)
356
+
357
+ # Remove progress task on failure
358
+ if self.progress and step_name in self.progress_tasks:
359
+ task_id = self.progress_tasks[step_name]
360
+ self.progress.remove_task(task_id)
361
+ del self.progress_tasks[step_name]
362
+ else:
363
+ icon = "⏸️"
364
+ text_obj = Text()
365
+ text_obj.append(f"{icon} ", style="dim")
366
+ text_obj.append(step_name, style="dim")
367
+ self.console.print(text_obj)
368
+
369
+ def _update_step_simple(
370
+ self,
371
+ step_name: str,
372
+ status: str,
373
+ duration: float = None,
374
+ cached: bool = False,
375
+ error: str = None,
376
+ ) -> None:
377
+ """Update step display using simple text."""
378
+ if status == "running":
379
+ print(f"⏳ {step_name} running...")
380
+ elif status == "success":
381
+ cached_text = " (cached)" if cached else ""
382
+ duration_text = f" ({duration:.2f}s)" if duration else ""
383
+ print(f"✅ {step_name}{cached_text}{duration_text}")
384
+ elif status == "failed":
385
+ error_text = f" - {error}" if error else ""
386
+ print(f"❌ {step_name}{error_text}")
387
+ else:
388
+ print(f"⏸️ {step_name}")
389
+
390
+ def show_summary(self, result: Any, ui_url: str = None, run_url: str = None) -> None:
391
+ """Show execution summary.
392
+
393
+ Args:
394
+ result: PipelineResult object
395
+ ui_url: Optional UI server URL
396
+ run_url: Optional run-specific UI URL
397
+ """
398
+ if not self.verbose:
399
+ return
400
+
401
+ total_duration = time.time() - self.start_time if self.start_time else result.duration_seconds
402
+
403
+ if RICH_AVAILABLE:
404
+ self._show_summary_rich(result, total_duration, ui_url, run_url)
405
+ else:
406
+ self._show_summary_simple(result, total_duration, ui_url, run_url)
407
+
408
+ def _show_summary_rich(self, result: Any, total_duration: float, ui_url: str = None, run_url: str = None) -> None:
409
+ """Show summary using rich."""
410
+ # Stop progress display if running
411
+ if self.progress:
412
+ self.progress.stop()
413
+ self.console.print()
414
+
415
+ self.console.print()
416
+
417
+ # Summary table with enhanced styling
418
+ table = Table(
419
+ title="[bold cyan]📊 Execution Summary[/bold cyan]",
420
+ box=box.ROUNDED,
421
+ show_header=True,
422
+ header_style="bold cyan",
423
+ border_style="cyan",
424
+ )
425
+ table.add_column("Metric", style="cyan", no_wrap=True, width=20)
426
+ table.add_column("Value", style="green", width=30)
427
+
428
+ # Use Text for status with better formatting
429
+ status_text = Text()
430
+ if result.success:
431
+ status_text.append("✅ ", style="green")
432
+ status_text.append("SUCCESS", style="bold green")
433
+ else:
434
+ status_text.append("❌ ", style="red")
435
+ status_text.append("FAILED", style="bold red")
436
+
437
+ table.add_row("Pipeline", self.pipeline_name)
438
+ table.add_row("Run ID", result.run_id[:16] + "...")
439
+ table.add_row("Status", status_text)
440
+ table.add_row("Duration", f"[green]{total_duration:.2f}s[/green]")
441
+ table.add_row("Steps", f"[cyan]{len(result.step_results)}[/cyan]")
442
+
443
+ # Count cached steps
444
+ cached_count = sum(1 for r in result.step_results.values() if r.cached)
445
+ if cached_count > 0:
446
+ table.add_row("Cached Steps", f"[yellow]{cached_count}[/yellow]")
447
+
448
+ self.console.print(table)
449
+ self.console.print()
450
+
451
+ # Show UI URL if available
452
+ if run_url:
453
+ ui_panel = Panel(
454
+ f"[bold cyan]🌐 View in UI:[/bold cyan]\n[link={run_url}]{run_url}[/link]",
455
+ border_style="cyan",
456
+ box=box.ROUNDED,
457
+ )
458
+ self.console.print(ui_panel)
459
+ self.console.print()
460
+ elif ui_url:
461
+ ui_panel = Panel(
462
+ f"[bold cyan]🌐 UI Available:[/bold cyan]\n[link={ui_url}]{ui_url}[/link]",
463
+ border_style="cyan",
464
+ box=box.ROUNDED,
465
+ )
466
+ self.console.print(ui_panel)
467
+ self.console.print()
468
+
469
+ # Step results table
470
+ step_table = Table(title="📋 Step Results", box=box.ROUNDED, show_header=True, header_style="bold cyan")
471
+ step_table.add_column("Step", style="cyan")
472
+ step_table.add_column("Status", justify="center")
473
+ step_table.add_column("Duration", justify="right")
474
+ step_table.add_column("Details")
475
+
476
+ for step_name, step_result in result.step_results.items():
477
+ if step_result.success:
478
+ status = "[green]✅[/green]"
479
+ details = "[dim]Cached[/dim]" if step_result.cached else ""
480
+ else:
481
+ status = "[red]❌[/red]"
482
+ details = f"[red]{step_result.error[:50]}...[/red]" if step_result.error else ""
483
+
484
+ duration = f"{step_result.duration_seconds:.2f}s" if step_result.duration_seconds else "N/A"
485
+ step_table.add_row(step_name, status, duration, details)
486
+
487
+ self.console.print(step_table)
488
+ self.console.print()
489
+
490
+ # Outputs summary
491
+ if result.outputs:
492
+ outputs_panel = Panel(
493
+ self._format_outputs_rich(result.outputs),
494
+ title="📦 Outputs",
495
+ border_style="cyan",
496
+ box=box.ROUNDED,
497
+ )
498
+ self.console.print(outputs_panel)
499
+ self.console.print()
500
+
501
+ def _format_outputs_rich(self, outputs: dict) -> str:
502
+ """Format outputs for rich display."""
503
+ lines = []
504
+ for key, value in outputs.items():
505
+ # Skip internal/duplicate outputs (step names vs output names)
506
+ if key in [s.name for s in self.steps] and any(
507
+ out_name in outputs for s in self.steps for out_name in (s.outputs or []) if s.name == key
508
+ ):
509
+ continue # Skip step name if we have the actual output name
510
+
511
+ if hasattr(value, "__class__"):
512
+ value_str = f"{value.__class__.__name__}"
513
+ if hasattr(value, "name"):
514
+ value_str += f" (name: {value.name})"
515
+ if hasattr(value, "version"):
516
+ value_str += f" (version: {value.version})"
517
+ # Try to get more info for Asset types
518
+ if hasattr(value, "__dict__"):
519
+ attrs = {
520
+ k: v
521
+ for k, v in value.__dict__.items()
522
+ if not k.startswith("_") and k not in ["name", "version"]
523
+ }
524
+ if attrs:
525
+ # Show first few attributes
526
+ attr_str = ", ".join(f"{k}={v}" for k, v in list(attrs.items())[:3])
527
+ if len(attrs) > 3:
528
+ attr_str += "..."
529
+ value_str += f" [{attr_str}]"
530
+ else:
531
+ # For simple types, show a preview
532
+ value_str = str(value)
533
+ if len(value_str) > 80:
534
+ value_str = value_str[:80] + "..."
535
+ lines.append(f"[cyan]{key}:[/cyan] {value_str}")
536
+ return "\n".join(lines) if lines else "[dim]No outputs[/dim]"
537
+
538
+ def _show_summary_simple(self, result: Any, total_duration: float, ui_url: str = None, run_url: str = None) -> None:
539
+ """Show summary using simple text."""
540
+ print()
541
+ print("=" * 70)
542
+ status_icon = "✅" if result.success else "❌"
543
+ status_text = "SUCCESS" if result.success else "FAILED"
544
+ print(f"{status_icon} Pipeline {status_text}!")
545
+ print("=" * 70)
546
+ print()
547
+ print(f"Pipeline: {self.pipeline_name}")
548
+ print(f"Run ID: {result.run_id}")
549
+ print(f"Duration: {total_duration:.2f}s")
550
+ print(f"Steps: {len(result.step_results)}")
551
+
552
+ # Show UI URL if available
553
+ if run_url:
554
+ print()
555
+ print(f"🌐 View in UI: {run_url}")
556
+ elif ui_url:
557
+ print()
558
+ print(f"🌐 UI Available: {ui_url}")
559
+ print()
560
+
561
+ print("Step Results:")
562
+ print("-" * 70)
563
+ for step_name, step_result in result.step_results.items():
564
+ icon = "✅" if step_result.success else "❌"
565
+ cached = " (cached)" if step_result.cached else ""
566
+ duration = f" ({step_result.duration_seconds:.2f}s)" if step_result.duration_seconds else ""
567
+ print(f" {icon} {step_name}{cached}{duration}")
568
+ if step_result.error:
569
+ print(f" Error: {step_result.error[:100]}")
570
+ print()
571
+
572
+ if result.outputs:
573
+ print("Outputs:")
574
+ print("-" * 70)
575
+ for key, value in result.outputs.items():
576
+ # Skip internal/duplicate outputs
577
+ if key in [s.name for s in self.steps] and any(
578
+ out_name in result.outputs for s in self.steps for out_name in (s.outputs or []) if s.name == key
579
+ ):
580
+ continue
581
+
582
+ if hasattr(value, "__class__"):
583
+ value_str = f"{value.__class__.__name__}"
584
+ if hasattr(value, "name"):
585
+ value_str += f" (name: {value.name})"
586
+ if hasattr(value, "version"):
587
+ value_str += f" (version: {value.version})"
588
+ else:
589
+ value_str = str(value)
590
+ if len(value_str) > 100:
591
+ value_str = value_str[:100] + "..."
592
+ print(f" {key}: {value_str}")
593
+ print()
594
+
595
+ print("=" * 70)
flowyml/core/executor.py CHANGED
@@ -78,7 +78,14 @@ class MonitorThread(threading.Thread):
78
78
  self.log_capture = log_capture
79
79
  self.interval = interval
80
80
  self._stop_event = threading.Event()
81
- self.api_url = os.getenv("FLOWYML_SERVER_URL", "http://localhost:8000")
81
+ # Get UI server URL from configuration (supports env vars, config, centralized deployments)
82
+ try:
83
+ from flowyml.ui.utils import get_ui_server_url
84
+
85
+ self.api_url = get_ui_server_url()
86
+ except Exception:
87
+ # Fallback to environment variable or default
88
+ self.api_url = os.getenv("FLOWYML_SERVER_URL", "http://localhost:8080")
82
89
 
83
90
  def stop(self):
84
91
  self._stop_event.set()
@@ -182,7 +189,8 @@ class Executor:
182
189
  self,
183
190
  step_group, # StepGroup
184
191
  inputs: dict[str, Any],
185
- context_params: dict[str, Any],
192
+ context: Any | None = None, # Context object for per-step injection
193
+ context_params: dict[str, Any] | None = None, # Deprecated: use context instead
186
194
  cache_store: Any | None = None,
187
195
  artifact_store: Any | None = None,
188
196
  run_id: str | None = None,
@@ -193,7 +201,8 @@ class Executor:
193
201
  Args:
194
202
  step_group: StepGroup to execute
195
203
  inputs: Input data available to the group
196
- context_params: Parameters from context
204
+ context: Context object for per-step parameter injection (preferred)
205
+ context_params: Parameters from context (deprecated, use context instead)
197
206
  cache_store: Cache store for caching
198
207
  artifact_store: Artifact store for materialization
199
208
  run_id: Run identifier
@@ -397,7 +406,8 @@ class LocalExecutor(Executor):
397
406
  self,
398
407
  step_group, # StepGroup from step_grouping module
399
408
  inputs: dict[str, Any],
400
- context_params: dict[str, Any],
409
+ context: Any | None = None, # Context object for per-step injection
410
+ context_params: dict[str, Any] | None = None, # Deprecated: use context instead
401
411
  cache_store: Any | None = None,
402
412
  artifact_store: Any | None = None,
403
413
  run_id: str | None = None,
@@ -410,7 +420,8 @@ class LocalExecutor(Executor):
410
420
  Args:
411
421
  step_group: StepGroup containing steps to execute
412
422
  inputs: Input data available to the group
413
- context_params: Parameters from context
423
+ context: Context object for per-step parameter injection (preferred)
424
+ context_params: Parameters from context (deprecated, use context instead)
414
425
  cache_store: Cache store for caching
415
426
  artifact_store: Artifact store for materialization
416
427
  run_id: Run identifier
@@ -433,11 +444,21 @@ class LocalExecutor(Executor):
433
444
  if input_name in step_outputs:
434
445
  step_inputs[input_name] = step_outputs[input_name]
435
446
 
447
+ # Inject context parameters for this specific step
448
+ if context is not None:
449
+ # Use context object to inject params per step
450
+ step_context_params = context.inject_params(step.func)
451
+ elif context_params is not None:
452
+ # Fallback to provided context_params (backward compatibility)
453
+ step_context_params = context_params
454
+ else:
455
+ step_context_params = {}
456
+
436
457
  # Execute this step
437
458
  result = self.execute_step(
438
459
  step=step,
439
460
  inputs=step_inputs,
440
- context_params=context_params,
461
+ context_params=step_context_params,
441
462
  cache_store=cache_store,
442
463
  artifact_store=artifact_store,
443
464
  run_id=run_id,