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,242 @@
1
+ """Base modal class and shared utilities for modal dialogs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from textual.app import ComposeResult, ScreenStackError
11
+ from textual.containers import Horizontal
12
+ from textual.css.query import NoMatches
13
+ from textual.screen import ModalScreen
14
+ from textual.widgets import Button, Checkbox, Input, Select
15
+
16
+ from dataeval_flow._app._model._item import DELETE_SENTINEL
17
+ from dataeval_flow._app._screens._pathpicker import PathPickerScreen
18
+
19
+ _log = logging.getLogger(__name__)
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Value helpers
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ def _select_value(sel: Select) -> str:
28
+ v = sel.value
29
+ if v is Select.BLANK or v is Select.NULL or v is None:
30
+ return ""
31
+ return str(v)
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Shared modal CSS
36
+ # ---------------------------------------------------------------------------
37
+
38
+ _MODAL_CSS = """
39
+ .modal-dialog {
40
+ width: 70;
41
+ height: auto;
42
+ max-height: 85%;
43
+ border: round $accent 40%;
44
+ background: $surface;
45
+ padding: 1 2;
46
+ }
47
+
48
+ .modal-dialog Vertical {
49
+ height: auto;
50
+ }
51
+
52
+ SectionModal,
53
+ ComponentModal {
54
+ align: center middle;
55
+ }
56
+
57
+ .modal-dialog Label {
58
+ margin: 1 0 0 0;
59
+ text-style: bold;
60
+ }
61
+
62
+ .modal-dialog Input {
63
+ margin: 0 0 1 0;
64
+ }
65
+
66
+ .modal-dialog Select {
67
+ margin: 0 0 1 0;
68
+ }
69
+
70
+ .modal-button-bar {
71
+ height: auto;
72
+ padding: 1 0 0 0;
73
+ align: right middle;
74
+ }
75
+
76
+ .modal-button-bar Button {
77
+ margin: 0 0 0 1;
78
+ min-width: 10;
79
+ }
80
+
81
+ .browse-row {
82
+ height: auto;
83
+ }
84
+
85
+ .browse-row Input {
86
+ width: 1fr;
87
+ }
88
+
89
+ .browse-row Button {
90
+ width: auto;
91
+ min-width: 10;
92
+ }
93
+
94
+ .conditional-field {
95
+ height: auto;
96
+ }
97
+
98
+ .hidden {
99
+ display: none;
100
+ }
101
+
102
+ .step-row {
103
+ height: auto;
104
+ padding: 0 1;
105
+ margin: 0 0 0 1;
106
+ }
107
+
108
+ .step-row:hover {
109
+ background: $accent 8%;
110
+ }
111
+
112
+ .step-row Static {
113
+ width: 1fr;
114
+ }
115
+
116
+ .step-row Button {
117
+ width: auto;
118
+ min-width: 4;
119
+ }
120
+
121
+ #modal-step-list {
122
+ height: auto;
123
+ max-height: 15;
124
+ margin: 0 0 1 0;
125
+ }
126
+
127
+ #modal-params-form {
128
+ height: auto;
129
+ }
130
+
131
+ .step-section-title {
132
+ text-style: bold;
133
+ padding: 1 0 0 0;
134
+ }
135
+
136
+ .nested-group {
137
+ margin: 0 0 1 1;
138
+ padding: 0 0 0 1;
139
+ border-left: tall $accent 40%;
140
+ }
141
+ """
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # ComponentModal — base class
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ class ComponentModal(ModalScreen[dict | str | None]):
150
+ """Base modal for creating/editing pipeline components."""
151
+
152
+ CSS = _MODAL_CSS
153
+ BINDINGS = [("escape", "cancel", "Cancel")]
154
+
155
+ def __init__(
156
+ self,
157
+ existing: dict[str, Any] | None = None,
158
+ data_dir: Path | None = None,
159
+ **kwargs: Any,
160
+ ) -> None:
161
+ super().__init__(**kwargs)
162
+ self._existing = existing
163
+ self._data_dir = data_dir
164
+
165
+ @property
166
+ def is_edit_mode(self) -> bool:
167
+ return self._existing is not None
168
+
169
+ def _collect_raw(self) -> dict[str, Any] | None:
170
+ raise NotImplementedError
171
+
172
+ def _check_dirty(self) -> bool:
173
+ raise NotImplementedError
174
+
175
+ def _collect(self) -> dict[str, Any] | None:
176
+ raise NotImplementedError
177
+
178
+ def _update_ok_state(self) -> None:
179
+ try:
180
+ btn = self.query_one("#btn-modal-ok", Button)
181
+ btn.disabled = not self._check_dirty()
182
+ except NoMatches:
183
+ pass
184
+
185
+ def compose_buttons(self) -> ComposeResult:
186
+ with Horizontal(classes="modal-button-bar"):
187
+ yield Button("OK", id="btn-modal-ok", variant="primary", disabled=True)
188
+ yield Button("Cancel", id="btn-modal-cancel")
189
+ if self.is_edit_mode:
190
+ yield Button("Delete", id="btn-modal-delete", variant="error")
191
+
192
+ def _safe_dismiss(self, result: dict | str | None) -> None:
193
+ with contextlib.suppress(ScreenStackError):
194
+ self.dismiss(result)
195
+
196
+ def on_button_pressed(self, event: Button.Pressed) -> None:
197
+ btn = event.button.id or ""
198
+ if btn == "btn-modal-ok":
199
+ result = self._collect()
200
+ if result is not None:
201
+ self._safe_dismiss(result)
202
+ elif btn == "btn-modal-cancel":
203
+ self._safe_dismiss(None)
204
+ elif btn == "btn-modal-delete":
205
+ self._safe_dismiss(DELETE_SENTINEL)
206
+
207
+ def on_input_changed(self, _event: Input.Changed) -> None:
208
+ self._update_ok_state()
209
+
210
+ def on_select_changed(self, _event: Select.Changed) -> None:
211
+ self._update_ok_state()
212
+
213
+ def on_checkbox_changed(self, _event: Checkbox.Changed) -> None:
214
+ self._update_ok_state()
215
+
216
+ def action_cancel(self) -> None:
217
+ with contextlib.suppress(ScreenStackError):
218
+ self.dismiss(None)
219
+
220
+ def _browse_for_input(self, input_id: str) -> None:
221
+ try:
222
+ current = self.query_one(f"#{input_id}", Input).value.strip() or "."
223
+ except NoMatches:
224
+ current = "."
225
+
226
+ data_dir = self._data_dir
227
+
228
+ def _on_result(result: str | None) -> None:
229
+ if result is None:
230
+ return
231
+ # Auto-relativize absolute paths against data_dir for portability
232
+ if Path(result).is_absolute() and data_dir is not None:
233
+ from dataeval_flow.config._paths import relativize_to_data_dir
234
+
235
+ try:
236
+ result = relativize_to_data_dir(result, data_dir)
237
+ except ValueError:
238
+ _log.warning("Browsed path '%s' is not under data root '%s'", result, data_dir)
239
+ with contextlib.suppress(NoMatches):
240
+ self.query_one(f"#{input_id}", Input).value = result
241
+
242
+ self.app.push_screen(PathPickerScreen(start_path=current, mode="folder"), callback=_on_result)
@@ -0,0 +1,333 @@
1
+ """Result detail modal for the dashboard.
2
+
3
+ Full-screen modal showing metadata, finding summaries, and expandable
4
+ detail sections for a single task's ``WorkflowResult``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from textual.app import ComposeResult
12
+ from textual.containers import Vertical, VerticalScroll
13
+ from textual.css.query import NoMatches
14
+ from textual.screen import ModalScreen
15
+ from textual.widgets import Button, DataTable, Static
16
+
17
+ from dataeval_flow._app._viewmodel._result_vm import ResultViewModel
18
+
19
+ __all__ = ["ErrorDetailModal", "ResultDetailModal"]
20
+
21
+ _SEVERITY_MARKUP: dict[str, str] = {
22
+ "ok": "[green][ok][/green]",
23
+ "info": "[blue][..][/blue]",
24
+ "warning": "[bold red][!!][/bold red]",
25
+ }
26
+
27
+ # Plain-text markers emitted by _summary_line() → Rich-markup replacements
28
+ _MARKER_COLORS: list[tuple[str, str]] = [
29
+ (" [!!]", " [bold red]\\[!!][/bold red]"),
30
+ (" [ok]", " [green]\\[ok][/green]"),
31
+ (" [..]", " [blue]\\[..][/blue]"),
32
+ ]
33
+
34
+
35
+ def _colorize_marker(line: str) -> str:
36
+ """Replace the trailing plain-text severity marker with a colored Rich-markup version."""
37
+ for plain, colored in _MARKER_COLORS:
38
+ if line.endswith(plain):
39
+ return line[: -len(plain)] + colored
40
+ return line
41
+
42
+
43
+ _CSS = """
44
+ ResultDetailModal {
45
+ align: center middle;
46
+ }
47
+
48
+ #rd-dialog {
49
+ width: 90%;
50
+ height: 90%;
51
+ border: round $accent 40%;
52
+ background: $surface;
53
+ padding: 1 2;
54
+ }
55
+
56
+ #rd-scroll {
57
+ height: 1fr;
58
+ }
59
+
60
+ #rd-title {
61
+ text-style: bold;
62
+ margin: 0 0 1 0;
63
+ }
64
+
65
+ .rd-metadata {
66
+ margin: 0 0 1 0;
67
+ color: $text-muted;
68
+ }
69
+
70
+ .rd-summary-line {
71
+ height: auto;
72
+ padding: 0 1;
73
+ }
74
+
75
+ .rd-health {
76
+ margin: 1 0;
77
+ text-style: bold;
78
+ }
79
+
80
+ .rd-finding-header {
81
+ height: 3;
82
+ padding: 0 1;
83
+ margin: 1 0 0 0;
84
+ background: $boost;
85
+ content-align: left middle;
86
+ }
87
+
88
+ .rd-finding-header:focus {
89
+ background: $accent 20%;
90
+ color: $text;
91
+ border-left: tall $accent;
92
+ }
93
+
94
+ .rd-finding-detail {
95
+ padding: 0 1 0 2;
96
+ height: auto;
97
+ background: $surface;
98
+ }
99
+
100
+ .rd-separator {
101
+ height: 1;
102
+ margin: 1 0;
103
+ background: $accent 15%;
104
+ }
105
+
106
+ #rd-buttons {
107
+ height: auto;
108
+ align: right middle;
109
+ margin: 1 0 0 0;
110
+ }
111
+ """
112
+
113
+
114
+ class _FindingHeader(Static):
115
+ """Clickable finding header — toggles detail expansion."""
116
+
117
+ can_focus = True
118
+
119
+ def __init__(self, content: str, finding_idx: int, **kw: Any) -> None:
120
+ super().__init__(content, **kw)
121
+ self.finding_idx = finding_idx
122
+
123
+
124
+ class ResultDetailModal(ModalScreen[None]):
125
+ """Full-screen modal showing detailed results for a single task."""
126
+
127
+ CSS = _CSS
128
+ BINDINGS = [("escape", "close", "Close")]
129
+
130
+ def __init__(self, task_name: str, result: Any, **kw: Any) -> None:
131
+ super().__init__(**kw)
132
+ self._task_name = task_name
133
+ self._result = result
134
+ self._rvm = ResultViewModel(result)
135
+ self._expanded_findings: set[int] = set()
136
+ self._gen: int = 0
137
+
138
+ def compose(self) -> ComposeResult:
139
+ with Vertical(id="rd-dialog"):
140
+ yield Static(f"[bold]Result: {self._task_name}[/bold]", id="rd-title", markup=True)
141
+ with VerticalScroll(id="rd-scroll"):
142
+ yield from self._compose_content()
143
+ with Vertical(id="rd-buttons"):
144
+ yield Button("Close", id="btn-rd-close", variant="primary")
145
+
146
+ def _compose_content(self) -> ComposeResult:
147
+ # Metadata
148
+ for line in self._rvm.metadata_lines():
149
+ yield Static(f"[dim]{line}[/dim]", classes="rd-metadata", markup=True)
150
+
151
+ yield Static("", classes="rd-separator")
152
+
153
+ # Summary
154
+ yield Static("[bold]SUMMARY[/bold]", markup=True)
155
+ summaries = self._rvm.finding_summaries()
156
+ for idx, _fs in enumerate(summaries):
157
+ line = self._rvm.finding_summary_markup(idx)
158
+ line = _colorize_marker(line)
159
+ yield Static(f" {line}", classes="rd-summary-line", markup=True)
160
+
161
+ # Health
162
+ health = self._rvm.health_line()
163
+ if "warning" in health.lower():
164
+ yield Static(f"[bold red] {health}[/bold red]", classes="rd-health", markup=True)
165
+ else:
166
+ yield Static(f"[green] {health}[/green]", classes="rd-health", markup=True)
167
+
168
+ yield Static("", classes="rd-separator")
169
+
170
+ # Finding detail sections
171
+ gen = self._gen
172
+ for idx, fs in enumerate(summaries):
173
+ expanded = idx in self._expanded_findings
174
+ arrow = "\u25bc" if expanded else "\u25b6"
175
+ marker = _SEVERITY_MARKUP.get(fs.severity, _SEVERITY_MARKUP["info"])
176
+ header = _FindingHeader(
177
+ f"{arrow} DETAIL: {fs.title} {marker}",
178
+ finding_idx=idx,
179
+ classes="rd-finding-header",
180
+ id=f"rd-fh-{gen}-{idx}",
181
+ markup=True,
182
+ )
183
+ yield header
184
+
185
+ if expanded:
186
+ # Try DataTable for tabular findings
187
+ table_data = self._rvm.finding_table_data(idx)
188
+ if table_data is not None:
189
+ headers, rows = table_data
190
+ dt = DataTable(id=f"rd-dt-{gen}-{idx}")
191
+ yield dt
192
+ # DataTable columns/rows added in _populate_tables
193
+ else:
194
+ # Rich markup detail
195
+ detail_text = self._rvm.finding_detail_markup(idx)
196
+ if detail_text.strip():
197
+ yield Static(detail_text, classes="rd-finding-detail", markup=False)
198
+
199
+ def on_mount(self) -> None:
200
+ """Populate DataTable widgets after initial compose."""
201
+ self._populate_tables()
202
+
203
+ def _populate_tables(self) -> None:
204
+ """Fill DataTable widgets with data for expanded findings."""
205
+ gen = self._gen
206
+ for idx in self._expanded_findings:
207
+ table_data = self._rvm.finding_table_data(idx)
208
+ if table_data is not None:
209
+ headers, rows = table_data
210
+ try:
211
+ dt = self.query_one(f"#rd-dt-{gen}-{idx}", DataTable)
212
+ for h in headers:
213
+ dt.add_column(h)
214
+ for row in rows:
215
+ dt.add_row(*row)
216
+ except NoMatches:
217
+ pass
218
+
219
+ def on_button_pressed(self, event: Button.Pressed) -> None:
220
+ if event.button.id == "btn-rd-close":
221
+ self.dismiss(None)
222
+
223
+ def on_click(self, event: Any) -> None:
224
+ widget = event.widget
225
+ while widget is not None:
226
+ if isinstance(widget, _FindingHeader):
227
+ self._toggle_finding(widget.finding_idx)
228
+ event.stop()
229
+ return
230
+ if isinstance(widget, VerticalScroll):
231
+ break
232
+ widget = widget.parent
233
+
234
+ async def _on_key(self, event: Any) -> None:
235
+ if event.key == "enter":
236
+ focused = self.app.focused
237
+ if isinstance(focused, _FindingHeader):
238
+ self._toggle_finding(focused.finding_idx)
239
+ event.stop()
240
+ event.prevent_default()
241
+ return
242
+ await super()._on_key(event)
243
+
244
+ def _toggle_finding(self, idx: int) -> None:
245
+ if idx in self._expanded_findings:
246
+ self._expanded_findings.discard(idx)
247
+ else:
248
+ self._expanded_findings.add(idx)
249
+ self._rebuild_content()
250
+
251
+ def _rebuild_content(self) -> None:
252
+ self._gen += 1
253
+ try:
254
+ scroll = self.query_one("#rd-scroll", VerticalScroll)
255
+ except NoMatches:
256
+ return
257
+ scroll.remove_children()
258
+ for widget in self._compose_content():
259
+ scroll.mount(widget)
260
+ self._populate_tables()
261
+
262
+ def action_close(self) -> None:
263
+ self.dismiss(None)
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # Error detail modal (for failed tasks)
268
+ # ---------------------------------------------------------------------------
269
+
270
+ _ERROR_CSS = """
271
+ ErrorDetailModal {
272
+ align: center middle;
273
+ }
274
+
275
+ #ed-dialog {
276
+ width: 80%;
277
+ height: auto;
278
+ max-height: 70%;
279
+ border: round $error 50%;
280
+ background: $surface;
281
+ padding: 1 2;
282
+ }
283
+
284
+ #ed-title {
285
+ text-style: bold;
286
+ color: $error;
287
+ margin: 0 0 1 0;
288
+ }
289
+
290
+ #ed-error {
291
+ height: auto;
292
+ max-height: 40;
293
+ padding: 1;
294
+ background: $boost;
295
+ margin: 1 0;
296
+ }
297
+
298
+ #ed-buttons {
299
+ height: auto;
300
+ align: right middle;
301
+ margin: 1 0 0 0;
302
+ }
303
+ """
304
+
305
+
306
+ class ErrorDetailModal(ModalScreen[None]):
307
+ """Modal showing error details for a failed task."""
308
+
309
+ CSS = _ERROR_CSS
310
+ BINDINGS = [("escape", "close", "Close")]
311
+
312
+ def __init__(self, task_name: str, error: str, **kw: Any) -> None:
313
+ super().__init__(**kw)
314
+ self._task_name = task_name
315
+ self._error = error
316
+
317
+ def compose(self) -> ComposeResult:
318
+ with Vertical(id="ed-dialog"):
319
+ yield Static(
320
+ f"[bold]FAILED: {self._task_name}[/bold]",
321
+ id="ed-title",
322
+ markup=True,
323
+ )
324
+ yield Static(self._error, id="ed-error")
325
+ with Vertical(id="ed-buttons"):
326
+ yield Button("Close", id="btn-ed-close", variant="primary")
327
+
328
+ def on_button_pressed(self, event: Button.Pressed) -> None:
329
+ if event.button.id == "btn-ed-close":
330
+ self.dismiss(None)
331
+
332
+ def action_close(self) -> None:
333
+ self.dismiss(None)
@@ -0,0 +1,102 @@
1
+ """ModelModal — flattened model creation/editing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.containers import Horizontal, Vertical, VerticalScroll
9
+ from textual.css.query import NoMatches
10
+ from textual.widgets import Button, Input, Label, Select, Static
11
+
12
+ from dataeval_flow._app._screens._base import ComponentModal, _select_value
13
+ from dataeval_flow._app._viewmodel._model_vm import MODEL_TYPES, ModelViewModel
14
+
15
+
16
+ class ModelModal(ComponentModal):
17
+ """Create or edit a model entry (flattened — no nested extractor).
18
+
19
+ Delegates business logic to :class:`ModelViewModel`.
20
+ """
21
+
22
+ def __init__(self, existing: dict[str, Any] | None = None, **kwargs: Any) -> None:
23
+ super().__init__(existing, **kwargs)
24
+ self._vm = ModelViewModel(existing)
25
+
26
+ def compose(self) -> ComposeResult:
27
+ title = "Edit Model" if self.is_edit_mode else "New Model"
28
+ with VerticalScroll(classes="modal-dialog"):
29
+ yield Static(f"[bold]{title}[/bold]")
30
+ yield Label("Name:")
31
+ yield Input(id="md-model-name", placeholder="e.g. resnet50")
32
+ yield Label("Type:")
33
+ yield Select(
34
+ [(t, t) for t in MODEL_TYPES],
35
+ id="md-model-type",
36
+ prompt="Select type",
37
+ )
38
+ with Vertical(id="md-model-path-group", classes="conditional-field hidden"):
39
+ yield Label("Model Path:")
40
+ with Horizontal(classes="browse-row"):
41
+ yield Input(id="md-model-path", placeholder="e.g. ./models/resnet50.onnx")
42
+ yield Button("Browse", id="btn-browse-md-model-path")
43
+ with Vertical(id="md-model-vocab-group", classes="conditional-field hidden"):
44
+ yield Label("Vocab Size:")
45
+ yield Input(id="md-model-vocab", placeholder="2048")
46
+ yield from self.compose_buttons()
47
+
48
+ def on_mount(self) -> None:
49
+ if self._existing:
50
+ try:
51
+ self.query_one("#md-model-name", Input).value = self._existing.get("name", "")
52
+ model_type = self._existing.get("type", "")
53
+ if model_type:
54
+ self.query_one("#md-model-type", Select).value = model_type
55
+ if self._existing.get("model_path"):
56
+ self.query_one("#md-model-path", Input).value = self._existing["model_path"]
57
+ if self._existing.get("vocab_size"):
58
+ self.query_one("#md-model-vocab", Input).value = str(self._existing["vocab_size"])
59
+ except NoMatches:
60
+ pass
61
+ self._toggle_fields()
62
+
63
+ def on_button_pressed(self, event: Button.Pressed) -> None:
64
+ if event.button.id == "btn-browse-md-model-path":
65
+ self._browse_for_input("md-model-path")
66
+ return
67
+ super().on_button_pressed(event)
68
+
69
+ def on_select_changed(self, event: Select.Changed) -> None:
70
+ if event.select.id == "md-model-type":
71
+ self._toggle_fields()
72
+ super().on_select_changed(event)
73
+
74
+ def _toggle_fields(self) -> None:
75
+ model_type = _select_value(self.query_one("#md-model-type", Select))
76
+ path_group = self.query_one("#md-model-path-group")
77
+ vocab_group = self.query_one("#md-model-vocab-group")
78
+ if ModelViewModel.needs_path(model_type):
79
+ path_group.remove_class("hidden")
80
+ else:
81
+ path_group.add_class("hidden")
82
+ if ModelViewModel.needs_vocab(model_type):
83
+ vocab_group.remove_class("hidden")
84
+ else:
85
+ vocab_group.add_class("hidden")
86
+
87
+ def _collect_raw(self) -> dict[str, Any] | None:
88
+ return self._vm.build_result(
89
+ name=self.query_one("#md-model-name", Input).value.strip(),
90
+ model_type=_select_value(self.query_one("#md-model-type", Select)),
91
+ model_path=self.query_one("#md-model-path", Input).value.strip(),
92
+ vocab_size_str=self.query_one("#md-model-vocab", Input).value.strip(),
93
+ )
94
+
95
+ def _check_dirty(self) -> bool:
96
+ return self._vm.check_dirty(self._collect_raw())
97
+
98
+ def _collect(self) -> dict[str, Any] | None:
99
+ result = self._collect_raw()
100
+ if result is None:
101
+ self.app.notify(ModelViewModel.validation_message(), severity="error")
102
+ return result