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,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"))