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.
- data_sampler/__init__.py +82 -0
- data_sampler/__main__.py +7 -0
- data_sampler/_logging.py +61 -0
- data_sampler/_names.py +60 -0
- data_sampler/anonymize.py +463 -0
- data_sampler/cli.py +363 -0
- data_sampler/engine.py +628 -0
- data_sampler/io.py +75 -0
- data_sampler/report.py +289 -0
- data_sampler/sampling.py +193 -0
- data_sampler/stats.py +174 -0
- data_sampler/tui/__init__.py +16 -0
- data_sampler/tui/app.py +904 -0
- data_sampler/workflow.py +285 -0
- data_sampler-3.2.1.dist-info/METADATA +427 -0
- data_sampler-3.2.1.dist-info/RECORD +19 -0
- data_sampler-3.2.1.dist-info/WHEEL +4 -0
- data_sampler-3.2.1.dist-info/entry_points.txt +3 -0
- data_sampler-3.2.1.dist-info/licenses/LICENSE +21 -0
data_sampler/tui/app.py
ADDED
|
@@ -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))
|