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,255 @@
|
|
|
1
|
+
"""Variant registry, section constants, cross-reference overlays, and field descriptor helpers.
|
|
2
|
+
|
|
3
|
+
Auto-built by introspecting :class:`~dataeval_flow.config._models.PipelineConfig`
|
|
4
|
+
so that new schema variants (e.g. a new workflow type) are picked up automatically.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from typing import Annotated, Any, Literal, Union, get_args, get_origin
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from dataeval_flow._app._model._introspect import (
|
|
15
|
+
FieldDescriptor,
|
|
16
|
+
FieldKind,
|
|
17
|
+
introspect_model,
|
|
18
|
+
)
|
|
19
|
+
from dataeval_flow.config._models import PipelineConfig
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"CROSS_REFS",
|
|
23
|
+
"MULTI_REF_FIELDS",
|
|
24
|
+
"SECTION_KEYS",
|
|
25
|
+
"SECTION_MODELS",
|
|
26
|
+
"SECTIONS",
|
|
27
|
+
"STEP_BUILDER_SECTIONS",
|
|
28
|
+
"VARIANT_REGISTRY",
|
|
29
|
+
"WORKFLOW_SKIP_FIELDS",
|
|
30
|
+
"get_discriminator_field",
|
|
31
|
+
"get_fields",
|
|
32
|
+
"get_model_for_variant",
|
|
33
|
+
"get_variant_choices",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Section ordering
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
SECTIONS: list[tuple[str, str]] = [
|
|
41
|
+
("datasets", "Datasets"),
|
|
42
|
+
("selections", "Selections"),
|
|
43
|
+
("sources", "Sources"),
|
|
44
|
+
("preprocessors", "Preprocessors"),
|
|
45
|
+
("extractors", "Extractors"),
|
|
46
|
+
("workflows", "Workflows"),
|
|
47
|
+
("tasks", "Tasks"),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
SECTION_KEYS: list[str] = [s[0] for s in SECTIONS]
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Variant registry building
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _get_literal_value(model: type[BaseModel], field_name: str) -> str | None:
|
|
58
|
+
"""Return the ``Literal`` default for *field_name*, or ``None``."""
|
|
59
|
+
field_info = model.model_fields.get(field_name)
|
|
60
|
+
if field_info is None:
|
|
61
|
+
return None
|
|
62
|
+
ann = field_info.annotation
|
|
63
|
+
if get_origin(ann) is Literal:
|
|
64
|
+
args = get_args(ann)
|
|
65
|
+
if args:
|
|
66
|
+
return str(args[0])
|
|
67
|
+
if field_info.default is not None:
|
|
68
|
+
return str(field_info.default)
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _unwrap_sequence_inner(annotation: Any) -> Any | None:
|
|
73
|
+
"""Unwrap ``Optional[Sequence[X]]`` and return *X*, or ``None``."""
|
|
74
|
+
args = get_args(annotation)
|
|
75
|
+
non_none = [a for a in args if a is not type(None)]
|
|
76
|
+
if len(non_none) == 1:
|
|
77
|
+
annotation = non_none[0]
|
|
78
|
+
origin = get_origin(annotation)
|
|
79
|
+
if not (origin is Sequence or (origin is not None and issubclass(origin, Sequence))):
|
|
80
|
+
return None
|
|
81
|
+
inner_args = get_args(annotation)
|
|
82
|
+
return inner_args[0] if inner_args else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _extract_discriminated_variants(inner: Any) -> tuple[str, dict[str, type[BaseModel]]] | None:
|
|
86
|
+
"""If *inner* is an ``Annotated`` discriminated union, return ``(disc, variants)``."""
|
|
87
|
+
if get_origin(inner) is Union:
|
|
88
|
+
for ua in get_args(inner):
|
|
89
|
+
if get_origin(ua) is Annotated:
|
|
90
|
+
inner = ua
|
|
91
|
+
break
|
|
92
|
+
if get_origin(inner) is not Annotated:
|
|
93
|
+
return None
|
|
94
|
+
annotated_args = get_args(inner)
|
|
95
|
+
union_type, field_meta = annotated_args[0], annotated_args[1]
|
|
96
|
+
disc = getattr(field_meta, "discriminator", None)
|
|
97
|
+
if not disc or not get_args(union_type):
|
|
98
|
+
return None
|
|
99
|
+
variants: dict[str, type[BaseModel]] = {}
|
|
100
|
+
for member in get_args(union_type):
|
|
101
|
+
if isinstance(member, type) and issubclass(member, BaseModel):
|
|
102
|
+
val = _get_literal_value(member, disc)
|
|
103
|
+
if val:
|
|
104
|
+
variants[val] = member
|
|
105
|
+
return (disc, variants) if variants else None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _build_registries() -> tuple[dict[str, tuple[str, dict[str, type[BaseModel]]]], dict[str, type[BaseModel]]]:
|
|
109
|
+
"""Introspect :class:`PipelineConfig` to derive the variant and section-model registries."""
|
|
110
|
+
variant_registry: dict[str, tuple[str, dict[str, type[BaseModel]]]] = {}
|
|
111
|
+
section_models: dict[str, type[BaseModel]] = {}
|
|
112
|
+
|
|
113
|
+
for name, field_info in PipelineConfig.model_fields.items():
|
|
114
|
+
inner = _unwrap_sequence_inner(field_info.annotation)
|
|
115
|
+
if inner is None:
|
|
116
|
+
continue
|
|
117
|
+
result = _extract_discriminated_variants(inner)
|
|
118
|
+
if result is not None:
|
|
119
|
+
variant_registry[name] = result
|
|
120
|
+
elif isinstance(inner, type) and issubclass(inner, BaseModel):
|
|
121
|
+
section_models[name] = inner
|
|
122
|
+
|
|
123
|
+
return variant_registry, section_models
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
VARIANT_REGISTRY, SECTION_MODELS = _build_registries()
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# Cross-reference overlays
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
CROSS_REFS: dict[str, dict[str, str]] = {
|
|
133
|
+
"sources": {"dataset": "datasets", "selection": "selections"},
|
|
134
|
+
"tasks": {"workflow": "workflows", "extractor": "extractors"},
|
|
135
|
+
"extractors": {"preprocessor": "preprocessors"},
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
MULTI_REF_FIELDS: dict[str, dict[str, str]] = {
|
|
139
|
+
"tasks": {"sources": "sources"},
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Step-builder sections (preprocessors, selections)
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
STEP_BUILDER_SECTIONS: dict[str, dict[str, str]] = {
|
|
147
|
+
"preprocessors": {
|
|
148
|
+
"step_key": "step",
|
|
149
|
+
"list_fn": "list_transforms",
|
|
150
|
+
"params_fn": "get_transform_params",
|
|
151
|
+
},
|
|
152
|
+
"selections": {
|
|
153
|
+
"step_key": "type",
|
|
154
|
+
"list_fn": "list_selection_classes",
|
|
155
|
+
"params_fn": "get_selection_params",
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
WORKFLOW_SKIP_FIELDS = frozenset({"name", "type", "mode"})
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Registry helpers
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_variant_choices(section: str) -> list[str] | None:
|
|
167
|
+
"""Return discriminator choices for *section*, or ``None`` if not discriminated."""
|
|
168
|
+
if section in VARIANT_REGISTRY:
|
|
169
|
+
_, variants = VARIANT_REGISTRY[section]
|
|
170
|
+
return list(variants)
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def get_discriminator_field(section: str) -> str | None:
|
|
175
|
+
"""Return the discriminator field name for *section*, or ``None``."""
|
|
176
|
+
if section in VARIANT_REGISTRY:
|
|
177
|
+
return VARIANT_REGISTRY[section][0]
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def get_model_for_variant(section: str, variant_value: str) -> type[BaseModel] | None:
|
|
182
|
+
"""Return the concrete Pydantic model for a variant value."""
|
|
183
|
+
if section in VARIANT_REGISTRY:
|
|
184
|
+
_, variants = VARIANT_REGISTRY[section]
|
|
185
|
+
return variants.get(variant_value)
|
|
186
|
+
return SECTION_MODELS.get(section)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
# Field descriptors with cross-ref overlay
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _apply_cross_refs(
|
|
195
|
+
descriptors: list[FieldDescriptor],
|
|
196
|
+
section: str,
|
|
197
|
+
state: Any,
|
|
198
|
+
) -> None:
|
|
199
|
+
cross_refs = CROSS_REFS.get(section, {})
|
|
200
|
+
multi_refs = MULTI_REF_FIELDS.get(section, {})
|
|
201
|
+
for desc in descriptors:
|
|
202
|
+
if desc.name in cross_refs:
|
|
203
|
+
ref_section = cross_refs[desc.name]
|
|
204
|
+
desc.choices = state.names(ref_section)
|
|
205
|
+
if desc.kind not in (FieldKind.MULTI_SELECT,):
|
|
206
|
+
desc.kind = FieldKind.SELECT
|
|
207
|
+
if desc.name in multi_refs:
|
|
208
|
+
ref_section = multi_refs[desc.name]
|
|
209
|
+
desc.choices = state.names(ref_section)
|
|
210
|
+
desc.kind = FieldKind.MULTI_SELECT
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _build_skip_set(section: str, *, skip_name: bool) -> set[str]:
|
|
214
|
+
disc_field = get_discriminator_field(section)
|
|
215
|
+
skip: set[str] = set()
|
|
216
|
+
if skip_name:
|
|
217
|
+
skip.add("name")
|
|
218
|
+
if disc_field:
|
|
219
|
+
skip.add(disc_field)
|
|
220
|
+
if section == "workflows":
|
|
221
|
+
skip.update(WORKFLOW_SKIP_FIELDS)
|
|
222
|
+
if section in STEP_BUILDER_SECTIONS:
|
|
223
|
+
skip.add("steps")
|
|
224
|
+
return skip
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def get_fields(
|
|
228
|
+
section: str,
|
|
229
|
+
variant_value: str | None,
|
|
230
|
+
state: Any,
|
|
231
|
+
*,
|
|
232
|
+
skip_name: bool = True,
|
|
233
|
+
) -> list[FieldDescriptor]:
|
|
234
|
+
"""Return field descriptors for creating/editing an item.
|
|
235
|
+
|
|
236
|
+
For discriminated sections, *variant_value* selects the concrete model.
|
|
237
|
+
Cross-reference fields get their ``choices`` populated from *state*.
|
|
238
|
+
|
|
239
|
+
The ``name`` field and the discriminator field are excluded by default
|
|
240
|
+
(the caller handles those separately).
|
|
241
|
+
"""
|
|
242
|
+
if section in VARIANT_REGISTRY:
|
|
243
|
+
if variant_value is None:
|
|
244
|
+
return []
|
|
245
|
+
model = get_model_for_variant(section, variant_value)
|
|
246
|
+
else:
|
|
247
|
+
model = SECTION_MODELS.get(section)
|
|
248
|
+
|
|
249
|
+
if model is None:
|
|
250
|
+
return []
|
|
251
|
+
|
|
252
|
+
descriptors = introspect_model(model)
|
|
253
|
+
_apply_cross_refs(descriptors, section, state)
|
|
254
|
+
skip = _build_skip_set(section, skip_name=skip_name)
|
|
255
|
+
return [d for d in descriptors if d.name not in skip]
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Shared mutable config state for the builder.
|
|
2
|
+
|
|
3
|
+
Both the Textual TUI and Click CLI operate on the same ``ConfigState``
|
|
4
|
+
object.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
from dataeval_flow._app._model._item import DELETE_SENTINEL, finalize_item
|
|
20
|
+
from dataeval_flow._app._model._registry import (
|
|
21
|
+
SECTION_KEYS,
|
|
22
|
+
SECTION_MODELS,
|
|
23
|
+
VARIANT_REGISTRY,
|
|
24
|
+
)
|
|
25
|
+
from dataeval_flow.config._models import PipelineConfig
|
|
26
|
+
|
|
27
|
+
__all__ = ["ConfigState"]
|
|
28
|
+
|
|
29
|
+
_log = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Helpers
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _strip_empty_params(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
38
|
+
"""Remove empty ``params`` dicts from step lists."""
|
|
39
|
+
cleaned: list[dict[str, Any]] = []
|
|
40
|
+
for item in items:
|
|
41
|
+
item = dict(item)
|
|
42
|
+
if "steps" in item:
|
|
43
|
+
item["steps"] = [{k: v for k, v in s.items() if k != "params" or v} for s in item["steps"]]
|
|
44
|
+
cleaned.append(item)
|
|
45
|
+
return cleaned
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _to_dict(obj: Any) -> dict[str, Any]:
|
|
49
|
+
"""Convert a Pydantic model or dict to a plain dict."""
|
|
50
|
+
if isinstance(obj, BaseModel):
|
|
51
|
+
return obj.model_dump(exclude_none=True)
|
|
52
|
+
return dict(obj)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# ConfigState
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ConfigState:
|
|
61
|
+
"""Shared mutable config state for the builder.
|
|
62
|
+
|
|
63
|
+
Both the Textual TUI and Click CLI operate on this same object.
|
|
64
|
+
The internal representation is a dict of lists (one per section),
|
|
65
|
+
where each item is a plain dict suitable for YAML serialization.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self) -> None:
|
|
69
|
+
self._data: dict[str, list[dict[str, Any]]] = {s: [] for s in SECTION_KEYS}
|
|
70
|
+
|
|
71
|
+
# -- Queries -----------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def items(self, section: str) -> list[dict[str, Any]]:
|
|
74
|
+
"""Return a shallow copy of items in *section*."""
|
|
75
|
+
return list(self._data[section])
|
|
76
|
+
|
|
77
|
+
def names(self, section: str) -> list[str]:
|
|
78
|
+
"""Return the names of all items in *section*."""
|
|
79
|
+
return [item.get("name", "") for item in self._data[section]]
|
|
80
|
+
|
|
81
|
+
def get(self, section: str, index: int) -> dict[str, Any] | None:
|
|
82
|
+
"""Return item at *index*, or ``None``."""
|
|
83
|
+
items = self._data[section]
|
|
84
|
+
if 0 <= index < len(items):
|
|
85
|
+
return items[index]
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def count(self, section: str) -> int:
|
|
89
|
+
"""Return the number of items in *section*."""
|
|
90
|
+
return len(self._data[section])
|
|
91
|
+
|
|
92
|
+
def is_empty(self) -> bool:
|
|
93
|
+
"""Return ``True`` if all sections are empty."""
|
|
94
|
+
return all(len(v) == 0 for v in self._data.values())
|
|
95
|
+
|
|
96
|
+
# -- Mutations ---------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def add(self, section: str, item: dict[str, Any]) -> None:
|
|
99
|
+
"""Append *item* to *section*."""
|
|
100
|
+
self._data[section].append(item)
|
|
101
|
+
|
|
102
|
+
def update(self, section: str, index: int, item: dict[str, Any]) -> None:
|
|
103
|
+
"""Replace the item at *index* in *section*."""
|
|
104
|
+
items = self._data[section]
|
|
105
|
+
if 0 <= index < len(items):
|
|
106
|
+
items[index] = item
|
|
107
|
+
|
|
108
|
+
def remove(self, section: str, index: int) -> tuple[str, list[str]]:
|
|
109
|
+
"""Remove item at *index*. Returns ``(removed_name, warnings)``."""
|
|
110
|
+
items = self._data[section]
|
|
111
|
+
if not (0 <= index < len(items)):
|
|
112
|
+
return ("", [])
|
|
113
|
+
removed = items.pop(index)
|
|
114
|
+
name = removed.get("name", "")
|
|
115
|
+
warnings = self._scrub_references(section, name) if name else []
|
|
116
|
+
return name, warnings
|
|
117
|
+
|
|
118
|
+
# -- Load / Save -------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def load_dict(self, data: dict[str, Any] | BaseModel) -> None:
|
|
121
|
+
"""Populate state from a raw dict or a ``PipelineConfig``."""
|
|
122
|
+
if isinstance(data, BaseModel):
|
|
123
|
+
data = data.model_dump(exclude_none=True)
|
|
124
|
+
self._data = {s: [] for s in SECTION_KEYS}
|
|
125
|
+
for section in SECTION_KEYS:
|
|
126
|
+
for item in data.get(section) or []:
|
|
127
|
+
self._data[section].append(_to_dict(item))
|
|
128
|
+
for task in self._data["tasks"]:
|
|
129
|
+
task.setdefault("enabled", True)
|
|
130
|
+
|
|
131
|
+
def to_dict(self) -> dict[str, Any]:
|
|
132
|
+
"""Export state as a plain dict suitable for YAML serialization."""
|
|
133
|
+
result: dict[str, Any] = {}
|
|
134
|
+
for section in SECTION_KEYS:
|
|
135
|
+
items = self._data[section]
|
|
136
|
+
if items:
|
|
137
|
+
if section in ("preprocessors", "selections"):
|
|
138
|
+
items = _strip_empty_params(items)
|
|
139
|
+
result[section] = items
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
def load_file(self, path: Path) -> str | None:
|
|
143
|
+
"""Load config from *path*. Returns a warning string or ``None``."""
|
|
144
|
+
fallback = False
|
|
145
|
+
if path.is_dir():
|
|
146
|
+
from dataeval_flow.config import load_config_folder
|
|
147
|
+
|
|
148
|
+
config = load_config_folder(path)
|
|
149
|
+
self.load_dict(config)
|
|
150
|
+
else:
|
|
151
|
+
try:
|
|
152
|
+
from dataeval_flow.config import load_config
|
|
153
|
+
|
|
154
|
+
config = load_config(path)
|
|
155
|
+
self.load_dict(config)
|
|
156
|
+
except (ValueError, TypeError, KeyError, OSError):
|
|
157
|
+
fallback = True
|
|
158
|
+
with open(path, encoding="utf-8") as f:
|
|
159
|
+
raw = yaml.safe_load(f) or {}
|
|
160
|
+
self.load_dict(raw)
|
|
161
|
+
if fallback:
|
|
162
|
+
return "Loaded as raw YAML — some fields may not validate"
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
def save_file(self, path: Path) -> None:
|
|
166
|
+
"""Save config to *path* (YAML or JSON based on suffix)."""
|
|
167
|
+
config = self.to_dict()
|
|
168
|
+
if not config:
|
|
169
|
+
return
|
|
170
|
+
if path.suffix not in (".yaml", ".yml", ".json"):
|
|
171
|
+
path = path.with_suffix(".yaml")
|
|
172
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
if path.suffix == ".json":
|
|
174
|
+
path.write_text(json.dumps(config, indent=2), encoding="utf-8")
|
|
175
|
+
else:
|
|
176
|
+
path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False), encoding="utf-8")
|
|
177
|
+
|
|
178
|
+
# -- Snapshot / Restore ------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def snapshot(self) -> dict[str, list[dict[str, Any]]]:
|
|
181
|
+
"""Return a deep copy of internal state."""
|
|
182
|
+
return copy.deepcopy(self._data)
|
|
183
|
+
|
|
184
|
+
def restore(self, snap: dict[str, list[dict[str, Any]]]) -> None:
|
|
185
|
+
"""Restore from a previous snapshot."""
|
|
186
|
+
self._data = copy.deepcopy(snap)
|
|
187
|
+
|
|
188
|
+
# -- Validation --------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
def validate_item(self, section: str, data: dict[str, Any]) -> list[str]:
|
|
191
|
+
"""Validate a single item dict. Returns a list of error strings."""
|
|
192
|
+
if section in VARIANT_REGISTRY:
|
|
193
|
+
disc_field, variants = VARIANT_REGISTRY[section]
|
|
194
|
+
disc_value = data.get(disc_field)
|
|
195
|
+
model = variants.get(str(disc_value)) if disc_value else None
|
|
196
|
+
else:
|
|
197
|
+
model = SECTION_MODELS.get(section)
|
|
198
|
+
|
|
199
|
+
if model is None:
|
|
200
|
+
return [f"Unknown {section} type"]
|
|
201
|
+
try:
|
|
202
|
+
model.model_validate(data)
|
|
203
|
+
return []
|
|
204
|
+
except (ValueError, TypeError) as e:
|
|
205
|
+
return [str(e)]
|
|
206
|
+
|
|
207
|
+
def validate_all(self) -> list[str]:
|
|
208
|
+
"""Validate the full config via ``PipelineConfig``. Returns errors."""
|
|
209
|
+
try:
|
|
210
|
+
PipelineConfig.model_validate(self.to_dict())
|
|
211
|
+
return []
|
|
212
|
+
except (ValueError, TypeError) as e:
|
|
213
|
+
return [str(e)]
|
|
214
|
+
|
|
215
|
+
def to_pipeline_config(self) -> PipelineConfig:
|
|
216
|
+
"""Return a validated ``PipelineConfig`` from current state.
|
|
217
|
+
|
|
218
|
+
Raises
|
|
219
|
+
------
|
|
220
|
+
ValueError
|
|
221
|
+
If the current state does not pass validation.
|
|
222
|
+
"""
|
|
223
|
+
return PipelineConfig.model_validate(self.to_dict())
|
|
224
|
+
|
|
225
|
+
# -- Reference scrubbing -----------------------------------------------
|
|
226
|
+
|
|
227
|
+
_TASK_FIELD_MAP: Mapping[str, str] = {
|
|
228
|
+
"sources": "sources",
|
|
229
|
+
"extractors": "extractor",
|
|
230
|
+
"workflows": "workflow",
|
|
231
|
+
}
|
|
232
|
+
_REQUIRED_TASK_KEYS = frozenset({"sources", "workflow"})
|
|
233
|
+
|
|
234
|
+
def _scrub_references(self, section: str, removed_name: str) -> list[str]:
|
|
235
|
+
"""Remove stale references after a deletion. Returns warnings."""
|
|
236
|
+
warnings: list[str] = []
|
|
237
|
+
|
|
238
|
+
task_key = self._TASK_FIELD_MAP.get(section)
|
|
239
|
+
if task_key:
|
|
240
|
+
to_remove: list[int] = []
|
|
241
|
+
for i, task in enumerate(self._data["tasks"]):
|
|
242
|
+
if not self._scrub_task_field(task, task_key, removed_name):
|
|
243
|
+
to_remove.append(i)
|
|
244
|
+
for i in reversed(to_remove):
|
|
245
|
+
removed_task = self._data["tasks"].pop(i)
|
|
246
|
+
warnings.append(f"Auto-removed task '{removed_task.get('name', '')}'")
|
|
247
|
+
|
|
248
|
+
if section == "datasets":
|
|
249
|
+
warnings.extend(self._scrub_comp("sources", "dataset", removed_name, required=True))
|
|
250
|
+
elif section == "selections":
|
|
251
|
+
warnings.extend(self._scrub_comp("sources", "selection", removed_name, required=False))
|
|
252
|
+
elif section == "preprocessors":
|
|
253
|
+
warnings.extend(self._scrub_comp("extractors", "preprocessor", removed_name, required=False))
|
|
254
|
+
|
|
255
|
+
return warnings
|
|
256
|
+
|
|
257
|
+
def _scrub_comp(self, list_key: str, field_key: str, name: str, *, required: bool) -> list[str]:
|
|
258
|
+
"""Scrub composition-layer references."""
|
|
259
|
+
warnings: list[str] = []
|
|
260
|
+
items = self._data[list_key]
|
|
261
|
+
to_remove: list[int] = []
|
|
262
|
+
for i, item in enumerate(items):
|
|
263
|
+
if item.get(field_key) == name:
|
|
264
|
+
if required:
|
|
265
|
+
to_remove.append(i)
|
|
266
|
+
else:
|
|
267
|
+
del item[field_key]
|
|
268
|
+
for i in reversed(to_remove):
|
|
269
|
+
removed_item = items.pop(i)
|
|
270
|
+
removed_name = removed_item.get("name", "")
|
|
271
|
+
warnings.append(f"Auto-removed {list_key[:-1]} '{removed_name}'")
|
|
272
|
+
if removed_name:
|
|
273
|
+
warnings.extend(self._scrub_references(list_key, removed_name))
|
|
274
|
+
return warnings
|
|
275
|
+
|
|
276
|
+
def _scrub_task_field(self, task: dict[str, Any], key: str, name: str) -> bool:
|
|
277
|
+
"""Scrub *name* from a single task field. Returns ``False`` if the task should be removed."""
|
|
278
|
+
val = task.get(key)
|
|
279
|
+
if val is None:
|
|
280
|
+
return True
|
|
281
|
+
if isinstance(val, list):
|
|
282
|
+
filtered = [v for v in val if v != name]
|
|
283
|
+
if not filtered:
|
|
284
|
+
return False
|
|
285
|
+
if len(filtered) != len(val):
|
|
286
|
+
task[key] = filtered[0] if len(filtered) == 1 else filtered
|
|
287
|
+
return True
|
|
288
|
+
if val != name:
|
|
289
|
+
return True
|
|
290
|
+
if key in self._REQUIRED_TASK_KEYS:
|
|
291
|
+
return False
|
|
292
|
+
del task[key]
|
|
293
|
+
return True
|
|
294
|
+
|
|
295
|
+
# -- Modal result handling (shared by TUI and CLI) ---------------------
|
|
296
|
+
|
|
297
|
+
def apply_modal_result(self, section: str, index: int, result: dict | str | None) -> tuple[str, list[str]] | None:
|
|
298
|
+
"""Apply the result of a create/edit/delete operation.
|
|
299
|
+
|
|
300
|
+
Returns ``(description, warnings)`` for mutations, or ``None`` for
|
|
301
|
+
cancel (result is ``None``).
|
|
302
|
+
"""
|
|
303
|
+
if result is None:
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
if result == DELETE_SENTINEL:
|
|
307
|
+
existing = self.get(section, index)
|
|
308
|
+
item_name = existing.get("name", "?") if existing else "?"
|
|
309
|
+
_, warnings = self.remove(section, index)
|
|
310
|
+
return (f"Delete {section[:-1]} '{item_name}'", warnings)
|
|
311
|
+
|
|
312
|
+
if not isinstance(result, dict):
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
result = finalize_item(section, result)
|
|
316
|
+
name = result.get("name", "?")
|
|
317
|
+
if index >= 0:
|
|
318
|
+
self.update(section, index, result)
|
|
319
|
+
return (f"Update {section[:-1]} '{name}'", [])
|
|
320
|
+
|
|
321
|
+
self.add(section, result)
|
|
322
|
+
return (f"Add {section[:-1]} '{name}'", [])
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Undo/redo stack for the configuration builder.
|
|
2
|
+
|
|
3
|
+
Pure data structure with no UI dependency. Uses full state snapshots
|
|
4
|
+
for simplicity and correctness.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"UndoEntry",
|
|
15
|
+
"UndoStack",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class UndoEntry:
|
|
21
|
+
"""A snapshot of builder state paired with a human-readable description."""
|
|
22
|
+
|
|
23
|
+
state: dict[str, list[dict[str, Any]]]
|
|
24
|
+
description: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class UndoStack:
|
|
29
|
+
"""Fixed-depth undo/redo history using full state snapshots."""
|
|
30
|
+
|
|
31
|
+
_undo: list[UndoEntry] = field(default_factory=list)
|
|
32
|
+
_redo: list[UndoEntry] = field(default_factory=list)
|
|
33
|
+
max_depth: int = 50
|
|
34
|
+
|
|
35
|
+
def push(self, state: dict[str, list[dict[str, Any]]], description: str) -> None:
|
|
36
|
+
self._undo.append(UndoEntry(state=copy.deepcopy(state), description=description))
|
|
37
|
+
if len(self._undo) > self.max_depth:
|
|
38
|
+
self._undo.pop(0)
|
|
39
|
+
self._redo.clear()
|
|
40
|
+
|
|
41
|
+
def undo(self, current_state: dict[str, list[dict[str, Any]]]) -> UndoEntry | None:
|
|
42
|
+
if not self._undo:
|
|
43
|
+
return None
|
|
44
|
+
entry = self._undo.pop()
|
|
45
|
+
self._redo.append(UndoEntry(state=copy.deepcopy(current_state), description=entry.description))
|
|
46
|
+
return entry
|
|
47
|
+
|
|
48
|
+
def redo(self, current_state: dict[str, list[dict[str, Any]]]) -> UndoEntry | None:
|
|
49
|
+
if not self._redo:
|
|
50
|
+
return None
|
|
51
|
+
entry = self._redo.pop()
|
|
52
|
+
self._undo.append(UndoEntry(state=copy.deepcopy(current_state), description=entry.description))
|
|
53
|
+
return entry
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def can_undo(self) -> bool:
|
|
57
|
+
return len(self._undo) > 0
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def can_redo(self) -> bool:
|
|
61
|
+
return len(self._redo) > 0
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Pane components and widgets for the dashboard app."""
|
|
2
|
+
|
|
3
|
+
from dataeval_flow._app._panes._config_pane import ConfigPaneMixin
|
|
4
|
+
from dataeval_flow._app._panes._result_pane import ResultPaneMixin
|
|
5
|
+
from dataeval_flow._app._panes._task_pane import TaskPaneMixin
|
|
6
|
+
from dataeval_flow._app._panes._widgets import (
|
|
7
|
+
_CONFIG_SECTIONS,
|
|
8
|
+
PANE_IDS,
|
|
9
|
+
SECTION_TITLES,
|
|
10
|
+
CfgItem,
|
|
11
|
+
CfgSectionHeader,
|
|
12
|
+
PaneWidget,
|
|
13
|
+
ResultCard,
|
|
14
|
+
ResultPaneHeader,
|
|
15
|
+
TaskCard,
|
|
16
|
+
TaskPaneHeader,
|
|
17
|
+
uid,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"_CONFIG_SECTIONS",
|
|
22
|
+
"CfgItem",
|
|
23
|
+
"CfgSectionHeader",
|
|
24
|
+
"ConfigPaneMixin",
|
|
25
|
+
"PANE_IDS",
|
|
26
|
+
"PaneWidget",
|
|
27
|
+
"ResultCard",
|
|
28
|
+
"ResultPaneHeader",
|
|
29
|
+
"ResultPaneMixin",
|
|
30
|
+
"SECTION_TITLES",
|
|
31
|
+
"TaskCard",
|
|
32
|
+
"TaskPaneMixin",
|
|
33
|
+
"TaskPaneHeader",
|
|
34
|
+
"uid",
|
|
35
|
+
]
|