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,189 @@
1
+ """Item snippet rendering for the configuration builder.
2
+
3
+ Presentation-layer functions that produce Rich-markup snippets for display
4
+ in the TUI cards. These live in the ViewModel layer because they transform
5
+ Model data into view-ready strings. No Textual widget dependency.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ import yaml
13
+
14
+ if TYPE_CHECKING:
15
+ from dataeval_flow._app._model._execution import TaskExecution
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Individual snippet renderers
19
+ # ---------------------------------------------------------------------------
20
+
21
+
22
+ def _snippet_steps(item: dict[str, Any], key: str) -> str:
23
+ lines = [f"[bold]{item.get('name', '?')}[/bold]"]
24
+ for s in item.get("steps", []):
25
+ label = s.get(key, "?")
26
+ params = s.get("params", {})
27
+ if params:
28
+ p_str = ", ".join(f"{k}={v}" for k, v in params.items())
29
+ lines.append(f" {label}({p_str})")
30
+ else:
31
+ lines.append(f" {label}")
32
+ return "\n".join(lines)
33
+
34
+
35
+ def _snippet_dataset(item: dict[str, Any]) -> str:
36
+ name = item.get("name", "?")
37
+ fmt = item.get("format", "?")
38
+ path = item.get("path", "?")
39
+ split = item.get("split", "")
40
+ lines = [f"[bold]{name}[/bold]", f" format: {fmt} path: {path}"]
41
+ if split:
42
+ lines.append(f" split: {split}")
43
+ return "\n".join(lines)
44
+
45
+
46
+ def _snippet_source(item: dict[str, Any]) -> str:
47
+ name = item.get("name", "?")
48
+ dataset = item.get("dataset", "?")
49
+ selection = item.get("selection", "")
50
+ lines = [f"[bold]{name}[/bold]", f" dataset: {dataset}"]
51
+ if selection:
52
+ lines.append(f" selection: {selection}")
53
+ return "\n".join(lines)
54
+
55
+
56
+ def _snippet_extractor(item: dict[str, Any]) -> str:
57
+ name = item.get("name", "?")
58
+ model_type = item.get("model", "?")
59
+ lines = [f"[bold]{name}[/bold] [dim]{model_type}[/dim]"]
60
+ if item.get("model_path"):
61
+ lines.append(f" path: {item['model_path']}")
62
+ if model_type == "bovw" and item.get("vocab_size"):
63
+ lines.append(f" vocab_size: {item['vocab_size']}")
64
+ pre = item.get("preprocessor", "")
65
+ if pre:
66
+ lines.append(f" preprocessor: {pre}")
67
+ batch = item.get("batch_size", "")
68
+ if batch:
69
+ lines.append(f" batch_size: {batch}")
70
+ return "\n".join(lines)
71
+
72
+
73
+ def _snippet_workflow(item: dict[str, Any]) -> str:
74
+ name = item.get("name", "?")
75
+ wf_type = item.get("type", "?")
76
+ lines = [f"[bold]{name}[/bold] [dim]{wf_type}[/dim]"]
77
+ extras = [f" {k}: {v}" for k, v in item.items() if k not in ("name", "type") and v]
78
+ lines.extend(extras)
79
+ return "\n".join(lines)
80
+
81
+
82
+ def _snippet_task(item: dict[str, Any]) -> str:
83
+ enabled = item.get("enabled", True)
84
+ check = "[bold green]\u2713[/bold green]" if enabled else "[dim]\u2717[/dim]"
85
+ name = item.get("name", "?")
86
+ wf = item.get("workflow", "?")
87
+ srcs = item.get("sources", "")
88
+ if isinstance(srcs, list):
89
+ srcs = ", ".join(srcs)
90
+ lines = [f"{check} [bold]{name}[/bold]"]
91
+ lines.append(f" workflow: {wf} sources: {srcs}")
92
+ extractor = item.get("extractor", "")
93
+ if extractor:
94
+ lines.append(f" extractor: {extractor}")
95
+ text = "\n".join(lines)
96
+ if not enabled:
97
+ text = f"{check} [dim strikethrough][bold]{name}[/bold]\n workflow: {wf} sources: {srcs}"
98
+ if extractor:
99
+ text += f"\n extractor: {extractor}"
100
+ text += "[/dim strikethrough]"
101
+ return text
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Dispatch table and entry point
106
+ # ---------------------------------------------------------------------------
107
+
108
+ _SNIPPET_RENDERERS: dict[str, Any] = {
109
+ "datasets": _snippet_dataset,
110
+ "preprocessors": lambda item: _snippet_steps(item, "step"),
111
+ "selections": lambda item: _snippet_steps(item, "type"),
112
+ "sources": _snippet_source,
113
+ "extractors": _snippet_extractor,
114
+ "workflows": _snippet_workflow,
115
+ "tasks": _snippet_task,
116
+ }
117
+
118
+
119
+ def _item_to_yaml_snippet(category: str, item: dict[str, Any]) -> str:
120
+ renderer = _SNIPPET_RENDERERS.get(category)
121
+ if renderer:
122
+ return renderer(item)
123
+ return yaml.dump(item, default_flow_style=True).strip()
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Execution-aware task snippet (for dashboard task pane)
128
+ # ---------------------------------------------------------------------------
129
+
130
+ _STATUS_INDICATORS: dict[str, str] = {
131
+ "idle": "[dim]\u25cf[/dim]",
132
+ "running": "[bold yellow]\u25d0[/bold yellow] [yellow]running...[/yellow]",
133
+ "completed": "[bold green]\u2713[/bold green]",
134
+ "failed": "[bold red]\u2717[/bold red] [red]failed[/red]",
135
+ }
136
+
137
+
138
+ def snippet_task_with_execution(task: dict[str, Any], execution: TaskExecution | None = None) -> str:
139
+ """Render a task card snippet with execution status indicator.
140
+
141
+ Used in the dashboard task pane where status is shown inline.
142
+ """
143
+ enabled = task.get("enabled", True)
144
+ check = "[bold green]\u2713[/bold green]" if enabled else "[dim]\u2717[/dim]"
145
+ name = task.get("name", "?")
146
+ wf = task.get("workflow", "?")
147
+ srcs = task.get("sources", "")
148
+ if isinstance(srcs, list):
149
+ srcs = ", ".join(srcs)
150
+
151
+ # Status indicator
152
+ if execution is not None:
153
+ status = _STATUS_INDICATORS.get(execution.status, _STATUS_INDICATORS["idle"])
154
+ if execution.status == "completed" and execution.elapsed_s is not None:
155
+ status += f" [dim]{execution.elapsed_s:.1f}s[/dim]"
156
+ else:
157
+ status = _STATUS_INDICATORS["idle"]
158
+
159
+ # Build the snippet
160
+ line1 = f"{check} [bold]{name}[/bold] {status}"
161
+ line2 = f" {wf} > {srcs}"
162
+ extractor = task.get("extractor", "")
163
+ if extractor:
164
+ line2 += f" [dim]({extractor})[/dim]"
165
+
166
+ if not enabled:
167
+ return f"{check} [dim strikethrough]{name} {wf} > {srcs}[/dim strikethrough] {status}"
168
+ return f"{line1}\n{line2}"
169
+
170
+
171
+ def snippet_config_item(category: str, item: dict[str, Any]) -> str:
172
+ """Compact one-line snippet for config sidebar items."""
173
+ name = item.get("name", "?")
174
+ if category == "datasets":
175
+ fmt = item.get("format", "")
176
+ return f"[bold]{name}[/bold] [dim]{fmt}[/dim]"
177
+ if category == "extractors":
178
+ model = item.get("model", "")
179
+ return f"[bold]{name}[/bold] [dim]{model}[/dim]"
180
+ if category == "workflows":
181
+ wf_type = item.get("type", "")
182
+ return f"[bold]{name}[/bold] [dim]{wf_type}[/dim]"
183
+ if category == "sources":
184
+ dataset = item.get("dataset", "")
185
+ return f"[bold]{name}[/bold] [dim]({dataset})[/dim]"
186
+ if category in ("preprocessors", "selections"):
187
+ n_steps = len(item.get("steps", []))
188
+ return f"[bold]{name}[/bold] [dim]{n_steps} step{'s' if n_steps != 1 else ''}[/dim]"
189
+ return f"[bold]{name}[/bold]"
@@ -0,0 +1,210 @@
1
+ """ViewModel for rendering workflow results in the TUI.
2
+
3
+ Transforms ``WorkflowResult`` data into view-ready structures.
4
+ No Textual dependency — consumed by the result modal and result cards.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from dataeval_flow.workflow._text_report import (
13
+ _brief_value,
14
+ _render_detail_section,
15
+ _summary_line,
16
+ )
17
+
18
+ __all__ = ["FindingSummary", "ResultViewModel"]
19
+
20
+
21
+ @dataclass
22
+ class FindingSummary:
23
+ """View-ready summary of a single report finding."""
24
+
25
+ title: str
26
+ severity: str # "ok" | "info" | "warning"
27
+ brief: str
28
+ report_type: str
29
+ has_table: bool
30
+
31
+
32
+ class ResultViewModel:
33
+ """Transforms a ``WorkflowResult`` into view-ready structures."""
34
+
35
+ def __init__(self, result: Any) -> None:
36
+ self._result = result
37
+ self._findings = self._extract_findings()
38
+
39
+ def _extract_findings(self) -> list[Any]:
40
+ report_obj = getattr(self._result.data, "report", None)
41
+ if report_obj is None:
42
+ return []
43
+ return list(getattr(report_obj, "findings", []))
44
+
45
+ # -- Summary -----------------------------------------------------------
46
+
47
+ def summary_line(self) -> str:
48
+ """One-line summary: finding count, warning count, duration."""
49
+ findings = self._findings
50
+ n = len(findings)
51
+ warnings = sum(1 for f in findings if getattr(f, "severity", "info") == "warning")
52
+ parts: list[str] = []
53
+ parts.append(f"{n} finding{'s' if n != 1 else ''}")
54
+ if warnings:
55
+ parts.append(f"{warnings} warning{'s' if warnings != 1 else ''}")
56
+ meta = self._result.metadata
57
+ if meta.execution_time_s is not None:
58
+ parts.append(f"{meta.execution_time_s:.1f}s")
59
+ return ", ".join(parts)
60
+
61
+ def report_summary(self) -> str:
62
+ """The workflow's own summary string (e.g. 'Data Cleaning Report')."""
63
+ report_obj = getattr(self._result.data, "report", None)
64
+ if report_obj is None:
65
+ return ""
66
+ return getattr(report_obj, "summary", "")
67
+
68
+ # -- Metadata ----------------------------------------------------------
69
+
70
+ def metadata_lines(self) -> list[str]:
71
+ """Human-readable metadata lines (timestamp, duration, source, model)."""
72
+ meta = self._result.metadata
73
+ lines: list[str] = []
74
+ if meta.timestamp:
75
+ lines.append(f"Timestamp: {meta.timestamp.isoformat()}")
76
+ if meta.execution_time_s is not None:
77
+ lines.append(f"Duration: {meta.execution_time_s:.2f}s")
78
+ lines.extend(f"Source: {desc}" for desc in getattr(meta, "source_descriptions", []))
79
+ if meta.model_id:
80
+ lines.append(f"Model: {meta.model_id}")
81
+ if meta.preprocessor_id:
82
+ lines.append(f"Preprocessor: {meta.preprocessor_id}")
83
+ return lines
84
+
85
+ # -- Findings ----------------------------------------------------------
86
+
87
+ def finding_count(self) -> int:
88
+ """Number of findings."""
89
+ return len(self._findings)
90
+
91
+ def warning_count(self) -> int:
92
+ """Number of findings with severity 'warning'."""
93
+ return sum(1 for f in self._findings if getattr(f, "severity", "info") == "warning")
94
+
95
+ def finding_summaries(self) -> list[FindingSummary]:
96
+ """Return view-ready summaries for all findings."""
97
+ summaries: list[FindingSummary] = []
98
+ for finding in self._findings:
99
+ rt = finding.report_type
100
+ summaries.append(
101
+ FindingSummary(
102
+ title=finding.title,
103
+ severity=getattr(finding, "severity", "info"),
104
+ brief=_brief_value(finding),
105
+ report_type=rt,
106
+ has_table=rt in ("table", "pivot_table", "classwise_table", "chunk_table"),
107
+ )
108
+ )
109
+ return summaries
110
+
111
+ def finding_summary_markup(self, idx: int) -> str:
112
+ """Rich-markup one-liner for finding at *idx* (dotted summary style)."""
113
+ if 0 <= idx < len(self._findings):
114
+ return _summary_line(self._findings[idx])
115
+ return ""
116
+
117
+ def finding_detail_markup(self, idx: int) -> str:
118
+ """Rich-markup detail block for finding at *idx*."""
119
+ if 0 <= idx < len(self._findings):
120
+ lines = _render_detail_section(self._findings[idx])
121
+ return "\n".join(lines)
122
+ return ""
123
+
124
+ def finding_table_data(self, idx: int) -> tuple[list[str], list[list[str]]] | None:
125
+ """Extract structured table data for ``DataTable`` rendering.
126
+
127
+ Returns ``(headers, rows)`` where each row is a list of strings,
128
+ or ``None`` if the finding doesn't have tabular data.
129
+ """
130
+ if not (0 <= idx < len(self._findings)):
131
+ return None
132
+
133
+ finding = self._findings[idx]
134
+ data = finding.data
135
+ if not isinstance(data, dict):
136
+ return None
137
+
138
+ rt = finding.report_type
139
+
140
+ if rt == "table":
141
+ return self._extract_simple_table(data)
142
+ if rt == "pivot_table":
143
+ return self._extract_pivot_table(data)
144
+ if rt in ("classwise_table", "chunk_table"):
145
+ return self._extract_row_table(data)
146
+ return None
147
+
148
+ # -- Health summary ----------------------------------------------------
149
+
150
+ def health_line(self) -> str:
151
+ """Health status string for the summary section."""
152
+ warnings = self.warning_count()
153
+ if warnings:
154
+ return f"Health: {warnings} warning(s) — review flagged findings"
155
+ return "Health: All checks passed"
156
+
157
+ # -- Table extraction helpers ------------------------------------------
158
+
159
+ @staticmethod
160
+ def _extract_simple_table(data: dict[str, Any]) -> tuple[list[str], list[list[str]]] | None:
161
+ """Extract from ``table`` report type (dict of name→count)."""
162
+ table_data: dict[str, int] = data.get("table_data", {})
163
+ if not table_data:
164
+ return None
165
+ headers_raw = data.get("table_headers", ("Name", "Value"))
166
+ headers: list[str] = [str(h) for h in headers_raw]
167
+ rows = [[str(k), str(v)] for k, v in sorted(table_data.items(), key=lambda x: -x[1])]
168
+ return headers, rows
169
+
170
+ @staticmethod
171
+ def _extract_pivot_table(data: dict[str, Any]) -> tuple[list[str], list[list[str]]] | None:
172
+ """Extract from ``pivot_table`` report type."""
173
+ rows_data: list[dict[str, Any]] = data.get("table_data", [])
174
+ headers: list[str] = data.get("table_headers", [])
175
+ if not rows_data or not headers:
176
+ return None
177
+
178
+ key_aliases: dict[str, str] = {"%": "pct", "Class Name": "class_name", "Count": "count"}
179
+ keys = [key_aliases.get(h, h) for h in headers]
180
+
181
+ rows: list[list[str]] = []
182
+ for row in rows_data:
183
+ cells: list[str] = []
184
+ for key in keys:
185
+ val = row.get(key, "")
186
+ if key == "pct" and isinstance(val, (int, float)):
187
+ cells.append(f"{val:.1f}%")
188
+ else:
189
+ cells.append(str(val) if val is not None else "")
190
+ rows.append(cells)
191
+ return headers, rows
192
+
193
+ @staticmethod
194
+ def _extract_row_table(data: dict[str, Any]) -> tuple[list[str], list[list[str]]] | None:
195
+ """Extract from ``classwise_table`` or ``chunk_table`` (list of row dicts)."""
196
+ rows_data: list[dict[str, Any]] = data.get("table_rows", [])
197
+ if not rows_data:
198
+ return None
199
+ headers = list(rows_data[0].keys())
200
+ rows: list[list[str]] = []
201
+ for row in rows_data:
202
+ cells: list[str] = []
203
+ for h in headers:
204
+ val = row.get(h, "")
205
+ if isinstance(val, float):
206
+ cells.append(f"{val:.4f}")
207
+ else:
208
+ cells.append(str(val) if val is not None else "")
209
+ rows.append(cells)
210
+ return headers, rows
@@ -0,0 +1,224 @@
1
+ """ViewModel for the SectionModal.
2
+
3
+ Manages field descriptors, step-builder state, union-list state,
4
+ and item collection/validation. No UI dependencies.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from dataeval_flow._app._model._introspect import FieldDescriptor, FieldKind, introspect_model
12
+ from dataeval_flow._app._model._item import (
13
+ SKIP,
14
+ build_item_dict,
15
+ collect_bool_value,
16
+ collect_field_value,
17
+ collect_json_value,
18
+ collect_multi_select_value,
19
+ diagnose_collect_failure,
20
+ )
21
+ from dataeval_flow._app._model._registry import (
22
+ STEP_BUILDER_SECTIONS,
23
+ get_discriminator_field,
24
+ get_fields,
25
+ get_variant_choices,
26
+ )
27
+ from dataeval_flow._app._model._state import ConfigState
28
+
29
+ __all__ = ["SectionViewModel"]
30
+
31
+
32
+ class SectionViewModel:
33
+ """ViewModel for creating/editing items in any config section."""
34
+
35
+ def __init__(
36
+ self,
37
+ section: str,
38
+ existing: dict[str, Any] | None = None,
39
+ state: ConfigState | None = None,
40
+ ) -> None:
41
+ self.section = section
42
+ self.state = state or ConfigState()
43
+ self.existing = existing
44
+ self.original: dict[str, Any] | None = dict(existing) if existing else None
45
+ self.descriptors: list[FieldDescriptor] = []
46
+ self.steps: list[dict[str, Any]] = []
47
+ self.list_items: dict[str, list[dict[str, Any]]] = {}
48
+ self._step_choices: list[str] = []
49
+ self._get_step_params: Any = None
50
+
51
+ if existing and section in STEP_BUILDER_SECTIONS:
52
+ self.steps = [dict(s) for s in existing.get("steps", [])]
53
+
54
+ # -- Properties --------------------------------------------------------
55
+
56
+ @property
57
+ def is_edit_mode(self) -> bool:
58
+ return self.existing is not None
59
+
60
+ @property
61
+ def is_step_builder(self) -> bool:
62
+ return self.section in STEP_BUILDER_SECTIONS
63
+
64
+ @property
65
+ def variant_choices(self) -> list[str] | None:
66
+ return get_variant_choices(self.section)
67
+
68
+ @property
69
+ def disc_field(self) -> str | None:
70
+ return get_discriminator_field(self.section)
71
+
72
+ @property
73
+ def step_choices(self) -> list[str]:
74
+ return self._step_choices
75
+
76
+ # -- Field descriptors -------------------------------------------------
77
+
78
+ def load_fields(self, variant_value: str | None) -> list[FieldDescriptor]:
79
+ """Load field descriptors for the given variant. Stores them internally."""
80
+ self.descriptors = get_fields(self.section, variant_value, self.state)
81
+ return self.descriptors
82
+
83
+ # -- Step builder ------------------------------------------------------
84
+
85
+ def init_step_builder(self) -> list[str]:
86
+ """Initialize step builder and return available step choices."""
87
+ spec = STEP_BUILDER_SECTIONS[self.section]
88
+ from dataeval_flow._app._model import _discover
89
+
90
+ self._step_choices = getattr(_discover, spec["list_fn"])()
91
+ self._get_step_params = getattr(_discover, spec["params_fn"])
92
+ return self._step_choices
93
+
94
+ def get_step_params(self, step_name: str) -> list[Any]:
95
+ """Return parameter info for a step type."""
96
+ if self._get_step_params:
97
+ return self._get_step_params(step_name)
98
+ return []
99
+
100
+ def add_step(self, step_name: str, params: dict[str, Any]) -> str:
101
+ """Add a step with collected params. Returns notification message."""
102
+ spec = STEP_BUILDER_SECTIONS[self.section]
103
+ step: dict[str, Any] = {spec["step_key"]: step_name}
104
+ if params:
105
+ step["params"] = params
106
+ self.steps.append(step)
107
+ return f"Added step '{step_name}'."
108
+
109
+ def remove_step(self, index: int) -> bool:
110
+ """Remove step at index. Returns True if removed."""
111
+ if 0 <= index < len(self.steps):
112
+ self.steps.pop(index)
113
+ return True
114
+ return False
115
+
116
+ def step_display_lines(self) -> list[str]:
117
+ """Return formatted display strings for each step."""
118
+ spec = STEP_BUILDER_SECTIONS.get(self.section, {})
119
+ step_key = spec.get("step_key", "step")
120
+ lines: list[str] = []
121
+ for idx, step in enumerate(self.steps):
122
+ name = step.get(step_key, "?")
123
+ params = step.get("params", {})
124
+ if params:
125
+ p_str = ", ".join(f"{k}={v}" for k, v in params.items())
126
+ lines.append(f"{idx + 1}. {name}({p_str})")
127
+ else:
128
+ lines.append(f"{idx + 1}. {name}")
129
+ return lines
130
+
131
+ # -- Union list builder ------------------------------------------------
132
+
133
+ def add_list_item(self, field_name: str, variant_key: str, field_values: dict[str, Any]) -> str:
134
+ """Add a union-list item. Returns notification message."""
135
+ desc = next((d for d in self.descriptors if d.name == field_name), None)
136
+ if not desc or not desc.discriminator:
137
+ return ""
138
+ item: dict[str, Any] = {desc.discriminator: variant_key}
139
+ item.update(field_values)
140
+ self.list_items.setdefault(field_name, []).append(item)
141
+ return f"Added {variant_key}."
142
+
143
+ def remove_list_item(self, field_name: str, index: int) -> bool:
144
+ """Remove a union-list item. Returns True if removed."""
145
+ items = self.list_items.get(field_name, [])
146
+ if 0 <= index < len(items):
147
+ items.pop(index)
148
+ return True
149
+ return False
150
+
151
+ def get_variant_descriptors(self, field_name: str, variant_key: str) -> list[FieldDescriptor]:
152
+ """Return field descriptors for a union variant, excluding the discriminator and complex fields."""
153
+ desc = next((d for d in self.descriptors if d.name == field_name), None)
154
+ if not desc or not desc.union_variants or not desc.discriminator:
155
+ return []
156
+ variant_model = desc.union_variants.get(variant_key)
157
+ if not variant_model:
158
+ return []
159
+ return [
160
+ vd
161
+ for vd in introspect_model(variant_model)
162
+ if vd.name != desc.discriminator and vd.kind.value not in ("nested", "list")
163
+ ]
164
+
165
+ # -- Collection (pure logic, takes raw values from view) ---------------
166
+
167
+ def collect_field(self, desc: FieldDescriptor, raw_value: Any) -> Any:
168
+ """Coerce a raw widget value for a single field. Returns SKIP if empty."""
169
+ if desc.kind == FieldKind.SELECT or (desc.kind == FieldKind.NESTED and desc.union_variants):
170
+ return raw_value if raw_value else SKIP
171
+ if desc.kind == FieldKind.MULTI_SELECT:
172
+ return collect_multi_select_value(raw_value or [], self.section, desc.name)
173
+ if desc.kind == FieldKind.BOOL:
174
+ return collect_bool_value(raw_value, desc.default)
175
+ if desc.kind in (FieldKind.INT, FieldKind.FLOAT, FieldKind.STRING):
176
+ return collect_field_value(desc, raw_value or "")
177
+ if desc.kind == FieldKind.LIST and desc.union_variants:
178
+ items = self.list_items.get(desc.name, [])
179
+ return list(items) if items else SKIP
180
+ if desc.kind == FieldKind.NESTED and desc.item_descriptors and isinstance(raw_value, dict):
181
+ coerced: dict[str, Any] = {}
182
+ for sub_desc in desc.item_descriptors:
183
+ sub_raw = raw_value.get(sub_desc.name)
184
+ sub_val = self.collect_field(sub_desc, sub_raw)
185
+ if sub_val is not SKIP:
186
+ coerced[sub_desc.name] = sub_val
187
+ return coerced if coerced else SKIP
188
+ if desc.kind in (FieldKind.LIST, FieldKind.NESTED):
189
+ return collect_json_value(raw_value or "")
190
+ return SKIP
191
+
192
+ def build_result(
193
+ self,
194
+ name: str,
195
+ variant_value: str | None,
196
+ field_values: dict[str, Any],
197
+ ) -> dict[str, Any] | None:
198
+ """Assemble the final item dict from collected values. Returns None if invalid."""
199
+ if not name:
200
+ return None
201
+ if self.disc_field and not variant_value:
202
+ return None
203
+ if self.is_step_builder:
204
+ if not self.steps:
205
+ return None
206
+ return build_item_dict(self.section, name, variant_value, {"steps": list(self.steps)})
207
+
208
+ # Preserve existing enabled state for tasks
209
+ if self.section == "tasks" and self.existing:
210
+ field_values.setdefault("enabled", self.existing.get("enabled", True))
211
+
212
+ return build_item_dict(self.section, name, variant_value, field_values)
213
+
214
+ def check_dirty(self, collected: dict[str, Any] | None) -> bool:
215
+ """Return True if collected data differs from original."""
216
+ if not collected:
217
+ return False
218
+ if not self.original:
219
+ return True
220
+ return collected != self.original
221
+
222
+ def diagnose_failure(self, name: str, result: dict[str, Any] | None = None) -> str:
223
+ """Return a human-readable error message when build_result returns None."""
224
+ return diagnose_collect_failure(self.section, name, self.steps, self.descriptors, result)