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