flowyml 1.7.0__py3-none-any.whl → 1.7.2__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 (35) hide show
  1. flowyml/assets/dataset.py +570 -17
  2. flowyml/assets/model.py +1052 -15
  3. flowyml/core/executor.py +70 -11
  4. flowyml/core/orchestrator.py +37 -2
  5. flowyml/core/pipeline.py +32 -4
  6. flowyml/core/scheduler.py +88 -5
  7. flowyml/integrations/keras.py +247 -82
  8. flowyml/storage/sql.py +24 -6
  9. flowyml/ui/backend/routers/runs.py +112 -0
  10. flowyml/ui/backend/routers/schedules.py +35 -15
  11. flowyml/ui/frontend/dist/assets/index-B40RsQDq.css +1 -0
  12. flowyml/ui/frontend/dist/assets/index-CjI0zKCn.js +685 -0
  13. flowyml/ui/frontend/dist/index.html +2 -2
  14. flowyml/ui/frontend/package-lock.json +11 -0
  15. flowyml/ui/frontend/package.json +1 -0
  16. flowyml/ui/frontend/src/app/assets/page.jsx +890 -321
  17. flowyml/ui/frontend/src/app/dashboard/page.jsx +1 -1
  18. flowyml/ui/frontend/src/app/experiments/[experimentId]/page.jsx +1 -1
  19. flowyml/ui/frontend/src/app/leaderboard/page.jsx +1 -1
  20. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectMetricsPanel.jsx +1 -1
  21. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRunsList.jsx +3 -3
  22. flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +590 -102
  23. flowyml/ui/frontend/src/components/ArtifactViewer.jsx +62 -2
  24. flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +401 -28
  25. flowyml/ui/frontend/src/components/AssetTreeHierarchy.jsx +119 -11
  26. flowyml/ui/frontend/src/components/DatasetViewer.jsx +753 -0
  27. flowyml/ui/frontend/src/components/TrainingHistoryChart.jsx +514 -0
  28. flowyml/ui/frontend/src/components/TrainingMetricsPanel.jsx +175 -0
  29. {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/METADATA +1 -1
  30. {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/RECORD +33 -30
  31. flowyml/ui/frontend/dist/assets/index-By4trVyv.css +0 -1
  32. flowyml/ui/frontend/dist/assets/index-CX5RV2C9.js +0 -630
  33. {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/WHEEL +0 -0
  34. {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/entry_points.txt +0 -0
  35. {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/licenses/LICENSE +0 -0
flowyml/core/executor.py CHANGED
@@ -87,10 +87,16 @@ class MonitorThread(threading.Thread):
87
87
  # Fallback to environment variable or default
88
88
  self.api_url = os.getenv("FLOWYML_SERVER_URL", "http://localhost:8080")
89
89
 
90
- def stop(self):
90
+ def stop(self, error: str | None = None):
91
+ """Stop the monitor thread.
92
+
93
+ Args:
94
+ error: Optional error message to send as final log entry
95
+ """
96
+ self._final_error = error
91
97
  self._stop_event.set()
92
98
 
93
- def _flush_logs(self):
99
+ def _flush_logs(self, level: str = "INFO"):
94
100
  """Send captured logs to the server."""
95
101
  if not self.log_capture:
96
102
  return
@@ -105,7 +111,20 @@ class MonitorThread(threading.Thread):
105
111
  f"{self.api_url}/api/runs/{self.run_id}/steps/{self.step_name}/logs",
106
112
  json={
107
113
  "content": content,
108
- "level": "INFO",
114
+ "level": level,
115
+ "timestamp": datetime.now().isoformat(),
116
+ },
117
+ timeout=2,
118
+ )
119
+
120
+ def _send_error(self, error: str):
121
+ """Send error message to the server."""
122
+ with contextlib.suppress(Exception):
123
+ requests.post(
124
+ f"{self.api_url}/api/runs/{self.run_id}/steps/{self.step_name}/logs",
125
+ json={
126
+ "content": f"ERROR: {error}",
127
+ "level": "ERROR",
109
128
  "timestamp": datetime.now().isoformat(),
110
129
  },
111
130
  timeout=2,
@@ -137,6 +156,10 @@ class MonitorThread(threading.Thread):
137
156
  # Final log flush
138
157
  self._flush_logs()
139
158
 
159
+ # Send error if there was one
160
+ if hasattr(self, "_final_error") and self._final_error:
161
+ self._send_error(self._final_error)
162
+
140
163
 
141
164
  # Keep HeartbeatThread as an alias for backwards compatibility
142
165
  HeartbeatThread = MonitorThread
@@ -335,8 +358,8 @@ class LocalExecutor(Executor):
335
358
 
336
359
  sys.stderr = original_stderr
337
360
 
338
- # Stop monitor thread
339
- if monitor_thread:
361
+ # Stop monitor thread (only if not already stopped in exception handler)
362
+ if monitor_thread and not monitor_thread._stop_event.is_set():
340
363
  monitor_thread.stop()
341
364
  monitor_thread.join()
342
365
 
@@ -374,6 +397,7 @@ class LocalExecutor(Executor):
374
397
 
375
398
  except Exception as e:
376
399
  last_error = str(e)
400
+ error_traceback = traceback.format_exc()
377
401
  retries += 1
378
402
 
379
403
  if attempt < max_retries:
@@ -382,12 +406,17 @@ class LocalExecutor(Executor):
382
406
  time.sleep(wait_time)
383
407
  continue
384
408
 
385
- # All retries exhausted
409
+ # All retries exhausted - send error to logs
410
+ if monitor_thread:
411
+ monitor_thread.stop(error=f"{last_error}\n{error_traceback}")
412
+ monitor_thread.join()
413
+ monitor_thread = None # Prevent double-stop in finally
414
+
386
415
  duration = time.time() - start_time
387
416
  return ExecutionResult(
388
417
  step_name=step.name,
389
418
  success=False,
390
- error=f"{last_error}\n{traceback.format_exc()}",
419
+ error=f"{last_error}\n{error_traceback}",
391
420
  duration_seconds=duration,
392
421
  retries=retries,
393
422
  )
@@ -438,11 +467,41 @@ class LocalExecutor(Executor):
438
467
  # Find the step object
439
468
  step = next(s for s in step_group.steps if s.name == step_name)
440
469
 
441
- # Prepare inputs for this step
470
+ # Prepare inputs for this step - map input names to function parameters
442
471
  step_inputs = {}
443
- for input_name in step.inputs:
444
- if input_name in step_outputs:
445
- step_inputs[input_name] = step_outputs[input_name]
472
+
473
+ # Get function signature to properly map inputs to parameters
474
+ sig = inspect.signature(step.func)
475
+ params = list(sig.parameters.values())
476
+ # Filter out self/cls
477
+ params = [p for p in params if p.name not in ("self", "cls")]
478
+ assigned_params = set()
479
+
480
+ if step.inputs:
481
+ for i, input_name in enumerate(step.inputs):
482
+ if input_name not in step_outputs:
483
+ continue
484
+
485
+ val = step_outputs[input_name]
486
+
487
+ # Check if input name matches a parameter directly
488
+ param_match = next((p for p in params if p.name == input_name), None)
489
+
490
+ if param_match:
491
+ step_inputs[param_match.name] = val
492
+ assigned_params.add(param_match.name)
493
+ elif i < len(params):
494
+ # Positional fallback - use the parameter at the same position
495
+ target_param = params[i]
496
+ if target_param.name not in assigned_params:
497
+ step_inputs[target_param.name] = val
498
+ assigned_params.add(target_param.name)
499
+
500
+ # Auto-map parameters from available outputs by name
501
+ for param in params:
502
+ if param.name in step_outputs and param.name not in step_inputs:
503
+ step_inputs[param.name] = step_outputs[param.name]
504
+ assigned_params.add(param.name)
446
505
 
447
506
  # Inject context parameters for this specific step
448
507
  if context is not None:
@@ -390,6 +390,7 @@ class LocalOrchestrator(Orchestrator):
390
390
  """Context object for conditional evaluation.
391
391
 
392
392
  Provides access to step outputs via ctx.steps['step_name'].outputs['output_name']
393
+ and context parameters via ctx.params
393
394
  """
394
395
 
395
396
  def __init__(self, result: "PipelineResult", pipeline: "Pipeline"):
@@ -397,6 +398,13 @@ class LocalOrchestrator(Orchestrator):
397
398
  self.pipeline = pipeline
398
399
  self._steps_cache = None
399
400
 
401
+ @property
402
+ def params(self):
403
+ """Get pipeline context parameters as a dictionary."""
404
+ if self.pipeline.context:
405
+ return self.pipeline.context._params
406
+ return {}
407
+
400
408
  @property
401
409
  def steps(self):
402
410
  """Lazy-load steps dictionary with outputs."""
@@ -627,6 +635,12 @@ class LocalOrchestrator(Orchestrator):
627
635
  if step_obj.name not in result.step_results:
628
636
  # Execute the selected step
629
637
  # The check above prevents re-execution of the same step
638
+ # If step has inputs defined, copy them to the step object for proper input mapping
639
+ if hasattr(selected_step, "_step_inputs") and selected_step._step_inputs:
640
+ step_obj.inputs = selected_step._step_inputs
641
+ elif hasattr(selected_step, "inputs"):
642
+ step_obj.inputs = selected_step.inputs or []
643
+
630
644
  self._execute_conditional_step(
631
645
  pipeline,
632
646
  step_obj,
@@ -659,10 +673,31 @@ class LocalOrchestrator(Orchestrator):
659
673
  step_inputs = {}
660
674
  sig = inspect.signature(step.func)
661
675
  params = [p for p in sig.parameters.values() if p.name not in ("self", "cls")]
662
-
676
+ assigned_params = set()
677
+
678
+ # First, try to map from declared inputs (like "model/trained" -> function param)
679
+ if step.inputs:
680
+ for i, input_name in enumerate(step.inputs):
681
+ if input_name not in step_outputs:
682
+ continue
683
+ val = step_outputs[input_name]
684
+ # Try to match input name directly to a parameter
685
+ param_match = next((p for p in params if p.name == input_name), None)
686
+ if param_match:
687
+ step_inputs[param_match.name] = val
688
+ assigned_params.add(param_match.name)
689
+ elif i < len(params):
690
+ # Positional fallback - use parameter at same position
691
+ target_param = params[i]
692
+ if target_param.name not in assigned_params:
693
+ step_inputs[target_param.name] = val
694
+ assigned_params.add(target_param.name)
695
+
696
+ # Then, try direct parameter name matching from step_outputs
663
697
  for param in params:
664
- if param.name in step_outputs:
698
+ if param.name not in assigned_params and param.name in step_outputs:
665
699
  step_inputs[param.name] = step_outputs[param.name]
700
+ assigned_params.add(param.name)
666
701
 
667
702
  # Get context parameters
668
703
  context_params = pipeline.context.inject_params(step.func)
flowyml/core/pipeline.py CHANGED
@@ -972,6 +972,26 @@ class Pipeline:
972
972
  if is_asset:
973
973
  # Handle flowyml Asset
974
974
  asset_type = value.__class__.__name__
975
+ # Get properties
976
+ props = (
977
+ self._sanitize_for_json(value.metadata.properties)
978
+ if hasattr(value.metadata, "properties")
979
+ else {}
980
+ )
981
+
982
+ # For Dataset assets, include the full data for visualization
983
+ # This enables histograms and statistics in the UI
984
+ data_value = None
985
+ if asset_type == "Dataset" and value.data:
986
+ try:
987
+ # Store full data as JSON-serializable dict
988
+ data_value = self._sanitize_for_json(value.data)
989
+ props["_full_data"] = data_value
990
+ except Exception:
991
+ data_value = str(value.data)[:1000]
992
+ else:
993
+ data_value = str(value.data)[:1000] if value.data else None
994
+
975
995
  artifact_metadata = {
976
996
  "artifact_id": artifact_id,
977
997
  "name": value.name,
@@ -979,12 +999,20 @@ class Pipeline:
979
999
  "run_id": result.run_id,
980
1000
  "step": step_name,
981
1001
  "path": None,
982
- "value": str(value.data)[:1000] if value.data else None,
1002
+ "value": data_value if isinstance(data_value, str) else None,
983
1003
  "created_at": datetime.now().isoformat(),
984
- "properties": self._sanitize_for_json(value.metadata.properties)
985
- if hasattr(value.metadata, "properties")
986
- else {},
1004
+ "properties": props,
987
1005
  }
1006
+
1007
+ # For Dataset, also include the data directly in the artifact
1008
+ if asset_type == "Dataset" and isinstance(data_value, dict):
1009
+ artifact_metadata["data"] = data_value
1010
+
1011
+ # Include training_history if present (for Model assets with Keras training)
1012
+ # This enables interactive training charts in the UI
1013
+ if hasattr(value, "training_history") and value.training_history:
1014
+ artifact_metadata["training_history"] = value.training_history
1015
+
988
1016
  self.metadata_store.save_artifact(artifact_id, artifact_metadata)
989
1017
 
990
1018
  # Special handling for Metrics asset
flowyml/core/scheduler.py CHANGED
@@ -224,6 +224,36 @@ class SchedulerPersistence:
224
224
  logger.error(f"Failed to load schedule {name}: {e}")
225
225
  return schedules
226
226
 
227
+ def load_schedule(self, name: str) -> Schedule | None:
228
+ """Load a single schedule from database by name.
229
+
230
+ Returns None if not found. Creates a Schedule without a pipeline_func
231
+ (the schedule will be enabled/disabled but won't actually run until
232
+ a pipeline function is registered).
233
+ """
234
+ with self.engine.connect() as conn:
235
+ stmt = select(self.schedules.c.name, self.schedules.c.data).where(
236
+ self.schedules.c.name == name,
237
+ )
238
+ result = conn.execute(stmt)
239
+ row = result.fetchone()
240
+ if row:
241
+ try:
242
+ data = json.loads(row.data)
243
+ # Create a minimal Schedule for enable/disable operations
244
+ # without requiring the pipeline function
245
+ return Schedule(
246
+ pipeline_name=data.get("pipeline_name", name),
247
+ pipeline_func=lambda: None, # Placeholder - not for execution
248
+ schedule_type=data.get("schedule_type", ""),
249
+ schedule_value=data.get("schedule_value", ""),
250
+ timezone=data.get("timezone", "UTC"),
251
+ enabled=data.get("enabled", True),
252
+ )
253
+ except Exception as e:
254
+ logger.error(f"Failed to load schedule {name}: {e}")
255
+ return None
256
+
227
257
  def delete_schedule(self, name: str) -> None:
228
258
  """Delete schedule from database using SQLAlchemy."""
229
259
  with self.engine.connect() as conn:
@@ -251,6 +281,29 @@ class SchedulerPersistence:
251
281
  conn.execute(stmt)
252
282
  conn.commit()
253
283
 
284
+ def list_all_schedules(self) -> list[dict[str, Any]]:
285
+ """List all schedules from database without requiring pipeline functions.
286
+
287
+ This is useful for displaying schedules in the UI regardless of whether
288
+ the pipeline code is loaded.
289
+ """
290
+ schedules = []
291
+ with self.engine.connect() as conn:
292
+ stmt = select(self.schedules.c.name, self.schedules.c.data, self.schedules.c.updated_at)
293
+ result = conn.execute(stmt)
294
+ for row in result:
295
+ try:
296
+ data = json.loads(row.data)
297
+ data["name"] = row.name
298
+ if row.updated_at:
299
+ data["updated_at"] = (
300
+ row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else str(row.updated_at)
301
+ )
302
+ schedules.append(data)
303
+ except Exception as e:
304
+ logger.error(f"Failed to parse schedule {row.name}: {e}")
305
+ return schedules
306
+
254
307
  def get_history(self, schedule_name: str, limit: int = 50) -> list[dict[str, Any]]:
255
308
  """Get execution history for a schedule using SQLAlchemy."""
256
309
  history = []
@@ -533,11 +586,17 @@ class PipelineScheduler:
533
586
  return schedule
534
587
 
535
588
  def unschedule(self, name: str) -> None:
536
- """Remove a scheduled pipeline."""
589
+ """Remove a scheduled pipeline.
590
+
591
+ Handles both in-memory schedules and persisted schedules.
592
+ """
593
+ # Remove from in-memory schedules if present
537
594
  if name in self.schedules:
538
595
  del self.schedules[name]
539
- if self._persistence:
540
- self._persistence.delete_schedule(name)
596
+
597
+ # Always try to remove from persistence (handles schedules created by other processes)
598
+ if self._persistence:
599
+ self._persistence.delete_schedule(name)
541
600
 
542
601
  def clear(self) -> None:
543
602
  """Remove all schedules."""
@@ -550,18 +609,42 @@ class PipelineScheduler:
550
609
  conn.commit()
551
610
 
552
611
  def enable(self, name: str) -> None:
553
- """Enable a schedule."""
612
+ """Enable a schedule.
613
+
614
+ Handles both in-memory schedules and persisted schedules.
615
+ """
554
616
  if name in self.schedules:
555
617
  self.schedules[name].enabled = True
556
618
  if self._persistence:
557
619
  self._persistence.save_schedule(self.schedules[name])
620
+ elif self._persistence:
621
+ # Schedule might be in persistence but not loaded in memory
622
+ # Load it, update, and save back
623
+ schedule = self._persistence.load_schedule(name)
624
+ if schedule:
625
+ schedule.enabled = True
626
+ self._persistence.save_schedule(schedule)
627
+ # Also add to in-memory schedules
628
+ self.schedules[name] = schedule
558
629
 
559
630
  def disable(self, name: str) -> None:
560
- """Disable a schedule."""
631
+ """Disable a schedule.
632
+
633
+ Handles both in-memory schedules and persisted schedules.
634
+ """
561
635
  if name in self.schedules:
562
636
  self.schedules[name].enabled = False
563
637
  if self._persistence:
564
638
  self._persistence.save_schedule(self.schedules[name])
639
+ elif self._persistence:
640
+ # Schedule might be in persistence but not loaded in memory
641
+ # Load it, update, and save back
642
+ schedule = self._persistence.load_schedule(name)
643
+ if schedule:
644
+ schedule.enabled = False
645
+ self._persistence.save_schedule(schedule)
646
+ # Also add to in-memory schedules
647
+ self.schedules[name] = schedule
565
648
 
566
649
  def _run_pipeline(self, schedule: Schedule) -> None:
567
650
  """Run a scheduled pipeline."""