data-sampler 3.2.1__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.
@@ -0,0 +1,904 @@
1
+ """The data-sampler terminal UI.
2
+
3
+ A panel-based, color-coded dashboard (in the spirit of btop / lazydocker):
4
+
5
+ - File screen — pick a data file (typed path or directory browser).
6
+ - Columns screen — Data Wrangler-style column stats table, a detail panel
7
+ with distribution bars, per-column anonymizer configuration, and
8
+ stratification skip toggles.
9
+ - Report screen — stratification report, anonymization summary, output path.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import tempfile
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ import pandas as pd
20
+ from rich.text import Text
21
+ from textual.app import App, ComposeResult
22
+ from textual.binding import Binding
23
+ from textual.containers import Container, Horizontal, Vertical, VerticalScroll
24
+ from textual.screen import Screen
25
+ from textual.widgets import (
26
+ Button,
27
+ ContentSwitcher,
28
+ DataTable,
29
+ DirectoryTree,
30
+ Footer,
31
+ Input,
32
+ Label,
33
+ Select,
34
+ Static,
35
+ Switch,
36
+ )
37
+
38
+ from .. import __version__
39
+ from .._logging import get_logger, redirect_to_file
40
+ from ..anonymize import make_anonymizer
41
+ from ..io import load_file, save_output
42
+ from ..report import format_stratification_report
43
+ from ..sampling import sample
44
+ from ..stats import ColumnStats, compute_stats, sparkline
45
+
46
+ log = get_logger(__name__)
47
+
48
+ # ── palette ───────────────────────────────────────────────────────────────────
49
+
50
+ CYAN = "#00d7ff"
51
+ MAGENTA = "#ff5fd7"
52
+ GREEN = "#5fff87"
53
+ YELLOW = "#ffd75f"
54
+ ORANGE = "#ff875f"
55
+ RED = "#ff5f5f"
56
+ DIM = "#5f6b85"
57
+ FG = "#c8d3f5"
58
+
59
+ KIND_COLORS = {
60
+ "numeric": CYAN,
61
+ "categorical": MAGENTA,
62
+ "boolean": YELLOW,
63
+ "datetime": ORANGE,
64
+ "text": GREEN,
65
+ "other": DIM,
66
+ }
67
+
68
+ ANON_CHOICES = [
69
+ ("none", "none"),
70
+ ("names (from name library)", "names"),
71
+ ("sequential id (start + interval)", "sequential_id"),
72
+ ("numeric jitter (± percent)", "numeric_jitter"),
73
+ ("datetime jitter (± window)", "datetime_jitter"),
74
+ ("random string", "random_string"),
75
+ ("hex string", "hex"),
76
+ ]
77
+
78
+ ANON_SHORT = {
79
+ "none": "—",
80
+ "names": "names",
81
+ "sequential_id": "seq id",
82
+ "numeric_jitter": "jitter",
83
+ "datetime_jitter": "date jit",
84
+ "random_string": "string",
85
+ "hex": "hex",
86
+ }
87
+
88
+
89
+ @dataclass
90
+ class ColumnConfig:
91
+ """Per-column TUI state: chosen anonymizer + stratification skip."""
92
+
93
+ kind: str = "none"
94
+ options: dict[str, str] = field(default_factory=dict)
95
+ skip_strat: bool = False
96
+
97
+
98
+ def build_anonymizer(cfg: ColumnConfig):
99
+ """Turn a ColumnConfig's raw option strings into an anonymizer instance."""
100
+ o = cfg.options
101
+ if cfg.kind == "names":
102
+ return make_anonymizer("names", style=o.get("style") or "first_last")
103
+ if cfg.kind == "sequential_id":
104
+ return make_anonymizer(
105
+ "sequential_id",
106
+ start=int(o.get("start") or 1),
107
+ interval=int(o.get("interval") or 1),
108
+ prefix=o.get("prefix") or "",
109
+ width=int(o.get("width") or 0),
110
+ )
111
+ if cfg.kind == "numeric_jitter":
112
+ kwargs = {"pct": float(o.get("pct") or 20) / 100.0}
113
+ if (o.get("round_to") or "").strip():
114
+ kwargs["round_to"] = int(o["round_to"])
115
+ return make_anonymizer("numeric_jitter", **kwargs)
116
+ if cfg.kind == "datetime_jitter":
117
+ return make_anonymizer(
118
+ "datetime_jitter",
119
+ max_delta=(o.get("max_delta") or "7D").strip(),
120
+ unit=(o.get("unit") or "s").strip(),
121
+ )
122
+ if cfg.kind == "random_string":
123
+ return make_anonymizer(
124
+ "random_string",
125
+ length=int(o.get("length") or 8),
126
+ prefix=o.get("prefix") or "",
127
+ )
128
+ if cfg.kind == "hex":
129
+ return make_anonymizer("hex", length=int(o.get("length") or 8))
130
+ raise ValueError(f"No anonymizer configured ({cfg.kind!r})")
131
+
132
+
133
+ def anon_label(cfg: ColumnConfig) -> str:
134
+ """Short table-cell label for a column's anonymizer config."""
135
+ if cfg.kind == "none":
136
+ return "—"
137
+ o = cfg.options
138
+ if cfg.kind == "sequential_id":
139
+ return f"seq {o.get('start') or 1}+{o.get('interval') or 1}"
140
+ if cfg.kind == "numeric_jitter":
141
+ return f"jitter ±{o.get('pct') or 20}%"
142
+ if cfg.kind == "datetime_jitter":
143
+ return f"date ±{o.get('max_delta') or '7D'}"
144
+ if cfg.kind in ("random_string", "hex"):
145
+ return f"{ANON_SHORT[cfg.kind]}[{o.get('length') or 8}]"
146
+ return ANON_SHORT[cfg.kind]
147
+
148
+
149
+ # ── screens ───────────────────────────────────────────────────────────────────
150
+
151
+
152
+ class FileScreen(Screen):
153
+ """Pick the source data file."""
154
+
155
+ BINDINGS = [Binding("ctrl+l", "load", "load file")]
156
+
157
+ def __init__(self, path: str | None = None, sheet: str | None = None):
158
+ super().__init__()
159
+ self._initial_path = path
160
+ self._initial_sheet = sheet
161
+
162
+ def compose(self) -> ComposeResult:
163
+ yield Static(
164
+ f" ▓▒░ DATA SAMPLER ░▒▓ v{__version__} — representative samples, "
165
+ "optionally anonymized",
166
+ id="titlebar",
167
+ )
168
+ with Horizontal(id="file-main"):
169
+ with Vertical(id="file-form"):
170
+ yield Label("file path", classes="field-label")
171
+ yield Input(
172
+ placeholder="path to .csv / .tsv / .json / .xlsx / .parquet",
173
+ id="path",
174
+ )
175
+ yield Label("excel sheet (blank = first)", classes="field-label")
176
+ yield Input(placeholder="sheet name", id="sheet")
177
+ yield Button("▶ load", id="load", variant="success")
178
+ yield Static("", id="file-status")
179
+ with Vertical(id="file-browser"):
180
+ yield DirectoryTree(str(Path.cwd()), id="browser")
181
+ yield Footer()
182
+
183
+ def on_mount(self) -> None:
184
+ self.query_one("#file-form").border_title = "source file"
185
+ self.query_one("#file-browser").border_title = "browse"
186
+ if self._initial_path:
187
+ self.query_one("#path", Input).value = self._initial_path
188
+ if self._initial_sheet:
189
+ self.query_one("#sheet", Input).value = self._initial_sheet
190
+ self.action_load()
191
+ else:
192
+ self.query_one("#path", Input).focus()
193
+
194
+ def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None:
195
+ self.query_one("#path", Input).value = str(event.path)
196
+
197
+ def on_input_submitted(self, event: Input.Submitted) -> None:
198
+ self.action_load()
199
+
200
+ def on_button_pressed(self, event: Button.Pressed) -> None:
201
+ if event.button.id == "load":
202
+ self.action_load()
203
+
204
+ def action_load(self) -> None:
205
+ path = self.query_one("#path", Input).value.strip().strip('"').strip("'")
206
+ sheet = self.query_one("#sheet", Input).value.strip() or None
207
+ status = self.query_one("#file-status", Static)
208
+ if not path:
209
+ status.update(Text("enter a file path or pick one from the browser", style=YELLOW))
210
+ return
211
+ if not os.path.isfile(path):
212
+ status.update(Text(f"file not found: {path}", style=RED))
213
+ return
214
+ status.update(Text("loading…", style=CYAN))
215
+ self.query_one("#load", Button).disabled = True
216
+ self.run_worker(lambda: self._load(path, sheet), thread=True, exclusive=True)
217
+
218
+ def _load(self, path: str, sheet: str | None) -> None:
219
+ app: DataSamplerApp = self.app # type: ignore[assignment]
220
+ try:
221
+ df = load_file(path, sheet=sheet)
222
+ # stats stay in this worker thread too: on large frames they take
223
+ # seconds, which would freeze the UI if run in on_mount
224
+ stats = compute_stats(df)
225
+ except Exception as exc: # surfaced to the user, never crash the TUI
226
+ log.exception("load failed")
227
+ app.call_from_thread(self._load_failed, exc)
228
+ return
229
+ app.call_from_thread(self._loaded, path, sheet, df, stats)
230
+
231
+ def _load_failed(self, exc: Exception) -> None:
232
+ self.query_one("#load", Button).disabled = False
233
+ self.query_one("#file-status", Static).update(
234
+ Text(f"✗ {type(exc).__name__}: {exc}", style=RED)
235
+ )
236
+
237
+ def _loaded(
238
+ self, path: str, sheet: str | None, df: pd.DataFrame, stats: list | None = None
239
+ ) -> None:
240
+ self.query_one("#load", Button).disabled = False
241
+ self.query_one("#file-status", Static).update(
242
+ Text(f"✓ {len(df):,} rows × {len(df.columns)} columns", style=GREEN)
243
+ )
244
+ app: DataSamplerApp = self.app # type: ignore[assignment]
245
+ app.df = df
246
+ app.source_path = path
247
+ app.sheet = sheet
248
+ app.column_stats = stats
249
+ app.push_screen(ColumnsScreen())
250
+
251
+
252
+ class ColumnsScreen(Screen):
253
+ """Column stats dashboard + anonymizer / stratification configuration."""
254
+
255
+ BINDINGS = [
256
+ Binding("ctrl+r", "run", "run sample"),
257
+ Binding("a", "suggest", "auto-suggest types"),
258
+ Binding("s", "toggle_skip", "toggle strat skip"),
259
+ Binding("escape", "back", "back to file"),
260
+ ]
261
+
262
+ def __init__(self) -> None:
263
+ super().__init__()
264
+ self.configs: dict[str, ColumnConfig] = {}
265
+ self.stats: dict[str, ColumnStats] = {}
266
+ self.selected: str | None = None
267
+ self._syncing = False
268
+ self._col_keys: list = []
269
+
270
+ # ── layout ────────────────────────────────────────────────────────────────
271
+
272
+ def compose(self) -> ComposeResult:
273
+ app: DataSamplerApp = self.app # type: ignore[assignment]
274
+ n_rows = len(app.df) if app.df is not None else 0
275
+ n_cols = len(app.df.columns) if app.df is not None else 0
276
+ yield Static(
277
+ f" ▓▒░ DATA SAMPLER ░▒▓ {Path(app.source_path or '?').name}"
278
+ f" · {n_rows:,} rows × {n_cols} columns",
279
+ id="titlebar",
280
+ )
281
+ with Horizontal(id="main"):
282
+ with Container(id="columns-panel"):
283
+ yield DataTable(id="columns-table")
284
+ with Vertical(id="side"):
285
+ with VerticalScroll(id="detail-panel"):
286
+ yield Static("", id="detail")
287
+ with VerticalScroll(id="config-panel"):
288
+ yield Label("anonymizer", classes="field-label")
289
+ yield Select(
290
+ ANON_CHOICES, value="none", allow_blank=False, id="anon-kind"
291
+ )
292
+ with ContentSwitcher(initial="opts-none", id="anon-options"):
293
+ yield Static(
294
+ "column is written through unchanged",
295
+ id="opts-none", classes="opts-note",
296
+ )
297
+ with Vertical(id="opts-names"):
298
+ yield Label("style", classes="field-label")
299
+ yield Select(
300
+ [
301
+ ("First Last", "first_last"),
302
+ ("First Middle Last", "first_middle_last"),
303
+ ("Last, First", "last_first"),
304
+ ("First only", "first"),
305
+ ("Last only", "last"),
306
+ ],
307
+ value="first_last",
308
+ allow_blank=False,
309
+ id="opt-names-style",
310
+ )
311
+ with Vertical(id="opts-sequential_id"):
312
+ with Horizontal(classes="optrow"):
313
+ yield Label("start", classes="opt-label")
314
+ yield Input("1", id="opt-seq-start", classes="opt-input")
315
+ yield Label("interval", classes="opt-label")
316
+ yield Input("1", id="opt-seq-interval", classes="opt-input")
317
+ with Horizontal(classes="optrow"):
318
+ yield Label("prefix", classes="opt-label")
319
+ yield Input("", id="opt-seq-prefix", classes="opt-input")
320
+ yield Label("pad width", classes="opt-label")
321
+ yield Input("0", id="opt-seq-width", classes="opt-input")
322
+ with Vertical(id="opts-numeric_jitter"):
323
+ with Horizontal(classes="optrow"):
324
+ yield Label("± percent", classes="opt-label")
325
+ yield Input("20", id="opt-jit-pct", classes="opt-input")
326
+ yield Label("round to", classes="opt-label")
327
+ yield Input("", id="opt-jit-round", classes="opt-input")
328
+ with Vertical(id="opts-datetime_jitter"):
329
+ with Horizontal(classes="optrow"):
330
+ yield Label("± window", classes="opt-label")
331
+ yield Input("7D", id="opt-dt-delta", classes="opt-input")
332
+ yield Label("unit", classes="opt-label")
333
+ yield Input("s", id="opt-dt-unit", classes="opt-input")
334
+ with Vertical(id="opts-random_string"):
335
+ with Horizontal(classes="optrow"):
336
+ yield Label("length", classes="opt-label")
337
+ yield Input("8", id="opt-str-length", classes="opt-input")
338
+ yield Label("prefix", classes="opt-label")
339
+ yield Input("", id="opt-str-prefix", classes="opt-input")
340
+ with Vertical(id="opts-hex"):
341
+ with Horizontal(classes="optrow"):
342
+ yield Label("length", classes="opt-label")
343
+ yield Input("8", id="opt-hex-length", classes="opt-input")
344
+ with Horizontal(id="skip-row"):
345
+ yield Switch(value=False, id="skip-strat")
346
+ yield Label("skip when stratifying", id="skip-label")
347
+ with Horizontal(id="runbar"):
348
+ yield Label("rows", classes="run-label")
349
+ yield Input("100", id="count", classes="run-input-s")
350
+ yield Label("out dir", classes="run-label")
351
+ yield Input("", placeholder="same folder as source", id="outdir", classes="run-input-l")
352
+ yield Label("seed", classes="run-label")
353
+ yield Input("", placeholder="—", id="seed", classes="run-input-s")
354
+ yield Label("random", classes="run-label")
355
+ yield Switch(value=False, id="random")
356
+ yield Button("▶ run sample", id="run", variant="success")
357
+ yield Footer()
358
+
359
+ def on_mount(self) -> None:
360
+ app: DataSamplerApp = self.app # type: ignore[assignment]
361
+ self.query_one("#columns-panel").border_title = "columns"
362
+ self.query_one("#detail-panel").border_title = "detail"
363
+ self.query_one("#config-panel").border_title = "anonymize · stratify"
364
+ self.query_one("#runbar").border_title = "sample"
365
+
366
+ df = app.df
367
+ # stats were precomputed in the file-load worker thread; the inline
368
+ # compute_stats is only a fallback for screens pushed directly (tests)
369
+ stats_list = app.column_stats if app.column_stats is not None else compute_stats(df)
370
+ self.stats = {s.name: s for s in stats_list}
371
+ self.configs = {str(c): ColumnConfig() for c in df.columns}
372
+
373
+ table = self.query_one("#columns-table", DataTable)
374
+ table.cursor_type = "row"
375
+ table.zebra_stripes = True
376
+ # actionable columns (anonymizer, strat) come right after type so they
377
+ # stay visible; the wider distribution/summary columns trail them
378
+ self._col_keys = list(
379
+ table.add_columns(
380
+ "column", "type", "anonymizer", "strat",
381
+ "miss%", "uniq", "distribution", "summary",
382
+ )
383
+ )
384
+ for name in self.configs:
385
+ table.add_row(*self._row_cells(name), key=name)
386
+ table.focus()
387
+ if self.configs:
388
+ self.selected = next(iter(self.configs))
389
+ self._show_detail(self.selected)
390
+
391
+ # ── table rendering ───────────────────────────────────────────────────────
392
+
393
+ def _row_cells(self, name: str) -> list:
394
+ s = self.stats[name]
395
+ cfg = self.configs[name]
396
+ color = KIND_COLORS.get(s.kind, DIM)
397
+ miss_style = RED if s.missing else DIM
398
+ if cfg.skip_strat:
399
+ strat = Text("✗ skip", style=RED)
400
+ elif s.stratifiable:
401
+ strat = Text("✓ auto", style=GREEN)
402
+ else:
403
+ strat = Text("—", style=DIM)
404
+ return [
405
+ Text(name, style=f"bold {FG}"),
406
+ Text(s.kind, style=color),
407
+ Text(anon_label(cfg), style=GREEN if cfg.kind != "none" else DIM),
408
+ strat,
409
+ Text(f"{s.missing_pct:.1f}", style=miss_style, justify="right"),
410
+ Text(f"{s.unique:,}", style=FG, justify="right"),
411
+ Text(sparkline(s.histogram), style=color),
412
+ Text(s.summary(), style=DIM),
413
+ ]
414
+
415
+ def _refresh_row(self, name: str) -> None:
416
+ table = self.query_one("#columns-table", DataTable)
417
+ for col_key, cell in zip(self._col_keys, self._row_cells(name)):
418
+ table.update_cell(name, col_key, cell)
419
+
420
+ # ── detail panel ─────────────────────────────────────────────────────────
421
+
422
+ def _show_detail(self, name: str) -> None:
423
+ s = self.stats[name]
424
+ color = KIND_COLORS.get(s.kind, DIM)
425
+ t = Text()
426
+ t.append(f"{s.name}\n", style=f"bold {color}")
427
+ t.append(f"{s.kind} · {s.dtype}\n\n", style=DIM)
428
+ t.append(f"{s.count:,}", style=FG)
429
+ t.append(" non-null · ", style=DIM)
430
+ t.append(f"{s.missing:,}", style=RED if s.missing else FG)
431
+ t.append(f" missing ({s.missing_pct:.1f}%) · ", style=DIM)
432
+ t.append(f"{s.unique:,}", style=FG)
433
+ t.append(" unique\n", style=DIM)
434
+ if s.kind == "numeric" and s.min is not None:
435
+ from ..stats import _fmt_num
436
+
437
+ t.append("\nmin ", style=DIM)
438
+ t.append(_fmt_num(s.min), style=CYAN)
439
+ t.append(" median ", style=DIM)
440
+ t.append(_fmt_num(s.median), style=CYAN)
441
+ t.append(" mean ", style=DIM)
442
+ t.append(_fmt_num(s.mean), style=CYAN)
443
+ t.append(" max ", style=DIM)
444
+ t.append(_fmt_num(s.max), style=CYAN)
445
+ t.append(" σ ", style=DIM)
446
+ t.append(_fmt_num(s.std), style=CYAN)
447
+ t.append("\n")
448
+ if s.histogram:
449
+ t.append("\n")
450
+ peak = max(s.histogram) or 1
451
+ total = sum(s.histogram) or 1
452
+ for label, count in zip(s.histogram_labels, s.histogram):
453
+ width = int(count / peak * 24)
454
+ t.append(f"{label[:18]:>18} ", style=FG)
455
+ t.append("█" * width + "▏" * (0 if width else 1), style=color)
456
+ t.append(f" {count:,} ({count / total * 100:.1f}%)\n", style=DIM)
457
+ if not s.stratifiable:
458
+ t.append("\nnot a stratification candidate ", style=DIM)
459
+ t.append("(too many unique values, long text, or constant)", style=DIM)
460
+ self.query_one("#detail", Static).update(t)
461
+
462
+ # ── config panel sync ────────────────────────────────────────────────────
463
+
464
+ _OPT_IDS = {
465
+ "opt-seq-start": ("sequential_id", "start"),
466
+ "opt-seq-interval": ("sequential_id", "interval"),
467
+ "opt-seq-prefix": ("sequential_id", "prefix"),
468
+ "opt-seq-width": ("sequential_id", "width"),
469
+ "opt-jit-pct": ("numeric_jitter", "pct"),
470
+ "opt-jit-round": ("numeric_jitter", "round_to"),
471
+ "opt-dt-delta": ("datetime_jitter", "max_delta"),
472
+ "opt-dt-unit": ("datetime_jitter", "unit"),
473
+ "opt-str-length": ("random_string", "length"),
474
+ "opt-str-prefix": ("random_string", "prefix"),
475
+ "opt-hex-length": ("hex", "length"),
476
+ }
477
+
478
+ _OPT_DEFAULTS = {
479
+ "opt-seq-start": "1", "opt-seq-interval": "1", "opt-seq-prefix": "",
480
+ "opt-seq-width": "0", "opt-jit-pct": "20", "opt-jit-round": "",
481
+ "opt-dt-delta": "7D", "opt-dt-unit": "s",
482
+ "opt-str-length": "8", "opt-str-prefix": "", "opt-hex-length": "8",
483
+ }
484
+
485
+ def _sync_config_panel(self, name: str) -> None:
486
+ cfg = self.configs[name]
487
+ self._syncing = True
488
+ try:
489
+ # only assign widgets whose value actually differs: assignments
490
+ # queue Changed messages that are processed AFTER _syncing clears,
491
+ # so gratuitous ones can clobber a config edit still in flight
492
+ kind_select = self.query_one("#anon-kind", Select)
493
+ if kind_select.value != cfg.kind:
494
+ kind_select.value = cfg.kind
495
+ self.query_one("#anon-options", ContentSwitcher).current = f"opts-{cfg.kind}"
496
+ style_select = self.query_one("#opt-names-style", Select)
497
+ style_value = cfg.options.get("style") or "first_last"
498
+ if style_select.value != style_value:
499
+ style_select.value = style_value
500
+ for widget_id, (kind, opt) in self._OPT_IDS.items():
501
+ value = str(
502
+ cfg.options.get(opt, self._OPT_DEFAULTS[widget_id])
503
+ if cfg.kind == kind
504
+ else self._OPT_DEFAULTS[widget_id]
505
+ )
506
+ widget = self.query_one(f"#{widget_id}", Input)
507
+ if widget.value != value:
508
+ widget.value = value
509
+ skip = self.query_one("#skip-strat", Switch)
510
+ if skip.value != cfg.skip_strat:
511
+ skip.value = cfg.skip_strat
512
+ finally:
513
+ self._syncing = False
514
+
515
+ # ── events ───────────────────────────────────────────────────────────────
516
+
517
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
518
+ if event.row_key is None or event.row_key.value is None:
519
+ return
520
+ if event.row_key.value == self.selected:
521
+ # duplicate highlight for the row already shown (e.g. the initial
522
+ # mount-time highlight arriving late): re-syncing here would reset
523
+ # panel widgets and queue stale Changed messages that clobber any
524
+ # user edit still in flight — the panel is already correct
525
+ return
526
+ self.selected = event.row_key.value
527
+ self._show_detail(self.selected)
528
+ self._sync_config_panel(self.selected)
529
+
530
+ def on_select_changed(self, event: Select.Changed) -> None:
531
+ if self._syncing or self.selected is None:
532
+ return
533
+ cfg = self.configs[self.selected]
534
+ if event.select.id == "anon-kind":
535
+ if str(event.value) == cfg.kind:
536
+ return # spurious/echoed change; keep existing options
537
+ cfg.kind = str(event.value)
538
+ cfg.options = {}
539
+ self.query_one("#anon-options", ContentSwitcher).current = f"opts-{cfg.kind}"
540
+ self._refresh_row(self.selected)
541
+ elif event.select.id == "opt-names-style":
542
+ cfg.options["style"] = str(event.value)
543
+
544
+ def on_switch_changed(self, event: Switch.Changed) -> None:
545
+ if event.switch.id == "skip-strat":
546
+ if self._syncing or self.selected is None:
547
+ return
548
+ self.configs[self.selected].skip_strat = event.value
549
+ self._refresh_row(self.selected)
550
+
551
+ def on_input_changed(self, event: Input.Changed) -> None:
552
+ if self._syncing or self.selected is None:
553
+ return
554
+ mapping = self._OPT_IDS.get(event.input.id or "")
555
+ if mapping is None:
556
+ return
557
+ kind, opt = mapping
558
+ cfg = self.configs[self.selected]
559
+ if cfg.kind == kind:
560
+ cfg.options[opt] = event.value
561
+ self._refresh_row(self.selected)
562
+
563
+ def on_button_pressed(self, event: Button.Pressed) -> None:
564
+ if event.button.id == "run":
565
+ self.action_run()
566
+
567
+ def action_toggle_skip(self) -> None:
568
+ if self.selected is None:
569
+ return
570
+ cfg = self.configs[self.selected]
571
+ cfg.skip_strat = not cfg.skip_strat
572
+ self._sync_config_panel(self.selected)
573
+ self._refresh_row(self.selected)
574
+
575
+ def action_suggest(self) -> None:
576
+ """Auto-assign a suggested anonymizer type to every column."""
577
+ from ..workflow import suggest_type
578
+
579
+ changed = 0
580
+ for name, stats in self.stats.items():
581
+ kind = suggest_type(stats)
582
+ cfg = self.configs[name]
583
+ if cfg.kind != kind:
584
+ cfg.kind = kind
585
+ cfg.options = {}
586
+ changed += 1
587
+ self._refresh_row(name)
588
+ if self.selected is not None:
589
+ self._sync_config_panel(self.selected)
590
+ self.notify(
591
+ f"suggested anonymizer types ({changed} column(s) changed)",
592
+ timeout=3,
593
+ )
594
+
595
+ def action_back(self) -> None:
596
+ self.app.pop_screen()
597
+
598
+ # ── run ──────────────────────────────────────────────────────────────────
599
+
600
+ def action_run(self) -> None:
601
+ app: DataSamplerApp = self.app # type: ignore[assignment]
602
+ try:
603
+ count = int(self.query_one("#count", Input).value)
604
+ if count < 1:
605
+ raise ValueError
606
+ except ValueError:
607
+ self.notify("sample count must be a positive integer", severity="error")
608
+ return
609
+ seed_raw = self.query_one("#seed", Input).value.strip()
610
+ try:
611
+ seed = int(seed_raw) if seed_raw else None
612
+ except ValueError:
613
+ self.notify("seed must be an integer (or blank)", severity="error")
614
+ return
615
+ outdir = self.query_one("#outdir", Input).value.strip() or None
616
+ use_random = self.query_one("#random", Switch).value
617
+ exclude = [c for c, cfg in self.configs.items() if cfg.skip_strat]
618
+ try:
619
+ spec = {
620
+ c: build_anonymizer(cfg)
621
+ for c, cfg in self.configs.items()
622
+ if cfg.kind != "none"
623
+ }
624
+ except (ValueError, TypeError) as exc:
625
+ self.notify(f"anonymizer options: {exc}", severity="error")
626
+ return
627
+
628
+ self.query_one("#run", Button).disabled = True
629
+ self.notify("sampling…", timeout=2)
630
+ self.run_worker(
631
+ lambda: self._do_run(count, use_random, exclude, spec, seed, outdir),
632
+ thread=True,
633
+ exclusive=True,
634
+ )
635
+
636
+ def _do_run(self, count, use_random, exclude, spec, seed, outdir) -> None:
637
+ app: DataSamplerApp = self.app # type: ignore[assignment]
638
+ try:
639
+ from ..anonymize import anonymize
640
+ from ..report import column_histogram_data
641
+
642
+ result = sample(
643
+ app.df, count,
644
+ use_random=use_random,
645
+ exclude_columns=exclude,
646
+ random_state=seed,
647
+ )
648
+ # source-vs-sample histograms use the pre-anonymization sample
649
+ hist_data = column_histogram_data(app.df, result.data)
650
+ data = result.data
651
+ if spec:
652
+ data = anonymize(data, spec, seed=seed)
653
+ tag = f"sample_{count}" + ("_anon" if spec else "")
654
+ out_path = save_output(data, app.source_path, tag, output_folder=outdir)
655
+
656
+ lines = list(result.notes)
657
+ if exclude:
658
+ lines.append(f"Columns excluded from stratification: {', '.join(exclude)}")
659
+ report = format_stratification_report(app.df, result)
660
+ if result.method == "stratified":
661
+ lines.append("")
662
+ lines.append(report)
663
+ if spec:
664
+ lines.append("")
665
+ lines.append("ANONYMIZED COLUMNS")
666
+ for col in spec:
667
+ lines.append(f" {col} → {anon_label(self.configs[col])}")
668
+ lines.append("")
669
+ lines.append(f"Sampled {len(data)} rows.")
670
+ lines.append(f"Output saved to: {out_path}")
671
+ app.call_from_thread(
672
+ self._run_done, "\n".join(lines), str(out_path), hist_data
673
+ )
674
+ except Exception as exc: # surfaced to the user, never crash the TUI
675
+ log.exception("run failed")
676
+ app.call_from_thread(self._run_failed, exc)
677
+
678
+ def _run_failed(self, exc: Exception) -> None:
679
+ self.query_one("#run", Button).disabled = False
680
+ self.notify(f"{type(exc).__name__}: {exc}", severity="error", timeout=10)
681
+
682
+ def _run_done(self, report: str, out_path: str, hist_data: list) -> None:
683
+ self.query_one("#run", Button).disabled = False
684
+ self.app.push_screen(ReportScreen(report, out_path, hist_data))
685
+
686
+
687
+ class ReportScreen(Screen):
688
+ """Post-run report: stratification comparison, per-column histograms,
689
+ anonymization summary."""
690
+
691
+ BINDINGS = [
692
+ Binding("escape", "back", "back to columns"),
693
+ Binding("n", "new_file", "new file"),
694
+ ]
695
+
696
+ HIST_BAR = 12 # width of each histogram bar in the panel
697
+
698
+ def __init__(self, report: str, out_path: str, hist_data: list | None = None):
699
+ super().__init__()
700
+ self._report = report
701
+ self._out_path = out_path
702
+ self._hist_data = hist_data or []
703
+
704
+ def compose(self) -> ComposeResult:
705
+ yield Static(" ▓▒░ DATA SAMPLER ░▒▓ sample complete", id="titlebar")
706
+ with Horizontal(id="report-main"):
707
+ with VerticalScroll(id="report-panel"):
708
+ yield Static(Text(self._report), id="report-text")
709
+ with VerticalScroll(id="hist-panel"):
710
+ yield Static(self._build_histograms(), id="hist-text")
711
+ with Horizontal(id="report-actions"):
712
+ yield Button("◀ back (esc)", id="back")
713
+ yield Button("new file (n)", id="new-file", variant="primary")
714
+ yield Footer()
715
+
716
+ def on_mount(self) -> None:
717
+ report_panel = self.query_one("#report-panel")
718
+ report_panel.border_title = "report"
719
+ self.query_one("#hist-panel").border_title = "column histograms"
720
+ report_panel.focus()
721
+
722
+ def _build_histograms(self) -> Text:
723
+ if not self._hist_data:
724
+ return Text("no columns to chart", style=DIM)
725
+ t = Text()
726
+ t.append("source ", style=DIM)
727
+ t.append("█", style=DIM)
728
+ t.append(" sample ", style=DIM)
729
+ t.append("█", style=CYAN)
730
+ t.append(" (% of non-null values)\n", style=DIM)
731
+ for d in self._hist_data:
732
+ labels = d["labels"]
733
+ if not labels:
734
+ continue
735
+ color = KIND_COLORS.get(d["kind"], DIM)
736
+ label_w = min(16, max(len(l) for l in labels))
737
+ peak = max([*d["source_pct"], *d["sample_pct"], 1e-9])
738
+ t.append(f"\n{d['name']} ", style=f"bold {color}")
739
+ t.append(f"({d['kind']})\n", style=DIM)
740
+ for label, s_pct, m_pct in zip(labels, d["source_pct"], d["sample_pct"]):
741
+ lbl = label if len(label) <= label_w else label[: label_w - 1] + "…"
742
+ s_len = int(s_pct / peak * self.HIST_BAR)
743
+ m_len = int(m_pct / peak * self.HIST_BAR)
744
+ t.append(f"{lbl:>{label_w}} ", style=FG)
745
+ t.append("█" * s_len + "░" * (self.HIST_BAR - s_len), style=DIM)
746
+ t.append(f"{s_pct:4.0f}% ", style=DIM)
747
+ t.append("█" * m_len + "░" * (self.HIST_BAR - m_len), style=color)
748
+ t.append(f"{m_pct:4.0f}%\n", style=DIM)
749
+ return t
750
+
751
+ def on_button_pressed(self, event: Button.Pressed) -> None:
752
+ if event.button.id == "back":
753
+ self.action_back()
754
+ elif event.button.id == "new-file":
755
+ self.action_new_file()
756
+
757
+ def action_back(self) -> None:
758
+ self.app.pop_screen()
759
+
760
+ def action_new_file(self) -> None:
761
+ app: DataSamplerApp = self.app # type: ignore[assignment]
762
+ app.pop_screen()
763
+ app.pop_screen()
764
+
765
+
766
+ # ── app ───────────────────────────────────────────────────────────────────────
767
+
768
+
769
+ class DataSamplerApp(App):
770
+ """Colorful terminal UI for sampling + anonymizing tabular data."""
771
+
772
+ TITLE = "Data Sampler"
773
+
774
+ BINDINGS = [Binding("ctrl+q", "quit", "quit")]
775
+
776
+ CSS = f"""
777
+ Screen {{
778
+ background: #0b0e14;
779
+ color: {FG};
780
+ }}
781
+ #titlebar {{
782
+ height: 1;
783
+ background: #10141c;
784
+ color: {CYAN};
785
+ text-style: bold;
786
+ }}
787
+ Footer {{
788
+ background: #10141c;
789
+ }}
790
+
791
+ /* file screen */
792
+ #file-main {{ height: 1fr; }}
793
+ #file-form {{
794
+ width: 50%;
795
+ border: round {CYAN};
796
+ border-title-color: {CYAN};
797
+ padding: 1 2;
798
+ }}
799
+ #file-browser {{
800
+ width: 50%;
801
+ border: round {MAGENTA};
802
+ border-title-color: {MAGENTA};
803
+ }}
804
+ #browser {{ background: transparent; }}
805
+ #file-status {{ margin-top: 1; }}
806
+ #load {{ margin-top: 1; }}
807
+ .field-label {{ color: {DIM}; margin-top: 1; }}
808
+
809
+ /* columns screen */
810
+ #main {{ height: 1fr; }}
811
+ #columns-panel {{
812
+ width: 58%;
813
+ border: round {CYAN};
814
+ border-title-color: {CYAN};
815
+ }}
816
+ #columns-table {{
817
+ background: transparent;
818
+ height: 1fr;
819
+ }}
820
+ #side {{ width: 42%; }}
821
+ #detail-panel {{
822
+ height: 45%;
823
+ border: round {MAGENTA};
824
+ border-title-color: {MAGENTA};
825
+ padding: 0 1;
826
+ }}
827
+ #config-panel {{
828
+ height: 55%;
829
+ border: round {YELLOW};
830
+ border-title-color: {YELLOW};
831
+ padding: 0 1;
832
+ }}
833
+ /* Vertical/Horizontal default to 1fr height; inside the scrollable
834
+ config panel that swallows the space and pushes #skip-row out of
835
+ view — force content-sized heights */
836
+ #anon-options {{ height: auto; }}
837
+ #anon-options > Vertical {{ height: auto; }}
838
+ .opts-note {{ color: {DIM}; margin: 1 0; }}
839
+ .optrow {{ height: 3; }}
840
+ .opt-label {{ width: 10; margin-top: 1; color: {DIM}; }}
841
+ .opt-input {{ width: 1fr; }}
842
+ #skip-row {{ height: 3; margin-top: 1; }}
843
+ #skip-label {{ margin-top: 1; color: {FG}; }}
844
+
845
+ /* run bar */
846
+ #runbar {{
847
+ height: 5;
848
+ border: round {GREEN};
849
+ border-title-color: {GREEN};
850
+ padding: 0 1;
851
+ }}
852
+ .run-label {{ margin-top: 1; margin-right: 1; color: {DIM}; }}
853
+ .run-input-s {{ width: 12; }}
854
+ .run-input-l {{ width: 1fr; }}
855
+ #random {{ margin-top: 0; }}
856
+ #run {{ margin-left: 2; }}
857
+
858
+ /* report screen */
859
+ #report-main {{ height: 1fr; }}
860
+ #report-panel {{
861
+ width: 55%;
862
+ border: round {GREEN};
863
+ border-title-color: {GREEN};
864
+ padding: 0 2;
865
+ }}
866
+ #hist-panel {{
867
+ width: 45%;
868
+ border: round {CYAN};
869
+ border-title-color: {CYAN};
870
+ padding: 0 1;
871
+ }}
872
+ #report-actions {{ height: 3; padding: 0 2; }}
873
+ #report-actions Button {{ margin-right: 2; }}
874
+
875
+ Input {{
876
+ background: #151a24;
877
+ }}
878
+ Input:focus {{
879
+ border: tall {CYAN};
880
+ }}
881
+ Select {{
882
+ background: #151a24;
883
+ }}
884
+ DataTable > .datatable--header {{
885
+ color: {CYAN};
886
+ text-style: bold;
887
+ background: #10141c;
888
+ }}
889
+ """
890
+
891
+ def __init__(self, path: str | None = None, sheet: str | None = None):
892
+ super().__init__()
893
+ self._initial_path = path
894
+ self._initial_sheet = sheet
895
+ self.df: pd.DataFrame | None = None
896
+ self.source_path: str | None = None
897
+ self.sheet: str | None = None
898
+ self.column_stats: list | None = None # precomputed by the load worker
899
+
900
+ def on_mount(self) -> None:
901
+ # keep log lines off the live display
902
+ log_path = Path(tempfile.gettempdir()) / "data_sampler_tui.log"
903
+ redirect_to_file(str(log_path))
904
+ self.push_screen(FileScreen(self._initial_path, self._initial_sheet))