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,592 @@
|
|
|
1
|
+
"""Lightweight click-based configuration builder/editor.
|
|
2
|
+
|
|
3
|
+
Uses ``click.prompt`` / ``click.confirm`` / ``click.Choice`` for interactive
|
|
4
|
+
input so it works without the ``textual`` dependency. Consumes the
|
|
5
|
+
:class:`~dataeval_flow._app._viewmodel._builder_vm.BuilderViewModel` for
|
|
6
|
+
state management and :class:`SectionViewModel` for field introspection.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from dataeval_flow._app._model._introspect import FieldDescriptor, FieldKind
|
|
19
|
+
from dataeval_flow._app._model._registry import SECTIONS, get_discriminator_field
|
|
20
|
+
from dataeval_flow._app._viewmodel._builder_vm import BuilderViewModel
|
|
21
|
+
|
|
22
|
+
__all__ = ["run_cli_builder"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Prompt helpers
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _prompt_select(desc: FieldDescriptor, hint: str, existing_value: Any) -> Any:
|
|
31
|
+
display = f"{desc.name}{hint}"
|
|
32
|
+
if not desc.required:
|
|
33
|
+
choices = list(desc.choices) + [""]
|
|
34
|
+
display += " [Enter to skip]"
|
|
35
|
+
else:
|
|
36
|
+
choices = list(desc.choices)
|
|
37
|
+
default = str(existing_value) if existing_value is not None and str(existing_value) in choices else None
|
|
38
|
+
if not desc.required and default is None:
|
|
39
|
+
default = ""
|
|
40
|
+
val = click.prompt(
|
|
41
|
+
display,
|
|
42
|
+
type=click.Choice(choices, case_sensitive=False),
|
|
43
|
+
default=default,
|
|
44
|
+
show_choices=True,
|
|
45
|
+
)
|
|
46
|
+
return val if val else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _prompt_multi_select(desc: FieldDescriptor, hint: str, existing_value: Any) -> list[str] | None:
|
|
50
|
+
click.echo(f"\n {desc.name}{hint} (check all that apply):")
|
|
51
|
+
existing_list = existing_value if isinstance(existing_value, list) else []
|
|
52
|
+
selected: list[str] = []
|
|
53
|
+
for choice in desc.choices:
|
|
54
|
+
default_yn = choice in existing_list
|
|
55
|
+
if click.confirm(f" Include '{choice}'?", default=default_yn):
|
|
56
|
+
selected.append(choice)
|
|
57
|
+
return selected if selected else None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _prompt_bool(desc: FieldDescriptor, hint: str, existing_value: Any) -> bool:
|
|
61
|
+
default = (
|
|
62
|
+
existing_value
|
|
63
|
+
if isinstance(existing_value, bool)
|
|
64
|
+
else (desc.default if isinstance(desc.default, bool) else False)
|
|
65
|
+
)
|
|
66
|
+
return click.confirm(f"{desc.name}{hint}", default=default)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _prompt_numeric(desc: FieldDescriptor, suffix: str, hint: str, existing_value: Any) -> Any:
|
|
70
|
+
prompt_text = f"{desc.name}{suffix}{hint}"
|
|
71
|
+
constraint_parts = [f"{k}={v}" for k, v in desc.constraints.items()]
|
|
72
|
+
if constraint_parts:
|
|
73
|
+
prompt_text += f" [{', '.join(constraint_parts)}]"
|
|
74
|
+
default = existing_value if existing_value is not None else desc.default
|
|
75
|
+
typ = float if desc.kind == FieldKind.FLOAT else int
|
|
76
|
+
if default is not None:
|
|
77
|
+
return click.prompt(prompt_text, default=default, type=typ)
|
|
78
|
+
if desc.required:
|
|
79
|
+
return click.prompt(prompt_text, type=typ)
|
|
80
|
+
raw = click.prompt(prompt_text, default="", show_default=False)
|
|
81
|
+
if raw == "":
|
|
82
|
+
return None
|
|
83
|
+
return typ(raw)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _prompt_json(desc: FieldDescriptor, suffix: str, hint: str, existing_value: Any) -> Any:
|
|
87
|
+
default = (
|
|
88
|
+
json.dumps(existing_value)
|
|
89
|
+
if existing_value is not None
|
|
90
|
+
else (json.dumps(desc.default) if desc.default is not None else "")
|
|
91
|
+
)
|
|
92
|
+
prompt_text = f"{desc.name} (JSON){suffix}{hint}"
|
|
93
|
+
raw = click.prompt(prompt_text, default=default, show_default=bool(default))
|
|
94
|
+
if not raw:
|
|
95
|
+
return None
|
|
96
|
+
try:
|
|
97
|
+
return json.loads(raw)
|
|
98
|
+
except (ValueError, TypeError):
|
|
99
|
+
click.echo(" Warning: could not parse as JSON, storing as string.")
|
|
100
|
+
return raw
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _prompt_field(desc: FieldDescriptor, existing_value: Any = None) -> Any:
|
|
104
|
+
"""Prompt the user for a single field value based on its descriptor."""
|
|
105
|
+
suffix = "" if desc.required else " (optional, Enter to skip)"
|
|
106
|
+
hint = f" ({desc.description})" if desc.description else ""
|
|
107
|
+
|
|
108
|
+
if desc.kind == FieldKind.SELECT and desc.choices:
|
|
109
|
+
return _prompt_select(desc, hint, existing_value)
|
|
110
|
+
|
|
111
|
+
if desc.kind == FieldKind.MULTI_SELECT and desc.choices:
|
|
112
|
+
return _prompt_multi_select(desc, hint, existing_value)
|
|
113
|
+
|
|
114
|
+
if desc.kind == FieldKind.BOOL:
|
|
115
|
+
return _prompt_bool(desc, hint, existing_value)
|
|
116
|
+
|
|
117
|
+
if desc.kind in (FieldKind.INT, FieldKind.FLOAT):
|
|
118
|
+
return _prompt_numeric(desc, suffix, hint, existing_value)
|
|
119
|
+
|
|
120
|
+
if desc.kind in (FieldKind.LIST, FieldKind.NESTED):
|
|
121
|
+
if desc.union_variants and desc.discriminator:
|
|
122
|
+
return _prompt_union_list(desc, existing_value)
|
|
123
|
+
if desc.nested_model:
|
|
124
|
+
return _prompt_nested_model(desc, existing_value)
|
|
125
|
+
return _prompt_json(desc, suffix, hint, existing_value)
|
|
126
|
+
|
|
127
|
+
# STRING (default)
|
|
128
|
+
default = (
|
|
129
|
+
str(existing_value) if existing_value is not None else (str(desc.default) if desc.default is not None else "")
|
|
130
|
+
)
|
|
131
|
+
prompt_text = f"{desc.name}{suffix}{hint}"
|
|
132
|
+
val = click.prompt(prompt_text, default=default, show_default=bool(default))
|
|
133
|
+
return val if val else None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _union_list_add(desc: FieldDescriptor, items: list[dict[str, Any]], singular: str) -> None:
|
|
137
|
+
variant_choices = list(desc.union_variants) # type: ignore[arg-type]
|
|
138
|
+
variant_key = click.prompt(
|
|
139
|
+
f" {desc.discriminator}",
|
|
140
|
+
type=click.Choice(variant_choices, case_sensitive=False),
|
|
141
|
+
show_choices=True,
|
|
142
|
+
)
|
|
143
|
+
variant_model = desc.union_variants[variant_key] # type: ignore[index]
|
|
144
|
+
from dataeval_flow._app._model._introspect import introspect_model as _introspect
|
|
145
|
+
|
|
146
|
+
variant_descs = _introspect(variant_model)
|
|
147
|
+
item: dict[str, Any] = {desc.discriminator: variant_key} # type: ignore[dict-item]
|
|
148
|
+
for vd in variant_descs:
|
|
149
|
+
if vd.name == desc.discriminator:
|
|
150
|
+
continue
|
|
151
|
+
val = _prompt_field(vd)
|
|
152
|
+
if val is not None:
|
|
153
|
+
item[vd.name] = val
|
|
154
|
+
items.append(item)
|
|
155
|
+
click.echo(f" Added {variant_key} {singular}.")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _union_list_remove(items: list[dict[str, Any]]) -> None:
|
|
159
|
+
if not items:
|
|
160
|
+
click.echo(" Nothing to remove.")
|
|
161
|
+
return
|
|
162
|
+
idx = click.prompt(" Remove index", type=int) - 1
|
|
163
|
+
if 0 <= idx < len(items):
|
|
164
|
+
items.pop(idx)
|
|
165
|
+
else:
|
|
166
|
+
click.echo(" Invalid index.")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _prompt_union_list(desc: FieldDescriptor, existing: Any) -> list[dict[str, Any]]:
|
|
170
|
+
"""Prompt for a list of discriminated-union items (e.g. detectors)."""
|
|
171
|
+
if not desc.union_variants or not desc.discriminator:
|
|
172
|
+
msg = "union_variants and discriminator are required"
|
|
173
|
+
raise ValueError(msg)
|
|
174
|
+
items: list[dict[str, Any]] = []
|
|
175
|
+
if isinstance(existing, list):
|
|
176
|
+
items = [dict(x) for x in existing]
|
|
177
|
+
|
|
178
|
+
singular = desc.name[:-1] if desc.name.endswith("s") else desc.name
|
|
179
|
+
|
|
180
|
+
while True:
|
|
181
|
+
if items:
|
|
182
|
+
click.echo(f"\n Current {desc.name}:")
|
|
183
|
+
for i, item in enumerate(items):
|
|
184
|
+
parts = [f"{k}={v}" for k, v in item.items()]
|
|
185
|
+
click.echo(f" {i + 1}. {', '.join(parts)}")
|
|
186
|
+
|
|
187
|
+
click.echo(f"\n Options: [a]dd {singular}, [r]emove, [d]one")
|
|
188
|
+
action = click.prompt(" Action", type=click.Choice(["a", "r", "d"]), default="d")
|
|
189
|
+
|
|
190
|
+
if action == "d":
|
|
191
|
+
break
|
|
192
|
+
if action == "r":
|
|
193
|
+
_union_list_remove(items)
|
|
194
|
+
elif action == "a":
|
|
195
|
+
_union_list_add(desc, items, singular)
|
|
196
|
+
|
|
197
|
+
return items
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _prompt_nested_model(desc: FieldDescriptor, existing: Any) -> dict[str, Any] | None:
|
|
201
|
+
"""Prompt for a nested Pydantic model."""
|
|
202
|
+
if desc.nested_model is None:
|
|
203
|
+
return None
|
|
204
|
+
from dataeval_flow._app._model._introspect import introspect_model as _introspect
|
|
205
|
+
|
|
206
|
+
click.echo(f"\n {desc.name}:")
|
|
207
|
+
nested_descs = _introspect(desc.nested_model)
|
|
208
|
+
result: dict[str, Any] = {}
|
|
209
|
+
existing_dict = existing if isinstance(existing, dict) else {}
|
|
210
|
+
for nested_desc in nested_descs:
|
|
211
|
+
val = _prompt_field(nested_desc, existing_dict.get(nested_desc.name))
|
|
212
|
+
if val is not None:
|
|
213
|
+
result[nested_desc.name] = val
|
|
214
|
+
return result if result else None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
# Step builder (preprocessors / selections)
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _steps_remove(steps: list[dict[str, Any]]) -> None:
|
|
223
|
+
if not steps:
|
|
224
|
+
click.echo(" No steps to remove.")
|
|
225
|
+
return
|
|
226
|
+
idx = click.prompt(" Remove index", type=int) - 1
|
|
227
|
+
if 0 <= idx < len(steps):
|
|
228
|
+
steps.pop(idx)
|
|
229
|
+
else:
|
|
230
|
+
click.echo(" Invalid index.")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _steps_prompt_param(p: Any) -> tuple[str, Any] | None:
|
|
234
|
+
suffix = "" if p.required else " (optional, Enter to skip)"
|
|
235
|
+
if p.choices:
|
|
236
|
+
return _steps_prompt_choice_param(p, suffix)
|
|
237
|
+
if p.type_hint == "bool":
|
|
238
|
+
return _steps_prompt_bool_param(p)
|
|
239
|
+
return _steps_prompt_text_param(p, suffix)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _steps_prompt_choice_param(p: Any, suffix: str) -> tuple[str, Any] | None:
|
|
243
|
+
choices = list(p.choices) + [""] if not p.required else list(p.choices)
|
|
244
|
+
val = click.prompt(
|
|
245
|
+
f" {p.name} [{p.type_hint}]{suffix}",
|
|
246
|
+
type=click.Choice(choices, case_sensitive=False),
|
|
247
|
+
default=str(p.default)
|
|
248
|
+
if p.default is not None and str(p.default) in choices
|
|
249
|
+
else ("" if not p.required else None),
|
|
250
|
+
show_choices=True,
|
|
251
|
+
)
|
|
252
|
+
return (p.name, val) if val else None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _steps_prompt_bool_param(p: Any) -> tuple[str, Any] | None:
|
|
256
|
+
default = p.default if isinstance(p.default, bool) else False
|
|
257
|
+
val = click.confirm(f" {p.name}", default=default)
|
|
258
|
+
return (p.name, val) if val != default else None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _steps_prompt_text_param(p: Any, suffix: str) -> tuple[str, Any] | None:
|
|
262
|
+
default_str = str(p.default) if p.default is not None else ""
|
|
263
|
+
raw = click.prompt(f" {p.name} [{p.type_hint}]{suffix}", default=default_str, show_default=bool(default_str))
|
|
264
|
+
if raw and raw != default_str or raw and p.required:
|
|
265
|
+
from dataeval_flow._app._model._coerce import coerce_value
|
|
266
|
+
|
|
267
|
+
return (p.name, coerce_value(raw, p.type_hint))
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _steps_add(steps: list[dict[str, Any]], step_key: str, sec_vm: Any) -> None:
|
|
272
|
+
available_steps = sec_vm.step_choices
|
|
273
|
+
step_name = click.prompt(
|
|
274
|
+
f" {step_key}",
|
|
275
|
+
type=click.Choice(available_steps, case_sensitive=True),
|
|
276
|
+
show_choices=False,
|
|
277
|
+
)
|
|
278
|
+
param_infos = sec_vm.get_step_params(step_name)
|
|
279
|
+
params: dict[str, Any] = {}
|
|
280
|
+
for p in param_infos:
|
|
281
|
+
result = _steps_prompt_param(p)
|
|
282
|
+
if result is not None:
|
|
283
|
+
params[result[0]] = result[1]
|
|
284
|
+
|
|
285
|
+
step: dict[str, Any] = {step_key: step_name}
|
|
286
|
+
if params:
|
|
287
|
+
step["params"] = params
|
|
288
|
+
steps.append(step)
|
|
289
|
+
click.echo(f" Added step '{step_name}'.")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _prompt_steps(sec_vm: Any, existing_steps: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]:
|
|
293
|
+
"""Interactive step builder for preprocessor/selection pipelines."""
|
|
294
|
+
from dataeval_flow._app._model._registry import STEP_BUILDER_SECTIONS
|
|
295
|
+
|
|
296
|
+
spec = STEP_BUILDER_SECTIONS[sec_vm.section]
|
|
297
|
+
step_key = spec["step_key"]
|
|
298
|
+
|
|
299
|
+
sec_vm.init_step_builder()
|
|
300
|
+
|
|
301
|
+
steps: list[dict[str, Any]] = list(existing_steps) if existing_steps else []
|
|
302
|
+
|
|
303
|
+
while True:
|
|
304
|
+
if steps:
|
|
305
|
+
click.echo("\n Steps:")
|
|
306
|
+
for i, s in enumerate(steps):
|
|
307
|
+
name = s.get(step_key, "?")
|
|
308
|
+
params = s.get("params", {})
|
|
309
|
+
if params:
|
|
310
|
+
p_str = ", ".join(f"{k}={v}" for k, v in params.items())
|
|
311
|
+
click.echo(f" {i + 1}. {name}({p_str})")
|
|
312
|
+
else:
|
|
313
|
+
click.echo(f" {i + 1}. {name}")
|
|
314
|
+
|
|
315
|
+
click.echo("\n Options: [a]dd step, [r]emove, [d]one")
|
|
316
|
+
action = click.prompt(" Action", type=click.Choice(["a", "r", "d"]), default="d")
|
|
317
|
+
|
|
318
|
+
if action == "d":
|
|
319
|
+
break
|
|
320
|
+
if action == "r":
|
|
321
|
+
_steps_remove(steps)
|
|
322
|
+
elif action == "a":
|
|
323
|
+
_steps_add(steps, step_key, sec_vm)
|
|
324
|
+
|
|
325
|
+
return steps
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
# Section editing
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _prompt_item(
|
|
334
|
+
section: str,
|
|
335
|
+
vm: BuilderViewModel,
|
|
336
|
+
existing: dict[str, Any] | None = None,
|
|
337
|
+
) -> dict[str, Any] | None:
|
|
338
|
+
"""Prompt for all fields of a section item. Returns the item dict or None."""
|
|
339
|
+
sec_vm = vm.create_section_vm(section, existing)
|
|
340
|
+
|
|
341
|
+
# Name
|
|
342
|
+
default_name = existing.get("name", "") if existing else ""
|
|
343
|
+
name = click.prompt("name", default=default_name, show_default=bool(default_name))
|
|
344
|
+
if not name:
|
|
345
|
+
click.echo("name is required.")
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
# Discriminator (if applicable)
|
|
349
|
+
variant_choices = sec_vm.variant_choices
|
|
350
|
+
variant_value: str | None = None
|
|
351
|
+
|
|
352
|
+
if variant_choices and sec_vm.disc_field:
|
|
353
|
+
default_variant = existing.get(sec_vm.disc_field, "") if existing else ""
|
|
354
|
+
variant_value = click.prompt(
|
|
355
|
+
sec_vm.disc_field.replace("_", " "),
|
|
356
|
+
type=click.Choice(variant_choices, case_sensitive=False),
|
|
357
|
+
default=default_variant if default_variant in variant_choices else None,
|
|
358
|
+
show_choices=True,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# Step-builder sections
|
|
362
|
+
if sec_vm.is_step_builder:
|
|
363
|
+
existing_steps = existing.get("steps", []) if existing else []
|
|
364
|
+
steps = _prompt_steps(sec_vm, existing_steps)
|
|
365
|
+
if not steps:
|
|
366
|
+
click.echo("At least one step is required.")
|
|
367
|
+
return None
|
|
368
|
+
sec_vm.steps = steps
|
|
369
|
+
return sec_vm.build_result(name, variant_value, {})
|
|
370
|
+
|
|
371
|
+
# Regular fields from introspection
|
|
372
|
+
sec_vm.load_fields(variant_value)
|
|
373
|
+
field_values: dict[str, Any] = {}
|
|
374
|
+
for desc in sec_vm.descriptors:
|
|
375
|
+
existing_val = existing.get(desc.name) if existing else None
|
|
376
|
+
val = _prompt_field(desc, existing_val)
|
|
377
|
+
if val is not None:
|
|
378
|
+
field_values[desc.name] = val
|
|
379
|
+
|
|
380
|
+
return sec_vm.build_result(name, variant_value, field_values)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _show_items(section: str, vm: BuilderViewModel) -> None:
|
|
384
|
+
"""Display items in a section."""
|
|
385
|
+
items = vm.items(section)
|
|
386
|
+
if not items:
|
|
387
|
+
click.echo(" (empty)")
|
|
388
|
+
return
|
|
389
|
+
for i, item in enumerate(items):
|
|
390
|
+
name = item.get("name", "?")
|
|
391
|
+
# Build a brief summary
|
|
392
|
+
parts: list[str] = []
|
|
393
|
+
disc_field = get_discriminator_field(section)
|
|
394
|
+
if disc_field and disc_field in item:
|
|
395
|
+
parts.append(f"{disc_field}={item[disc_field]}")
|
|
396
|
+
# Add a few key fields
|
|
397
|
+
for key, val in item.items():
|
|
398
|
+
if key in ("name",) or key == disc_field:
|
|
399
|
+
continue
|
|
400
|
+
if isinstance(val, (list, dict)):
|
|
401
|
+
continue
|
|
402
|
+
parts.append(f"{key}={val}")
|
|
403
|
+
if len(parts) >= 3:
|
|
404
|
+
break
|
|
405
|
+
summary = " ".join(parts) if parts else ""
|
|
406
|
+
enabled_marker = ""
|
|
407
|
+
if section == "tasks":
|
|
408
|
+
enabled = item.get("enabled", True)
|
|
409
|
+
enabled_marker = "[on] " if enabled else "[off] "
|
|
410
|
+
click.echo(f" {i + 1}. {enabled_marker}{name} {summary}")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _edit_action_add(section: str, vm: BuilderViewModel) -> None:
|
|
414
|
+
item = _prompt_item(section, vm)
|
|
415
|
+
if not item:
|
|
416
|
+
return
|
|
417
|
+
errors = vm.validate_item(section, item)
|
|
418
|
+
if errors:
|
|
419
|
+
click.echo(" Validation warnings:")
|
|
420
|
+
for e in errors:
|
|
421
|
+
click.echo(f" - {e}")
|
|
422
|
+
if not click.confirm(" Add anyway?", default=True):
|
|
423
|
+
return
|
|
424
|
+
outcome = vm.apply_result(section, -1, item)
|
|
425
|
+
if outcome:
|
|
426
|
+
click.echo(f" Added '{item.get('name', '?')}'.")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _edit_action_edit(section: str, vm: BuilderViewModel) -> None:
|
|
430
|
+
idx = click.prompt(" Edit index", type=int) - 1
|
|
431
|
+
existing = vm.get_item(section, idx)
|
|
432
|
+
if existing is None:
|
|
433
|
+
click.echo(" Invalid index.")
|
|
434
|
+
return
|
|
435
|
+
item = _prompt_item(section, vm, existing=existing)
|
|
436
|
+
if not item:
|
|
437
|
+
return
|
|
438
|
+
errors = vm.validate_item(section, item)
|
|
439
|
+
if errors:
|
|
440
|
+
click.echo(" Validation warnings:")
|
|
441
|
+
for e in errors:
|
|
442
|
+
click.echo(f" - {e}")
|
|
443
|
+
if not click.confirm(" Save anyway?", default=True):
|
|
444
|
+
return
|
|
445
|
+
outcome = vm.apply_result(section, idx, item)
|
|
446
|
+
if outcome:
|
|
447
|
+
click.echo(f" Updated '{item.get('name', '?')}'.")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _edit_action_delete(section: str, vm: BuilderViewModel) -> None:
|
|
451
|
+
idx = click.prompt(" Delete index", type=int) - 1
|
|
452
|
+
outcome = vm.delete_item(section, idx)
|
|
453
|
+
if outcome:
|
|
454
|
+
description, warnings = outcome
|
|
455
|
+
# description is like "Delete dataset 'ds1'" — extract the name
|
|
456
|
+
removed_name = description.split("'")[1] if "'" in description else "?"
|
|
457
|
+
click.echo(f" Removed '{removed_name}'.")
|
|
458
|
+
for w in warnings:
|
|
459
|
+
click.echo(f" Warning: {w}")
|
|
460
|
+
else:
|
|
461
|
+
click.echo(" Invalid index.")
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _edit_action_toggle(vm: BuilderViewModel) -> None:
|
|
465
|
+
idx = click.prompt(" Toggle index", type=int) - 1
|
|
466
|
+
desc = vm.toggle_task(idx)
|
|
467
|
+
if desc:
|
|
468
|
+
click.echo(f" {desc}.")
|
|
469
|
+
else:
|
|
470
|
+
click.echo(" Invalid index.")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _edit_section(section: str, section_title: str, vm: BuilderViewModel) -> None:
|
|
474
|
+
"""Interactive loop for editing a single section."""
|
|
475
|
+
while True:
|
|
476
|
+
click.echo(f"\n--- {section_title} ---")
|
|
477
|
+
_show_items(section, vm)
|
|
478
|
+
|
|
479
|
+
options = ["a", "b"]
|
|
480
|
+
option_desc = "[a]dd, [b]ack"
|
|
481
|
+
if vm.count(section) > 0:
|
|
482
|
+
options.extend(["e", "d"])
|
|
483
|
+
option_desc = "[a]dd, [e]dit, [d]elete, [b]ack"
|
|
484
|
+
if section == "tasks":
|
|
485
|
+
options.append("t")
|
|
486
|
+
option_desc += ", [t]oggle"
|
|
487
|
+
|
|
488
|
+
click.echo(f"\n {option_desc}")
|
|
489
|
+
action = click.prompt(" Action", type=click.Choice(options), default="b", show_choices=False)
|
|
490
|
+
|
|
491
|
+
if action == "b":
|
|
492
|
+
break
|
|
493
|
+
if action == "a":
|
|
494
|
+
_edit_action_add(section, vm)
|
|
495
|
+
elif action == "e":
|
|
496
|
+
_edit_action_edit(section, vm)
|
|
497
|
+
elif action == "d":
|
|
498
|
+
_edit_action_delete(section, vm)
|
|
499
|
+
elif action == "t" and section == "tasks":
|
|
500
|
+
_edit_action_toggle(vm)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# ---------------------------------------------------------------------------
|
|
504
|
+
# Main menu
|
|
505
|
+
# ---------------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _menu_save(vm: BuilderViewModel) -> None:
|
|
509
|
+
default_path = vm.config_file_path or "config.yaml"
|
|
510
|
+
out = click.prompt("Save to", default=default_path)
|
|
511
|
+
success, msg = vm.save_file(Path(out))
|
|
512
|
+
click.echo(msg)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _menu_load(vm: BuilderViewModel) -> None:
|
|
516
|
+
load_path = click.prompt("Load from", default=vm.config_file_path or "")
|
|
517
|
+
if load_path:
|
|
518
|
+
p = Path(load_path)
|
|
519
|
+
if p.exists():
|
|
520
|
+
success, msg = vm.load_file(p)
|
|
521
|
+
click.echo(msg)
|
|
522
|
+
else:
|
|
523
|
+
click.echo(f"File not found: {p}")
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _menu_view(vm: BuilderViewModel) -> None:
|
|
527
|
+
config = vm.to_dict()
|
|
528
|
+
if config:
|
|
529
|
+
click.echo("\n" + yaml.dump(config, default_flow_style=False, sort_keys=False))
|
|
530
|
+
else:
|
|
531
|
+
click.echo("(empty config)")
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _menu_display(vm: BuilderViewModel) -> None:
|
|
535
|
+
click.echo("\n=== DataEval Flow Config Builder ===")
|
|
536
|
+
if vm.config_file_path:
|
|
537
|
+
click.echo(f" File: {vm.config_file_path}")
|
|
538
|
+
for key, title in SECTIONS:
|
|
539
|
+
count = vm.count(key)
|
|
540
|
+
click.echo(f" {title}: {count} item{'s' if count != 1 else ''}")
|
|
541
|
+
click.echo("\nSections:")
|
|
542
|
+
for i, (_, title) in enumerate(SECTIONS):
|
|
543
|
+
click.echo(f" {i + 1}. {title}")
|
|
544
|
+
click.echo(" s. Save")
|
|
545
|
+
click.echo(" l. Load")
|
|
546
|
+
click.echo(" v. View YAML")
|
|
547
|
+
click.echo(" q. Quit")
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _main_menu(vm: BuilderViewModel) -> None:
|
|
551
|
+
"""Main interactive loop."""
|
|
552
|
+
while True:
|
|
553
|
+
_menu_display(vm)
|
|
554
|
+
choice = click.prompt("\nChoice", default="q")
|
|
555
|
+
|
|
556
|
+
if choice.isdigit():
|
|
557
|
+
idx = int(choice) - 1
|
|
558
|
+
if 0 <= idx < len(SECTIONS):
|
|
559
|
+
key, title = SECTIONS[idx]
|
|
560
|
+
_edit_section(key, title, vm)
|
|
561
|
+
else:
|
|
562
|
+
click.echo("Invalid choice.")
|
|
563
|
+
elif choice == "s":
|
|
564
|
+
_menu_save(vm)
|
|
565
|
+
elif choice == "l":
|
|
566
|
+
_menu_load(vm)
|
|
567
|
+
elif choice == "v":
|
|
568
|
+
_menu_view(vm)
|
|
569
|
+
elif choice == "q":
|
|
570
|
+
if not vm.is_empty() and not click.confirm("Unsaved changes may be lost. Quit?", default=False):
|
|
571
|
+
continue
|
|
572
|
+
break
|
|
573
|
+
else:
|
|
574
|
+
click.echo("Invalid choice.")
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
# ---------------------------------------------------------------------------
|
|
578
|
+
# Entry point
|
|
579
|
+
# ---------------------------------------------------------------------------
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def run_cli_builder(config_path: str | Path | None = None) -> None:
|
|
583
|
+
"""Launch the lightweight click-based configuration builder."""
|
|
584
|
+
vm = BuilderViewModel(config_path)
|
|
585
|
+
|
|
586
|
+
if config_path:
|
|
587
|
+
p = Path(config_path)
|
|
588
|
+
if p.exists():
|
|
589
|
+
success, msg = vm.load_file(p)
|
|
590
|
+
click.echo(msg)
|
|
591
|
+
|
|
592
|
+
_main_menu(vm)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Logging configuration for the dataeval_flow package."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_initialized: bool = False
|
|
10
|
+
_APP_LOGGERS: tuple[str, ...] = ("dataeval_flow",)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def setup_logging(output_dir: Path | None = None, verbosity: int = 0) -> None:
|
|
14
|
+
"""Configure root logger with optional FileHandler + StreamHandler.
|
|
15
|
+
|
|
16
|
+
Called once at startup, before config loads. The module-level
|
|
17
|
+
``_initialized`` flag prevents duplicate handler attachment.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
output_dir : Path | None
|
|
22
|
+
Directory for the pipeline log file. When ``None``, no file
|
|
23
|
+
handler is created and output is console-only.
|
|
24
|
+
verbosity : int
|
|
25
|
+
Console verbosity level (0=quiet, 1=report, 2=+INFO, 3=+DEBUG).
|
|
26
|
+
"""
|
|
27
|
+
global _initialized
|
|
28
|
+
if _initialized:
|
|
29
|
+
return
|
|
30
|
+
_initialized = True
|
|
31
|
+
|
|
32
|
+
fmt = "%(asctime)s [%(levelname)-5s] %(name)s: %(message)s"
|
|
33
|
+
datefmt = "%Y-%m-%dT%H:%M:%SZ"
|
|
34
|
+
formatter = logging.Formatter(fmt, datefmt=datefmt)
|
|
35
|
+
formatter.converter = time.gmtime
|
|
36
|
+
|
|
37
|
+
root = logging.getLogger()
|
|
38
|
+
# Root stays at WARNING — third-party loggers inherit this level,
|
|
39
|
+
# suppressing their DEBUG/INFO messages by default.
|
|
40
|
+
|
|
41
|
+
# --- FileHandler (DEBUG) — only when output_dir is provided ---
|
|
42
|
+
if output_dir is not None:
|
|
43
|
+
try:
|
|
44
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
45
|
+
fh = logging.FileHandler(
|
|
46
|
+
output_dir / "result.log",
|
|
47
|
+
mode="w",
|
|
48
|
+
encoding="utf-8",
|
|
49
|
+
)
|
|
50
|
+
fh.setLevel(logging.DEBUG)
|
|
51
|
+
fh.setFormatter(formatter)
|
|
52
|
+
root.addHandler(fh)
|
|
53
|
+
except OSError:
|
|
54
|
+
pass # fallback — StreamHandler still works if dir is unwritable
|
|
55
|
+
|
|
56
|
+
# --- StreamHandler — level driven by verbosity ---
|
|
57
|
+
sh = logging.StreamHandler(sys.stdout)
|
|
58
|
+
if verbosity >= 3:
|
|
59
|
+
sh.setLevel(logging.DEBUG)
|
|
60
|
+
elif verbosity >= 2:
|
|
61
|
+
sh.setLevel(logging.INFO)
|
|
62
|
+
else:
|
|
63
|
+
sh.setLevel(logging.WARNING)
|
|
64
|
+
sh.setFormatter(formatter)
|
|
65
|
+
root.addHandler(sh)
|
|
66
|
+
|
|
67
|
+
# --- App loggers at DEBUG ---
|
|
68
|
+
for name in _APP_LOGGERS:
|
|
69
|
+
logging.getLogger(name).setLevel(logging.DEBUG)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def configure_log_levels(
|
|
73
|
+
app_level: str = "DEBUG",
|
|
74
|
+
lib_level: str = "WARNING",
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Apply config-driven log level overrides.
|
|
77
|
+
|
|
78
|
+
Called after config loads so that user YAML settings take effect.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
app_level : str
|
|
83
|
+
Level for ``dataeval_flow`` loggers.
|
|
84
|
+
lib_level : str
|
|
85
|
+
Level for root logger (controls third-party effective level).
|
|
86
|
+
"""
|
|
87
|
+
level = getattr(logging, app_level, logging.DEBUG)
|
|
88
|
+
for name in _APP_LOGGERS:
|
|
89
|
+
logging.getLogger(name).setLevel(level)
|
|
90
|
+
|
|
91
|
+
root_level = getattr(logging, lib_level, logging.WARNING)
|
|
92
|
+
logging.getLogger().setLevel(root_level)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def flush_logs() -> None:
|
|
96
|
+
"""Flush all root-logger handlers.
|
|
97
|
+
|
|
98
|
+
Call after important checkpoints (e.g. after each task) so that
|
|
99
|
+
buffered log records are written even if the process is killed.
|
|
100
|
+
"""
|
|
101
|
+
for handler in logging.getLogger().handlers:
|
|
102
|
+
handler.flush()
|