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