flowyml 1.7.2__py3-none-any.whl → 1.8.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 (126) hide show
  1. flowyml/assets/base.py +15 -0
  2. flowyml/assets/metrics.py +5 -0
  3. flowyml/cli/main.py +709 -0
  4. flowyml/cli/stack_cli.py +138 -25
  5. flowyml/core/__init__.py +17 -0
  6. flowyml/core/executor.py +161 -26
  7. flowyml/core/image_builder.py +129 -0
  8. flowyml/core/log_streamer.py +227 -0
  9. flowyml/core/orchestrator.py +22 -2
  10. flowyml/core/pipeline.py +34 -10
  11. flowyml/core/routing.py +558 -0
  12. flowyml/core/step.py +9 -1
  13. flowyml/core/step_grouping.py +49 -35
  14. flowyml/core/types.py +407 -0
  15. flowyml/monitoring/alerts.py +10 -0
  16. flowyml/monitoring/notifications.py +104 -25
  17. flowyml/monitoring/slack_blocks.py +323 -0
  18. flowyml/plugins/__init__.py +251 -0
  19. flowyml/plugins/alerters/__init__.py +1 -0
  20. flowyml/plugins/alerters/slack.py +168 -0
  21. flowyml/plugins/base.py +752 -0
  22. flowyml/plugins/config.py +478 -0
  23. flowyml/plugins/deployers/__init__.py +22 -0
  24. flowyml/plugins/deployers/gcp_cloud_run.py +200 -0
  25. flowyml/plugins/deployers/sagemaker.py +306 -0
  26. flowyml/plugins/deployers/vertex.py +290 -0
  27. flowyml/plugins/integration.py +369 -0
  28. flowyml/plugins/manager.py +510 -0
  29. flowyml/plugins/model_registries/__init__.py +22 -0
  30. flowyml/plugins/model_registries/mlflow.py +159 -0
  31. flowyml/plugins/model_registries/sagemaker.py +489 -0
  32. flowyml/plugins/model_registries/vertex.py +386 -0
  33. flowyml/plugins/orchestrators/__init__.py +13 -0
  34. flowyml/plugins/orchestrators/sagemaker.py +443 -0
  35. flowyml/plugins/orchestrators/vertex_ai.py +461 -0
  36. flowyml/plugins/registries/__init__.py +13 -0
  37. flowyml/plugins/registries/ecr.py +321 -0
  38. flowyml/plugins/registries/gcr.py +313 -0
  39. flowyml/plugins/registry.py +454 -0
  40. flowyml/plugins/stack.py +494 -0
  41. flowyml/plugins/stack_config.py +537 -0
  42. flowyml/plugins/stores/__init__.py +13 -0
  43. flowyml/plugins/stores/gcs.py +460 -0
  44. flowyml/plugins/stores/s3.py +453 -0
  45. flowyml/plugins/trackers/__init__.py +11 -0
  46. flowyml/plugins/trackers/mlflow.py +316 -0
  47. flowyml/plugins/validators/__init__.py +3 -0
  48. flowyml/plugins/validators/deepchecks.py +119 -0
  49. flowyml/registry/__init__.py +2 -1
  50. flowyml/registry/model_environment.py +109 -0
  51. flowyml/registry/model_registry.py +241 -96
  52. flowyml/serving/__init__.py +17 -0
  53. flowyml/serving/model_server.py +628 -0
  54. flowyml/stacks/__init__.py +60 -0
  55. flowyml/stacks/aws.py +93 -0
  56. flowyml/stacks/base.py +62 -0
  57. flowyml/stacks/components.py +12 -0
  58. flowyml/stacks/gcp.py +44 -9
  59. flowyml/stacks/plugins.py +115 -0
  60. flowyml/stacks/registry.py +2 -1
  61. flowyml/storage/sql.py +401 -12
  62. flowyml/tracking/experiment.py +8 -5
  63. flowyml/ui/backend/Dockerfile +87 -16
  64. flowyml/ui/backend/auth.py +12 -2
  65. flowyml/ui/backend/main.py +149 -5
  66. flowyml/ui/backend/routers/ai_context.py +226 -0
  67. flowyml/ui/backend/routers/assets.py +23 -4
  68. flowyml/ui/backend/routers/auth.py +96 -0
  69. flowyml/ui/backend/routers/deployments.py +660 -0
  70. flowyml/ui/backend/routers/model_explorer.py +597 -0
  71. flowyml/ui/backend/routers/plugins.py +103 -51
  72. flowyml/ui/backend/routers/projects.py +91 -8
  73. flowyml/ui/backend/routers/runs.py +20 -1
  74. flowyml/ui/backend/routers/schedules.py +22 -17
  75. flowyml/ui/backend/routers/templates.py +319 -0
  76. flowyml/ui/backend/routers/websocket.py +2 -2
  77. flowyml/ui/frontend/Dockerfile +55 -6
  78. flowyml/ui/frontend/dist/assets/index-B5AsPTSz.css +1 -0
  79. flowyml/ui/frontend/dist/assets/index-dFbZ8wD8.js +753 -0
  80. flowyml/ui/frontend/dist/index.html +2 -2
  81. flowyml/ui/frontend/dist/logo.png +0 -0
  82. flowyml/ui/frontend/nginx.conf +65 -4
  83. flowyml/ui/frontend/package-lock.json +1404 -74
  84. flowyml/ui/frontend/package.json +3 -0
  85. flowyml/ui/frontend/public/logo.png +0 -0
  86. flowyml/ui/frontend/src/App.jsx +10 -7
  87. flowyml/ui/frontend/src/app/auth/Login.jsx +90 -0
  88. flowyml/ui/frontend/src/app/dashboard/page.jsx +8 -8
  89. flowyml/ui/frontend/src/app/deployments/page.jsx +786 -0
  90. flowyml/ui/frontend/src/app/model-explorer/page.jsx +1031 -0
  91. flowyml/ui/frontend/src/app/pipelines/page.jsx +12 -2
  92. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectExperimentsList.jsx +19 -6
  93. flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +36 -24
  94. flowyml/ui/frontend/src/app/runs/page.jsx +8 -2
  95. flowyml/ui/frontend/src/app/settings/page.jsx +267 -253
  96. flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +29 -7
  97. flowyml/ui/frontend/src/components/Layout.jsx +6 -0
  98. flowyml/ui/frontend/src/components/PipelineGraph.jsx +79 -29
  99. flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +36 -6
  100. flowyml/ui/frontend/src/components/RunMetaPanel.jsx +113 -0
  101. flowyml/ui/frontend/src/components/ai/AIAssistantButton.jsx +71 -0
  102. flowyml/ui/frontend/src/components/ai/AIAssistantPanel.jsx +420 -0
  103. flowyml/ui/frontend/src/components/header/Header.jsx +22 -0
  104. flowyml/ui/frontend/src/components/plugins/PluginManager.jsx +4 -4
  105. flowyml/ui/frontend/src/components/plugins/{ZenMLIntegration.jsx → StackImport.jsx} +38 -12
  106. flowyml/ui/frontend/src/components/sidebar/Sidebar.jsx +36 -13
  107. flowyml/ui/frontend/src/contexts/AIAssistantContext.jsx +245 -0
  108. flowyml/ui/frontend/src/contexts/AuthContext.jsx +108 -0
  109. flowyml/ui/frontend/src/hooks/useAIContext.js +156 -0
  110. flowyml/ui/frontend/src/hooks/useWebGPU.js +54 -0
  111. flowyml/ui/frontend/src/layouts/MainLayout.jsx +6 -0
  112. flowyml/ui/frontend/src/router/index.jsx +47 -20
  113. flowyml/ui/frontend/src/services/pluginService.js +3 -1
  114. flowyml/ui/server_manager.py +5 -5
  115. flowyml/ui/utils.py +157 -39
  116. flowyml/utils/config.py +37 -15
  117. flowyml/utils/model_introspection.py +123 -0
  118. flowyml/utils/observability.py +30 -0
  119. flowyml-1.8.0.dist-info/METADATA +174 -0
  120. {flowyml-1.7.2.dist-info → flowyml-1.8.0.dist-info}/RECORD +123 -65
  121. {flowyml-1.7.2.dist-info → flowyml-1.8.0.dist-info}/WHEEL +1 -1
  122. flowyml/ui/frontend/dist/assets/index-B40RsQDq.css +0 -1
  123. flowyml/ui/frontend/dist/assets/index-CjI0zKCn.js +0 -685
  124. flowyml-1.7.2.dist-info/METADATA +0 -477
  125. {flowyml-1.7.2.dist-info → flowyml-1.8.0.dist-info}/entry_points.txt +0 -0
  126. {flowyml-1.7.2.dist-info → flowyml-1.8.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,558 @@
1
+ """FlowyML Artifact Routing - Automatic Type-Based Artifact Routing.
2
+
3
+ This module provides automatic routing of step outputs to appropriate
4
+ infrastructure based on their Python types. When a step returns a
5
+ `Model`, `Dataset`, `Metrics`, or other artifact type, the runtime
6
+ automatically routes it to the configured stores and registries.
7
+
8
+ Usage:
9
+ from flowyml.core.routing import route_artifact
10
+
11
+ # After step execution
12
+ result = step.func(**inputs)
13
+
14
+ # Route based on type and stack config
15
+ artifact_info = route_artifact(
16
+ output=result,
17
+ step_name="train_model",
18
+ run_id="run-123",
19
+ )
20
+
21
+ The routing is configured via flowyml.yaml:
22
+ stacks:
23
+ gcp-prod:
24
+ artifact_routing:
25
+ Model: { store: gcs, register: true }
26
+ Dataset: { store: gcs }
27
+ Metrics: { log_to_tracker: true }
28
+ """
29
+
30
+ import logging
31
+ from typing import Any, get_type_hints
32
+ from dataclasses import dataclass, field
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ @dataclass
38
+ class RoutingResult:
39
+ """Result of artifact routing.
40
+
41
+ Attributes:
42
+ artifact_type: Name of the artifact type (Model, Dataset, etc.)
43
+ store_uri: URI where the artifact was stored
44
+ registered: Whether the artifact was registered (e.g., in model registry)
45
+ deployed: Whether the artifact was deployed (e.g., to endpoint)
46
+ endpoint_uri: URI of the deployment endpoint
47
+ logged: Whether the artifact was logged (e.g., metrics to tracker)
48
+ metadata: Additional metadata from routing
49
+ """
50
+
51
+ artifact_type: str | None = None
52
+ store_uri: str | None = None
53
+ registered: bool = False
54
+ deployed: bool = False
55
+ endpoint_uri: str | None = None
56
+ logged: bool = False
57
+ metadata: dict[str, Any] = field(default_factory=dict)
58
+
59
+
60
+ def get_step_return_type(step_func: callable) -> type | None:
61
+ """Get the return type annotation from a step function.
62
+
63
+ Args:
64
+ step_func: The step function to inspect.
65
+
66
+ Returns:
67
+ The return type annotation, or None if not annotated.
68
+ """
69
+ try:
70
+ hints = get_type_hints(step_func)
71
+ return hints.get("return")
72
+ except Exception:
73
+ # Fallback to __annotations__ if get_type_hints fails
74
+ try:
75
+ return step_func.__annotations__.get("return")
76
+ except Exception:
77
+ return None
78
+
79
+
80
+ def detect_artifact_type(output: Any) -> str | None:
81
+ """Detect the artifact type from an output value.
82
+
83
+ This checks if the output is an instance of one of our artifact types
84
+ or if it matches specific patterns (like dict for Metrics).
85
+
86
+ Args:
87
+ output: The step output value.
88
+
89
+ Returns:
90
+ Type name string or None.
91
+ """
92
+ # Import types here to avoid circular imports
93
+ from flowyml.core.types import Artifact, Model, Dataset, Metrics, Parameters
94
+
95
+ if isinstance(output, Model):
96
+ return "Model"
97
+ elif isinstance(output, Dataset):
98
+ return "Dataset"
99
+ elif isinstance(output, Metrics):
100
+ return "Metrics"
101
+ elif isinstance(output, Parameters):
102
+ return "Parameters"
103
+ elif isinstance(output, Artifact):
104
+ return type(output).__name__
105
+
106
+ return None
107
+
108
+
109
+ def route_artifact(
110
+ output: Any,
111
+ step_name: str,
112
+ run_id: str,
113
+ return_type: type | None = None,
114
+ project_name: str = "default",
115
+ ) -> RoutingResult:
116
+ """Route a step output to appropriate infrastructure based on type.
117
+
118
+ This is the main entry point for type-based artifact routing.
119
+ It inspects the output type and routes to configured stores/registries.
120
+
121
+ Args:
122
+ output: The step output to route.
123
+ step_name: Name of the step that produced this output.
124
+ run_id: Current run identifier.
125
+ return_type: Optional return type annotation (if known).
126
+ project_name: Project name for namespacing.
127
+
128
+ Returns:
129
+ RoutingResult with routing information.
130
+ """
131
+ result = RoutingResult()
132
+
133
+ # Skip None outputs
134
+ if output is None:
135
+ return result
136
+
137
+ # Detect artifact type
138
+ artifact_type = detect_artifact_type(output)
139
+
140
+ # If not detected from value, try from type annotation
141
+ if artifact_type is None and return_type is not None:
142
+ try:
143
+ type_name = return_type.__name__ if hasattr(return_type, "__name__") else str(return_type)
144
+ if type_name in ("Model", "Dataset", "Metrics", "Parameters"):
145
+ artifact_type = type_name
146
+ except Exception:
147
+ pass
148
+
149
+ if artifact_type is None:
150
+ # Not a routable artifact type
151
+ return result
152
+
153
+ result.artifact_type = artifact_type
154
+ logger.debug(f"Routing {artifact_type} artifact from step '{step_name}'")
155
+
156
+ # Get routing configuration from active stack
157
+ try:
158
+ from flowyml.plugins.stack_config import get_routing_for_type, get_active_stack
159
+
160
+ routing_rule = get_routing_for_type(artifact_type)
161
+ stack = get_active_stack()
162
+
163
+ if routing_rule is None:
164
+ logger.debug(f"No routing rule for {artifact_type}, using defaults")
165
+ return result
166
+
167
+ # Route to artifact store
168
+ if routing_rule.store:
169
+ result.store_uri = _save_to_store(
170
+ output=output,
171
+ artifact_type=artifact_type,
172
+ store_name=routing_rule.store,
173
+ path=routing_rule.format_path(
174
+ run_id=run_id,
175
+ step_name=step_name,
176
+ artifact_name=artifact_type.lower(),
177
+ ),
178
+ stack=stack,
179
+ )
180
+
181
+ # Register model if configured
182
+ if routing_rule.register and artifact_type == "Model":
183
+ result.registered = _register_model(
184
+ output=output,
185
+ step_name=step_name,
186
+ run_id=run_id,
187
+ stack=stack,
188
+ )
189
+
190
+ # Deploy model if configured and conditions are met
191
+ # Note: deploy=True just enables deployment - actual deployment depends on deploy_condition
192
+ if routing_rule.deploy and artifact_type == "Model":
193
+ # Get metrics from model metadata for conditional deployment
194
+ model_metrics = None
195
+ if hasattr(output, "metadata") and output.metadata:
196
+ model_metrics = output.metadata.get("metrics", {})
197
+
198
+ # Check if auto-deployment should proceed
199
+ if routing_rule.should_auto_deploy(model_metrics):
200
+ endpoint_name = routing_rule.endpoint_name or f"{step_name}-endpoint"
201
+ result.deployed, result.endpoint_uri = _deploy_model(
202
+ output=output,
203
+ step_name=step_name,
204
+ run_id=run_id,
205
+ endpoint_name=endpoint_name,
206
+ stack=stack,
207
+ )
208
+ else:
209
+ # Log that deployment is pending approval/manual action
210
+ condition = routing_rule.deploy_condition
211
+ if condition == "manual":
212
+ logger.info(
213
+ f"Model registered but not deployed (deploy_condition='manual'). "
214
+ f"Use 'flowyml model deploy {output.name}' to deploy.",
215
+ )
216
+ elif condition == "on_approval":
217
+ logger.info("Model registered, awaiting approval for deployment.")
218
+ elif condition == "auto" and routing_rule.deploy_min_metrics:
219
+ logger.info(
220
+ f"Model not deployed - metrics did not meet thresholds: " f"{routing_rule.deploy_min_metrics}",
221
+ )
222
+
223
+ # Log metrics if configured
224
+ if routing_rule.log_to_tracker and artifact_type == "Metrics":
225
+ result.logged = _log_metrics(
226
+ output=output,
227
+ step_name=step_name,
228
+ run_id=run_id,
229
+ stack=stack,
230
+ )
231
+
232
+ # Log parameters if configured
233
+ if routing_rule.log_to_tracker and artifact_type == "Parameters":
234
+ result.logged = _log_parameters(
235
+ output=output,
236
+ step_name=step_name,
237
+ run_id=run_id,
238
+ stack=stack,
239
+ )
240
+
241
+ # Add routing metadata
242
+ result.metadata = {
243
+ "store": routing_rule.store,
244
+ "path": routing_rule.path,
245
+ "registered": result.registered,
246
+ "deployed": result.deployed,
247
+ "logged": result.logged,
248
+ }
249
+
250
+ except ImportError:
251
+ logger.debug("Stack config not available, skipping routing")
252
+ except Exception as e:
253
+ logger.warning(f"Error during artifact routing: {e}")
254
+
255
+ return result
256
+
257
+
258
+ def _save_to_store(
259
+ output: Any,
260
+ artifact_type: str,
261
+ store_name: str,
262
+ path: str,
263
+ stack: Any,
264
+ ) -> str | None:
265
+ """Save artifact to the configured store.
266
+
267
+ Args:
268
+ output: The artifact to save.
269
+ artifact_type: Type of the artifact.
270
+ store_name: Name of the store (gcs, s3, local).
271
+ path: Path within the store.
272
+ stack: Stack configuration.
273
+
274
+ Returns:
275
+ URI of the saved artifact or None.
276
+ """
277
+ try:
278
+ # Get artifact store from stack
279
+ if store_name and stack and stack.artifact_stores:
280
+ store_config = stack.artifact_stores.get(store_name)
281
+ if store_config:
282
+ # Instantiate and use the store
283
+ from flowyml.plugins.config import get_artifact_store
284
+
285
+ store = get_artifact_store()
286
+ if store:
287
+ # Extract data if it's an Artifact wrapper
288
+ from flowyml.core.types import Artifact
289
+
290
+ data = output.data if isinstance(output, Artifact) else output
291
+ return store.save(data, path)
292
+
293
+ # Fallback to default artifact store
294
+ from flowyml.plugins.config import get_artifact_store
295
+
296
+ store = get_artifact_store()
297
+ if store:
298
+ from flowyml.core.types import Artifact
299
+
300
+ data = output.data if isinstance(output, Artifact) else output
301
+ return store.save(data, path)
302
+
303
+ except Exception as e:
304
+ logger.warning(f"Failed to save artifact to store: {e}")
305
+
306
+ return None
307
+
308
+
309
+ def _register_model(
310
+ output: Any,
311
+ step_name: str,
312
+ run_id: str,
313
+ stack: Any,
314
+ ) -> bool:
315
+ """Register a model in the model registry.
316
+
317
+ Args:
318
+ output: The Model artifact.
319
+ step_name: Step that produced the model.
320
+ run_id: Current run ID.
321
+ stack: Stack configuration.
322
+
323
+ Returns:
324
+ True if registration was successful.
325
+ """
326
+ try:
327
+ from flowyml.core.types import Model
328
+
329
+ if not isinstance(output, Model):
330
+ return False
331
+
332
+ # Get model registry from plugins
333
+ from flowyml.plugins.config import get_config
334
+
335
+ config = get_config()
336
+ registry = config._get_plugin("model_registry")
337
+
338
+ if registry:
339
+ model_name = output.name or f"{step_name}_model"
340
+ model_uri = output.uri or f"runs/{run_id}/models/{step_name}"
341
+
342
+ registry.register_model(
343
+ name=model_name,
344
+ model_uri=model_uri,
345
+ version=output.version,
346
+ metadata={
347
+ "framework": output.framework,
348
+ "step_name": step_name,
349
+ "run_id": run_id,
350
+ **output.metadata,
351
+ },
352
+ )
353
+ logger.info(f"Registered model '{model_name}' to registry")
354
+ return True
355
+
356
+ except Exception as e:
357
+ logger.warning(f"Failed to register model: {e}")
358
+
359
+ return False
360
+
361
+
362
+ def _deploy_model(
363
+ output: Any,
364
+ step_name: str,
365
+ run_id: str,
366
+ endpoint_name: str,
367
+ stack: Any,
368
+ ) -> tuple[bool, str | None]:
369
+ """Deploy a model to an endpoint.
370
+
371
+ Args:
372
+ output: The Model artifact.
373
+ step_name: Step that produced the model.
374
+ run_id: Current run ID.
375
+ endpoint_name: Name for the endpoint.
376
+ stack: Stack configuration.
377
+
378
+ Returns:
379
+ Tuple of (success, endpoint_uri).
380
+ """
381
+ try:
382
+ from flowyml.core.types import Model
383
+
384
+ if not isinstance(output, Model):
385
+ return False, None
386
+
387
+ # Get model deployer from stack config
388
+ if stack and stack.model_deployer:
389
+ deployer_config = stack.model_deployer
390
+ deployer_type = deployer_config.get("type", "")
391
+
392
+ deployer = None
393
+
394
+ # Instantiate the appropriate deployer
395
+ if "vertex" in deployer_type:
396
+ from flowyml.plugins.deployers.vertex import VertexEndpointDeployer
397
+
398
+ deployer = VertexEndpointDeployer(
399
+ project=deployer_config.get("project"),
400
+ location=deployer_config.get("location", "us-central1"),
401
+ )
402
+ elif "sagemaker" in deployer_type:
403
+ from flowyml.plugins.deployers.sagemaker import SageMakerEndpointDeployer
404
+
405
+ deployer = SageMakerEndpointDeployer(
406
+ region=deployer_config.get("region"),
407
+ role_arn=deployer_config.get("role_arn"),
408
+ )
409
+
410
+ if deployer:
411
+ deployer.initialize()
412
+
413
+ # Get model URI (from artifact store or output)
414
+ model_uri = output.uri or f"runs/{run_id}/models/{step_name}"
415
+
416
+ endpoint_uri = deployer.deploy(
417
+ model_uri=model_uri,
418
+ endpoint_name=endpoint_name,
419
+ )
420
+
421
+ logger.info(f"Deployed model to endpoint: {endpoint_uri}")
422
+ return True, endpoint_uri
423
+
424
+ # No deployer configured
425
+ logger.debug("No model deployer configured in stack")
426
+ return False, None
427
+
428
+ except Exception as e:
429
+ logger.warning(f"Failed to deploy model: {e}")
430
+
431
+ return False, None
432
+
433
+
434
+ def _log_metrics(
435
+ output: Any,
436
+ step_name: str,
437
+ run_id: str,
438
+ stack: Any,
439
+ ) -> bool:
440
+ """Log metrics to the experiment tracker.
441
+
442
+ Args:
443
+ output: The Metrics artifact (dict-like).
444
+ step_name: Step that produced the metrics.
445
+ run_id: Current run ID.
446
+ stack: Stack configuration.
447
+
448
+ Returns:
449
+ True if logging was successful.
450
+ """
451
+ try:
452
+ from flowyml.core.types import Metrics
453
+ from flowyml.plugins.config import get_tracker
454
+
455
+ tracker = get_tracker()
456
+ if tracker:
457
+ # Get metrics values
458
+ if isinstance(output, Metrics):
459
+ metrics_dict = dict(output)
460
+ step_num = output._step
461
+ else:
462
+ metrics_dict = dict(output)
463
+ step_num = None
464
+
465
+ tracker.log_metrics(metrics_dict, step=step_num)
466
+ logger.debug(f"Logged metrics from step '{step_name}': {list(metrics_dict.keys())}")
467
+ return True
468
+
469
+ except Exception as e:
470
+ logger.warning(f"Failed to log metrics: {e}")
471
+
472
+ return False
473
+
474
+
475
+ def _log_parameters(
476
+ output: Any,
477
+ step_name: str,
478
+ run_id: str,
479
+ stack: Any,
480
+ ) -> bool:
481
+ """Log parameters to the experiment tracker.
482
+
483
+ Args:
484
+ output: The Parameters artifact (dict-like).
485
+ step_name: Step that uses the parameters.
486
+ run_id: Current run ID.
487
+ stack: Stack configuration.
488
+
489
+ Returns:
490
+ True if logging was successful.
491
+ """
492
+ try:
493
+ from flowyml.core.types import Parameters
494
+ from flowyml.plugins.config import get_tracker
495
+
496
+ tracker = get_tracker()
497
+ if tracker:
498
+ # Get parameter values
499
+ if isinstance(output, Parameters):
500
+ params_dict = dict(output)
501
+ else:
502
+ params_dict = dict(output)
503
+
504
+ # Log parameters (with step prefix for clarity)
505
+ prefixed_params = {f"{step_name}/{k}": v for k, v in params_dict.items()}
506
+ tracker.log_params(prefixed_params)
507
+ logger.debug(f"Logged parameters from step '{step_name}': {list(params_dict.keys())}")
508
+ return True
509
+
510
+ except Exception as e:
511
+ logger.warning(f"Failed to log parameters: {e}")
512
+
513
+ return False
514
+
515
+
516
+ def should_route(output: Any) -> bool:
517
+ """Check if an output should be routed.
518
+
519
+ Args:
520
+ output: The step output.
521
+
522
+ Returns:
523
+ True if the output should be routed.
524
+ """
525
+ if output is None:
526
+ return False
527
+
528
+ from flowyml.core.types import is_artifact_type
529
+
530
+ return is_artifact_type(output)
531
+
532
+
533
+ def auto_route_metrics_and_params(
534
+ output: Any,
535
+ step_name: str,
536
+ run_id: str,
537
+ ) -> bool:
538
+ """Automatically route Metrics and Parameters without explicit config.
539
+
540
+ This is a convenience function that can be called to log Metrics
541
+ and Parameters even when no routing rule is configured.
542
+
543
+ Args:
544
+ output: The step output.
545
+ step_name: Step name.
546
+ run_id: Run ID.
547
+
548
+ Returns:
549
+ True if logging was successful.
550
+ """
551
+ from flowyml.core.types import Metrics, Parameters
552
+
553
+ if isinstance(output, Metrics):
554
+ return _log_metrics(output, step_name, run_id, None)
555
+ elif isinstance(output, Parameters):
556
+ return _log_parameters(output, step_name, run_id, None)
557
+
558
+ return False
flowyml/core/step.py CHANGED
@@ -31,6 +31,8 @@ class StepConfig:
31
31
  tags: dict[str, str] = field(default_factory=dict)
32
32
  condition: Callable | None = None
33
33
  execution_group: str | None = None
34
+ source_file: str | None = None
35
+ source_line: int | None = None
34
36
 
35
37
  def __hash__(self):
36
38
  """Make StepConfig hashable."""
@@ -84,11 +86,15 @@ class Step:
84
86
  self.condition = condition
85
87
  self.execution_group = execution_group
86
88
 
87
- # Capture source code for UI display
89
+ # Capture source code and location for UI display
88
90
  try:
89
91
  self.source_code = inspect.getsource(func)
92
+ self.source_file = inspect.getsourcefile(func)
93
+ _, self.source_line = inspect.getsourcelines(func)
90
94
  except (OSError, TypeError):
91
95
  self.source_code = "# Source code not available"
96
+ self.source_file = None
97
+ self.source_line = None
92
98
 
93
99
  self.config = StepConfig(
94
100
  name=self.name,
@@ -102,6 +108,8 @@ class Step:
102
108
  tags=self.tags,
103
109
  condition=self.condition,
104
110
  execution_group=self.execution_group,
111
+ source_file=self.source_file,
112
+ source_line=self.source_line,
105
113
  )
106
114
 
107
115
  def __call__(self, *args, **kwargs):
@@ -172,26 +172,23 @@ class StepGroupAnalyzer:
172
172
  Returns:
173
173
  True if steps can execute consecutively
174
174
  """
175
- # Get all dependencies of step2
176
- step2_deps = dag.get_all_dependencies(step2.name)
175
+ # Get ALL transitively producing and consuming nodes between step1 and step2
176
+ # Steps are consecutive if there are no intermediate steps NOT in this group
177
+ # that must execute between step1 and step2.
178
+ all_deps_of_s2 = dag.get_all_dependencies(step2.name)
177
179
 
178
- # If step2 doesn't depend on anything in the group, they can be consecutive
179
- # (parallel steps in same group are OK if no dependencies)
180
- group_deps = step2_deps & group_step_names
181
- if not group_deps:
182
- # No dependencies from this group, consecutive is OK
183
- return True
180
+ # If step1 is not even a dependency of step2, they are independent.
181
+ # They can be grouped as long as there is no path from step1 to step2
182
+ # through an external step.
184
183
 
185
- # If step2 depends on step1, check for intermediate group steps
186
- if step1.name in step2_deps:
187
- # Get all group steps that step2 depends on (excluding step1)
188
- intermediate = group_deps - {step1.name}
184
+ # All nodes on any path from step1 to step2:
185
+ all_successors_of_s1 = dag.get_all_dependents(step1.name)
186
+ intermediate_nodes = all_successors_of_s1 & all_deps_of_s2
189
187
 
190
- # If there are NO intermediate group steps, they're consecutive
191
- return len(intermediate) == 0
188
+ # If any node on a path from s1 to s2 is NOT in the group, they are not consecutive
189
+ external_intermediates = intermediate_nodes - group_step_names
192
190
 
193
- # step2 doesn't depend on step1, not consecutive
194
- return False
191
+ return len(external_intermediates) == 0
195
192
 
196
193
  def _get_execution_order(self, steps: list[Step], dag: DAG) -> list[str]:
197
194
  """Get topological execution order for steps in a group.
@@ -264,29 +261,46 @@ def get_execution_units(dag: DAG, steps: list[Step]) -> list[Step | StepGroup]:
264
261
  for step in group.steps:
265
262
  step_to_group[step.name] = group
266
263
 
267
- # Get topological order of all nodes
268
- all_nodes = dag.topological_sort()
264
+ # To correctly determine execution order of units (which may have changed due to grouping),
265
+ # we build a new DAG where each node is an execution unit (Step or StepGroup).
266
+ from flowyml.core.graph import Node as DAGNode
269
267
 
270
- # Build execution units, avoiding duplicates for grouped steps
271
- execution_units: list[Step | StepGroup] = []
272
- processed_groups: set[str] = set()
268
+ units_dag = DAG()
269
+ unit_map: dict[str, Step | StepGroup] = {}
273
270
 
274
- for node in all_nodes:
275
- # Find the step object
276
- step = next((s for s in steps if s.name == node.name), None)
277
- if not step:
271
+ # Add units as nodes
272
+ processed_steps = set()
273
+ for step in steps:
274
+ if step.name in processed_steps:
278
275
  continue
279
276
 
280
- # Check if this step belongs to a group
277
+ unit: Step | StepGroup
281
278
  if step.name in step_to_group:
282
- group = step_to_group[step.name]
283
-
284
- # Only add the group once (when we encounter its first step)
285
- if group.group_name not in processed_groups:
286
- execution_units.append(group)
287
- processed_groups.add(group.group_name)
279
+ unit = step_to_group[step.name]
280
+ unit_name = f"group:{unit.group_name}"
281
+ # Extract names for inputs/outputs
282
+ u_inputs_set = set()
283
+ u_outputs_set = set()
284
+ for s in unit.steps:
285
+ u_inputs_set.update(s.inputs)
286
+ u_outputs_set.update(s.outputs)
287
+ processed_steps.add(s.name)
288
+
289
+ # External inputs are those not produced within the group
290
+ u_inputs = list(u_inputs_set - u_outputs_set)
291
+ u_outputs = list(u_outputs_set)
288
292
  else:
289
- # Ungrouped step, add as-is
290
- execution_units.append(step)
293
+ unit = step
294
+ unit_name = step.name
295
+ u_inputs = step.inputs
296
+ u_outputs = step.outputs
297
+ processed_steps.add(step.name)
298
+
299
+ unit_map[unit_name] = unit
300
+ units_dag.add_node(DAGNode(name=unit_name, step=unit, inputs=u_inputs, outputs=u_outputs))
301
+
302
+ # Build edges and sort
303
+ units_dag.build_edges()
304
+ sorted_unit_nodes = units_dag.topological_sort()
291
305
 
292
- return execution_units
306
+ return [unit_map[node.name] for node in sorted_unit_nodes]