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/cli.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
``data-sampler`` with no arguments launches the TUI. With a source file and
|
|
4
|
+
count it runs headless:
|
|
5
|
+
|
|
6
|
+
data-sampler data.csv 500 --sheet S --outdir out --random --seed 7 \\
|
|
7
|
+
--skip region,tier --anon "name=names" \\
|
|
8
|
+
--anon "cust_id=sequential_id:start=1000,interval=7" \\
|
|
9
|
+
--anon "salary=numeric_jitter:pct=0.1" --anon "email=hex:length=12"
|
|
10
|
+
|
|
11
|
+
For inputs too large for memory, ``--engine duckdb`` (or ``--engine auto``,
|
|
12
|
+
which picks DuckDB for Parquet/large files) samples out-of-core in parallel:
|
|
13
|
+
|
|
14
|
+
data-sampler huge.parquet 10000 --engine duckdb --threads 8 \\
|
|
15
|
+
--memory-limit 8GB --suggest
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
from . import __version__
|
|
24
|
+
from ._logging import get_logger
|
|
25
|
+
from .anonymize import anonymize, make_anonymizer
|
|
26
|
+
from .io import load_file, save_output
|
|
27
|
+
from .report import format_column_histograms, format_stratification_report
|
|
28
|
+
from .sampling import sample
|
|
29
|
+
|
|
30
|
+
log = get_logger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _coerce_value(value: str):
|
|
34
|
+
for cast in (int, float):
|
|
35
|
+
try:
|
|
36
|
+
return cast(value)
|
|
37
|
+
except ValueError:
|
|
38
|
+
pass
|
|
39
|
+
if value.lower() in ("true", "false"):
|
|
40
|
+
return value.lower() == "true"
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_anon_option(text: str) -> tuple[str, str, dict]:
|
|
45
|
+
"""Parse ``COL=KIND[:key=value,key=value]`` into (column, kind, options)."""
|
|
46
|
+
col, sep, rhs = text.partition("=")
|
|
47
|
+
if not sep or not col.strip() or not rhs.strip():
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"--anon expects COL=KIND[:key=value,...], got {text!r}"
|
|
50
|
+
)
|
|
51
|
+
kind, _, optstr = rhs.partition(":")
|
|
52
|
+
options: dict = {}
|
|
53
|
+
if optstr:
|
|
54
|
+
for pair in optstr.split(","):
|
|
55
|
+
key, psep, value = pair.partition("=")
|
|
56
|
+
if not psep or not key.strip():
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"--anon option {pair!r} must be key=value (in {text!r})"
|
|
59
|
+
)
|
|
60
|
+
options[key.strip()] = _coerce_value(value.strip())
|
|
61
|
+
return col.strip(), kind.strip(), options
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
65
|
+
parser = argparse.ArgumentParser(
|
|
66
|
+
prog="data-sampler",
|
|
67
|
+
description=(
|
|
68
|
+
"Create representative (stratified) samples from data files, "
|
|
69
|
+
"optionally anonymizing columns. Run with no arguments to open "
|
|
70
|
+
"the terminal UI."
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"source", nargs="?",
|
|
75
|
+
help="path to the source data file (omit to launch the TUI)",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"count", nargs="?", type=int, help="number of rows to sample",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument("--sheet", help="sheet name for Excel files (default: first)")
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--random", action="store_true", dest="use_random",
|
|
83
|
+
help="pure random sampling instead of stratified",
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument("--outdir", help="output folder (default: source folder)")
|
|
86
|
+
parser.add_argument("--seed", type=int, help="seed for reproducible runs")
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--skip", action="append", default=[], metavar="COL[,COL...]",
|
|
89
|
+
help="column(s) to exclude from stratification (repeatable)",
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--anon", action="append", default=[], metavar="COL=KIND[:k=v,...]",
|
|
93
|
+
help=(
|
|
94
|
+
"anonymize a column (repeatable). KIND: names, sequential_id, "
|
|
95
|
+
"numeric_jitter, datetime_jitter, random_string, hex. "
|
|
96
|
+
"Example: --anon \"id=sequential_id:start=1000,interval=7\""
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"-i", "--interactive", action="store_true",
|
|
101
|
+
help=(
|
|
102
|
+
"guided anonymization workflow: choose a type for each column from "
|
|
103
|
+
"a menu (seeded by any --anon flags and per-column suggestions)"
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
"--suggest", action="store_true",
|
|
108
|
+
help=(
|
|
109
|
+
"auto-assign a suggested anonymizer type to each column from its "
|
|
110
|
+
"stats (columns set via --anon are left as given)"
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
parser.add_argument(
|
|
114
|
+
"--engine", choices=("auto", "pandas", "duckdb"), default="auto",
|
|
115
|
+
help=(
|
|
116
|
+
"sampling engine: 'pandas' (in-memory, default for small/medium data), "
|
|
117
|
+
"'duckdb' (out-of-core, parallel; needs the 'large' extra), or 'auto' "
|
|
118
|
+
"(duckdb for Parquet/large inputs, pandas otherwise)"
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"--threads", type=int, metavar="N",
|
|
123
|
+
help="DuckDB engine: number of threads (default: all cores)",
|
|
124
|
+
)
|
|
125
|
+
parser.add_argument(
|
|
126
|
+
"--memory-limit", metavar="SIZE", dest="memory_limit",
|
|
127
|
+
help="DuckDB engine: memory limit before spilling to disk (e.g. '8GB')",
|
|
128
|
+
)
|
|
129
|
+
parser.add_argument(
|
|
130
|
+
"--tui", action="store_true",
|
|
131
|
+
help="open the TUI (optionally preloading SOURCE)",
|
|
132
|
+
)
|
|
133
|
+
parser.add_argument(
|
|
134
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
135
|
+
)
|
|
136
|
+
return parser
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _build_engine_spec(args, parser, engine, cols) -> dict:
|
|
140
|
+
"""Build the anonymizer spec for the engine path (validates against ``cols``,
|
|
141
|
+
supports explicit --anon and --suggest via the engine's approximate stats)."""
|
|
142
|
+
anon_specs: list[tuple[str, str, dict]] = []
|
|
143
|
+
for raw in args.anon:
|
|
144
|
+
try:
|
|
145
|
+
col, kind, options = parse_anon_option(raw)
|
|
146
|
+
if col not in cols:
|
|
147
|
+
raise ValueError(f"column {col!r} not found in file")
|
|
148
|
+
anon_specs.append((col, kind, options))
|
|
149
|
+
except (ValueError, TypeError) as exc:
|
|
150
|
+
parser.error(f"--anon {raw!r}: {exc}")
|
|
151
|
+
|
|
152
|
+
if args.suggest:
|
|
153
|
+
from .workflow import AnonymizationPlan, suggest_type
|
|
154
|
+
|
|
155
|
+
plan = AnonymizationPlan.for_columns(cols)
|
|
156
|
+
try:
|
|
157
|
+
for col, kind, options in anon_specs:
|
|
158
|
+
plan.assign(col, kind, **options)
|
|
159
|
+
except (ValueError, TypeError) as exc:
|
|
160
|
+
parser.error(f"--anon: {exc}")
|
|
161
|
+
# approximate stats over the full (possibly huge) source, cheaply
|
|
162
|
+
for cs in engine.stats(args.source, distributions=False):
|
|
163
|
+
if plan.type_of(cs.name) == "none":
|
|
164
|
+
plan.assignments[cs.name] = (suggest_type(cs), {})
|
|
165
|
+
return plan.build_spec()
|
|
166
|
+
|
|
167
|
+
spec = {}
|
|
168
|
+
for col, kind, options in anon_specs:
|
|
169
|
+
try:
|
|
170
|
+
spec[col] = make_anonymizer(kind, **options)
|
|
171
|
+
except (ValueError, TypeError) as exc:
|
|
172
|
+
parser.error(f"--anon {col}={kind}: {exc}")
|
|
173
|
+
return spec
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _run_with_engine(args, parser, skip) -> int:
|
|
177
|
+
"""Headless sampling via the DuckDB out-of-core engine."""
|
|
178
|
+
from .engine import DuckDBEngine
|
|
179
|
+
|
|
180
|
+
with DuckDBEngine(threads=args.threads, memory_limit=args.memory_limit) as engine:
|
|
181
|
+
cols = engine.columns(args.source)
|
|
182
|
+
total = engine.row_count(args.source)
|
|
183
|
+
print(
|
|
184
|
+
f"Loaded {total:,} rows, {len(cols)} columns from {args.source} "
|
|
185
|
+
f"(DuckDB engine, {engine.threads} threads)"
|
|
186
|
+
)
|
|
187
|
+
unknown_skip = [c for c in skip if c not in cols]
|
|
188
|
+
if unknown_skip:
|
|
189
|
+
parser.error(
|
|
190
|
+
f"--skip: column(s) not found in file: {', '.join(unknown_skip)}"
|
|
191
|
+
)
|
|
192
|
+
spec = _build_engine_spec(args, parser, engine, cols)
|
|
193
|
+
result = engine.sample(
|
|
194
|
+
args.source, args.count,
|
|
195
|
+
use_random=args.use_random, exclude_columns=skip, seed=args.seed,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
for note in result.notes:
|
|
199
|
+
print(note)
|
|
200
|
+
if skip:
|
|
201
|
+
print(f"Columns excluded from stratification: {', '.join(skip)}")
|
|
202
|
+
if result.method == "stratified" and result.group_sizes is not None:
|
|
203
|
+
print(
|
|
204
|
+
f" ({len(result.group_sizes)} strata; "
|
|
205
|
+
f"allocations sum to {int(result.allocations.sum())})"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
data = result.data
|
|
209
|
+
if spec:
|
|
210
|
+
try:
|
|
211
|
+
data = anonymize(data, spec, seed=args.seed)
|
|
212
|
+
except (ValueError, TypeError) as exc:
|
|
213
|
+
parser.error(f"anonymization failed: {exc}")
|
|
214
|
+
print(f"Anonymized columns: {', '.join(spec)}")
|
|
215
|
+
|
|
216
|
+
tag = f"sample_{args.count}" + ("_anon" if spec else "")
|
|
217
|
+
out_path = save_output(data, args.source, tag, output_folder=args.outdir)
|
|
218
|
+
print(f"\nSampled {len(data):,} rows.")
|
|
219
|
+
print(f"Output saved to: {out_path}")
|
|
220
|
+
return 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def main(argv: list[str] | None = None) -> int:
|
|
224
|
+
# ensure UTF-8 output on Windows (stdout is None in windowed frozen apps)
|
|
225
|
+
if sys.stdout is not None and sys.stdout.encoding != "utf-8":
|
|
226
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
227
|
+
|
|
228
|
+
parser = build_parser()
|
|
229
|
+
args = parser.parse_args(argv)
|
|
230
|
+
|
|
231
|
+
if args.tui or args.source is None:
|
|
232
|
+
from .tui import run_tui
|
|
233
|
+
|
|
234
|
+
run_tui(path=args.source, sheet=args.sheet)
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
if args.count is None:
|
|
238
|
+
parser.error("count is required when a source file is given (or use --tui)")
|
|
239
|
+
|
|
240
|
+
skip = [c.strip() for chunk in args.skip for c in chunk.split(",") if c.strip()]
|
|
241
|
+
|
|
242
|
+
# ── engine selection ──────────────────────────────────────────────────────
|
|
243
|
+
from .engine import (
|
|
244
|
+
DuckDBUnavailable,
|
|
245
|
+
duckdb_available,
|
|
246
|
+
large_materialization_warning,
|
|
247
|
+
should_use_engine,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if args.engine == "duckdb":
|
|
251
|
+
use_engine = True
|
|
252
|
+
elif args.engine == "auto":
|
|
253
|
+
use_engine = should_use_engine(args.source)
|
|
254
|
+
else:
|
|
255
|
+
use_engine = False
|
|
256
|
+
|
|
257
|
+
if use_engine:
|
|
258
|
+
if args.interactive:
|
|
259
|
+
parser.error(
|
|
260
|
+
"--interactive is not supported with the DuckDB engine; "
|
|
261
|
+
"use --engine pandas, or --suggest"
|
|
262
|
+
)
|
|
263
|
+
try:
|
|
264
|
+
return _run_with_engine(args, parser, skip)
|
|
265
|
+
except (DuckDBUnavailable, ValueError) as exc:
|
|
266
|
+
# ValueError: source DuckDB can't read (unsupported format,
|
|
267
|
+
# columns-oriented JSON, ...)
|
|
268
|
+
if args.engine == "duckdb":
|
|
269
|
+
parser.error(str(exc))
|
|
270
|
+
print(f"Note: DuckDB engine declined ({exc}); using pandas.")
|
|
271
|
+
use_engine = False # auto: fall back to pandas
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
# real DuckDB read/parse failures (InvalidInputException etc.) are
|
|
274
|
+
# duckdb.Error subclasses, NOT ValueError — under auto they must
|
|
275
|
+
# also fall back to pandas instead of escaping as a traceback
|
|
276
|
+
if not type(exc).__module__.startswith("duckdb"):
|
|
277
|
+
raise
|
|
278
|
+
if args.engine == "duckdb":
|
|
279
|
+
parser.error(f"DuckDB engine failed: {exc}")
|
|
280
|
+
print(f"Note: DuckDB engine failed ({type(exc).__name__}); using pandas.")
|
|
281
|
+
use_engine = False
|
|
282
|
+
|
|
283
|
+
df = load_file(args.source, sheet=args.sheet)
|
|
284
|
+
print(f"Loaded {len(df)} rows, {len(df.columns)} columns from {args.source}")
|
|
285
|
+
warn = large_materialization_warning(len(df), len(df.columns))
|
|
286
|
+
if warn and duckdb_available():
|
|
287
|
+
print(f"Note: {warn}")
|
|
288
|
+
unknown_skip = [c for c in skip if c not in df.columns]
|
|
289
|
+
if unknown_skip:
|
|
290
|
+
parser.error(f"--skip: column(s) not found in file: {', '.join(unknown_skip)}")
|
|
291
|
+
|
|
292
|
+
# parse explicit --anon flags into (column, kind, options) triples
|
|
293
|
+
anon_specs: list[tuple[str, str, dict]] = []
|
|
294
|
+
for raw in args.anon:
|
|
295
|
+
try:
|
|
296
|
+
col, kind, options = parse_anon_option(raw)
|
|
297
|
+
if col not in df.columns:
|
|
298
|
+
raise ValueError(f"column {col!r} not found in file")
|
|
299
|
+
anon_specs.append((col, kind, options))
|
|
300
|
+
except (ValueError, TypeError) as exc:
|
|
301
|
+
parser.error(f"--anon {raw!r}: {exc}")
|
|
302
|
+
|
|
303
|
+
if args.interactive or args.suggest:
|
|
304
|
+
from .workflow import AnonymizationPlan
|
|
305
|
+
|
|
306
|
+
plan = AnonymizationPlan.for_columns(df.columns)
|
|
307
|
+
try:
|
|
308
|
+
for col, kind, options in anon_specs:
|
|
309
|
+
plan.assign(col, kind, **options)
|
|
310
|
+
except (ValueError, TypeError) as exc:
|
|
311
|
+
parser.error(f"--anon: {exc}")
|
|
312
|
+
if args.suggest:
|
|
313
|
+
suggested = AnonymizationPlan.suggest(df)
|
|
314
|
+
for col in df.columns:
|
|
315
|
+
if plan.type_of(col) == "none":
|
|
316
|
+
plan.assignments[col] = suggested.assignments[col]
|
|
317
|
+
if args.interactive:
|
|
318
|
+
plan.choose_interactively(df)
|
|
319
|
+
spec = plan.build_spec()
|
|
320
|
+
else:
|
|
321
|
+
spec = {}
|
|
322
|
+
for col, kind, options in anon_specs:
|
|
323
|
+
try:
|
|
324
|
+
spec[col] = make_anonymizer(kind, **options)
|
|
325
|
+
except (ValueError, TypeError) as exc:
|
|
326
|
+
parser.error(f"--anon {col}={kind}: {exc}")
|
|
327
|
+
|
|
328
|
+
result = sample(
|
|
329
|
+
df, args.count,
|
|
330
|
+
use_random=args.use_random,
|
|
331
|
+
exclude_columns=skip,
|
|
332
|
+
random_state=args.seed,
|
|
333
|
+
)
|
|
334
|
+
for note in result.notes:
|
|
335
|
+
print(note)
|
|
336
|
+
if skip:
|
|
337
|
+
print(f"Columns excluded from stratification: {', '.join(skip)}")
|
|
338
|
+
if result.method == "stratified":
|
|
339
|
+
print(format_stratification_report(df, result))
|
|
340
|
+
|
|
341
|
+
# per-column source-vs-sample histograms (only when a real sample was taken)
|
|
342
|
+
if len(result.data) < len(df):
|
|
343
|
+
hist = format_column_histograms(df, result.data)
|
|
344
|
+
if hist:
|
|
345
|
+
print(hist)
|
|
346
|
+
|
|
347
|
+
data = result.data
|
|
348
|
+
if spec:
|
|
349
|
+
try:
|
|
350
|
+
data = anonymize(data, spec, seed=args.seed)
|
|
351
|
+
except (ValueError, TypeError) as exc:
|
|
352
|
+
parser.error(f"anonymization failed: {exc}")
|
|
353
|
+
print(f"Anonymized columns: {', '.join(spec)}")
|
|
354
|
+
|
|
355
|
+
tag = f"sample_{args.count}" + ("_anon" if spec else "")
|
|
356
|
+
out_path = save_output(data, args.source, tag, output_folder=args.outdir)
|
|
357
|
+
print(f"\nSampled {len(data)} rows.")
|
|
358
|
+
print(f"Output saved to: {out_path}")
|
|
359
|
+
return 0
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
if __name__ == "__main__":
|
|
363
|
+
sys.exit(main())
|