dataeval-flow 0.1.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 (94) hide show
  1. dataeval_flow/__init__.py +93 -0
  2. dataeval_flow/__main__.py +149 -0
  3. dataeval_flow/_app/__init__.py +5 -0
  4. dataeval_flow/_app/_model/__init__.py +5 -0
  5. dataeval_flow/_app/_model/_coerce.py +126 -0
  6. dataeval_flow/_app/_model/_discover.py +171 -0
  7. dataeval_flow/_app/_model/_execution.py +108 -0
  8. dataeval_flow/_app/_model/_introspect.py +280 -0
  9. dataeval_flow/_app/_model/_item.py +213 -0
  10. dataeval_flow/_app/_model/_registry.py +255 -0
  11. dataeval_flow/_app/_model/_state.py +322 -0
  12. dataeval_flow/_app/_model/_undo.py +61 -0
  13. dataeval_flow/_app/_panes/__init__.py +35 -0
  14. dataeval_flow/_app/_panes/_config_pane.py +173 -0
  15. dataeval_flow/_app/_panes/_result_pane.py +125 -0
  16. dataeval_flow/_app/_panes/_task_pane.py +91 -0
  17. dataeval_flow/_app/_panes/_widgets.py +111 -0
  18. dataeval_flow/_app/_screens/__init__.py +25 -0
  19. dataeval_flow/_app/_screens/_base.py +242 -0
  20. dataeval_flow/_app/_screens/_detail.py +333 -0
  21. dataeval_flow/_app/_screens/_model.py +102 -0
  22. dataeval_flow/_app/_screens/_params.py +80 -0
  23. dataeval_flow/_app/_screens/_pathpicker.py +68 -0
  24. dataeval_flow/_app/_screens/_section.py +621 -0
  25. dataeval_flow/_app/_screens/_settings.py +183 -0
  26. dataeval_flow/_app/_viewmodel/__init__.py +15 -0
  27. dataeval_flow/_app/_viewmodel/_builder_vm.py +272 -0
  28. dataeval_flow/_app/_viewmodel/_model_vm.py +70 -0
  29. dataeval_flow/_app/_viewmodel/_rendering.py +189 -0
  30. dataeval_flow/_app/_viewmodel/_result_vm.py +210 -0
  31. dataeval_flow/_app/_viewmodel/_section_vm.py +224 -0
  32. dataeval_flow/_app/app.py +742 -0
  33. dataeval_flow/_app/cli.py +592 -0
  34. dataeval_flow/_logging.py +102 -0
  35. dataeval_flow/cache.py +1355 -0
  36. dataeval_flow/config/__init__.py +80 -0
  37. dataeval_flow/config/_loader.py +79 -0
  38. dataeval_flow/config/_merge.py +92 -0
  39. dataeval_flow/config/_models.py +115 -0
  40. dataeval_flow/config/_paths.py +85 -0
  41. dataeval_flow/config/schemas/__init__.py +112 -0
  42. dataeval_flow/config/schemas/_dataset.py +111 -0
  43. dataeval_flow/config/schemas/_extractor.py +119 -0
  44. dataeval_flow/config/schemas/_metadata.py +28 -0
  45. dataeval_flow/config/schemas/_preprocessor.py +18 -0
  46. dataeval_flow/config/schemas/_selection.py +100 -0
  47. dataeval_flow/config/schemas/_task.py +89 -0
  48. dataeval_flow/config/schemas/_workflow.py +135 -0
  49. dataeval_flow/dataset.py +635 -0
  50. dataeval_flow/embeddings.py +135 -0
  51. dataeval_flow/metadata.py +48 -0
  52. dataeval_flow/preprocessing.py +141 -0
  53. dataeval_flow/py.typed +0 -0
  54. dataeval_flow/runner.py +118 -0
  55. dataeval_flow/selection.py +50 -0
  56. dataeval_flow/workflow/__init__.py +328 -0
  57. dataeval_flow/workflow/_text_report.py +511 -0
  58. dataeval_flow/workflow/base.py +69 -0
  59. dataeval_flow/workflow/orchestrator.py +454 -0
  60. dataeval_flow/workflows/__init__.py +1 -0
  61. dataeval_flow/workflows/analysis/__init__.py +38 -0
  62. dataeval_flow/workflows/analysis/outputs.py +202 -0
  63. dataeval_flow/workflows/analysis/params.py +114 -0
  64. dataeval_flow/workflows/analysis/workflow.py +1313 -0
  65. dataeval_flow/workflows/cleaning/__init__.py +23 -0
  66. dataeval_flow/workflows/cleaning/outputs.py +200 -0
  67. dataeval_flow/workflows/cleaning/params.py +160 -0
  68. dataeval_flow/workflows/cleaning/report.py +304 -0
  69. dataeval_flow/workflows/cleaning/workflow.py +794 -0
  70. dataeval_flow/workflows/drift/__init__.py +1 -0
  71. dataeval_flow/workflows/drift/outputs.py +144 -0
  72. dataeval_flow/workflows/drift/params.py +332 -0
  73. dataeval_flow/workflows/drift/report.py +201 -0
  74. dataeval_flow/workflows/drift/workflow.py +647 -0
  75. dataeval_flow/workflows/ood/__init__.py +1 -0
  76. dataeval_flow/workflows/ood/outputs.py +134 -0
  77. dataeval_flow/workflows/ood/params.py +161 -0
  78. dataeval_flow/workflows/ood/report.py +311 -0
  79. dataeval_flow/workflows/ood/workflow.py +728 -0
  80. dataeval_flow/workflows/prioritization/__init__.py +1 -0
  81. dataeval_flow/workflows/prioritization/outputs.py +122 -0
  82. dataeval_flow/workflows/prioritization/params.py +124 -0
  83. dataeval_flow/workflows/prioritization/report.py +117 -0
  84. dataeval_flow/workflows/prioritization/workflow.py +587 -0
  85. dataeval_flow/workflows/splitting/__init__.py +25 -0
  86. dataeval_flow/workflows/splitting/outputs.py +101 -0
  87. dataeval_flow/workflows/splitting/params.py +61 -0
  88. dataeval_flow/workflows/splitting/report.py +485 -0
  89. dataeval_flow/workflows/splitting/workflow.py +371 -0
  90. dataeval_flow-0.1.0.dist-info/METADATA +305 -0
  91. dataeval_flow-0.1.0.dist-info/RECORD +94 -0
  92. dataeval_flow-0.1.0.dist-info/WHEEL +4 -0
  93. dataeval_flow-0.1.0.dist-info/entry_points.txt +2 -0
  94. dataeval_flow-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,742 @@
1
+ """Three-pane dashboard application for DataEval Flow.
2
+
3
+ Layout:
4
+ config sidebar (left) | task pane (center-top) / result pane (center-bottom)
5
+
6
+ Config editing uses the same ``SectionModal`` as before. Task execution
7
+ and result viewing are layered on in subsequent phases.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import logging
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from textual.app import App, ComposeResult, ScreenStackError
18
+ from textual.binding import Binding
19
+ from textual.containers import Horizontal, Vertical, VerticalScroll
20
+ from textual.css.query import NoMatches
21
+ from textual.screen import ModalScreen
22
+ from textual.theme import Theme
23
+ from textual.widgets import Footer, Header, Static
24
+
25
+ from dataeval_flow._app._panes import (
26
+ PANE_IDS,
27
+ CfgItem,
28
+ CfgSectionHeader,
29
+ ConfigPaneMixin,
30
+ PaneWidget,
31
+ ResultCard,
32
+ ResultPaneMixin,
33
+ TaskCard,
34
+ TaskPaneHeader,
35
+ TaskPaneMixin,
36
+ )
37
+ from dataeval_flow._app._screens import ExecutionSettings, PathPickerScreen, SectionModal, SettingsModal
38
+ from dataeval_flow._app._viewmodel._builder_vm import BuilderViewModel
39
+ from dataeval_flow._app._viewmodel._rendering import snippet_task_with_execution
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # CSS
43
+ # ---------------------------------------------------------------------------
44
+
45
+ CSS = """
46
+ #dashboard {
47
+ height: 1fr;
48
+ }
49
+
50
+ /* --- Config sidebar --- */
51
+ #config-pane {
52
+ width: 36;
53
+ min-width: 28;
54
+ border-right: tall $surface-lighten-2;
55
+ }
56
+
57
+ .cfg-section-header {
58
+ height: 3;
59
+ padding: 0 1;
60
+ margin: 1 0 0 0;
61
+ background: $boost;
62
+ content-align: left middle;
63
+ text-style: bold;
64
+ }
65
+ .cfg-section-header:focus {
66
+ background: $accent 20%;
67
+ color: $text;
68
+ border-left: tall $accent;
69
+ }
70
+
71
+ .cfg-item {
72
+ padding: 0 1 0 2;
73
+ height: auto;
74
+ background: $surface;
75
+ }
76
+ .cfg-item:focus {
77
+ background: $accent 12%;
78
+ border-left: tall $accent;
79
+ }
80
+
81
+ /* --- Main (right) area --- */
82
+ #main-pane {
83
+ width: 1fr;
84
+ }
85
+
86
+ /* Task pane (center-top) */
87
+ #task-pane {
88
+ height: 1fr;
89
+ min-height: 5;
90
+ border-bottom: tall $surface-lighten-2;
91
+ }
92
+
93
+ .task-pane-header {
94
+ height: 3;
95
+ padding: 0 1;
96
+ margin: 1 0 0 0;
97
+ background: $boost;
98
+ content-align: left middle;
99
+ text-style: bold;
100
+ }
101
+ .task-pane-header:focus {
102
+ background: $accent 20%;
103
+ color: $text;
104
+ border-left: tall $accent;
105
+ }
106
+
107
+ .task-card {
108
+ padding: 0 1 0 2;
109
+ height: auto;
110
+ background: $surface;
111
+ }
112
+ .task-card:focus {
113
+ background: $accent 12%;
114
+ border-left: tall $accent;
115
+ }
116
+
117
+ /* Result pane (center-bottom) */
118
+ #result-pane {
119
+ height: 2fr;
120
+ min-height: 5;
121
+ }
122
+
123
+ .result-pane-header {
124
+ height: 3;
125
+ padding: 0 1;
126
+ background: $boost;
127
+ content-align: left middle;
128
+ text-style: bold;
129
+ }
130
+
131
+ .result-card {
132
+ padding: 0 1 0 2;
133
+ height: auto;
134
+ background: $surface;
135
+ }
136
+ .result-card:focus {
137
+ background: $accent 12%;
138
+ border-left: tall $accent;
139
+ }
140
+ """
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Loading screen
145
+ # ---------------------------------------------------------------------------
146
+
147
+
148
+ class LoadingScreen(ModalScreen[None]):
149
+ CSS = """
150
+ LoadingScreen { align: center middle; }
151
+ #loading-dialog {
152
+ width: 44; height: 5; border: round $accent 40%;
153
+ background: $surface; padding: 1 2;
154
+ content-align: center middle; text-align: center;
155
+ }
156
+ """
157
+
158
+ def compose(self) -> ComposeResult:
159
+ with Vertical(id="loading-dialog"):
160
+ yield Static("[dim]Loading...[/dim]", id="loading-message")
161
+
162
+ def update_message(self, msg: str) -> None:
163
+ with contextlib.suppress(NoMatches):
164
+ self.query_one("#loading-message", Static).update(f"[dim]{msg}[/dim]")
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # App
169
+ # ---------------------------------------------------------------------------
170
+
171
+
172
+ _FLOW_DARK = Theme(
173
+ name="flow-dark",
174
+ primary="#0178D4",
175
+ secondary="#004578",
176
+ accent="#04B5D4",
177
+ warning="#ffa62b",
178
+ error="#ba3c5b",
179
+ success="#4EBF71",
180
+ foreground="#e0e0e0",
181
+ dark=True,
182
+ )
183
+
184
+
185
+ class FlowApp(ConfigPaneMixin, TaskPaneMixin, ResultPaneMixin, App):
186
+ """DataEval Flow interactive dashboard."""
187
+
188
+ TITLE = "DataEval Flow Dashboard"
189
+ CSS = CSS
190
+ BINDINGS = [
191
+ ("q", "quit", "Quit"),
192
+ ("escape", "blur"),
193
+ ("a", "add_item", "Add"),
194
+ ("r", "run", "Run"),
195
+ ("ctrl+n", "new_config", "New"),
196
+ ("ctrl+o", "open_config", "Open"),
197
+ ("ctrl+s", "save_config", "Save"),
198
+ ("ctrl+e", "export_results", "Export"),
199
+ ("ctrl+z", "undo", "Undo"),
200
+ ("ctrl+y", "redo", "Redo"),
201
+ ("f10", "open_settings", "Settings"),
202
+ ("f12", "save_config_as", "SaveAs"),
203
+ Binding("enter", "activate_item", "Open", show=False),
204
+ Binding("space", "space_item", "Toggle", show=False),
205
+ Binding("delete", "delete_item", "Del", show=False),
206
+ ]
207
+
208
+ def __init__(
209
+ self,
210
+ config_path: str | Path | None = None,
211
+ data_dir: str | Path | None = None,
212
+ cache_dir: str | Path | None = None,
213
+ **kwargs: Any,
214
+ ) -> None:
215
+ super().__init__(**kwargs)
216
+ self.register_theme(_FLOW_DARK)
217
+ self.theme = "flow-dark"
218
+ self._initial_config_path = Path(config_path) if config_path else None
219
+ self._vm = BuilderViewModel(config_path)
220
+ self._settings = ExecutionSettings(
221
+ data_dir=str(data_dir) if data_dir else "",
222
+ cache_dir=str(cache_dir) if cache_dir else "",
223
+ )
224
+ self._editing_category: str = ""
225
+ self._editing_index: int = -1
226
+
227
+ @property
228
+ def _data_dir(self) -> Path | None:
229
+ return Path(self._settings.data_dir) if self._settings.data_dir else None
230
+
231
+ @property
232
+ def _cache_dir(self) -> Path | None:
233
+ return Path(self._settings.cache_dir) if self._settings.cache_dir else None
234
+
235
+ @property
236
+ def _output_dir(self) -> Path | None:
237
+ return Path(self._settings.output_dir) if self._settings.output_dir else None
238
+
239
+ # ==================================================================
240
+ # Compose
241
+ # ==================================================================
242
+
243
+ def compose(self) -> ComposeResult:
244
+ yield Header(show_clock=True)
245
+ with Horizontal(id="dashboard"):
246
+ with VerticalScroll(id="config-pane"):
247
+ yield Static("[dim]Loading...[/dim]")
248
+ with Vertical(id="main-pane"):
249
+ with VerticalScroll(id="task-pane"):
250
+ yield Static("[dim]Loading...[/dim]")
251
+ with VerticalScroll(id="result-pane"):
252
+ yield Static("[dim] (no results yet)[/dim]")
253
+ yield Footer()
254
+
255
+ # ==================================================================
256
+ # Lifecycle
257
+ # ==================================================================
258
+
259
+ def on_mount(self) -> None:
260
+ self._loading_screen = LoadingScreen()
261
+ self.push_screen(self._loading_screen)
262
+ self.run_worker(self._deferred_init_worker, thread=True)
263
+
264
+ def _deferred_init_worker(self) -> None:
265
+ log = logging.getLogger(__name__)
266
+ loading = self._loading_screen
267
+
268
+ try:
269
+ self.call_from_thread(loading.update_message, "Loading workflows...")
270
+ from dataeval_flow.workflow import list_workflows
271
+
272
+ list_workflows()
273
+
274
+ self.call_from_thread(loading.update_message, "Loading transforms...")
275
+ from dataeval_flow._app._model._discover import list_transforms
276
+
277
+ list_transforms()
278
+
279
+ self.call_from_thread(loading.update_message, "Loading selections...")
280
+ from dataeval_flow._app._model._discover import list_selection_classes
281
+
282
+ list_selection_classes()
283
+
284
+ initial = self._initial_config_path
285
+ if initial:
286
+ self.call_from_thread(loading.update_message, "Loading config...")
287
+
288
+ def _load_initial() -> None:
289
+ success, msg = self._vm.load_file(initial)
290
+ self.sub_title = self._vm.config_file_path
291
+ self.notify(msg, severity="information" if success else "error")
292
+
293
+ self.call_from_thread(_load_initial)
294
+ except Exception:
295
+ log.exception("Failed during deferred initialization")
296
+ self.call_from_thread(self.notify, "Initialization failed — check logs.", severity="error")
297
+ finally:
298
+
299
+ def _dismiss() -> None:
300
+ with contextlib.suppress(ScreenStackError):
301
+ self._loading_screen.dismiss(None)
302
+ self._rebuild_all()
303
+
304
+ self.call_from_thread(_dismiss)
305
+
306
+ def action_blur(self) -> None:
307
+ if self.focused is not None:
308
+ self.set_focus(None)
309
+
310
+ # ==================================================================
311
+ # Pane-aware navigation
312
+ # ==================================================================
313
+
314
+ def _get_pane_widgets(self, pane_id: str) -> list[PaneWidget]:
315
+ """Return all focusable PaneWidget children inside a pane."""
316
+ try:
317
+ container = self.query_one(f"#{pane_id}", VerticalScroll)
318
+ except NoMatches:
319
+ return []
320
+ return [c for c in container.children if isinstance(c, PaneWidget)]
321
+
322
+ def _focus_within_pane(self, pane_id: str, direction: int) -> None:
323
+ """Move focus up/down within a single pane."""
324
+ items = self._get_pane_widgets(pane_id)
325
+ if not items:
326
+ return
327
+ focused = self.focused
328
+ current_idx: int | None = None
329
+ for i, w in enumerate(items):
330
+ if w is focused:
331
+ current_idx = i
332
+ break
333
+ if current_idx is None:
334
+ self.set_focus(items[0] if direction > 0 else items[-1])
335
+ else:
336
+ new_idx = (current_idx + direction) % len(items)
337
+ self.set_focus(items[new_idx])
338
+
339
+ def _cycle_pane(self, direction: int) -> None:
340
+ """Tab to the next/prev pane, focusing the first widget in it."""
341
+ focused = self.focused
342
+ current_pane_idx = 0
343
+ if isinstance(focused, PaneWidget) and focused.pane in PANE_IDS:
344
+ current_pane_idx = PANE_IDS.index(focused.pane)
345
+
346
+ for offset in range(1, len(PANE_IDS) + 1):
347
+ next_idx = (current_pane_idx + direction * offset) % len(PANE_IDS)
348
+ items = self._get_pane_widgets(PANE_IDS[next_idx])
349
+ if items:
350
+ self.set_focus(items[0])
351
+ return
352
+
353
+ # ==================================================================
354
+ # Rebuild orchestration
355
+ # ==================================================================
356
+
357
+ def _rebuild_all(self) -> None:
358
+ """Full rebuild of all three panes (undo/redo/load/new)."""
359
+ self._rebuild_config_pane()
360
+ self._rebuild_task_pane()
361
+ self._rebuild_result_pane()
362
+
363
+ # ==================================================================
364
+ # Key actions (config editing — delegate to ViewModel)
365
+ # ==================================================================
366
+
367
+ def _focused_category(self) -> str | None:
368
+ focused = self.focused
369
+ if isinstance(focused, (CfgSectionHeader, CfgItem)):
370
+ return focused.fc_category
371
+ if isinstance(focused, (TaskPaneHeader, TaskCard)):
372
+ return "tasks"
373
+ return None
374
+
375
+ def action_undo(self) -> None:
376
+ success, msg = self._vm.undo()
377
+ if not success:
378
+ self.notify(msg, severity="warning")
379
+ return
380
+ self._rebuild_all()
381
+ self.notify(msg)
382
+
383
+ def action_redo(self) -> None:
384
+ success, msg = self._vm.redo()
385
+ if not success:
386
+ self.notify(msg, severity="warning")
387
+ return
388
+ self._rebuild_all()
389
+ self.notify(msg)
390
+
391
+ def action_add_item(self) -> None:
392
+ category = self._focused_category()
393
+ if category:
394
+ self._open_modal(category, None, -1)
395
+
396
+ def action_activate_item(self) -> None:
397
+ """Enter key — context-sensitive open/edit/run/view."""
398
+ focused = self.focused
399
+ if isinstance(focused, CfgItem):
400
+ existing = self._vm.get_item(focused.fc_category, focused.fc_index)
401
+ if existing:
402
+ self._open_modal(focused.fc_category, existing, focused.fc_index)
403
+ elif isinstance(focused, CfgSectionHeader):
404
+ self._open_modal(focused.fc_category, None, -1)
405
+ elif isinstance(focused, TaskCard):
406
+ self.action_run_task()
407
+ elif isinstance(focused, TaskPaneHeader):
408
+ self._open_modal("tasks", None, -1)
409
+ elif isinstance(focused, ResultCard):
410
+ self._view_result(focused)
411
+
412
+ def action_space_item(self) -> None:
413
+ """Space key — context-sensitive toggle."""
414
+ focused = self.focused
415
+ if isinstance(focused, TaskCard):
416
+ self._toggle_task(focused)
417
+
418
+ def _toggle_task(self, card: TaskCard) -> None:
419
+ desc = self._vm.toggle_task(card.fc_index)
420
+ if desc:
421
+ task = self._vm.get_item("tasks", card.fc_index)
422
+ if task:
423
+ execution = self._vm.task_execution(task.get("name", ""))
424
+ card.update(snippet_task_with_execution(task, execution))
425
+
426
+ def _view_result(self, card: ResultCard) -> None:
427
+ execution = self._vm.task_execution(card.task_name)
428
+ if execution is None:
429
+ return
430
+ if execution.status == "failed":
431
+ from dataeval_flow._app._screens._detail import ErrorDetailModal
432
+
433
+ self.push_screen(ErrorDetailModal(card.task_name, execution.error or "Unknown error"))
434
+ return
435
+ if execution.result is None:
436
+ return
437
+ from dataeval_flow._app._screens._detail import ResultDetailModal
438
+
439
+ self.push_screen(ResultDetailModal(card.task_name, execution.result))
440
+
441
+ def action_delete_item(self) -> None:
442
+ focused = self.focused
443
+ category: str | None = None
444
+ index: int = -1
445
+ if isinstance(focused, CfgItem):
446
+ category = focused.fc_category
447
+ index = focused.fc_index
448
+ elif isinstance(focused, TaskCard):
449
+ category = "tasks"
450
+ index = focused.fc_index
451
+
452
+ if category is None or index < 0:
453
+ return
454
+
455
+ outcome = self._vm.delete_item(category, index)
456
+ if outcome is None:
457
+ return
458
+ description, warnings = outcome
459
+ self.notify(f"{description}. ctrl+z to undo.")
460
+ for w in warnings:
461
+ self.notify(w, severity="warning")
462
+ if category == "tasks":
463
+ self._rebuild_task_pane()
464
+ else:
465
+ self._rebuild_config_section(category)
466
+
467
+ def action_open_settings(self) -> None:
468
+ """Open execution settings modal (F10)."""
469
+
470
+ def _on_result(result: ExecutionSettings | None) -> None:
471
+ if result is not None:
472
+ self._settings = result
473
+ self.notify("Settings updated.")
474
+
475
+ self.push_screen(SettingsModal(self._settings), callback=_on_result)
476
+
477
+ # ==================================================================
478
+ # Task execution
479
+ # ==================================================================
480
+
481
+ def action_run_task(self) -> None:
482
+ """Run the focused task."""
483
+ focused = self.focused
484
+ if not isinstance(focused, TaskCard):
485
+ self.notify("Focus a task card to run it.", severity="warning")
486
+ return
487
+ task = self._vm.get_item("tasks", focused.fc_index)
488
+ if task is None:
489
+ return
490
+ task_name = task.get("name", "")
491
+ if not task_name:
492
+ return
493
+
494
+ try:
495
+ config = self._vm.build_pipeline_config()
496
+ except (ValueError, TypeError) as e:
497
+ self.notify(f"Config validation failed: {e}", severity="error")
498
+ return
499
+
500
+ self._vm.mark_task_running(task_name)
501
+ execution = self._vm.task_execution(task_name)
502
+ focused.update(snippet_task_with_execution(task, execution))
503
+ self.notify(f"Running task '{task_name}'...")
504
+
505
+ self.run_worker(
506
+ lambda: self._execute_task_worker(task_name, config),
507
+ thread=True,
508
+ )
509
+
510
+ def action_run_all(self) -> None:
511
+ """Run all enabled tasks sequentially."""
512
+ tasks = self._vm.items("tasks")
513
+ enabled = [t for t in tasks if t.get("enabled", True)]
514
+ if not enabled:
515
+ self.notify("No enabled tasks to run.", severity="warning")
516
+ return
517
+
518
+ try:
519
+ config = self._vm.build_pipeline_config()
520
+ except (ValueError, TypeError) as e:
521
+ self.notify(f"Config validation failed: {e}", severity="error")
522
+ return
523
+
524
+ task_names = [t.get("name", "") for t in enabled if t.get("name")]
525
+ self.notify(f"Running {len(task_names)} task(s)...")
526
+
527
+ self.run_worker(
528
+ lambda: self._execute_all_worker(task_names, config),
529
+ thread=True,
530
+ )
531
+
532
+ def _execute_task_worker(self, task_name: str, config: Any) -> None:
533
+ """Worker thread: run a single task and update state."""
534
+ from dataeval_flow.workflow.orchestrator import _run_single_task
535
+
536
+ try:
537
+ task_cfg = next(t for t in config.tasks if t.name == task_name)
538
+ result = _run_single_task(task_cfg, config, data_dir=self._data_dir, cache_dir=self._cache_dir)
539
+
540
+ def _on_done() -> None:
541
+ self._vm.mark_task_completed(task_name, result)
542
+ self._update_task_card_by_name(task_name)
543
+ self._append_or_update_result(task_name)
544
+ elapsed = result.metadata.execution_time_s
545
+ time_str = f" ({elapsed:.1f}s)" if elapsed is not None else ""
546
+ self.notify(f"Task '{task_name}' completed{time_str}.")
547
+
548
+ self.call_from_thread(_on_done)
549
+ except Exception as exc: # noqa: BLE001
550
+ error_msg = str(exc)
551
+
552
+ def _on_fail() -> None:
553
+ self._vm.mark_task_failed(task_name, error_msg)
554
+ self._update_task_card_by_name(task_name)
555
+ self._append_or_update_result(task_name)
556
+ self.notify(f"Task '{task_name}' failed: {error_msg}", severity="error")
557
+
558
+ self.call_from_thread(_on_fail)
559
+
560
+ def _execute_all_worker(self, task_names: list[str], config: Any) -> None:
561
+ """Worker thread: run multiple tasks sequentially."""
562
+ from dataeval_flow.workflow.orchestrator import _run_single_task
563
+
564
+ for task_name in task_names:
565
+
566
+ def _mark_running(name: str = task_name) -> None:
567
+ self._vm.mark_task_running(name)
568
+ self._update_task_card_by_name(name)
569
+
570
+ self.call_from_thread(_mark_running)
571
+
572
+ try:
573
+ task_cfg = next(t for t in config.tasks if t.name == task_name)
574
+ result = _run_single_task(task_cfg, config, data_dir=self._data_dir, cache_dir=self._cache_dir)
575
+
576
+ def _on_done(name: str = task_name, res: Any = result) -> None:
577
+ self._vm.mark_task_completed(name, res)
578
+ self._update_task_card_by_name(name)
579
+ self._append_or_update_result(name)
580
+
581
+ self.call_from_thread(_on_done)
582
+ except Exception as exc: # noqa: BLE001
583
+ error_msg = str(exc)
584
+
585
+ def _on_fail(name: str = task_name, err: str = error_msg) -> None:
586
+ self._vm.mark_task_failed(name, err)
587
+ self._update_task_card_by_name(name)
588
+ self._append_or_update_result(name)
589
+
590
+ self.call_from_thread(_on_fail)
591
+
592
+ def _on_all_done() -> None:
593
+ completed = len([entry for entry in self._vm.all_executions() if entry.status == "completed"])
594
+ failed = len([entry for entry in self._vm.all_executions() if entry.status == "failed"])
595
+ self.notify(f"Run all: {completed} completed, {failed} failed.")
596
+
597
+ self.call_from_thread(_on_all_done)
598
+
599
+ def action_export_results(self) -> None:
600
+ """Export all completed results to a directory."""
601
+ if not self._vm.completed_results():
602
+ self.notify("No results to export.", severity="warning")
603
+ return
604
+
605
+ if self._output_dir:
606
+ success, msg = self._vm.export_results(self._output_dir)
607
+ self.notify(msg, severity="information" if success else "warning")
608
+ return
609
+
610
+ start = self._default_browse_path()
611
+
612
+ def _on_result(result: str | None) -> None:
613
+ if result is not None:
614
+ success, msg = self._vm.export_results(Path(result))
615
+ self.notify(msg, severity="information" if success else "warning")
616
+
617
+ self.push_screen(PathPickerScreen(start_path=start, mode="folder"), callback=_on_result)
618
+
619
+ # ==================================================================
620
+ # File operations (delegate to ViewModel)
621
+ # ==================================================================
622
+
623
+ def _default_browse_path(self) -> str:
624
+ """Starting path for file pickers: config parent dir > data_dir > cwd."""
625
+ if self._vm.config_file_path:
626
+ p = Path(self._vm.config_file_path)
627
+ return str(p.parent if p.is_file() else p)
628
+ if self._data_dir:
629
+ return str(self._data_dir)
630
+ return "."
631
+
632
+ def action_new_config(self) -> None:
633
+ msg = self._vm.new_config()
634
+ self._rebuild_all()
635
+ self.sub_title = ""
636
+ self.notify(msg)
637
+
638
+ def action_open_config(self) -> None:
639
+ start = self._default_browse_path()
640
+
641
+ def _on_result(result: str | None) -> None:
642
+ if result is not None:
643
+ success, msg = self._vm.load_file(Path(result))
644
+ self.sub_title = self._vm.config_file_path
645
+ self.notify(msg, severity="information" if success else "error")
646
+ if success:
647
+ self._rebuild_all()
648
+
649
+ self.push_screen(PathPickerScreen(start_path=start, mode="file"), callback=_on_result)
650
+
651
+ def action_save_config(self) -> None:
652
+ if self._vm.config_file_path:
653
+ success, msg = self._vm.save_file(Path(self._vm.config_file_path))
654
+ self.sub_title = self._vm.config_file_path
655
+ self.notify(msg, severity="information" if success else "warning")
656
+ else:
657
+ self.action_save_config_as()
658
+
659
+ def action_save_config_as(self) -> None:
660
+ start = self._default_browse_path()
661
+
662
+ def _on_folder(result: str | None) -> None:
663
+ if result is not None:
664
+ save_path = str(Path(result) / "params.yaml") if Path(result).is_dir() else result
665
+ success, msg = self._vm.save_file(Path(save_path))
666
+ self.sub_title = self._vm.config_file_path
667
+ self.notify(msg, severity="information" if success else "warning")
668
+
669
+ self.push_screen(PathPickerScreen(start_path=start, mode="file"), callback=_on_folder)
670
+
671
+ # ==================================================================
672
+ # Click routing
673
+ # ==================================================================
674
+
675
+ def on_click(self, event: Any) -> None:
676
+ widget = event.widget
677
+ while widget is not None:
678
+ if isinstance(widget, CfgItem):
679
+ existing = self._vm.get_item(widget.fc_category, widget.fc_index)
680
+ if existing:
681
+ self._open_modal(widget.fc_category, existing, widget.fc_index)
682
+ event.stop()
683
+ return
684
+ if isinstance(widget, TaskCard):
685
+ if hasattr(event, "x") and event.x < 4:
686
+ self._toggle_task(widget)
687
+ else:
688
+ self.set_focus(widget)
689
+ self.action_run_task()
690
+ event.stop()
691
+ return
692
+ if isinstance(widget, ResultCard):
693
+ self._view_result(widget)
694
+ event.stop()
695
+ return
696
+ if isinstance(widget, (VerticalScroll, Vertical, Horizontal)):
697
+ break
698
+ widget = widget.parent
699
+
700
+ # ==================================================================
701
+ # Modal launching
702
+ # ==================================================================
703
+
704
+ def _open_modal(self, category: str, existing: dict[str, Any] | None, index: int) -> None:
705
+ self._editing_category = category
706
+ self._editing_index = index
707
+
708
+ sec_vm = self._vm.create_section_vm(category, existing)
709
+ modal = SectionModal(
710
+ section=category,
711
+ existing=existing,
712
+ section_vm=sec_vm,
713
+ data_dir=self._data_dir,
714
+ )
715
+ self.push_screen(modal, callback=self._on_modal_result)
716
+
717
+ def _on_modal_result(self, result: dict | str | None) -> None:
718
+ category = self._editing_category
719
+ index = self._editing_index
720
+
721
+ outcome = self._vm.apply_result(category, index, result)
722
+ if outcome is None:
723
+ return
724
+
725
+ description, warnings = outcome
726
+ self.notify(description)
727
+ for w in warnings:
728
+ self.notify(w, severity="warning")
729
+ if category == "tasks":
730
+ self._rebuild_task_pane()
731
+ else:
732
+ self._rebuild_config_section(category)
733
+
734
+
735
+ def run_builder(
736
+ config_path: str | Path | None = None,
737
+ data_dir: str | Path | None = None,
738
+ cache_dir: str | Path | None = None,
739
+ ) -> None:
740
+ """Launch the interactive dashboard."""
741
+ app = FlowApp(config_path=config_path, data_dir=data_dir, cache_dir=cache_dir)
742
+ app.run()