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.
- flowyml/assets/dataset.py +570 -17
- flowyml/assets/model.py +1052 -15
- flowyml/core/executor.py +70 -11
- flowyml/core/orchestrator.py +37 -2
- flowyml/core/pipeline.py +32 -4
- flowyml/core/scheduler.py +88 -5
- flowyml/integrations/keras.py +247 -82
- flowyml/storage/sql.py +24 -6
- flowyml/ui/backend/routers/runs.py +112 -0
- flowyml/ui/backend/routers/schedules.py +35 -15
- flowyml/ui/frontend/dist/assets/index-B40RsQDq.css +1 -0
- flowyml/ui/frontend/dist/assets/index-CjI0zKCn.js +685 -0
- flowyml/ui/frontend/dist/index.html +2 -2
- flowyml/ui/frontend/package-lock.json +11 -0
- flowyml/ui/frontend/package.json +1 -0
- flowyml/ui/frontend/src/app/assets/page.jsx +890 -321
- flowyml/ui/frontend/src/app/dashboard/page.jsx +1 -1
- flowyml/ui/frontend/src/app/experiments/[experimentId]/page.jsx +1 -1
- flowyml/ui/frontend/src/app/leaderboard/page.jsx +1 -1
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectMetricsPanel.jsx +1 -1
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRunsList.jsx +3 -3
- flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +590 -102
- flowyml/ui/frontend/src/components/ArtifactViewer.jsx +62 -2
- flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +401 -28
- flowyml/ui/frontend/src/components/AssetTreeHierarchy.jsx +119 -11
- flowyml/ui/frontend/src/components/DatasetViewer.jsx +753 -0
- flowyml/ui/frontend/src/components/TrainingHistoryChart.jsx +514 -0
- flowyml/ui/frontend/src/components/TrainingMetricsPanel.jsx +175 -0
- {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/METADATA +1 -1
- {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/RECORD +33 -30
- flowyml/ui/frontend/dist/assets/index-By4trVyv.css +0 -1
- flowyml/ui/frontend/dist/assets/index-CX5RV2C9.js +0 -630
- {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/WHEEL +0 -0
- {flowyml-1.7.0.dist-info → flowyml-1.7.2.dist-info}/entry_points.txt +0 -0
- {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":
|
|
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{
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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:
|
flowyml/core/orchestrator.py
CHANGED
|
@@ -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":
|
|
1002
|
+
"value": data_value if isinstance(data_value, str) else None,
|
|
983
1003
|
"created_at": datetime.now().isoformat(),
|
|
984
|
-
"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
|
-
|
|
540
|
-
|
|
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."""
|