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,511 @@
1
+ """Text report rendering helpers for executive-summary output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ from dataeval_flow.workflow.base import Reportable
9
+
10
+ __all__ = ["_WIDTH", "_render_config_section", "_render_detail_section", "_summary_line"]
11
+
12
+ # Width of the report (matches the === bars).
13
+ _WIDTH = 80
14
+ # Maximum bar chart width in characters.
15
+ _BAR_MAX = 30
16
+ # Unicode left-filling fractional block characters (index 1 = 1/8 .. 7 = 7/8).
17
+ _FRAC_BLOCKS = " \u258f\u258e\u258d\u258c\u258b\u258a\u2589"
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Summary line (one per finding, for the SUMMARY section)
22
+ # ---------------------------------------------------------------------------
23
+
24
+
25
+ def _brief_value(finding: Reportable) -> str:
26
+ """Extract a short value string from a finding's data for the summary line."""
27
+ data = finding.data
28
+ if isinstance(data, dict):
29
+ brief = data.get("brief")
30
+ if brief is not None:
31
+ return str(brief)
32
+ return ""
33
+
34
+
35
+ def _summary_line(finding: Reportable) -> str:
36
+ """Build a single dotted summary line for a finding."""
37
+ label = finding.title
38
+ value = _brief_value(finding)
39
+ severity = getattr(finding, "severity", "info")
40
+ marker = {"warning": " [!!]", "ok": " [ok]", "info": " [..]"}.get(severity, " [..]")
41
+
42
+ # Dotted fill between label and value
43
+ dots_len = _WIDTH - 4 - len(label) - len(value) - len(marker)
44
+ dots = " " + "." * max(dots_len - 2, 1) + " " if dots_len > 3 else " "
45
+ return f" {label}{dots}{value}{marker}"
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Detail section rendering
50
+ # ---------------------------------------------------------------------------
51
+
52
+
53
+ def _section_header(title: str, right_text: str = "") -> list[str]:
54
+ """Render a section header with === bars."""
55
+ lines: list[str] = [""]
56
+ lines.append("=" * _WIDTH)
57
+ if right_text:
58
+ padding = _WIDTH - 2 - len(title) - len(right_text)
59
+ lines.append(f" {title}{' ' * max(padding, 1)}{right_text}")
60
+ else:
61
+ lines.append(f" {title}")
62
+ lines.append("=" * _WIDTH)
63
+ return lines
64
+
65
+
66
+ def _render_detail_section(finding: Reportable) -> list[str]:
67
+ """Render a full detail section for a finding."""
68
+ data = finding.data
69
+ brief = _brief_value(finding)
70
+ lines = _section_header(finding.title.upper(), brief)
71
+
72
+ if finding.description:
73
+ lines.append(f" {finding.description}")
74
+
75
+ if not isinstance(data, dict):
76
+ # Plain text or non-dict data
77
+ if isinstance(data, str) and data:
78
+ lines.append(f" {data}")
79
+ return lines
80
+
81
+ rt = finding.report_type
82
+
83
+ if rt == "pivot_table":
84
+ lines.extend(_render_pivot_table(data))
85
+ elif rt == "chunk_table":
86
+ lines.extend(_render_chunk_table(data))
87
+ elif rt == "classwise_table":
88
+ lines.extend(_render_classwise_table(data))
89
+ elif rt == "table":
90
+ lines.extend(_render_table(data))
91
+ elif rt == "key_value":
92
+ lines.extend(_render_key_value(data))
93
+ else:
94
+ # text / image / unknown — just show description (already added above)
95
+ pass
96
+
97
+ return lines
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Type-specific renderers
102
+ # ---------------------------------------------------------------------------
103
+
104
+
105
+ def _render_key_value(data: dict[str, Any]) -> list[str]:
106
+ """Render key_value findings — metric tables, detail lines, generic pairs."""
107
+ lines: list[str] = []
108
+
109
+ # Outlier per-metric breakdown
110
+ per_metric = data.get("per_metric")
111
+ if per_metric and isinstance(per_metric, dict):
112
+ lines.append("")
113
+ col1 = "Metric"
114
+ col2 = "Count"
115
+ w1 = max(len(col1), *(len(k) for k in per_metric))
116
+ lines.append(f" {col1:<{w1}} {col2}")
117
+ lines.append(f" {'-' * w1} -----")
118
+ for metric, count in sorted(per_metric.items(), key=lambda x: -x[1]):
119
+ lines.append(f" {metric:<{w1}} {count:>5}")
120
+ total_flags = data.get("total_flags", 0)
121
+ unique_count = data.get("count", 0)
122
+ if total_flags > unique_count:
123
+ subject = data.get("multi_metric_subject", "items")
124
+ lines.append("")
125
+ lines.append(f" (Some {subject} trigger multiple metrics.)")
126
+
127
+ # Generic detail lines (workflow-provided)
128
+ detail_lines = data.get("detail_lines", [])
129
+ if detail_lines:
130
+ lines.append("")
131
+ lines.extend(f" {line}" for line in detail_lines)
132
+
133
+ # Fallback: render remaining scalar key-value pairs as a simple table
134
+ handled = {"brief", "per_metric", "detail_lines", "total_flags", "count", "multi_metric_subject"}
135
+ generic = {k: v for k, v in data.items() if k not in handled and not isinstance(v, (dict, list))}
136
+ if generic:
137
+ lines.append("")
138
+ w_key = max(len(str(k)) for k in generic)
139
+ for key, val in generic.items():
140
+ lines.append(f" {key:<{w_key}} {val}")
141
+
142
+ return lines
143
+
144
+
145
+ def _render_pivot_table(data: dict[str, Any]) -> list[str]: # noqa: C901
146
+ """Render pivot-table findings — multi-column tables like classwise outliers.
147
+
148
+ ``table_headers`` are display names; row dicts use field keys. A
149
+ ``field_keys`` list in *data* maps each header to its row-dict key
150
+ (defaults to headers themselves). The ``%`` header is special-cased
151
+ to read the ``pct`` field and format with one decimal + ``%`` suffix.
152
+ """
153
+ lines: list[str] = []
154
+ rows: list[dict[str, Any]] = data.get("table_data", [])
155
+ headers: list[str] = data.get("table_headers", [])
156
+ if not rows or not headers:
157
+ return lines
158
+
159
+ # Map display header → row-dict key
160
+ key_aliases: dict[str, str] = {"%": "pct", "Class Name": "class_name", "Count": "count"}
161
+ keys = [key_aliases.get(h, h) for h in headers]
162
+
163
+ # Format cell values: pct → "12.3%", others → str
164
+ def _fmt(key: str, val: object) -> str:
165
+ if key == "pct" and isinstance(val, (int, float)):
166
+ return f"{val:.1f}%"
167
+ return str(val) if val is not None else ""
168
+
169
+ # Pre-format all cells; split on \n for multi-line support
170
+ formatted: list[list[str]] = [[_fmt(k, row.get(k, "")) for k in keys] for row in rows]
171
+
172
+ # Split each cell into sub-lines for multi-line cells
173
+ formatted_lines: list[list[list[str]]] = [[cell.split("\n") for cell in cells] for cells in formatted]
174
+
175
+ # Compute column widths (max width across all sub-lines of all rows)
176
+ col_widths = [len(h) for h in headers]
177
+ for row_cells in formatted_lines:
178
+ for i, sub_lines in enumerate(row_cells):
179
+ for sub_line in sub_lines:
180
+ col_widths[i] = max(col_widths[i], len(sub_line))
181
+
182
+ # Header
183
+ lines.append("")
184
+ header_line = " " + " ".join(
185
+ f"{h:<{w}}" if i == 0 else f"{h:>{w}}" for i, (h, w) in enumerate(zip(headers, col_widths, strict=False))
186
+ )
187
+ lines.append(header_line)
188
+ lines.append(" " + " ".join("-" * w for w in col_widths))
189
+
190
+ # Data rows (with multi-line cell support)
191
+ for row_cells in formatted_lines:
192
+ n_sub = max(len(sub) for sub in row_cells)
193
+ for line_idx in range(n_sub):
194
+ parts: list[str] = []
195
+ for i, (sub_lines, w) in enumerate(zip(row_cells, col_widths, strict=False)):
196
+ cell = sub_lines[line_idx] if line_idx < len(sub_lines) else ""
197
+ parts.append(f"{cell:<{w}}" if i == 0 else f"{cell:>{w}}")
198
+ lines.append(" " + " ".join(parts))
199
+
200
+ # Footer lines (workflow-provided)
201
+ footer_lines = data.get("footer_lines", [])
202
+ if footer_lines:
203
+ lines.append("")
204
+ lines.extend(f" {line}" for line in footer_lines)
205
+
206
+ return lines
207
+
208
+
209
+ def _render_chunk_table(data: dict[str, Any]) -> list[str]:
210
+ """Render chunked drift results — bar chart with threshold scale."""
211
+ lines: list[str] = []
212
+ rows: list[dict[str, Any]] = data.get("table_rows", [])
213
+ if not rows:
214
+ return lines
215
+
216
+ # --- Bar chart with threshold scale ---
217
+ bar_width = _BAR_MAX
218
+
219
+ # Collect all values for scale calculation
220
+ distances = [r["Distance"] for r in rows]
221
+ upper_thresholds = [r["UpperThreshold"] for r in rows if r.get("UpperThreshold") is not None]
222
+ lower_thresholds = [r["LowerThreshold"] for r in rows if r.get("LowerThreshold") is not None]
223
+
224
+ # Thresholds (use first row's as representative)
225
+ lower_thresh = lower_thresholds[0] if lower_thresholds else None
226
+ upper_thresh = upper_thresholds[0] if upper_thresholds else None
227
+
228
+ # Scale range: extend to cover both thresholds and all distances
229
+ all_vals = distances + upper_thresholds + lower_thresholds
230
+ scale_min = min(min(all_vals), 0.0)
231
+ scale_max = max(all_vals) if all_vals else 1.0
232
+ scale_range = scale_max - scale_min or 1.0
233
+
234
+ def _val_to_pos(val: float) -> int:
235
+ return int(((val - scale_min) / scale_range) * bar_width)
236
+
237
+ # Threshold positions on the bar
238
+ lower_pos = _val_to_pos(lower_thresh) if lower_thresh is not None else None
239
+ upper_pos = _val_to_pos(upper_thresh) if upper_thresh is not None else None
240
+
241
+ # Column widths
242
+ w_chunk = max(5, *(len(str(r["Chunk"])) for r in rows))
243
+ w_dist = max(8, *(len(f"{r['Distance']:.4f}") for r in rows))
244
+
245
+ lines.append("")
246
+ hdr = f" {'Chunk':<{w_chunk}} {'Distance':>{w_dist}} {'':>{bar_width}} Status"
247
+ lines.append(hdr)
248
+ lines.append(f" {'-' * w_chunk} {'-' * w_dist} {'-' * bar_width} ------")
249
+
250
+ for row in rows:
251
+ dist = row["Distance"]
252
+ status = row["Status"]
253
+
254
+ # Bar: █ from zero to distance, ░ elsewhere
255
+ zero_pos = max(0, min(_val_to_pos(0.0), bar_width))
256
+ dist_pos = max(0, min(_val_to_pos(dist), bar_width))
257
+ bar_chars = ["\u2591"] * bar_width
258
+ lo, hi = min(zero_pos, dist_pos), max(zero_pos, dist_pos)
259
+ for p in range(lo, hi):
260
+ bar_chars[p] = "\u2588"
261
+ bar = "".join(bar_chars)
262
+
263
+ lines.append(f" {row['Chunk']:<{w_chunk}} {dist:>{w_dist}.4f} {bar} {status}")
264
+
265
+ # --- Threshold scale line: "(lower)|--------|(upper)" ---
266
+ # The lower label can extend left into the prefix area (Threshold + Distance cols).
267
+ if lower_pos is not None or upper_pos is not None:
268
+ lower_label = f"({lower_thresh:.4f})" if lower_thresh is not None else ""
269
+ upper_label = f"({upper_thresh:.4f})" if upper_thresh is not None else ""
270
+
271
+ # prefix_width = indentation + Chunk col + gap + Distance col + gap before bar
272
+ prefix_width = 2 + w_chunk + 2 + w_dist + 2
273
+ # "Threshold" label takes up the first part of the prefix
274
+ thresh_label = " Threshold"
275
+ min_prefix = len(thresh_label) + 1 # at least one space after "Threshold"
276
+
277
+ if lower_pos is not None and upper_pos is not None:
278
+ lp = min(max(lower_pos, 0), bar_width - 1)
279
+ up = min(max(upper_pos, 0), bar_width - 1)
280
+ if lp == up:
281
+ scale_core = f"{lower_label}|{upper_label}"
282
+ else:
283
+ gap = "-" * max(0, up - lp - 1)
284
+ scale_core = f"{lower_label}|{gap}|{upper_label}"
285
+ # Position of first | in scale_core
286
+ pipe_idx = len(lower_label)
287
+ # We want pipe_idx to land at (prefix_width + lp) in the full line
288
+ line_start = prefix_width + lp - pipe_idx
289
+ elif upper_pos is not None:
290
+ up = min(max(upper_pos, 0), bar_width - 1)
291
+ scale_core = "-" * up + f"|{upper_label}"
292
+ line_start = prefix_width
293
+ else:
294
+ lp = min(max(lower_pos, 0), bar_width - 1) # type: ignore[arg-type]
295
+ scale_core = f"{lower_label}|"
296
+ pipe_idx = len(lower_label)
297
+ line_start = prefix_width + lp - pipe_idx
298
+
299
+ # Build the full line, ensuring "Threshold" label is visible
300
+ line_start = max(line_start, min_prefix)
301
+ full_line = thresh_label + " " * (line_start - len(thresh_label)) + scale_core
302
+ lines.append(full_line)
303
+
304
+ return lines
305
+
306
+
307
+ def _render_classwise_table(data: dict[str, Any]) -> list[str]:
308
+ """Render classwise drift results — bar chart per class."""
309
+ lines: list[str] = []
310
+ rows: list[dict[str, Any]] = data.get("table_rows", [])
311
+ if not rows:
312
+ return lines
313
+
314
+ bar_width = _BAR_MAX
315
+
316
+ # Collect distances for scale
317
+ distances = [abs(r["Distance"]) for r in rows]
318
+ scale_max = max(distances) if distances else 1.0
319
+ scale_max = scale_max or 1.0 # avoid division by zero
320
+
321
+ def _val_to_pos(val: float) -> int:
322
+ return int((val / scale_max) * bar_width)
323
+
324
+ # Column widths
325
+ w_class = max(5, *(len(str(r["Class"])) for r in rows))
326
+ w_dist = max(8, *(len(f"{r['Distance']:.4f}") for r in rows))
327
+
328
+ # Optional p_val column
329
+ has_pval = any(r.get("PVal") is not None for r in rows)
330
+ w_pval = 6
331
+ if has_pval:
332
+ w_pval = max(w_pval, *(len(f"{r['PVal']:.2f}") for r in rows if r.get("PVal") is not None))
333
+
334
+ lines.append("")
335
+ hdr = f" {'Class':<{w_class}} {'Distance':>{w_dist}}"
336
+ sep = f" {'-' * w_class} {'-' * w_dist}"
337
+ if has_pval:
338
+ hdr += f" {'PVal':>{w_pval}}"
339
+ sep += f" {'-' * w_pval}"
340
+ hdr += f" {'':>{bar_width}} Status"
341
+ sep += f" {'-' * bar_width} ------"
342
+ lines.append(hdr)
343
+ lines.append(sep)
344
+
345
+ for row in rows:
346
+ dist = abs(row["Distance"])
347
+ status = row["Status"]
348
+ drifted = status == "DRIFT"
349
+
350
+ # Bar: █ for distance, ░ for remainder
351
+ dist_pos = max(0, min(_val_to_pos(dist), bar_width))
352
+ fill_char = "\u2588" if drifted else "\u2591"
353
+ bar = fill_char * dist_pos + "\u2591" * (bar_width - dist_pos)
354
+
355
+ line = f" {row['Class']:<{w_class}} {row['Distance']:>{w_dist}.4f}"
356
+ if has_pval:
357
+ pval = row.get("PVal")
358
+ line += f" {pval:>{w_pval}.2f}" if pval is not None else f" {'':>{w_pval}}"
359
+ line += f" {bar} {status}"
360
+ lines.append(line)
361
+
362
+ return lines
363
+
364
+
365
+ def _render_table(data: dict[str, Any]) -> list[str]:
366
+ """Render table findings — generic table with bar chart."""
367
+ lines: list[str] = []
368
+ table_data: dict[str, int] = data.get("table_data", {})
369
+ if not table_data:
370
+ return lines
371
+
372
+ max_count = max(table_data.values())
373
+ headers = data.get("table_headers", ("Name", "Value"))
374
+ col1, col2 = headers[0], headers[1]
375
+ w1 = max(len(col1), *(len(k) for k in table_data))
376
+ w2 = max(len(col2), len(str(max_count)))
377
+
378
+ lines.append("")
379
+ lines.append(f" {col1:<{w1}} {col2:>{w2}}")
380
+ lines.append(f" {'-' * w1} {'-' * w2}")
381
+
382
+ for cls, count in sorted(table_data.items(), key=lambda x: -x[1]):
383
+ bar_len = (count / max_count) * _BAR_MAX if max_count else 0
384
+ full = int(bar_len)
385
+ frac = bar_len - full
386
+ bar = "\u2588" * full
387
+ # Append fractional block (1/8 to 7/8) for the remainder
388
+ frac_idx = int(frac * 8)
389
+ if frac_idx > 0:
390
+ bar += _FRAC_BLOCKS[frac_idx]
391
+ lines.append(f" {cls:<{w1}} {count:>{w2}} {bar}")
392
+
393
+ # Generic footer lines (workflow-provided)
394
+ footer_lines = data.get("footer_lines", [])
395
+ if footer_lines:
396
+ lines.append("")
397
+ lines.extend(f" {line}" for line in footer_lines)
398
+
399
+ return lines
400
+
401
+
402
+ # ---------------------------------------------------------------------------
403
+ # Configuration section
404
+ # ---------------------------------------------------------------------------
405
+
406
+
407
+ def _render_config_section(resolved_config: dict[str, Any]) -> list[str]:
408
+ """Render a CONFIGURATION section showing the fully resolved config."""
409
+ if not resolved_config:
410
+ return []
411
+
412
+ lines = _section_header("CONFIGURATION")
413
+ _format_value(lines, resolved_config, indent=2, max_width=_WIDTH)
414
+ return lines
415
+
416
+
417
+ _INDENT_STEP = 2
418
+
419
+
420
+ def _flow_repr(obj: Any) -> str:
421
+ """Render a value as a compact, unquoted, single-line string.
422
+
423
+ Dicts use ``{k: v, ...}`` syntax, lists use ``[v, ...]``, and
424
+ contiguous int lists collapse to ``range(...)`` shorthand.
425
+ """
426
+ if isinstance(obj, dict):
427
+ inner = ", ".join(f"{k}: {_flow_repr(v)}" for k, v in obj.items())
428
+ return "{" + inner + "}"
429
+ if isinstance(obj, list):
430
+ if obj and all(isinstance(i, int) for i in obj):
431
+ compact = _compact_indices(obj)
432
+ if compact != str(obj):
433
+ return compact
434
+ return "[" + ", ".join(_flow_repr(v) for v in obj) + "]"
435
+ return str(obj)
436
+
437
+
438
+ def _format_value(lines: list[str], obj: Any, indent: int, max_width: int) -> None:
439
+ """Recursively format *obj*, using flow style when it fits in *max_width*.
440
+
441
+ Dicts and lists are rendered block-style (one key/item per line) only
442
+ when their flow representation would exceed *max_width*. Otherwise
443
+ the value is kept inline.
444
+ """
445
+ prefix = " " * indent
446
+
447
+ if isinstance(obj, dict):
448
+ _format_dict(lines, obj, indent, max_width)
449
+ elif isinstance(obj, list):
450
+ _format_list(lines, obj, indent, max_width)
451
+ else:
452
+ lines.append(f"{prefix}{obj}")
453
+
454
+
455
+ def _format_dict(lines: list[str], obj: dict[str, Any], indent: int, max_width: int) -> None:
456
+ """Format a dict, keeping values inline when they fit."""
457
+ prefix = " " * indent
458
+ for key, val in obj.items():
459
+ flow = _flow_repr(val)
460
+ if len(f"{prefix}{key}: {flow}") <= max_width:
461
+ lines.append(f"{prefix}{key}: {flow}")
462
+ else:
463
+ lines.append(f"{prefix}{key}:")
464
+ _format_value(lines, val, indent + _INDENT_STEP, max_width)
465
+
466
+
467
+ def _format_list(lines: list[str], obj: list[Any], indent: int, max_width: int) -> None:
468
+ """Format a list, putting the first dict key on the ``- `` line."""
469
+ prefix = " " * indent
470
+ for item in obj:
471
+ flow = _flow_repr(item)
472
+ if len(f"{prefix}- {flow}") <= max_width:
473
+ lines.append(f"{prefix}- {flow}")
474
+ elif isinstance(item, dict) and item:
475
+ _format_list_dict_item(lines, item, indent, max_width)
476
+ else:
477
+ lines.append(f"{prefix}-")
478
+ _format_value(lines, item, indent + _INDENT_STEP, max_width)
479
+
480
+
481
+ def _format_list_dict_item(lines: list[str], item: dict[str, Any], indent: int, max_width: int) -> None:
482
+ """Format a dict inside a list, inlining the first key on the ``- `` line."""
483
+ prefix = " " * indent
484
+ it = iter(item.items())
485
+ first_key, first_val = next(it)
486
+ first_flow = _flow_repr(first_val)
487
+ if len(f"{prefix}- {first_key}: {first_flow}") <= max_width:
488
+ lines.append(f"{prefix}- {first_key}: {first_flow}")
489
+ else:
490
+ lines.append(f"{prefix}- {first_key}:")
491
+ _format_value(lines, first_val, indent + _INDENT_STEP * 2, max_width)
492
+ # Remaining keys align under the first key (one indent step past the "- ")
493
+ rest_indent = indent + _INDENT_STEP
494
+ _format_dict(lines, dict(it), rest_indent, max_width)
495
+
496
+
497
+ def _compact_indices(indices: list[int]) -> str:
498
+ """Collapse a contiguous int list into range shorthand for display."""
499
+ if not indices:
500
+ return "[]"
501
+ if len(indices) < 2:
502
+ return str(indices)
503
+ step = indices[1] - indices[0]
504
+ if step == 0:
505
+ return str(indices)
506
+ stop = indices[-1] + (1 if step > 0 else -1)
507
+ if indices == list(range(indices[0], stop, step)):
508
+ if step == 1:
509
+ return f"range({indices[0]}, {stop})"
510
+ return f"range({indices[0]}, {stop}, {step})"
511
+ return str(indices)
@@ -0,0 +1,69 @@
1
+ """Workflow base classes - shared by all concrete workflows."""
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from typing import Any, Literal
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from dataeval_flow.config.schemas import AutoBinMethod
9
+
10
+ __all__ = [
11
+ "MetadataConfigMixin",
12
+ "Reportable",
13
+ "WorkflowOutputsBase",
14
+ "WorkflowParametersBase",
15
+ "WorkflowReportBase",
16
+ ]
17
+
18
+
19
+ # --- Metadata configuration mixin ---
20
+
21
+
22
+ class MetadataConfigMixin(BaseModel):
23
+ """Mixin for workflows that need dataset metadata configuration.
24
+
25
+ Provides binning and exclusion settings for metadata analysis.
26
+ Mix into workflow parameter classes that require metadata processing
27
+ (e.g. data cleaning).
28
+ """
29
+
30
+ metadata_auto_bin_method: AutoBinMethod | None = None
31
+ metadata_exclude: Sequence[str] = Field(default_factory=list)
32
+ metadata_continuous_factor_bins: Mapping[str, int | Sequence[float]] | None = None
33
+
34
+
35
+ # --- Parameter base ---
36
+
37
+
38
+ class WorkflowParametersBase(BaseModel):
39
+ """Base class for all workflow parameters."""
40
+
41
+ mode: Literal["advisory", "preparatory"] = Field(
42
+ default="advisory",
43
+ description="advisory: report only, preparatory: modify dataset",
44
+ )
45
+
46
+
47
+ # --- Output bases ---
48
+
49
+
50
+ class Reportable(BaseModel):
51
+ """Human-readable report item."""
52
+
53
+ report_type: Literal["table", "key_value", "image", "text", "pivot_table", "chunk_table", "classwise_table"]
54
+ severity: Literal["ok", "info", "warning"] = "info"
55
+ title: str
56
+ data: dict[str, Any] | list[dict[str, Any]] | str
57
+ description: str | None = None
58
+
59
+
60
+ class WorkflowOutputsBase(BaseModel):
61
+ """Base class for all workflow outputs."""
62
+
63
+ dataset_size: int = Field(description="Number of items in dataset")
64
+
65
+
66
+ class WorkflowReportBase(BaseModel):
67
+ """Base class for all workflow reports."""
68
+
69
+ summary: str