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,621 @@
|
|
|
1
|
+
"""SectionModal — generic modal for any config section."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from textual.app import ComposeResult
|
|
12
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
13
|
+
from textual.css.query import NoMatches
|
|
14
|
+
from textual.widgets import Button, Checkbox, Input, Label, Select, Static
|
|
15
|
+
|
|
16
|
+
from dataeval_flow._app._model._introspect import FieldDescriptor, FieldKind
|
|
17
|
+
from dataeval_flow._app._model._item import SKIP
|
|
18
|
+
from dataeval_flow._app._model._state import ConfigState
|
|
19
|
+
from dataeval_flow._app._screens._base import ComponentModal, _select_value
|
|
20
|
+
from dataeval_flow._app._screens._params import build_param_form, collect_param_form, validate_param_form
|
|
21
|
+
from dataeval_flow._app._viewmodel._section_vm import SectionViewModel
|
|
22
|
+
|
|
23
|
+
_log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SectionModal(ComponentModal):
|
|
27
|
+
"""Generic modal for creating/editing items in any config section.
|
|
28
|
+
|
|
29
|
+
Delegates all business logic to :class:`SectionViewModel`.
|
|
30
|
+
This class handles only widget creation, event routing, and
|
|
31
|
+
reading/writing widget values.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
section: str,
|
|
37
|
+
existing: dict[str, Any] | None = None,
|
|
38
|
+
state: ConfigState | None = None,
|
|
39
|
+
section_vm: SectionViewModel | None = None,
|
|
40
|
+
data_dir: Path | None = None,
|
|
41
|
+
**kwargs: Any,
|
|
42
|
+
) -> None:
|
|
43
|
+
super().__init__(existing, data_dir=data_dir, **kwargs)
|
|
44
|
+
self._vm = section_vm if section_vm is not None else SectionViewModel(section, existing, state)
|
|
45
|
+
self._section = section # convenience alias
|
|
46
|
+
self._gen: int = 0
|
|
47
|
+
self._variant_param_gen: int = 0
|
|
48
|
+
|
|
49
|
+
def _wid(self, name: str) -> str:
|
|
50
|
+
return f"md-{self._gen}-{name}"
|
|
51
|
+
|
|
52
|
+
def compose(self) -> ComposeResult:
|
|
53
|
+
singular = self._section[:-1].replace("_", " ").title()
|
|
54
|
+
title = f"Edit {singular}" if self.is_edit_mode else f"New {singular}"
|
|
55
|
+
|
|
56
|
+
with VerticalScroll(classes="modal-dialog"):
|
|
57
|
+
yield Static(f"[bold]{title}[/bold]")
|
|
58
|
+
yield Label("name:")
|
|
59
|
+
yield Input(id="md-name", placeholder=f"e.g. my_{self._section[:-1]}")
|
|
60
|
+
|
|
61
|
+
if self._vm.variant_choices:
|
|
62
|
+
disc_field = self._vm.disc_field or "type"
|
|
63
|
+
yield Label(f"{disc_field.replace('_', ' ')}:")
|
|
64
|
+
yield Select(
|
|
65
|
+
[(c, c) for c in self._vm.variant_choices],
|
|
66
|
+
id="md-disc",
|
|
67
|
+
prompt=f"select {disc_field}",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if self._vm.is_step_builder:
|
|
71
|
+
yield Static("[bold]steps[/bold]", classes="step-section-title")
|
|
72
|
+
yield Vertical(id="modal-step-list")
|
|
73
|
+
yield Label("add step:")
|
|
74
|
+
yield Select[str]([], id="md-step-select", allow_blank=True, prompt="select step")
|
|
75
|
+
yield Vertical(id="modal-params-form")
|
|
76
|
+
yield Button("Add Step", id="btn-modal-add-step", variant="success")
|
|
77
|
+
else:
|
|
78
|
+
yield Vertical(id="md-fields")
|
|
79
|
+
|
|
80
|
+
yield from self.compose_buttons()
|
|
81
|
+
|
|
82
|
+
def on_mount(self) -> None:
|
|
83
|
+
if self._vm.is_step_builder:
|
|
84
|
+
choices = self._vm.init_step_builder()
|
|
85
|
+
with contextlib.suppress(NoMatches):
|
|
86
|
+
self.query_one("#md-step-select", Select).set_options([(c, c) for c in choices])
|
|
87
|
+
|
|
88
|
+
if self._existing:
|
|
89
|
+
with contextlib.suppress(NoMatches):
|
|
90
|
+
self.query_one("#md-name", Input).value = self._existing.get("name", "")
|
|
91
|
+
|
|
92
|
+
if self._vm.variant_choices and self._vm.disc_field:
|
|
93
|
+
val = self._existing.get(self._vm.disc_field, "")
|
|
94
|
+
if val:
|
|
95
|
+
with contextlib.suppress(NoMatches):
|
|
96
|
+
self.query_one("#md-disc", Select).value = val
|
|
97
|
+
self._rebuild_fields()
|
|
98
|
+
self._populate_fields()
|
|
99
|
+
elif not self._vm.is_step_builder:
|
|
100
|
+
self._rebuild_fields()
|
|
101
|
+
self._populate_fields()
|
|
102
|
+
|
|
103
|
+
if self._vm.is_step_builder:
|
|
104
|
+
self._refresh_step_list()
|
|
105
|
+
elif not self._vm.variant_choices and not self._vm.is_step_builder:
|
|
106
|
+
self._rebuild_fields()
|
|
107
|
+
|
|
108
|
+
def on_select_changed(self, event: Select.Changed) -> None:
|
|
109
|
+
if event.select.id == "md-disc":
|
|
110
|
+
self._rebuild_fields()
|
|
111
|
+
elif event.select.id == "md-step-select":
|
|
112
|
+
self._rebuild_step_params()
|
|
113
|
+
else:
|
|
114
|
+
# Check for variant picker changes in LIST fields
|
|
115
|
+
select_id = event.select.id or ""
|
|
116
|
+
if select_id.endswith("-picker"):
|
|
117
|
+
field_name = self._parse_picker_field(select_id)
|
|
118
|
+
if field_name:
|
|
119
|
+
self._rebuild_variant_params(field_name)
|
|
120
|
+
super().on_select_changed(event)
|
|
121
|
+
|
|
122
|
+
def _handle_remove_step(self, btn: str) -> None:
|
|
123
|
+
"""Handle a step-removal button press."""
|
|
124
|
+
try:
|
|
125
|
+
idx = int(btn.split("-")[-1])
|
|
126
|
+
if self._vm.remove_step(idx):
|
|
127
|
+
self._refresh_step_list()
|
|
128
|
+
self._update_ok_state()
|
|
129
|
+
except (ValueError, IndexError):
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
def _handle_list_remove(self, btn: str) -> None:
|
|
133
|
+
"""Handle a list-item removal button press."""
|
|
134
|
+
parsed = self._parse_list_rem(btn)
|
|
135
|
+
if parsed:
|
|
136
|
+
field_name, idx = parsed
|
|
137
|
+
if self._vm.remove_list_item(field_name, idx):
|
|
138
|
+
self._refresh_list_items(field_name)
|
|
139
|
+
self._update_ok_state()
|
|
140
|
+
|
|
141
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
142
|
+
btn = event.button.id or ""
|
|
143
|
+
if btn == "btn-modal-add-step":
|
|
144
|
+
self._add_step()
|
|
145
|
+
return
|
|
146
|
+
if btn.startswith("btn-remove-step-"):
|
|
147
|
+
self._handle_remove_step(btn)
|
|
148
|
+
return
|
|
149
|
+
if btn.endswith("-add"):
|
|
150
|
+
field_name = self._parse_add_field(btn)
|
|
151
|
+
if field_name:
|
|
152
|
+
self._add_list_item(field_name)
|
|
153
|
+
return
|
|
154
|
+
if btn.startswith("md-listrem-"):
|
|
155
|
+
self._handle_list_remove(btn)
|
|
156
|
+
return
|
|
157
|
+
if btn.startswith("btn-browse-"):
|
|
158
|
+
input_id = btn[len("btn-browse-") :]
|
|
159
|
+
self._browse_for_input(input_id)
|
|
160
|
+
return
|
|
161
|
+
super().on_button_pressed(event)
|
|
162
|
+
|
|
163
|
+
# -- Field rendering ---------------------------------------------------
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def _mount_scalar_field(container: Vertical, desc: FieldDescriptor, wid: str, *, browse: bool = False) -> None:
|
|
167
|
+
"""Mount a single scalar field widget (SELECT, BOOL, INT, FLOAT, STRING)."""
|
|
168
|
+
suffix = "" if desc.required else " (optional)"
|
|
169
|
+
label_text = f"{desc.name}{suffix}"
|
|
170
|
+
|
|
171
|
+
if desc.kind == FieldKind.SELECT:
|
|
172
|
+
container.mount(Label(f"{label_text}:"))
|
|
173
|
+
options = [(c, c) for c in desc.choices]
|
|
174
|
+
has_default = desc.default is not None and desc.default in desc.choices
|
|
175
|
+
kw: dict[str, Any] = {"id": wid, "allow_blank": not desc.required or not options}
|
|
176
|
+
if has_default:
|
|
177
|
+
kw["value"] = desc.default
|
|
178
|
+
elif not desc.required or not options:
|
|
179
|
+
kw["prompt"] = "(default)" if not desc.required else "(no options)"
|
|
180
|
+
container.mount(Select(options, **kw))
|
|
181
|
+
elif desc.kind == FieldKind.BOOL:
|
|
182
|
+
default_val = desc.default if isinstance(desc.default, bool) else False
|
|
183
|
+
container.mount(Checkbox(label_text, value=default_val, id=wid))
|
|
184
|
+
elif desc.kind in (FieldKind.INT, FieldKind.FLOAT, FieldKind.STRING):
|
|
185
|
+
container.mount(Label(f"{label_text}:"))
|
|
186
|
+
placeholder = str(desc.default) if desc.default is not None else ""
|
|
187
|
+
if desc.constraints:
|
|
188
|
+
parts = [f"{k}={v}" for k, v in desc.constraints.items()]
|
|
189
|
+
placeholder += f" ({', '.join(parts)})" if placeholder else ", ".join(parts)
|
|
190
|
+
if browse and "path" in desc.name.lower():
|
|
191
|
+
row = Horizontal(classes="browse-row")
|
|
192
|
+
container.mount(row)
|
|
193
|
+
row.mount(Input(placeholder=placeholder, id=wid))
|
|
194
|
+
row.mount(Button("Browse", id=f"btn-browse-{wid}"))
|
|
195
|
+
else:
|
|
196
|
+
container.mount(Input(placeholder=placeholder, id=wid))
|
|
197
|
+
|
|
198
|
+
def _mount_multi_select_field(self, container: Vertical, desc: FieldDescriptor) -> None:
|
|
199
|
+
suffix = "" if desc.required else " (optional)"
|
|
200
|
+
container.mount(Label(f"{desc.name}{suffix} (check all that apply):"))
|
|
201
|
+
for choice in desc.choices:
|
|
202
|
+
checked = isinstance(desc.default, list) and choice in desc.default
|
|
203
|
+
container.mount(Checkbox(choice, value=checked, id=self._wid(f"{desc.name}-{choice}")))
|
|
204
|
+
|
|
205
|
+
def _mount_union_list_field(self, container: Vertical, desc: FieldDescriptor) -> None:
|
|
206
|
+
suffix = "" if desc.required else " (optional)"
|
|
207
|
+
label_text = f"{desc.name}{suffix}"
|
|
208
|
+
if desc.name not in self._vm.list_items:
|
|
209
|
+
self._vm.list_items[desc.name] = []
|
|
210
|
+
container.mount(Static(f"[bold]{label_text}[/bold]", classes="step-section-title"))
|
|
211
|
+
container.mount(Vertical(id=self._wid(f"{desc.name}-list")))
|
|
212
|
+
container.mount(Label("add:"))
|
|
213
|
+
variant_names = list(desc.union_variants) # type: ignore[arg-type]
|
|
214
|
+
container.mount(
|
|
215
|
+
Select(
|
|
216
|
+
[(v, v) for v in variant_names],
|
|
217
|
+
id=self._wid(f"{desc.name}-picker"),
|
|
218
|
+
prompt=f"select {desc.discriminator}",
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
container.mount(Vertical(id=self._wid(f"{desc.name}-params")))
|
|
222
|
+
singular = desc.name[:-1] if desc.name.endswith("s") else desc.name
|
|
223
|
+
container.mount(Button(f"Add {singular}", id=self._wid(f"{desc.name}-add"), variant="success"))
|
|
224
|
+
self._refresh_list_items(desc.name)
|
|
225
|
+
|
|
226
|
+
def _mount_nested_union_field(self, container: Vertical, desc: FieldDescriptor, wid: str) -> None:
|
|
227
|
+
suffix = "" if desc.required else " (optional)"
|
|
228
|
+
container.mount(Label(f"{desc.name}{suffix}:"))
|
|
229
|
+
variant_names = list(desc.union_variants) # type: ignore[arg-type]
|
|
230
|
+
container.mount(
|
|
231
|
+
Select(
|
|
232
|
+
[(v, v) for v in variant_names],
|
|
233
|
+
id=wid,
|
|
234
|
+
prompt=f"select {desc.name}",
|
|
235
|
+
allow_blank=not desc.required,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def _mount_json_field(self, container: Vertical, desc: FieldDescriptor, wid: str) -> None:
|
|
240
|
+
suffix = "" if desc.required else " (optional)"
|
|
241
|
+
container.mount(Label(f"{desc.name}{suffix} (JSON):"))
|
|
242
|
+
placeholder = ""
|
|
243
|
+
if desc.default is not None:
|
|
244
|
+
try:
|
|
245
|
+
placeholder = json.dumps(desc.default)
|
|
246
|
+
except (TypeError, ValueError):
|
|
247
|
+
placeholder = str(desc.default)
|
|
248
|
+
container.mount(Input(placeholder=placeholder, id=wid))
|
|
249
|
+
|
|
250
|
+
def _mount_nested_model_field(self, container: Vertical, desc: FieldDescriptor) -> None:
|
|
251
|
+
"""Mount a nested BaseModel as expanded scalar controls with defaults pre-filled."""
|
|
252
|
+
suffix = "" if desc.required else " (optional)"
|
|
253
|
+
container.mount(Static(f"[bold]{desc.name}{suffix}[/bold]"))
|
|
254
|
+
group = Vertical(id=self._wid(f"{desc.name}-group"), classes="nested-group")
|
|
255
|
+
container.mount(group)
|
|
256
|
+
for sub_desc in desc.item_descriptors: # type: ignore[union-attr]
|
|
257
|
+
sub_wid = self._wid(f"{desc.name}__{sub_desc.name}")
|
|
258
|
+
self._mount_scalar_field(group, sub_desc, sub_wid)
|
|
259
|
+
# Pre-fill with defaults so the form isn't empty
|
|
260
|
+
if sub_desc.default is not None and sub_desc.kind in (FieldKind.INT, FieldKind.FLOAT, FieldKind.STRING):
|
|
261
|
+
group.query_one(f"#{sub_wid}", Input).value = str(sub_desc.default)
|
|
262
|
+
|
|
263
|
+
def _mount_field(self, container: Vertical, desc: FieldDescriptor) -> None:
|
|
264
|
+
"""Mount a single field descriptor as the appropriate widget(s)."""
|
|
265
|
+
wid = self._wid(desc.name)
|
|
266
|
+
|
|
267
|
+
if desc.kind in (FieldKind.SELECT, FieldKind.BOOL, FieldKind.INT, FieldKind.FLOAT, FieldKind.STRING):
|
|
268
|
+
self._mount_scalar_field(container, desc, wid, browse=True)
|
|
269
|
+
elif desc.kind == FieldKind.MULTI_SELECT:
|
|
270
|
+
self._mount_multi_select_field(container, desc)
|
|
271
|
+
elif desc.kind == FieldKind.LIST and desc.union_variants and desc.discriminator:
|
|
272
|
+
self._mount_union_list_field(container, desc)
|
|
273
|
+
elif desc.kind == FieldKind.NESTED and desc.union_variants:
|
|
274
|
+
self._mount_nested_union_field(container, desc, wid)
|
|
275
|
+
elif desc.kind == FieldKind.NESTED and desc.item_descriptors:
|
|
276
|
+
self._mount_nested_model_field(container, desc)
|
|
277
|
+
elif desc.kind in (FieldKind.LIST, FieldKind.NESTED):
|
|
278
|
+
self._mount_json_field(container, desc, wid)
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _force_scroll_recalc(container: Vertical) -> None:
|
|
282
|
+
try:
|
|
283
|
+
parent = container.parent
|
|
284
|
+
while parent is not None:
|
|
285
|
+
if isinstance(parent, VerticalScroll):
|
|
286
|
+
parent.refresh(layout=True)
|
|
287
|
+
break
|
|
288
|
+
parent = parent.parent
|
|
289
|
+
except NoMatches:
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
def _rebuild_fields(self) -> None:
|
|
293
|
+
"""Rebuild the dynamic fields form."""
|
|
294
|
+
if self._vm.is_step_builder:
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
container = self.query_one("#md-fields", Vertical)
|
|
299
|
+
except NoMatches:
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
container.remove_children()
|
|
303
|
+
self._gen += 1
|
|
304
|
+
self._vm.list_items.clear()
|
|
305
|
+
|
|
306
|
+
variant = None
|
|
307
|
+
if self._vm.variant_choices:
|
|
308
|
+
try:
|
|
309
|
+
variant = _select_value(self.query_one("#md-disc", Select))
|
|
310
|
+
except NoMatches:
|
|
311
|
+
return
|
|
312
|
+
if not variant:
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
self._vm.load_fields(variant)
|
|
316
|
+
for desc in self._vm.descriptors:
|
|
317
|
+
self._mount_field(container, desc)
|
|
318
|
+
self._force_scroll_recalc(container)
|
|
319
|
+
|
|
320
|
+
def _populate_nested_subs(self, desc: FieldDescriptor, val: dict[str, Any]) -> None:
|
|
321
|
+
"""Populate nested sub-field widgets from a dict."""
|
|
322
|
+
for sub_desc in desc.item_descriptors: # type: ignore[union-attr]
|
|
323
|
+
if sub_desc.name not in val:
|
|
324
|
+
continue
|
|
325
|
+
sub_wid = self._wid(f"{desc.name}__{sub_desc.name}")
|
|
326
|
+
sub_val = val[sub_desc.name]
|
|
327
|
+
if sub_desc.kind == FieldKind.BOOL:
|
|
328
|
+
self.query_one(f"#{sub_wid}", Checkbox).value = bool(sub_val)
|
|
329
|
+
elif sub_desc.kind == FieldKind.SELECT:
|
|
330
|
+
self.query_one(f"#{sub_wid}", Select).value = str(sub_val)
|
|
331
|
+
elif sub_desc.kind in (FieldKind.INT, FieldKind.FLOAT, FieldKind.STRING):
|
|
332
|
+
self.query_one(f"#{sub_wid}", Input).value = str(sub_val)
|
|
333
|
+
|
|
334
|
+
def _populate_one_field(self, desc: FieldDescriptor, val: Any) -> None:
|
|
335
|
+
"""Populate a single field widget from existing data."""
|
|
336
|
+
wid = self._wid(desc.name)
|
|
337
|
+
if desc.kind == FieldKind.SELECT or (desc.kind == FieldKind.NESTED and desc.union_variants):
|
|
338
|
+
self.query_one(f"#{wid}", Select).value = str(val)
|
|
339
|
+
elif desc.kind == FieldKind.MULTI_SELECT:
|
|
340
|
+
selected = val if isinstance(val, list) else [val]
|
|
341
|
+
for choice in desc.choices:
|
|
342
|
+
with contextlib.suppress(NoMatches):
|
|
343
|
+
self.query_one(f"#{self._wid(f'{desc.name}-{choice}')}", Checkbox).value = choice in selected
|
|
344
|
+
elif desc.kind == FieldKind.BOOL:
|
|
345
|
+
self.query_one(f"#{wid}", Checkbox).value = bool(val)
|
|
346
|
+
elif desc.kind in (FieldKind.INT, FieldKind.FLOAT, FieldKind.STRING):
|
|
347
|
+
self.query_one(f"#{wid}", Input).value = str(val)
|
|
348
|
+
elif desc.kind == FieldKind.LIST and desc.union_variants and isinstance(val, list):
|
|
349
|
+
self._vm.list_items[desc.name] = [dict(v) if isinstance(v, dict) else v for v in val]
|
|
350
|
+
self._refresh_list_items(desc.name)
|
|
351
|
+
elif desc.kind == FieldKind.NESTED and desc.item_descriptors and isinstance(val, dict):
|
|
352
|
+
self._populate_nested_subs(desc, val)
|
|
353
|
+
elif desc.kind in (FieldKind.LIST, FieldKind.NESTED):
|
|
354
|
+
try:
|
|
355
|
+
self.query_one(f"#{wid}", Input).value = json.dumps(val)
|
|
356
|
+
except (TypeError, ValueError):
|
|
357
|
+
self.query_one(f"#{wid}", Input).value = str(val)
|
|
358
|
+
|
|
359
|
+
def _populate_fields(self) -> None:
|
|
360
|
+
"""Populate field widgets from existing data."""
|
|
361
|
+
if not self._existing:
|
|
362
|
+
return
|
|
363
|
+
for desc in self._vm.descriptors:
|
|
364
|
+
if desc.name not in self._existing:
|
|
365
|
+
continue
|
|
366
|
+
try:
|
|
367
|
+
self._populate_one_field(desc, self._existing[desc.name])
|
|
368
|
+
except NoMatches:
|
|
369
|
+
_log.debug("Widget %s not found while populating field '%s'", self._wid(desc.name), desc.name)
|
|
370
|
+
|
|
371
|
+
# -- Collection --------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
def _read_name_and_variant(self) -> tuple[str, str | None] | None:
|
|
374
|
+
"""Read name and discriminator from widgets. Returns None if invalid."""
|
|
375
|
+
try:
|
|
376
|
+
name = self.query_one("#md-name", Input).value.strip()
|
|
377
|
+
except NoMatches:
|
|
378
|
+
return None
|
|
379
|
+
if not name:
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
variant_value: str | None = None
|
|
383
|
+
if self._vm.disc_field:
|
|
384
|
+
try:
|
|
385
|
+
variant_value = _select_value(self.query_one("#md-disc", Select))
|
|
386
|
+
except NoMatches:
|
|
387
|
+
return None
|
|
388
|
+
if not variant_value:
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
return name, variant_value
|
|
392
|
+
|
|
393
|
+
def _read_nested_subs(self, desc: FieldDescriptor) -> dict[str, Any]:
|
|
394
|
+
"""Read nested sub-field widget values into a dict."""
|
|
395
|
+
result: dict[str, Any] = {}
|
|
396
|
+
for sub_desc in desc.item_descriptors: # type: ignore[union-attr]
|
|
397
|
+
sub_wid = self._wid(f"{desc.name}__{sub_desc.name}")
|
|
398
|
+
if sub_desc.kind == FieldKind.BOOL:
|
|
399
|
+
result[sub_desc.name] = self.query_one(f"#{sub_wid}", Checkbox).value
|
|
400
|
+
elif sub_desc.kind == FieldKind.SELECT:
|
|
401
|
+
result[sub_desc.name] = _select_value(self.query_one(f"#{sub_wid}", Select))
|
|
402
|
+
elif sub_desc.kind in (FieldKind.INT, FieldKind.FLOAT, FieldKind.STRING):
|
|
403
|
+
result[sub_desc.name] = self.query_one(f"#{sub_wid}", Input).value.strip()
|
|
404
|
+
return result
|
|
405
|
+
|
|
406
|
+
def _read_raw_field(self, desc: FieldDescriptor) -> Any:
|
|
407
|
+
"""Read a single raw widget value for a field descriptor."""
|
|
408
|
+
wid = self._wid(desc.name)
|
|
409
|
+
if desc.kind == FieldKind.SELECT or (desc.kind == FieldKind.NESTED and desc.union_variants):
|
|
410
|
+
return _select_value(self.query_one(f"#{wid}", Select))
|
|
411
|
+
if desc.kind == FieldKind.MULTI_SELECT:
|
|
412
|
+
return [c for c in desc.choices if self.query_one(f"#{self._wid(f'{desc.name}-{c}')}", Checkbox).value]
|
|
413
|
+
if desc.kind == FieldKind.BOOL:
|
|
414
|
+
return self.query_one(f"#{wid}", Checkbox).value
|
|
415
|
+
if desc.kind in (FieldKind.INT, FieldKind.FLOAT, FieldKind.STRING):
|
|
416
|
+
return self.query_one(f"#{wid}", Input).value.strip()
|
|
417
|
+
if desc.kind == FieldKind.LIST and desc.union_variants:
|
|
418
|
+
return None # data comes from vm.list_items, not a widget
|
|
419
|
+
if desc.kind == FieldKind.NESTED and desc.item_descriptors:
|
|
420
|
+
return self._read_nested_subs(desc)
|
|
421
|
+
if desc.kind in (FieldKind.LIST, FieldKind.NESTED):
|
|
422
|
+
return self.query_one(f"#{wid}", Input).value.strip()
|
|
423
|
+
return None
|
|
424
|
+
|
|
425
|
+
def _collect_all_fields(self) -> dict[str, Any]:
|
|
426
|
+
"""Read all widget values and delegate coercion to ViewModel."""
|
|
427
|
+
field_values: dict[str, Any] = {}
|
|
428
|
+
for desc in self._vm.descriptors:
|
|
429
|
+
val = self._try_collect_one(desc)
|
|
430
|
+
if val is not SKIP:
|
|
431
|
+
field_values[desc.name] = val
|
|
432
|
+
|
|
433
|
+
if self._section == "tasks" and self._existing:
|
|
434
|
+
field_values.setdefault("enabled", self._existing.get("enabled", True))
|
|
435
|
+
|
|
436
|
+
return field_values
|
|
437
|
+
|
|
438
|
+
def _try_collect_one(self, desc: FieldDescriptor) -> Any:
|
|
439
|
+
"""Read + coerce one field, returning SKIP on missing widget."""
|
|
440
|
+
try:
|
|
441
|
+
raw = self._read_raw_field(desc)
|
|
442
|
+
return self._vm.collect_field(desc, raw)
|
|
443
|
+
except NoMatches:
|
|
444
|
+
return SKIP
|
|
445
|
+
|
|
446
|
+
def _collect_raw(self) -> dict[str, Any] | None:
|
|
447
|
+
parsed = self._read_name_and_variant()
|
|
448
|
+
if parsed is None:
|
|
449
|
+
return None
|
|
450
|
+
name, variant_value = parsed
|
|
451
|
+
return self._vm.build_result(name, variant_value, self._collect_all_fields())
|
|
452
|
+
|
|
453
|
+
def _check_dirty(self) -> bool:
|
|
454
|
+
collected = self._collect_raw()
|
|
455
|
+
return self._vm.check_dirty(collected)
|
|
456
|
+
|
|
457
|
+
def _collect(self) -> dict[str, Any] | None:
|
|
458
|
+
result = self._collect_raw()
|
|
459
|
+
if result is None:
|
|
460
|
+
name = ""
|
|
461
|
+
with contextlib.suppress(NoMatches):
|
|
462
|
+
name = self.query_one("#md-name", Input).value.strip()
|
|
463
|
+
msg = self._vm.diagnose_failure(name, result)
|
|
464
|
+
self.app.notify(msg, severity="error")
|
|
465
|
+
return result
|
|
466
|
+
|
|
467
|
+
# -- Step builder (preprocessors / selections) -------------------------
|
|
468
|
+
|
|
469
|
+
def _rebuild_step_params(self) -> None:
|
|
470
|
+
step_name = _select_value(self.query_one("#md-step-select", Select))
|
|
471
|
+
container = self.query_one("#modal-params-form", Vertical)
|
|
472
|
+
if step_name:
|
|
473
|
+
params = self._vm.get_step_params(step_name)
|
|
474
|
+
build_param_form(container, params, "mdsp")
|
|
475
|
+
else:
|
|
476
|
+
container.remove_children()
|
|
477
|
+
|
|
478
|
+
def _add_step(self) -> None:
|
|
479
|
+
step_name = _select_value(self.query_one("#md-step-select", Select))
|
|
480
|
+
if not step_name:
|
|
481
|
+
self.app.notify("Select a step first.", severity="error")
|
|
482
|
+
return
|
|
483
|
+
param_infos = self._vm.get_step_params(step_name)
|
|
484
|
+
container = self.query_one("#modal-params-form", Vertical)
|
|
485
|
+
errors = validate_param_form(container, param_infos, "mdsp")
|
|
486
|
+
if errors:
|
|
487
|
+
for e in errors:
|
|
488
|
+
self.app.notify(e, severity="error")
|
|
489
|
+
return
|
|
490
|
+
params = collect_param_form(container, param_infos, "mdsp")
|
|
491
|
+
msg = self._vm.add_step(step_name, params)
|
|
492
|
+
self._refresh_step_list()
|
|
493
|
+
self._update_ok_state()
|
|
494
|
+
self.app.notify(msg)
|
|
495
|
+
|
|
496
|
+
def _refresh_step_list(self) -> None:
|
|
497
|
+
try:
|
|
498
|
+
container = self.query_one("#modal-step-list", Vertical)
|
|
499
|
+
except NoMatches:
|
|
500
|
+
return
|
|
501
|
+
container.remove_children()
|
|
502
|
+
lines = self._vm.step_display_lines()
|
|
503
|
+
if not lines:
|
|
504
|
+
container.mount(Static("[dim] No steps yet.[/dim]"))
|
|
505
|
+
return
|
|
506
|
+
for idx, text in enumerate(lines):
|
|
507
|
+
row = Horizontal(classes="step-row")
|
|
508
|
+
container.mount(row)
|
|
509
|
+
row.mount(Static(text))
|
|
510
|
+
row.mount(Button("X", id=f"btn-remove-step-{idx}", variant="error"))
|
|
511
|
+
|
|
512
|
+
# -- Union variant list builder (e.g. workflow detectors) ---------------
|
|
513
|
+
|
|
514
|
+
def _parse_picker_field(self, select_id: str) -> str | None:
|
|
515
|
+
prefix = f"md-{self._gen}-"
|
|
516
|
+
if not select_id.startswith(prefix) or not select_id.endswith("-picker"):
|
|
517
|
+
return None
|
|
518
|
+
return select_id[len(prefix) :].rsplit("-picker", 1)[0]
|
|
519
|
+
|
|
520
|
+
def _parse_add_field(self, btn_id: str) -> str | None:
|
|
521
|
+
prefix = f"md-{self._gen}-"
|
|
522
|
+
if not btn_id.startswith(prefix) or not btn_id.endswith("-add"):
|
|
523
|
+
return None
|
|
524
|
+
return btn_id[len(prefix) :].rsplit("-add", 1)[0]
|
|
525
|
+
|
|
526
|
+
def _parse_list_rem(self, btn_id: str) -> tuple[str, int] | None:
|
|
527
|
+
if not btn_id.startswith("md-listrem-"):
|
|
528
|
+
return None
|
|
529
|
+
rest = btn_id[len("md-listrem-") :]
|
|
530
|
+
parts = rest.rsplit("-", 1)
|
|
531
|
+
if len(parts) != 2:
|
|
532
|
+
return None
|
|
533
|
+
try:
|
|
534
|
+
return parts[0], int(parts[1])
|
|
535
|
+
except ValueError:
|
|
536
|
+
return None
|
|
537
|
+
|
|
538
|
+
def _rebuild_variant_params(self, field_name: str) -> None:
|
|
539
|
+
picker_id = self._wid(f"{field_name}-picker")
|
|
540
|
+
params_id = self._wid(f"{field_name}-params")
|
|
541
|
+
self._variant_param_gen += 1
|
|
542
|
+
vpg = self._variant_param_gen
|
|
543
|
+
|
|
544
|
+
try:
|
|
545
|
+
variant_key = _select_value(self.query_one(f"#{picker_id}", Select))
|
|
546
|
+
container = self.query_one(f"#{params_id}", Vertical)
|
|
547
|
+
container.remove_children()
|
|
548
|
+
if not variant_key:
|
|
549
|
+
return
|
|
550
|
+
variant_descs = self._vm.get_variant_descriptors(field_name, variant_key)
|
|
551
|
+
for vd in variant_descs:
|
|
552
|
+
vid = f"md-vp-{vpg}-{field_name}-{vd.name}"
|
|
553
|
+
self._mount_scalar_field(container, vd, vid)
|
|
554
|
+
except NoMatches:
|
|
555
|
+
_log.debug("Widget not found while rebuilding variant params for '%s'", field_name)
|
|
556
|
+
|
|
557
|
+
def _add_list_item(self, field_name: str) -> None:
|
|
558
|
+
desc = next((d for d in self._vm.descriptors if d.name == field_name), None)
|
|
559
|
+
if not desc or not desc.discriminator:
|
|
560
|
+
return
|
|
561
|
+
|
|
562
|
+
picker_id = self._wid(f"{field_name}-picker")
|
|
563
|
+
variant_key = _select_value(self.query_one(f"#{picker_id}", Select))
|
|
564
|
+
if not variant_key:
|
|
565
|
+
self.app.notify(f"Select a {desc.discriminator}.", severity="error")
|
|
566
|
+
return
|
|
567
|
+
|
|
568
|
+
variant_descs = self._vm.get_variant_descriptors(field_name, variant_key)
|
|
569
|
+
field_values: dict[str, Any] = {}
|
|
570
|
+
|
|
571
|
+
params_id = self._wid(f"{field_name}-params")
|
|
572
|
+
try:
|
|
573
|
+
container = self.query_one(f"#{params_id}", Vertical)
|
|
574
|
+
except NoMatches:
|
|
575
|
+
return
|
|
576
|
+
|
|
577
|
+
vpg = self._variant_param_gen
|
|
578
|
+
for vd in variant_descs:
|
|
579
|
+
wid = f"md-vp-{vpg}-{field_name}-{vd.name}"
|
|
580
|
+
try:
|
|
581
|
+
raw = self._read_variant_widget(container, vd, wid)
|
|
582
|
+
val = self._vm.collect_field(vd, raw)
|
|
583
|
+
if val is not SKIP:
|
|
584
|
+
field_values[vd.name] = val
|
|
585
|
+
except NoMatches:
|
|
586
|
+
pass
|
|
587
|
+
|
|
588
|
+
msg = self._vm.add_list_item(field_name, variant_key, field_values)
|
|
589
|
+
self._refresh_list_items(field_name)
|
|
590
|
+
self._update_ok_state()
|
|
591
|
+
if msg:
|
|
592
|
+
self.app.notify(msg)
|
|
593
|
+
|
|
594
|
+
def _read_variant_widget(self, container: Vertical, vd: FieldDescriptor, wid: str) -> Any:
|
|
595
|
+
"""Read a raw value from a variant param widget."""
|
|
596
|
+
if vd.kind == FieldKind.SELECT:
|
|
597
|
+
return _select_value(container.query_one(f"#{wid}", Select))
|
|
598
|
+
if vd.kind == FieldKind.BOOL:
|
|
599
|
+
return container.query_one(f"#{wid}", Checkbox).value
|
|
600
|
+
if vd.kind in (FieldKind.INT, FieldKind.FLOAT, FieldKind.STRING):
|
|
601
|
+
return container.query_one(f"#{wid}", Input).value.strip()
|
|
602
|
+
return None
|
|
603
|
+
|
|
604
|
+
def _refresh_list_items(self, field_name: str) -> None:
|
|
605
|
+
list_id = self._wid(f"{field_name}-list")
|
|
606
|
+
try:
|
|
607
|
+
container = self.query_one(f"#{list_id}", Vertical)
|
|
608
|
+
except NoMatches:
|
|
609
|
+
return
|
|
610
|
+
container.remove_children()
|
|
611
|
+
items = self._vm.list_items.get(field_name, [])
|
|
612
|
+
if not items:
|
|
613
|
+
container.mount(Static("[dim] (none added)[/dim]"))
|
|
614
|
+
return
|
|
615
|
+
for idx, item in enumerate(items):
|
|
616
|
+
parts = [f"{k}={v}" for k, v in item.items()]
|
|
617
|
+
text = f"{idx + 1}. {', '.join(parts)}"
|
|
618
|
+
row = Horizontal(classes="step-row")
|
|
619
|
+
container.mount(row)
|
|
620
|
+
row.mount(Static(text))
|
|
621
|
+
row.mount(Button("X", id=f"md-listrem-{field_name}-{idx}", variant="error"))
|