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.
- dataeval_flow/__init__.py +93 -0
- dataeval_flow/__main__.py +149 -0
- dataeval_flow/_app/__init__.py +5 -0
- dataeval_flow/_app/_model/__init__.py +5 -0
- dataeval_flow/_app/_model/_coerce.py +126 -0
- dataeval_flow/_app/_model/_discover.py +171 -0
- dataeval_flow/_app/_model/_execution.py +108 -0
- dataeval_flow/_app/_model/_introspect.py +280 -0
- dataeval_flow/_app/_model/_item.py +213 -0
- dataeval_flow/_app/_model/_registry.py +255 -0
- dataeval_flow/_app/_model/_state.py +322 -0
- dataeval_flow/_app/_model/_undo.py +61 -0
- dataeval_flow/_app/_panes/__init__.py +35 -0
- dataeval_flow/_app/_panes/_config_pane.py +173 -0
- dataeval_flow/_app/_panes/_result_pane.py +125 -0
- dataeval_flow/_app/_panes/_task_pane.py +91 -0
- dataeval_flow/_app/_panes/_widgets.py +111 -0
- dataeval_flow/_app/_screens/__init__.py +25 -0
- dataeval_flow/_app/_screens/_base.py +242 -0
- dataeval_flow/_app/_screens/_detail.py +333 -0
- dataeval_flow/_app/_screens/_model.py +102 -0
- dataeval_flow/_app/_screens/_params.py +80 -0
- dataeval_flow/_app/_screens/_pathpicker.py +68 -0
- dataeval_flow/_app/_screens/_section.py +621 -0
- dataeval_flow/_app/_screens/_settings.py +183 -0
- dataeval_flow/_app/_viewmodel/__init__.py +15 -0
- dataeval_flow/_app/_viewmodel/_builder_vm.py +272 -0
- dataeval_flow/_app/_viewmodel/_model_vm.py +70 -0
- dataeval_flow/_app/_viewmodel/_rendering.py +189 -0
- dataeval_flow/_app/_viewmodel/_result_vm.py +210 -0
- dataeval_flow/_app/_viewmodel/_section_vm.py +224 -0
- dataeval_flow/_app/app.py +742 -0
- dataeval_flow/_app/cli.py +592 -0
- dataeval_flow/_logging.py +102 -0
- dataeval_flow/cache.py +1355 -0
- dataeval_flow/config/__init__.py +80 -0
- dataeval_flow/config/_loader.py +79 -0
- dataeval_flow/config/_merge.py +92 -0
- dataeval_flow/config/_models.py +115 -0
- dataeval_flow/config/_paths.py +85 -0
- dataeval_flow/config/schemas/__init__.py +112 -0
- dataeval_flow/config/schemas/_dataset.py +111 -0
- dataeval_flow/config/schemas/_extractor.py +119 -0
- dataeval_flow/config/schemas/_metadata.py +28 -0
- dataeval_flow/config/schemas/_preprocessor.py +18 -0
- dataeval_flow/config/schemas/_selection.py +100 -0
- dataeval_flow/config/schemas/_task.py +89 -0
- dataeval_flow/config/schemas/_workflow.py +135 -0
- dataeval_flow/dataset.py +635 -0
- dataeval_flow/embeddings.py +135 -0
- dataeval_flow/metadata.py +48 -0
- dataeval_flow/preprocessing.py +141 -0
- dataeval_flow/py.typed +0 -0
- dataeval_flow/runner.py +118 -0
- dataeval_flow/selection.py +50 -0
- dataeval_flow/workflow/__init__.py +328 -0
- dataeval_flow/workflow/_text_report.py +511 -0
- dataeval_flow/workflow/base.py +69 -0
- dataeval_flow/workflow/orchestrator.py +454 -0
- dataeval_flow/workflows/__init__.py +1 -0
- dataeval_flow/workflows/analysis/__init__.py +38 -0
- dataeval_flow/workflows/analysis/outputs.py +202 -0
- dataeval_flow/workflows/analysis/params.py +114 -0
- dataeval_flow/workflows/analysis/workflow.py +1313 -0
- dataeval_flow/workflows/cleaning/__init__.py +23 -0
- dataeval_flow/workflows/cleaning/outputs.py +200 -0
- dataeval_flow/workflows/cleaning/params.py +160 -0
- dataeval_flow/workflows/cleaning/report.py +304 -0
- dataeval_flow/workflows/cleaning/workflow.py +794 -0
- dataeval_flow/workflows/drift/__init__.py +1 -0
- dataeval_flow/workflows/drift/outputs.py +144 -0
- dataeval_flow/workflows/drift/params.py +332 -0
- dataeval_flow/workflows/drift/report.py +201 -0
- dataeval_flow/workflows/drift/workflow.py +647 -0
- dataeval_flow/workflows/ood/__init__.py +1 -0
- dataeval_flow/workflows/ood/outputs.py +134 -0
- dataeval_flow/workflows/ood/params.py +161 -0
- dataeval_flow/workflows/ood/report.py +311 -0
- dataeval_flow/workflows/ood/workflow.py +728 -0
- dataeval_flow/workflows/prioritization/__init__.py +1 -0
- dataeval_flow/workflows/prioritization/outputs.py +122 -0
- dataeval_flow/workflows/prioritization/params.py +124 -0
- dataeval_flow/workflows/prioritization/report.py +117 -0
- dataeval_flow/workflows/prioritization/workflow.py +587 -0
- dataeval_flow/workflows/splitting/__init__.py +25 -0
- dataeval_flow/workflows/splitting/outputs.py +101 -0
- dataeval_flow/workflows/splitting/params.py +61 -0
- dataeval_flow/workflows/splitting/report.py +485 -0
- dataeval_flow/workflows/splitting/workflow.py +371 -0
- dataeval_flow-0.1.0.dist-info/METADATA +305 -0
- dataeval_flow-0.1.0.dist-info/RECORD +94 -0
- dataeval_flow-0.1.0.dist-info/WHEEL +4 -0
- dataeval_flow-0.1.0.dist-info/entry_points.txt +2 -0
- 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()
|