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.
- flowyml/__init__.py +2 -1
- flowyml/assets/featureset.py +30 -5
- flowyml/assets/metrics.py +47 -4
- flowyml/cli/main.py +397 -0
- flowyml/cli/models.py +444 -0
- flowyml/cli/rich_utils.py +95 -0
- flowyml/core/checkpoint.py +6 -1
- flowyml/core/conditional.py +104 -0
- flowyml/core/display.py +595 -0
- flowyml/core/executor.py +27 -6
- flowyml/core/orchestrator.py +500 -7
- flowyml/core/pipeline.py +447 -11
- flowyml/core/project.py +4 -1
- flowyml/core/scheduler.py +225 -81
- flowyml/core/versioning.py +13 -4
- flowyml/registry/model_registry.py +1 -1
- flowyml/ui/frontend/dist/assets/index-By4trVyv.css +1 -0
- flowyml/ui/frontend/dist/assets/{index-DF8dJaFL.js → index-CX5RV2C9.js} +118 -117
- flowyml/ui/frontend/dist/index.html +2 -2
- flowyml/ui/frontend/src/components/PipelineGraph.jsx +43 -4
- flowyml/ui/server_manager.py +189 -0
- flowyml/ui/utils.py +66 -2
- flowyml/utils/config.py +7 -0
- {flowyml-1.5.0.dist-info → flowyml-1.7.0.dist-info}/METADATA +5 -3
- {flowyml-1.5.0.dist-info → flowyml-1.7.0.dist-info}/RECORD +28 -24
- flowyml/ui/frontend/dist/assets/index-CBUXOWze.css +0 -1
- {flowyml-1.5.0.dist-info → flowyml-1.7.0.dist-info}/WHEEL +0 -0
- {flowyml-1.5.0.dist-info → flowyml-1.7.0.dist-info}/entry_points.txt +0 -0
- {flowyml-1.5.0.dist-info → flowyml-1.7.0.dist-info}/licenses/LICENSE +0 -0
flowyml/core/orchestrator.py
CHANGED
|
@@ -17,7 +17,9 @@ from flowyml.core.observability import get_metrics_collector
|
|
|
17
17
|
from flowyml.core.retry_policy import with_retry
|
|
18
18
|
|
|
19
19
|
if TYPE_CHECKING:
|
|
20
|
-
from flowyml.core.pipeline import Pipeline
|
|
20
|
+
from flowyml.core.pipeline import Pipeline, PipelineResult
|
|
21
|
+
from flowyml.core.executor import ExecutionResult
|
|
22
|
+
from flowyml.core.step import Step
|
|
21
23
|
|
|
22
24
|
|
|
23
25
|
class LocalOrchestrator(Orchestrator):
|
|
@@ -89,21 +91,26 @@ class LocalOrchestrator(Orchestrator):
|
|
|
89
91
|
# Get execution units (individual steps or groups)
|
|
90
92
|
execution_units = get_execution_units(pipeline.dag, pipeline.steps)
|
|
91
93
|
|
|
94
|
+
# Check if we're resuming from checkpoint
|
|
95
|
+
resume_from_checkpoint = getattr(pipeline, "_resume_from_checkpoint", False)
|
|
96
|
+
completed_steps_from_checkpoint = getattr(pipeline, "_completed_steps_from_checkpoint", set())
|
|
97
|
+
checkpoint = getattr(pipeline, "_checkpoint", None)
|
|
98
|
+
|
|
92
99
|
# Execute steps/groups in order
|
|
93
100
|
for unit in execution_units:
|
|
94
101
|
# Check if unit is a group or individual step
|
|
95
102
|
if isinstance(unit, StepGroup):
|
|
96
103
|
# Execute entire group
|
|
104
|
+
# Show group execution start
|
|
105
|
+
if hasattr(pipeline, "_display") and pipeline._display:
|
|
106
|
+
for step in unit.steps:
|
|
107
|
+
pipeline._display.update_step_status(step_name=step.name, status="running")
|
|
97
108
|
|
|
98
|
-
#
|
|
99
|
-
first_step = unit.steps[0]
|
|
100
|
-
context_params = pipeline.context.inject_params(first_step.func)
|
|
101
|
-
|
|
102
|
-
# Execute the group
|
|
109
|
+
# Pass pipeline context so each step can get its own injected params
|
|
103
110
|
group_results = pipeline.executor.execute_step_group(
|
|
104
111
|
step_group=unit,
|
|
105
112
|
inputs=step_outputs,
|
|
106
|
-
|
|
113
|
+
context=pipeline.context, # Pass full context object
|
|
107
114
|
cache_store=pipeline.cache_store,
|
|
108
115
|
artifact_store=pipeline.stack.artifact_store if pipeline.stack else None,
|
|
109
116
|
run_id=run_id,
|
|
@@ -112,6 +119,16 @@ class LocalOrchestrator(Orchestrator):
|
|
|
112
119
|
|
|
113
120
|
# Process each step result
|
|
114
121
|
for step_result in group_results:
|
|
122
|
+
# Update display
|
|
123
|
+
if hasattr(pipeline, "_display") and pipeline._display:
|
|
124
|
+
pipeline._display.update_step_status(
|
|
125
|
+
step_name=step_result.step_name,
|
|
126
|
+
status="success" if step_result.success else "failed",
|
|
127
|
+
duration=step_result.duration_seconds,
|
|
128
|
+
cached=step_result.cached,
|
|
129
|
+
error=step_result.error,
|
|
130
|
+
)
|
|
131
|
+
|
|
115
132
|
result.add_step_result(step_result)
|
|
116
133
|
|
|
117
134
|
# Handle failure
|
|
@@ -124,10 +141,76 @@ class LocalOrchestrator(Orchestrator):
|
|
|
124
141
|
if step_result.output is not None:
|
|
125
142
|
self._process_step_output(pipeline, step_result, step_outputs, result)
|
|
126
143
|
|
|
144
|
+
# Save checkpoint after successful step
|
|
145
|
+
checkpoint = getattr(pipeline, "_checkpoint", None)
|
|
146
|
+
if checkpoint and step_result.success:
|
|
147
|
+
try:
|
|
148
|
+
# Save step outputs to checkpoint
|
|
149
|
+
checkpoint.save_step_state(
|
|
150
|
+
step_name=step_result.step_name,
|
|
151
|
+
outputs=step_outputs,
|
|
152
|
+
metadata={
|
|
153
|
+
"duration": step_result.duration_seconds,
|
|
154
|
+
"cached": step_result.cached,
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
except Exception as e:
|
|
158
|
+
# Don't fail pipeline if checkpoint save fails
|
|
159
|
+
import warnings
|
|
160
|
+
|
|
161
|
+
warnings.warn(
|
|
162
|
+
f"Failed to save checkpoint for step {step_result.step_name}: {e}",
|
|
163
|
+
stacklevel=2,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Check for control flows that need to be evaluated after this step
|
|
167
|
+
self._evaluate_control_flows(pipeline, step_result, step_outputs, result, run_id)
|
|
168
|
+
|
|
127
169
|
else:
|
|
128
170
|
# Execute single ungrouped step
|
|
129
171
|
step = unit
|
|
130
172
|
|
|
173
|
+
# Skip step if already completed in checkpoint
|
|
174
|
+
if resume_from_checkpoint and step.name in completed_steps_from_checkpoint:
|
|
175
|
+
if hasattr(pipeline, "_display") and pipeline._display:
|
|
176
|
+
pipeline._display.update_step_status(
|
|
177
|
+
step_name=step.name,
|
|
178
|
+
status="success",
|
|
179
|
+
cached=True,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Load step outputs from checkpoint
|
|
183
|
+
try:
|
|
184
|
+
if checkpoint:
|
|
185
|
+
step_outputs_from_checkpoint = checkpoint.load_step_state(step.name)
|
|
186
|
+
|
|
187
|
+
# Process checkpoint outputs
|
|
188
|
+
if isinstance(step_outputs_from_checkpoint, dict):
|
|
189
|
+
for output_name, output_value in step_outputs_from_checkpoint.items():
|
|
190
|
+
step_outputs[output_name] = output_value
|
|
191
|
+
result.outputs[output_name] = output_value
|
|
192
|
+
|
|
193
|
+
# Create a mock ExecutionResult for checkpointed step
|
|
194
|
+
step_result = ExecutionResult(
|
|
195
|
+
step_name=step.name,
|
|
196
|
+
success=True,
|
|
197
|
+
output=step_outputs_from_checkpoint,
|
|
198
|
+
duration_seconds=0.0,
|
|
199
|
+
cached=True,
|
|
200
|
+
)
|
|
201
|
+
result.add_step_result(step_result)
|
|
202
|
+
|
|
203
|
+
# Continue to next step
|
|
204
|
+
continue
|
|
205
|
+
except Exception as e:
|
|
206
|
+
# If checkpoint load fails, execute the step normally
|
|
207
|
+
import warnings
|
|
208
|
+
|
|
209
|
+
warnings.warn(
|
|
210
|
+
f"Failed to load checkpoint for step {step.name}: {e}. Executing step normally.",
|
|
211
|
+
stacklevel=2,
|
|
212
|
+
)
|
|
213
|
+
|
|
131
214
|
# Prepare step inputs
|
|
132
215
|
step_inputs = {}
|
|
133
216
|
|
|
@@ -186,6 +269,10 @@ class LocalOrchestrator(Orchestrator):
|
|
|
186
269
|
# Get context parameters for this step
|
|
187
270
|
context_params = pipeline.context.inject_params(step.func)
|
|
188
271
|
|
|
272
|
+
# Update display - step starting
|
|
273
|
+
if hasattr(pipeline, "_display") and pipeline._display:
|
|
274
|
+
pipeline._display.update_step_status(step_name=step.name, status="running")
|
|
275
|
+
|
|
189
276
|
# Run step start hooks
|
|
190
277
|
hooks.run_step_start_hooks(step, step_inputs)
|
|
191
278
|
|
|
@@ -203,6 +290,16 @@ class LocalOrchestrator(Orchestrator):
|
|
|
203
290
|
# Run step end hooks
|
|
204
291
|
hooks.run_step_end_hooks(step, step_result)
|
|
205
292
|
|
|
293
|
+
# Update display - step completed
|
|
294
|
+
if hasattr(pipeline, "_display") and pipeline._display:
|
|
295
|
+
pipeline._display.update_step_status(
|
|
296
|
+
step_name=step.name,
|
|
297
|
+
status="success" if step_result.success else "failed",
|
|
298
|
+
duration=step_result.duration_seconds,
|
|
299
|
+
cached=step_result.cached,
|
|
300
|
+
error=step_result.error,
|
|
301
|
+
)
|
|
302
|
+
|
|
206
303
|
result.add_step_result(step_result)
|
|
207
304
|
|
|
208
305
|
# Handle failure
|
|
@@ -216,9 +313,48 @@ class LocalOrchestrator(Orchestrator):
|
|
|
216
313
|
if step_result.output is not None:
|
|
217
314
|
self._process_step_output(pipeline, step_result, step_outputs, result)
|
|
218
315
|
|
|
316
|
+
# Save checkpoint after successful step
|
|
317
|
+
checkpoint = getattr(pipeline, "_checkpoint", None)
|
|
318
|
+
if checkpoint and step_result.success:
|
|
319
|
+
try:
|
|
320
|
+
# Save step outputs to checkpoint
|
|
321
|
+
checkpoint.save_step_state(
|
|
322
|
+
step_name=step.name,
|
|
323
|
+
outputs=step_outputs,
|
|
324
|
+
metadata={
|
|
325
|
+
"duration": step_result.duration_seconds,
|
|
326
|
+
"cached": step_result.cached,
|
|
327
|
+
},
|
|
328
|
+
)
|
|
329
|
+
except Exception as e:
|
|
330
|
+
# Don't fail pipeline if checkpoint save fails
|
|
331
|
+
import warnings
|
|
332
|
+
|
|
333
|
+
warnings.warn(f"Failed to save checkpoint for step {step.name}: {e}", stacklevel=2)
|
|
334
|
+
|
|
335
|
+
# Check for control flows that need to be evaluated after this step
|
|
336
|
+
self._evaluate_control_flows(pipeline, step_result, step_outputs, result, run_id)
|
|
337
|
+
|
|
219
338
|
# Success! Finalize and return
|
|
220
339
|
result.finalize(success=True)
|
|
221
340
|
|
|
341
|
+
# Save final checkpoint if checkpointing is enabled
|
|
342
|
+
checkpoint = getattr(pipeline, "_checkpoint", None)
|
|
343
|
+
if checkpoint and result.success:
|
|
344
|
+
try:
|
|
345
|
+
checkpoint.save_step_state(
|
|
346
|
+
"pipeline_complete",
|
|
347
|
+
result.outputs,
|
|
348
|
+
metadata={
|
|
349
|
+
"duration": result.duration_seconds,
|
|
350
|
+
"success": True,
|
|
351
|
+
},
|
|
352
|
+
)
|
|
353
|
+
except Exception as e:
|
|
354
|
+
import warnings
|
|
355
|
+
|
|
356
|
+
warnings.warn(f"Failed to save final checkpoint: {e}", stacklevel=2)
|
|
357
|
+
|
|
222
358
|
# Run pipeline end hooks
|
|
223
359
|
hooks.run_pipeline_end_hooks(pipeline, result)
|
|
224
360
|
|
|
@@ -230,6 +366,363 @@ class LocalOrchestrator(Orchestrator):
|
|
|
230
366
|
pipeline._save_pipeline_definition()
|
|
231
367
|
return result
|
|
232
368
|
|
|
369
|
+
def _evaluate_control_flows(
|
|
370
|
+
self,
|
|
371
|
+
pipeline: "Pipeline",
|
|
372
|
+
step_result: "ExecutionResult",
|
|
373
|
+
step_outputs: dict[str, Any],
|
|
374
|
+
result: "PipelineResult",
|
|
375
|
+
run_id: str,
|
|
376
|
+
) -> None:
|
|
377
|
+
"""Evaluate control flows after a step completes.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
pipeline: Pipeline instance
|
|
381
|
+
step_result: Result of the step that just completed
|
|
382
|
+
step_outputs: Current step outputs dictionary
|
|
383
|
+
result: Pipeline result object
|
|
384
|
+
run_id: Run identifier
|
|
385
|
+
"""
|
|
386
|
+
from flowyml.core.conditional import If
|
|
387
|
+
|
|
388
|
+
# Create a context object for condition evaluation
|
|
389
|
+
class ExecutionContext:
|
|
390
|
+
"""Context object for conditional evaluation.
|
|
391
|
+
|
|
392
|
+
Provides access to step outputs via ctx.steps['step_name'].outputs['output_name']
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
def __init__(self, result: "PipelineResult", pipeline: "Pipeline"):
|
|
396
|
+
self.result = result
|
|
397
|
+
self.pipeline = pipeline
|
|
398
|
+
self._steps_cache = None
|
|
399
|
+
|
|
400
|
+
@property
|
|
401
|
+
def steps(self):
|
|
402
|
+
"""Lazy-load steps dictionary with outputs."""
|
|
403
|
+
if self._steps_cache is None:
|
|
404
|
+
self._steps_cache = {}
|
|
405
|
+
# Build steps dictionary with outputs
|
|
406
|
+
for step_name, step_res in self.result.step_results.items():
|
|
407
|
+
if step_res.success and step_res.output is not None:
|
|
408
|
+
step_def = next((s for s in self.pipeline.steps if s.name == step_name), None)
|
|
409
|
+
if step_def:
|
|
410
|
+
# Create step outputs dictionary
|
|
411
|
+
step_outputs = {}
|
|
412
|
+
if len(step_def.outputs) == 1:
|
|
413
|
+
step_outputs[step_def.outputs[0]] = step_res.output
|
|
414
|
+
elif isinstance(step_res.output, dict):
|
|
415
|
+
step_outputs = step_res.output
|
|
416
|
+
elif step_def.outputs:
|
|
417
|
+
# Try to map tuple/list outputs
|
|
418
|
+
if isinstance(step_res.output, (list, tuple)) and len(step_res.output) == len(
|
|
419
|
+
step_def.outputs,
|
|
420
|
+
):
|
|
421
|
+
for name, val in zip(step_def.outputs, step_res.output, strict=False):
|
|
422
|
+
step_outputs[name] = val
|
|
423
|
+
else:
|
|
424
|
+
step_outputs[step_def.outputs[0]] = step_res.output
|
|
425
|
+
|
|
426
|
+
# Create step object with outputs attribute that supports Asset objects
|
|
427
|
+
class StepContext:
|
|
428
|
+
def __init__(self, outputs):
|
|
429
|
+
# Wrap outputs to support Asset object access
|
|
430
|
+
self._raw_outputs = outputs
|
|
431
|
+
self.outputs = self._wrap_outputs(outputs)
|
|
432
|
+
|
|
433
|
+
def _wrap_outputs(self, outputs):
|
|
434
|
+
"""Wrap outputs to support Asset object property access."""
|
|
435
|
+
wrapped = {}
|
|
436
|
+
for key, value in outputs.items():
|
|
437
|
+
wrapped[key] = self._wrap_asset(value)
|
|
438
|
+
return wrapped
|
|
439
|
+
|
|
440
|
+
def _wrap_asset(self, value):
|
|
441
|
+
"""Wrap Asset objects to expose their properties."""
|
|
442
|
+
# Check if it's an Asset object
|
|
443
|
+
from flowyml.assets.base import Asset
|
|
444
|
+
from flowyml.assets.metrics import Metrics
|
|
445
|
+
from flowyml.assets.featureset import FeatureSet
|
|
446
|
+
|
|
447
|
+
if isinstance(value, Asset):
|
|
448
|
+
# Create a wrapper that exposes Asset properties
|
|
449
|
+
class AssetWrapper:
|
|
450
|
+
def __init__(self, asset):
|
|
451
|
+
self._asset = asset
|
|
452
|
+
# Expose the asset itself
|
|
453
|
+
self._self = asset
|
|
454
|
+
|
|
455
|
+
def __getattr__(self, name): # noqa: B023
|
|
456
|
+
# Handle special cases FIRST before generic hasattr check
|
|
457
|
+
# This is important because Asset has a 'metadata' attribute
|
|
458
|
+
# that is an AssetMetadata dataclass, not a dict
|
|
459
|
+
|
|
460
|
+
# Expose metadata as a merged dict of properties + tags
|
|
461
|
+
# This MUST come before hasattr check because Asset.metadata
|
|
462
|
+
# is an AssetMetadata dataclass, not a dict
|
|
463
|
+
if name == "metadata": # noqa: B023
|
|
464
|
+
# Create a dict that merges properties and tags
|
|
465
|
+
# Tags take precedence if there's a conflict
|
|
466
|
+
metadata_dict = (
|
|
467
|
+
dict(self._asset.properties)
|
|
468
|
+
if self._asset.properties
|
|
469
|
+
else {}
|
|
470
|
+
)
|
|
471
|
+
if self._asset.tags:
|
|
472
|
+
metadata_dict.update(self._asset.tags)
|
|
473
|
+
return metadata_dict
|
|
474
|
+
|
|
475
|
+
# For Metrics, map .metrics to .data or .get_all_metrics()
|
|
476
|
+
if isinstance(self._asset, Metrics):
|
|
477
|
+
if name == "metrics": # noqa: B023
|
|
478
|
+
return (
|
|
479
|
+
self._asset.get_all_metrics() or self._asset.data or {} # noqa: B023
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
# Now try to get from asset (handles all Asset properties)
|
|
483
|
+
try:
|
|
484
|
+
if hasattr(self._asset, name): # noqa: B023
|
|
485
|
+
attr = getattr(self._asset, name) # noqa: B023
|
|
486
|
+
# If it's a property/method, return it
|
|
487
|
+
# If it's callable but we want the value, call it
|
|
488
|
+
if callable(attr) and not isinstance(attr, type):
|
|
489
|
+
# It's a method, not a property - return as-is
|
|
490
|
+
return attr
|
|
491
|
+
return attr
|
|
492
|
+
except Exception as e:
|
|
493
|
+
# If accessing the attribute fails, log and continue to fallback logic
|
|
494
|
+
# This can happen if a property raises an exception
|
|
495
|
+
import warnings
|
|
496
|
+
|
|
497
|
+
warnings.warn(
|
|
498
|
+
f"Failed to access attribute '{name}' on {type(self._asset).__name__}: {e}", # noqa: B023
|
|
499
|
+
stacklevel=3,
|
|
500
|
+
)
|
|
501
|
+
pass
|
|
502
|
+
|
|
503
|
+
# Fallback: expose common properties/tags/data
|
|
504
|
+
if name == "data": # noqa: B023
|
|
505
|
+
return self._asset.data
|
|
506
|
+
|
|
507
|
+
if name == "properties": # noqa: B023
|
|
508
|
+
return self._asset.properties
|
|
509
|
+
|
|
510
|
+
if name == "tags": # noqa: B023
|
|
511
|
+
return self._asset.tags
|
|
512
|
+
|
|
513
|
+
raise AttributeError( # noqa: B023
|
|
514
|
+
f"'{type(self).__name__}' object has no attribute '{name}'", # noqa: B023
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
def __getitem__(self, key):
|
|
518
|
+
"""Allow dict-like access for Metrics.data and Asset.data."""
|
|
519
|
+
# For Metrics, access via get_all_metrics()
|
|
520
|
+
if isinstance(self._asset, Metrics):
|
|
521
|
+
metrics = (
|
|
522
|
+
self._asset.get_all_metrics() or self._asset.data or {}
|
|
523
|
+
)
|
|
524
|
+
if isinstance(metrics, dict):
|
|
525
|
+
return metrics[key] # noqa: B023
|
|
526
|
+
|
|
527
|
+
# For all Assets, allow dict access to .data if it's a dict
|
|
528
|
+
if isinstance(self._asset.data, dict):
|
|
529
|
+
return self._asset.data[key]
|
|
530
|
+
|
|
531
|
+
# For FeatureSet, allow access to statistics
|
|
532
|
+
if isinstance(self._asset, FeatureSet):
|
|
533
|
+
if key in self._asset.statistics:
|
|
534
|
+
return self._asset.statistics[key]
|
|
535
|
+
|
|
536
|
+
raise KeyError(f"'{key}' not found in {type(self._asset).__name__}")
|
|
537
|
+
|
|
538
|
+
def __contains__(self, key):
|
|
539
|
+
"""Support 'in' operator."""
|
|
540
|
+
# For Metrics, check in metrics dict
|
|
541
|
+
if isinstance(self._asset, Metrics):
|
|
542
|
+
metrics = (
|
|
543
|
+
self._asset.get_all_metrics() or self._asset.data or {}
|
|
544
|
+
)
|
|
545
|
+
if isinstance(metrics, dict):
|
|
546
|
+
return key in metrics
|
|
547
|
+
|
|
548
|
+
# For all Assets, check in .data if it's a dict
|
|
549
|
+
if isinstance(self._asset.data, dict):
|
|
550
|
+
return key in self._asset.data
|
|
551
|
+
|
|
552
|
+
# For FeatureSet, check in statistics
|
|
553
|
+
if isinstance(self._asset, FeatureSet):
|
|
554
|
+
return key in self._asset.statistics
|
|
555
|
+
|
|
556
|
+
return False
|
|
557
|
+
|
|
558
|
+
def __repr__(self):
|
|
559
|
+
return f"<AssetWrapper({type(self._asset).__name__})>"
|
|
560
|
+
|
|
561
|
+
return AssetWrapper(value)
|
|
562
|
+
# For dict values, return as-is but allow attribute access
|
|
563
|
+
elif isinstance(value, dict):
|
|
564
|
+
|
|
565
|
+
class DictWrapper(dict):
|
|
566
|
+
"""Dict wrapper that allows attribute access."""
|
|
567
|
+
|
|
568
|
+
def __getattr__(self, name): # noqa: B023
|
|
569
|
+
if name in self: # noqa: B023
|
|
570
|
+
return self[name] # noqa: B023
|
|
571
|
+
raise AttributeError( # noqa: B023
|
|
572
|
+
f"'{type(self).__name__}' object has no attribute '{name}'", # noqa: B023
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
return DictWrapper(value)
|
|
576
|
+
# For other types, return as-is
|
|
577
|
+
return value
|
|
578
|
+
|
|
579
|
+
self._steps_cache[step_name] = StepContext(step_outputs)
|
|
580
|
+
return self._steps_cache
|
|
581
|
+
|
|
582
|
+
context = ExecutionContext(result, pipeline)
|
|
583
|
+
|
|
584
|
+
# Evaluate each control flow
|
|
585
|
+
for control_flow in pipeline.control_flows:
|
|
586
|
+
if isinstance(control_flow, If):
|
|
587
|
+
try:
|
|
588
|
+
selected_step = control_flow.evaluate(context)
|
|
589
|
+
except Exception as e:
|
|
590
|
+
# If condition evaluation fails, log the error with full traceback for debugging
|
|
591
|
+
import warnings
|
|
592
|
+
import traceback
|
|
593
|
+
|
|
594
|
+
warnings.warn(
|
|
595
|
+
f"Failed to evaluate control flow condition: {e}\n{traceback.format_exc()}",
|
|
596
|
+
stacklevel=2,
|
|
597
|
+
)
|
|
598
|
+
# If condition evaluation fails, try to execute else_step as fallback
|
|
599
|
+
# This ensures we don't silently skip execution
|
|
600
|
+
selected_step = control_flow.else_step
|
|
601
|
+
|
|
602
|
+
# Execute selected_step if it exists (could be then_step, else_step, or None)
|
|
603
|
+
if selected_step:
|
|
604
|
+
from flowyml.core.step import Step
|
|
605
|
+
|
|
606
|
+
# Check if selected_step is already a Step object or a function
|
|
607
|
+
if isinstance(selected_step, Step):
|
|
608
|
+
# Already a Step object (e.g., from @step decorator), use it directly
|
|
609
|
+
step_obj = selected_step
|
|
610
|
+
else:
|
|
611
|
+
# It's a function, try to find existing Step in pipeline.steps
|
|
612
|
+
# or check if any step has this function
|
|
613
|
+
step_obj = next((s for s in pipeline.steps if s.func == selected_step), None)
|
|
614
|
+
|
|
615
|
+
# If step not found in pipeline.steps, it's a conditional step - create Step object on the fly
|
|
616
|
+
if step_obj is None:
|
|
617
|
+
# Get function name safely
|
|
618
|
+
func_name = getattr(selected_step, "__name__", "conditional_step")
|
|
619
|
+
# Create a Step object for the conditional step function
|
|
620
|
+
step_obj = Step(
|
|
621
|
+
func=selected_step,
|
|
622
|
+
name=func_name,
|
|
623
|
+
inputs=[], # Conditional steps may not have explicit inputs
|
|
624
|
+
outputs=[], # Conditional steps may not have explicit outputs
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
if step_obj.name not in result.step_results:
|
|
628
|
+
# Execute the selected step
|
|
629
|
+
# The check above prevents re-execution of the same step
|
|
630
|
+
self._execute_conditional_step(
|
|
631
|
+
pipeline,
|
|
632
|
+
step_obj,
|
|
633
|
+
step_outputs,
|
|
634
|
+
result,
|
|
635
|
+
run_id,
|
|
636
|
+
)
|
|
637
|
+
# Note: Control flows will be re-evaluated after conditional step completes
|
|
638
|
+
|
|
639
|
+
def _execute_conditional_step(
|
|
640
|
+
self,
|
|
641
|
+
pipeline: "Pipeline",
|
|
642
|
+
step: "Step",
|
|
643
|
+
step_outputs: dict[str, Any],
|
|
644
|
+
result: "PipelineResult",
|
|
645
|
+
run_id: str,
|
|
646
|
+
) -> None:
|
|
647
|
+
"""Execute a step that was selected by conditional logic.
|
|
648
|
+
|
|
649
|
+
Args:
|
|
650
|
+
pipeline: Pipeline instance
|
|
651
|
+
step: Step to execute
|
|
652
|
+
step_outputs: Current step outputs
|
|
653
|
+
result: Pipeline result object
|
|
654
|
+
run_id: Run identifier
|
|
655
|
+
"""
|
|
656
|
+
# Prepare step inputs (similar to regular step execution)
|
|
657
|
+
import inspect
|
|
658
|
+
|
|
659
|
+
step_inputs = {}
|
|
660
|
+
sig = inspect.signature(step.func)
|
|
661
|
+
params = [p for p in sig.parameters.values() if p.name not in ("self", "cls")]
|
|
662
|
+
|
|
663
|
+
for param in params:
|
|
664
|
+
if param.name in step_outputs:
|
|
665
|
+
step_inputs[param.name] = step_outputs[param.name]
|
|
666
|
+
|
|
667
|
+
# Get context parameters
|
|
668
|
+
context_params = pipeline.context.inject_params(step.func)
|
|
669
|
+
|
|
670
|
+
# Update display
|
|
671
|
+
if hasattr(pipeline, "_display") and pipeline._display:
|
|
672
|
+
pipeline._display.update_step_status(step_name=step.name, status="running")
|
|
673
|
+
|
|
674
|
+
# Execute step
|
|
675
|
+
step_result = pipeline.executor.execute_step(
|
|
676
|
+
step,
|
|
677
|
+
step_inputs,
|
|
678
|
+
context_params,
|
|
679
|
+
pipeline.cache_store,
|
|
680
|
+
artifact_store=pipeline.stack.artifact_store if pipeline.stack else None,
|
|
681
|
+
run_id=run_id,
|
|
682
|
+
project_name=pipeline.name,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
# Update display
|
|
686
|
+
if hasattr(pipeline, "_display") and pipeline._display:
|
|
687
|
+
pipeline._display.update_step_status(
|
|
688
|
+
step_name=step.name,
|
|
689
|
+
status="success" if step_result.success else "failed",
|
|
690
|
+
duration=step_result.duration_seconds,
|
|
691
|
+
cached=step_result.cached,
|
|
692
|
+
error=step_result.error,
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
result.add_step_result(step_result)
|
|
696
|
+
|
|
697
|
+
# Handle failure
|
|
698
|
+
if not step_result.success:
|
|
699
|
+
result.finalize(success=False)
|
|
700
|
+
return
|
|
701
|
+
|
|
702
|
+
# Process outputs
|
|
703
|
+
if step_result.output is not None:
|
|
704
|
+
self._process_step_output(pipeline, step_result, step_outputs, result)
|
|
705
|
+
|
|
706
|
+
# Save checkpoint after successful conditional step
|
|
707
|
+
checkpoint = getattr(pipeline, "_checkpoint", None)
|
|
708
|
+
if checkpoint and step_result.success:
|
|
709
|
+
try:
|
|
710
|
+
checkpoint.save_step_state(
|
|
711
|
+
step_name=step.name,
|
|
712
|
+
outputs=step_outputs,
|
|
713
|
+
metadata={
|
|
714
|
+
"duration": step_result.duration_seconds,
|
|
715
|
+
"cached": step_result.cached,
|
|
716
|
+
},
|
|
717
|
+
)
|
|
718
|
+
except Exception as e:
|
|
719
|
+
import warnings
|
|
720
|
+
|
|
721
|
+
warnings.warn(f"Failed to save checkpoint for conditional step {step.name}: {e}", stacklevel=2)
|
|
722
|
+
|
|
723
|
+
# Check for control flows that need to be evaluated after conditional step
|
|
724
|
+
self._evaluate_control_flows(pipeline, step_result, step_outputs, result, run_id)
|
|
725
|
+
|
|
233
726
|
def _process_step_output(self, pipeline, step_result, step_outputs, result):
|
|
234
727
|
"""Helper to process step outputs and update state."""
|
|
235
728
|
from pathlib import Path
|