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,183 @@
|
|
|
1
|
+
"""Settings modal for execution context (F10).
|
|
2
|
+
|
|
3
|
+
These settings are *not* part of the pipeline JSON/YAML config.
|
|
4
|
+
They control how tasks are executed (paths, resource limits, etc.)
|
|
5
|
+
and are ephemeral to the dashboard session (or persisted separately).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from textual.app import ComposeResult
|
|
15
|
+
from textual.containers import Horizontal, Vertical
|
|
16
|
+
from textual.screen import ModalScreen
|
|
17
|
+
from textual.widgets import Button, Input, Label, Static
|
|
18
|
+
|
|
19
|
+
from dataeval_flow._app._screens._pathpicker import PathPickerScreen
|
|
20
|
+
|
|
21
|
+
__all__ = ["ExecutionSettings", "SettingsModal"]
|
|
22
|
+
|
|
23
|
+
_CSS = """
|
|
24
|
+
SettingsModal {
|
|
25
|
+
align: center middle;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#settings-dialog {
|
|
29
|
+
width: 70;
|
|
30
|
+
height: auto;
|
|
31
|
+
max-height: 80%;
|
|
32
|
+
border: round $accent 40%;
|
|
33
|
+
background: $surface;
|
|
34
|
+
padding: 1 2;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#settings-title {
|
|
38
|
+
text-style: bold;
|
|
39
|
+
margin: 0 0 1 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.settings-label {
|
|
43
|
+
margin: 1 0 0 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.settings-row {
|
|
47
|
+
height: auto;
|
|
48
|
+
margin: 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.settings-row Input {
|
|
52
|
+
width: 1fr;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.settings-row Button {
|
|
56
|
+
width: auto;
|
|
57
|
+
min-width: 10;
|
|
58
|
+
margin: 0 0 0 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.settings-hint {
|
|
62
|
+
color: $text-muted;
|
|
63
|
+
margin: 0 0 0 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#settings-buttons {
|
|
67
|
+
height: auto;
|
|
68
|
+
align: right middle;
|
|
69
|
+
margin: 1 0 0 0;
|
|
70
|
+
}
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class ExecutionSettings:
|
|
76
|
+
"""Execution context settings — not part of pipeline config."""
|
|
77
|
+
|
|
78
|
+
data_dir: str = ""
|
|
79
|
+
cache_dir: str = ""
|
|
80
|
+
output_dir: str = ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SettingsModal(ModalScreen[ExecutionSettings | None]):
|
|
84
|
+
"""Modal for editing execution settings (F10)."""
|
|
85
|
+
|
|
86
|
+
CSS = _CSS
|
|
87
|
+
BINDINGS = [("escape", "cancel", "Cancel")]
|
|
88
|
+
|
|
89
|
+
def __init__(self, current: ExecutionSettings, **kw: Any) -> None:
|
|
90
|
+
super().__init__(**kw)
|
|
91
|
+
self._current = current
|
|
92
|
+
|
|
93
|
+
def compose(self) -> ComposeResult:
|
|
94
|
+
with Vertical(id="settings-dialog"):
|
|
95
|
+
yield Static("[bold]Execution Settings[/bold]", id="settings-title", markup=True)
|
|
96
|
+
yield Static(
|
|
97
|
+
"[dim]These control how tasks run — not saved to pipeline config.[/dim]",
|
|
98
|
+
markup=True,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
yield Label("Data directory", classes="settings-label")
|
|
102
|
+
with Horizontal(classes="settings-row"):
|
|
103
|
+
yield Input(
|
|
104
|
+
value=self._current.data_dir,
|
|
105
|
+
placeholder="$DATAEVAL_DATA or working directory",
|
|
106
|
+
id="input-data-dir",
|
|
107
|
+
)
|
|
108
|
+
yield Button("Browse", id="btn-browse-data-dir")
|
|
109
|
+
yield Static("[dim]Root for resolving relative dataset paths[/dim]", classes="settings-hint", markup=True)
|
|
110
|
+
|
|
111
|
+
yield Label("Cache directory", classes="settings-label")
|
|
112
|
+
with Horizontal(classes="settings-row"):
|
|
113
|
+
yield Input(
|
|
114
|
+
value=self._current.cache_dir,
|
|
115
|
+
placeholder="(disabled — no caching)",
|
|
116
|
+
id="input-cache-dir",
|
|
117
|
+
)
|
|
118
|
+
yield Button("Browse", id="btn-browse-cache-dir")
|
|
119
|
+
yield Static(
|
|
120
|
+
"[dim]Disk cache for embeddings, metadata, stats[/dim]",
|
|
121
|
+
classes="settings-hint",
|
|
122
|
+
markup=True,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
yield Label("Output directory", classes="settings-label")
|
|
126
|
+
with Horizontal(classes="settings-row"):
|
|
127
|
+
yield Input(
|
|
128
|
+
value=self._current.output_dir,
|
|
129
|
+
placeholder="(console only — no file output)",
|
|
130
|
+
id="input-output-dir",
|
|
131
|
+
)
|
|
132
|
+
yield Button("Browse", id="btn-browse-output-dir")
|
|
133
|
+
yield Static(
|
|
134
|
+
"[dim]Where to write result.json / result.txt on export[/dim]",
|
|
135
|
+
classes="settings-hint",
|
|
136
|
+
markup=True,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
with Vertical(id="settings-buttons"):
|
|
140
|
+
yield Button("Save", id="btn-settings-save", variant="primary")
|
|
141
|
+
yield Button("Cancel", id="btn-settings-cancel")
|
|
142
|
+
|
|
143
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
144
|
+
btn_id = event.button.id
|
|
145
|
+
if btn_id == "btn-settings-save":
|
|
146
|
+
self.dismiss(self._collect())
|
|
147
|
+
elif btn_id == "btn-settings-cancel":
|
|
148
|
+
self.dismiss(None)
|
|
149
|
+
elif btn_id == "btn-browse-data-dir":
|
|
150
|
+
self._browse_for("input-data-dir")
|
|
151
|
+
elif btn_id == "btn-browse-cache-dir":
|
|
152
|
+
self._browse_for("input-cache-dir")
|
|
153
|
+
elif btn_id == "btn-browse-output-dir":
|
|
154
|
+
self._browse_for("input-output-dir")
|
|
155
|
+
|
|
156
|
+
def _browse_for(self, input_id: str) -> None:
|
|
157
|
+
"""Open a folder picker and write the result into the input field."""
|
|
158
|
+
current_value = self.query_one(f"#{input_id}", Input).value.strip()
|
|
159
|
+
start = current_value if current_value else "."
|
|
160
|
+
|
|
161
|
+
def _on_picked(result: str | None, _input_id: str = input_id) -> None:
|
|
162
|
+
if result is not None:
|
|
163
|
+
self.query_one(f"#{_input_id}", Input).value = result
|
|
164
|
+
|
|
165
|
+
self.app.push_screen(PathPickerScreen(start_path=start, mode="folder"), callback=_on_picked)
|
|
166
|
+
|
|
167
|
+
def action_cancel(self) -> None:
|
|
168
|
+
self.dismiss(None)
|
|
169
|
+
|
|
170
|
+
def _collect(self) -> ExecutionSettings:
|
|
171
|
+
return ExecutionSettings(
|
|
172
|
+
data_dir=self.query_one("#input-data-dir", Input).value.strip(),
|
|
173
|
+
cache_dir=self.query_one("#input-cache-dir", Input).value.strip(),
|
|
174
|
+
output_dir=self.query_one("#input-output-dir", Input).value.strip(),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def to_paths(settings: ExecutionSettings) -> tuple[Path | None, Path | None, Path | None]:
|
|
179
|
+
"""Convert settings strings to Path | None triples (data, cache, output)."""
|
|
180
|
+
data = Path(settings.data_dir) if settings.data_dir else None
|
|
181
|
+
cache = Path(settings.cache_dir) if settings.cache_dir else None
|
|
182
|
+
output = Path(settings.output_dir) if settings.output_dir else None
|
|
183
|
+
return data, cache, output
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""ViewModel layer for the configuration builder.
|
|
2
|
+
|
|
3
|
+
ViewModels expose state and commands that Views bind to.
|
|
4
|
+
They depend on the Model layer but never on UI frameworks.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataeval_flow._app._viewmodel._builder_vm import BuilderViewModel
|
|
8
|
+
from dataeval_flow._app._viewmodel._model_vm import ModelViewModel
|
|
9
|
+
from dataeval_flow._app._viewmodel._section_vm import SectionViewModel
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"BuilderViewModel",
|
|
13
|
+
"ModelViewModel",
|
|
14
|
+
"SectionViewModel",
|
|
15
|
+
]
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""ViewModel for the main builder application.
|
|
2
|
+
|
|
3
|
+
Orchestrates ConfigState, ExecutionState, UndoStack, and file I/O.
|
|
4
|
+
No UI dependencies. The View (FlowApp) calls methods here and
|
|
5
|
+
translates return values into notifications and widget updates.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json as json_mod
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from dataeval_flow._app._model._execution import ExecutionState, TaskExecution
|
|
15
|
+
from dataeval_flow._app._model._item import DELETE_SENTINEL
|
|
16
|
+
from dataeval_flow._app._model._registry import SECTIONS
|
|
17
|
+
from dataeval_flow._app._model._state import ConfigState
|
|
18
|
+
from dataeval_flow._app._model._undo import UndoStack
|
|
19
|
+
from dataeval_flow._app._viewmodel._rendering import _item_to_yaml_snippet, _snippet_task
|
|
20
|
+
from dataeval_flow._app._viewmodel._section_vm import SectionViewModel
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from dataeval_flow.config._models import PipelineConfig
|
|
24
|
+
from dataeval_flow.workflow import WorkflowResult
|
|
25
|
+
|
|
26
|
+
__all__ = ["BuilderViewModel"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BuilderViewModel:
|
|
30
|
+
"""ViewModel for the main builder application."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, config_path: str | Path | None = None) -> None:
|
|
33
|
+
self._state = ConfigState()
|
|
34
|
+
self._execution = ExecutionState()
|
|
35
|
+
self.history = UndoStack()
|
|
36
|
+
self.config_file_path: str = str(config_path) if config_path else ""
|
|
37
|
+
|
|
38
|
+
# -- Queries -----------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def sections(self) -> list[tuple[str, str]]:
|
|
42
|
+
"""Return the ordered list of (key, title) section pairs."""
|
|
43
|
+
return SECTIONS
|
|
44
|
+
|
|
45
|
+
def section_data(self) -> list[tuple[str, str, list[dict[str, Any]]]]:
|
|
46
|
+
"""Return ``(key, title, items)`` for every section."""
|
|
47
|
+
return [(key, title, self._state.items(key)) for key, title in SECTIONS]
|
|
48
|
+
|
|
49
|
+
def get_item(self, section: str, index: int) -> dict[str, Any] | None:
|
|
50
|
+
"""Return item at *index* in *section*, or ``None``."""
|
|
51
|
+
return self._state.get(section, index)
|
|
52
|
+
|
|
53
|
+
def items(self, section: str) -> list[dict[str, Any]]:
|
|
54
|
+
"""Return a shallow copy of items in *section*."""
|
|
55
|
+
return self._state.items(section)
|
|
56
|
+
|
|
57
|
+
def names(self, section: str) -> list[str]:
|
|
58
|
+
"""Return the names of all items in *section*."""
|
|
59
|
+
return self._state.names(section)
|
|
60
|
+
|
|
61
|
+
def count(self, section: str) -> int:
|
|
62
|
+
"""Return the number of items in *section*."""
|
|
63
|
+
return self._state.count(section)
|
|
64
|
+
|
|
65
|
+
def is_empty(self) -> bool:
|
|
66
|
+
"""Return ``True`` if all sections are empty."""
|
|
67
|
+
return self._state.is_empty()
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict[str, Any]:
|
|
70
|
+
"""Export state as a plain dict suitable for YAML serialization."""
|
|
71
|
+
return self._state.to_dict()
|
|
72
|
+
|
|
73
|
+
def validate_item(self, section: str, data: dict[str, Any]) -> list[str]:
|
|
74
|
+
"""Validate a single item dict. Returns a list of error strings."""
|
|
75
|
+
return self._state.validate_item(section, data)
|
|
76
|
+
|
|
77
|
+
def validate_all(self) -> list[str]:
|
|
78
|
+
"""Validate the full config. Returns errors."""
|
|
79
|
+
return self._state.validate_all()
|
|
80
|
+
|
|
81
|
+
# -- Rendering ---------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def item_snippet(self, category: str, item: dict[str, Any]) -> str:
|
|
84
|
+
"""Render a config item as a Rich-markup snippet string."""
|
|
85
|
+
return _item_to_yaml_snippet(category, item)
|
|
86
|
+
|
|
87
|
+
def task_snippet(self, task: dict[str, Any]) -> str:
|
|
88
|
+
"""Render a task item snippet (used for inline toggle updates)."""
|
|
89
|
+
return _snippet_task(task)
|
|
90
|
+
|
|
91
|
+
# -- Factory -----------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def create_section_vm(
|
|
94
|
+
self,
|
|
95
|
+
section: str,
|
|
96
|
+
existing: dict[str, Any] | None = None,
|
|
97
|
+
) -> SectionViewModel:
|
|
98
|
+
"""Create a SectionViewModel bound to this builder's state."""
|
|
99
|
+
return SectionViewModel(section, existing, self._state)
|
|
100
|
+
|
|
101
|
+
# -- Undo / Redo -------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def snapshot(self, description: str) -> None:
|
|
104
|
+
"""Save a state snapshot for undo."""
|
|
105
|
+
self.history.push(self._state.snapshot(), description)
|
|
106
|
+
|
|
107
|
+
def undo(self) -> tuple[bool, str]:
|
|
108
|
+
"""Undo the last action. Returns ``(success, message)``."""
|
|
109
|
+
entry = self.history.undo(self._state.snapshot())
|
|
110
|
+
if entry is None:
|
|
111
|
+
return False, "Nothing to undo."
|
|
112
|
+
self._state.restore(entry.state)
|
|
113
|
+
return True, f"Undone: {entry.description}"
|
|
114
|
+
|
|
115
|
+
def redo(self) -> tuple[bool, str]:
|
|
116
|
+
"""Redo the last undone action. Returns ``(success, message)``."""
|
|
117
|
+
entry = self.history.redo(self._state.snapshot())
|
|
118
|
+
if entry is None:
|
|
119
|
+
return False, "Nothing to redo."
|
|
120
|
+
self._state.restore(entry.state)
|
|
121
|
+
return True, f"Redone: {entry.description}"
|
|
122
|
+
|
|
123
|
+
# -- Item mutations ----------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def toggle_task(self, index: int) -> str | None:
|
|
126
|
+
"""Toggle a task's enabled state. Returns description or None."""
|
|
127
|
+
task = self._state.get("tasks", index)
|
|
128
|
+
if task is None:
|
|
129
|
+
return None
|
|
130
|
+
toggling_to = "off" if task.get("enabled", True) else "on"
|
|
131
|
+
desc = f"Toggle task '{task.get('name', '?')}' {toggling_to}"
|
|
132
|
+
self.snapshot(desc)
|
|
133
|
+
task["enabled"] = not task.get("enabled", True)
|
|
134
|
+
return desc
|
|
135
|
+
|
|
136
|
+
def delete_item(self, category: str, index: int) -> tuple[str, list[str]] | None:
|
|
137
|
+
"""Delete an item. Snapshots before mutation.
|
|
138
|
+
|
|
139
|
+
Returns ``(description, warnings)`` or None.
|
|
140
|
+
"""
|
|
141
|
+
existing = self._state.get(category, index)
|
|
142
|
+
if existing is None:
|
|
143
|
+
return None
|
|
144
|
+
desc = f"Delete {category[:-1]} '{existing.get('name', '?')}'"
|
|
145
|
+
self.snapshot(desc)
|
|
146
|
+
return self._state.apply_modal_result(category, index, DELETE_SENTINEL)
|
|
147
|
+
|
|
148
|
+
def apply_result(self, category: str, index: int, result: dict | str | None) -> tuple[str, list[str]] | None:
|
|
149
|
+
"""Apply a modal result (add/update/delete). Snapshots before mutation.
|
|
150
|
+
|
|
151
|
+
Returns ``(description, warnings)`` or None.
|
|
152
|
+
"""
|
|
153
|
+
if result is None:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
if result == DELETE_SENTINEL:
|
|
157
|
+
return self.delete_item(category, index)
|
|
158
|
+
|
|
159
|
+
# Derive description before mutating
|
|
160
|
+
name = result.get("name", "?") if isinstance(result, dict) else "?"
|
|
161
|
+
action = "Update" if index >= 0 else "Add"
|
|
162
|
+
self.snapshot(f"{action} {category[:-1]} '{name}'")
|
|
163
|
+
return self._state.apply_modal_result(category, index, result)
|
|
164
|
+
|
|
165
|
+
# -- File I/O ----------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def new_config(self) -> str:
|
|
168
|
+
"""Reset to empty config. Returns status message."""
|
|
169
|
+
if not self._state.is_empty():
|
|
170
|
+
self.snapshot("New config (clear)")
|
|
171
|
+
self._state = ConfigState()
|
|
172
|
+
self.config_file_path = ""
|
|
173
|
+
return "New config."
|
|
174
|
+
|
|
175
|
+
def load_file(self, path: Path) -> tuple[bool, str]:
|
|
176
|
+
"""Load config from *path*. Returns ``(success, message)``."""
|
|
177
|
+
if not path.exists():
|
|
178
|
+
return False, f"File not found: {path}"
|
|
179
|
+
self.snapshot("Load config file")
|
|
180
|
+
try:
|
|
181
|
+
warning = self._state.load_file(path)
|
|
182
|
+
except (ValueError, TypeError, OSError) as e:
|
|
183
|
+
return False, f"Failed to load config: {e}"
|
|
184
|
+
self.config_file_path = str(path)
|
|
185
|
+
msg = f"Loaded config from {path}"
|
|
186
|
+
if warning:
|
|
187
|
+
msg += f" ({warning})"
|
|
188
|
+
return True, msg
|
|
189
|
+
|
|
190
|
+
def save_file(self, path: Path, *, disable_tasks: bool = False) -> tuple[bool, str]:
|
|
191
|
+
"""Save config to *path*. Returns ``(success, message)``.
|
|
192
|
+
|
|
193
|
+
When *disable_tasks* is ``True``, all tasks are written with
|
|
194
|
+
``enabled: false`` (for programmatic use).
|
|
195
|
+
"""
|
|
196
|
+
if self._state.is_empty():
|
|
197
|
+
return False, "Nothing to save."
|
|
198
|
+
if disable_tasks:
|
|
199
|
+
# Temporarily disable all tasks for export
|
|
200
|
+
tasks = self._state.items("tasks")
|
|
201
|
+
original_states = [(i, t.get("enabled", True)) for i, t in enumerate(tasks)]
|
|
202
|
+
for task in tasks:
|
|
203
|
+
task["enabled"] = False
|
|
204
|
+
try:
|
|
205
|
+
self._state.save_file(path)
|
|
206
|
+
finally:
|
|
207
|
+
# Restore original enabled states
|
|
208
|
+
for idx, enabled in original_states:
|
|
209
|
+
t = self._state.get("tasks", idx)
|
|
210
|
+
if t is not None:
|
|
211
|
+
t["enabled"] = enabled
|
|
212
|
+
else:
|
|
213
|
+
self._state.save_file(path)
|
|
214
|
+
self.config_file_path = str(path)
|
|
215
|
+
return True, f"Saved to {path.resolve()}"
|
|
216
|
+
|
|
217
|
+
# -- Config building ---------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def build_pipeline_config(self) -> PipelineConfig:
|
|
220
|
+
"""Validate and return a ``PipelineConfig`` from current state.
|
|
221
|
+
|
|
222
|
+
Raises ``ValueError`` if the config is invalid.
|
|
223
|
+
"""
|
|
224
|
+
return self._state.to_pipeline_config()
|
|
225
|
+
|
|
226
|
+
# -- Execution ---------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
def task_execution(self, name: str) -> TaskExecution | None:
|
|
229
|
+
"""Return execution state for task *name*, or ``None``."""
|
|
230
|
+
return self._execution.get(name)
|
|
231
|
+
|
|
232
|
+
def mark_task_running(self, name: str) -> TaskExecution:
|
|
233
|
+
"""Mark a task as running."""
|
|
234
|
+
return self._execution.mark_running(name)
|
|
235
|
+
|
|
236
|
+
def mark_task_completed(self, name: str, result: WorkflowResult[Any, Any]) -> TaskExecution:
|
|
237
|
+
"""Mark a task as completed with its result."""
|
|
238
|
+
return self._execution.mark_completed(name, result)
|
|
239
|
+
|
|
240
|
+
def mark_task_failed(self, name: str, error: str) -> TaskExecution:
|
|
241
|
+
"""Mark a task as failed."""
|
|
242
|
+
return self._execution.mark_failed(name, error)
|
|
243
|
+
|
|
244
|
+
def clear_task_execution(self, name: str | None = None) -> None:
|
|
245
|
+
"""Clear execution state. ``None`` clears all."""
|
|
246
|
+
self._execution.clear(name)
|
|
247
|
+
|
|
248
|
+
def all_executions(self) -> list[TaskExecution]:
|
|
249
|
+
"""Return all execution entries."""
|
|
250
|
+
return self._execution.entries()
|
|
251
|
+
|
|
252
|
+
def completed_results(self) -> list[tuple[str, WorkflowResult[Any, Any]]]:
|
|
253
|
+
"""Return ``(name, result)`` for all completed tasks."""
|
|
254
|
+
return self._execution.completed_results()
|
|
255
|
+
|
|
256
|
+
def export_results(self, output_dir: Path) -> tuple[bool, str]:
|
|
257
|
+
"""Write result.json and result.txt for all completed tasks."""
|
|
258
|
+
results = self._execution.completed_results()
|
|
259
|
+
if not results:
|
|
260
|
+
return False, "No completed results to export."
|
|
261
|
+
|
|
262
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
263
|
+
|
|
264
|
+
merged: dict[str, dict] = {}
|
|
265
|
+
text_parts: list[str] = []
|
|
266
|
+
for name, result in results:
|
|
267
|
+
merged[name] = result.to_dict()
|
|
268
|
+
text_parts.append(result.report(detailed=True))
|
|
269
|
+
|
|
270
|
+
(output_dir / "result.json").write_text(json_mod.dumps(merged, indent=2), encoding="utf-8")
|
|
271
|
+
(output_dir / "result.txt").write_text("\n".join(text_parts), encoding="utf-8")
|
|
272
|
+
return True, f"Exported {len(results)} result(s) to {output_dir}"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""ViewModel for the ModelModal.
|
|
2
|
+
|
|
3
|
+
Pure logic for model creation/editing. No UI dependencies.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
__all__ = ["ModelViewModel"]
|
|
11
|
+
|
|
12
|
+
MODEL_TYPES = ["onnx", "bovw", "flatten", "torch", "uncertainty"]
|
|
13
|
+
_PATH_TYPES = frozenset({"onnx", "torch", "uncertainty"})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ModelViewModel:
|
|
17
|
+
"""ViewModel for creating/editing model entries."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, existing: dict[str, Any] | None = None) -> None:
|
|
20
|
+
self.existing = existing
|
|
21
|
+
self.original: dict[str, Any] | None = dict(existing) if existing else None
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def is_edit_mode(self) -> bool:
|
|
25
|
+
return self.existing is not None
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def needs_path(model_type: str) -> bool:
|
|
29
|
+
"""Return True if this model type requires a model_path."""
|
|
30
|
+
return model_type in _PATH_TYPES
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def needs_vocab(model_type: str) -> bool:
|
|
34
|
+
"""Return True if this model type requires a vocab_size."""
|
|
35
|
+
return model_type == "bovw"
|
|
36
|
+
|
|
37
|
+
def build_result(
|
|
38
|
+
self,
|
|
39
|
+
name: str,
|
|
40
|
+
model_type: str,
|
|
41
|
+
model_path: str,
|
|
42
|
+
vocab_size_str: str,
|
|
43
|
+
) -> dict[str, Any] | None:
|
|
44
|
+
"""Assemble the result dict. Returns None if invalid."""
|
|
45
|
+
if not name or not model_type:
|
|
46
|
+
return None
|
|
47
|
+
result: dict[str, Any] = {"name": name, "type": model_type}
|
|
48
|
+
if self.needs_path(model_type):
|
|
49
|
+
if not model_path:
|
|
50
|
+
return None
|
|
51
|
+
result["model_path"] = model_path
|
|
52
|
+
if self.needs_vocab(model_type) and vocab_size_str:
|
|
53
|
+
try:
|
|
54
|
+
result["vocab_size"] = int(vocab_size_str)
|
|
55
|
+
except ValueError:
|
|
56
|
+
return None
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
def check_dirty(self, collected: dict[str, Any] | None) -> bool:
|
|
60
|
+
"""Return True if collected data differs from original."""
|
|
61
|
+
if not collected:
|
|
62
|
+
return False
|
|
63
|
+
if not self.original:
|
|
64
|
+
return True
|
|
65
|
+
return collected != self.original
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def validation_message() -> str:
|
|
69
|
+
"""Return the error message for invalid input."""
|
|
70
|
+
return "Name and type are required. Path required for onnx/torch/uncertainty."
|