data-sampler 3.2.1__tar.gz → 3.3.1__tar.gz
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-3.2.1 → data_sampler-3.3.1}/CHANGELOG.md +28 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/PKG-INFO +128 -13
- {data_sampler-3.2.1 → data_sampler-3.3.1}/README.md +126 -11
- {data_sampler-3.2.1 → data_sampler-3.3.1}/RELEASING.md +13 -17
- {data_sampler-3.2.1 → data_sampler-3.3.1}/ROADMAP.md +10 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/TROUBLESHOOTING.md +57 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/pyproject.toml +2 -2
- {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/__init__.py +1 -1
- {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/engine.py +121 -21
- {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/tui/app.py +20 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/tests/test_engine.py +128 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/tests/test_tui.py +66 -6
- {data_sampler-3.2.1 → data_sampler-3.3.1}/.gitattributes +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/.github/workflows/release.yml +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/.gitignore +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/LICENSE +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/examples/employees.csv +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/examples/using_data_sampler.ipynb +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/requirements.txt +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/scripts/run-tui.bat +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/scripts/run-tui.sh +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/__main__.py +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/_logging.py +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/_names.py +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/anonymize.py +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/cli.py +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/io.py +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/report.py +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/sampling.py +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/stats.py +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/tui/__init__.py +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/workflow.py +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/tests/conftest.py +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/tests/test_anonymize.py +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/tests/test_cli.py +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/tests/test_io.py +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/tests/test_report.py +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/tests/test_sampling.py +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/tests/test_stats.py +0 -0
- {data_sampler-3.2.1 → data_sampler-3.3.1}/tests/test_workflow.py +0 -0
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.3.1 — 2026-07-23
|
|
4
|
+
|
|
5
|
+
- **Fixed the second CI-only TUI race** (caught, again, by the release CI —
|
|
6
|
+
it blocked v3.3.0's PyPI publish): a stale `Changed` message (mount-time
|
|
7
|
+
echo or superseded edit, delivered late because every Textual widget runs
|
|
8
|
+
its own message pump) could roll an anonymizer choice back to `none` and
|
|
9
|
+
wipe its options. All Select/Input/Switch handlers now drop any `Changed`
|
|
10
|
+
whose value no longer matches the widget's current value — provably stale
|
|
11
|
+
— which also subsumes the v3.2.1 duplicate-highlight fix. v3.3.0 was never
|
|
12
|
+
published to PyPI; this release carries all of its changes.
|
|
13
|
+
|
|
14
|
+
## v3.3.0 — 2026-07-23
|
|
15
|
+
|
|
16
|
+
- **Two-phase narrow sampling in the DuckDB engine.** The expensive phase of a
|
|
17
|
+
sample (the per-stratum window sort, or the reservoir buffer) now runs over
|
|
18
|
+
only the stratification columns plus a stable row id — `file_row_number` for
|
|
19
|
+
single-file Parquet sources, a positional id for DataFrames — and a second
|
|
20
|
+
pass fetches the winning rows with every column. Measured on 300k rows ×
|
|
21
|
+
401 columns: stratified sampling 2.5× faster on Parquet and 9.5× faster for
|
|
22
|
+
DataFrame sources (whose wide payload never enters the SQL engine; winners
|
|
23
|
+
return as dtype-preserving pandas slices). CSV/TSV/JSON and multi-file
|
|
24
|
+
Parquet globs keep the previous single-pass shape (per-file row numbers are
|
|
25
|
+
not a global id — verification caught that a glob would otherwise be
|
|
26
|
+
silently over-sampled — and text formats must re-parse per scan anyway).
|
|
27
|
+
No behavioral change to counts, allocation, NaN strata, or determinism;
|
|
28
|
+
note that *which* rows a given seed selects changes vs v3.2.1 for
|
|
29
|
+
single-file Parquet and DataFrame sources.
|
|
30
|
+
|
|
3
31
|
## v3.2.1 — 2026-07-23
|
|
4
32
|
|
|
5
33
|
- **Fixed a TUI race that only surfaced on slow machines** (caught by the
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: data-sampler
|
|
3
|
-
Version: 3.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 3.3.1
|
|
4
|
+
Summary: Hand someone data that looks and behaves like your production data, isn't your production data, and provably kept its statistical variety — stratified sampling + consistent anonymization, with a terminal UI.
|
|
5
5
|
Project-URL: Homepage, https://github.com/aaronified/data-sampler
|
|
6
6
|
Project-URL: Changelog, https://github.com/aaronified/data-sampler/blob/main/CHANGELOG.md
|
|
7
7
|
Author: aaronified
|
|
@@ -55,23 +55,51 @@ Description-Content-Type: text/markdown
|
|
|
55
55
|
|
|
56
56
|
# data-sampler
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
anonymization and a colorful terminal UI.
|
|
58
|
+
[](https://pypi.org/project/data-sampler/)
|
|
59
|
+
[](https://pypi.org/project/data-sampler/)
|
|
61
60
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
**Hand someone data that looks and behaves like your production data, isn't
|
|
62
|
+
your production data, and provably kept its statistical variety — in one
|
|
63
|
+
command.**
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
data-sampler customers.xlsx 500 --suggest
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Use it to:
|
|
70
|
+
|
|
71
|
+
- **Share a realistic slice with a vendor or contractor** — names, ids,
|
|
72
|
+
emails, salaries, and dates anonymized, but every distribution, duplicate,
|
|
73
|
+
and group structure intact.
|
|
74
|
+
- **Attach a repro to a bug report** without leaking the real records that
|
|
75
|
+
trigger it.
|
|
76
|
+
- **Cut a 2 GB export down to 500 rows** for a demo or prototype that still
|
|
77
|
+
behaves like the real thing.
|
|
78
|
+
- **Build test fixtures that mirror production skew** instead of uniform toy
|
|
79
|
+
data.
|
|
80
|
+
- **Give a class or workshop realistic data** without a data-sharing
|
|
81
|
+
agreement.
|
|
82
|
+
- **Pull 10,000 representative rows from a 100M-row Parquet file** without
|
|
83
|
+
loading it — the optional DuckDB engine samples out-of-core, in parallel.
|
|
84
|
+
- **Prove the sample is representative**: every run produces a side-by-side
|
|
85
|
+
source-vs-sample distribution report, per column.
|
|
86
|
+
|
|
87
|
+
How: stratified sampling preserves the statistical variety of your data
|
|
88
|
+
(strata are detected automatically), and every anonymizer maps each unique
|
|
89
|
+
original value to exactly one replacement — so repeated values stay repeated
|
|
90
|
+
and the joint distributions survive anonymization. Everything ships as a
|
|
91
|
+
single Python package: a colorful terminal UI for non-programmers, a headless
|
|
92
|
+
CLI, and a plain Python API.
|
|
65
93
|
|
|
66
94
|
## Install
|
|
67
95
|
|
|
68
96
|
```sh
|
|
69
|
-
pip install data-sampler
|
|
70
|
-
#
|
|
71
|
-
pip install -e ".[dev]"
|
|
97
|
+
pip install data-sampler # from PyPI
|
|
98
|
+
pip install "data-sampler[large]" # + the out-of-core DuckDB engine for huge files
|
|
72
99
|
```
|
|
73
100
|
|
|
74
|
-
Requires Python 3.10+.
|
|
101
|
+
Requires Python 3.10+. For development, clone the repo and
|
|
102
|
+
`pip install -e ".[dev]"`.
|
|
75
103
|
|
|
76
104
|
## Terminal UI
|
|
77
105
|
|
|
@@ -394,6 +422,92 @@ The pandas path on the same file: 5.6 s total while materializing a ~0.9 GB
|
|
|
394
422
|
frame in RAM — the engine's reservoir sampling is ~50× faster and never
|
|
395
423
|
materializes the source at all.
|
|
396
424
|
|
|
425
|
+
## How it scales: the algorithms
|
|
426
|
+
|
|
427
|
+
Every trick used to handle millions-to-billions of rows and thousands of
|
|
428
|
+
columns, in one place.
|
|
429
|
+
|
|
430
|
+
### Millions (to billions) of rows
|
|
431
|
+
|
|
432
|
+
- **Out-of-core execution.** With the DuckDB engine, loading, stratification,
|
|
433
|
+
and sampling run inside a vectorized, multi-threaded SQL engine with a
|
|
434
|
+
memory limit and a temp directory — it spills to disk instead of OOM-ing,
|
|
435
|
+
and only the resulting sample ever becomes a DataFrame.
|
|
436
|
+
- **Reservoir sampling** for the random case: a single streaming pass with
|
|
437
|
+
O(sample size) memory, an exact row count, and `REPEATABLE(seed)`
|
|
438
|
+
reproducibility that is independent of the file format and thread count.
|
|
439
|
+
- **Two-pass stratified sampling.** Pass 1 is a `GROUP BY` over the
|
|
440
|
+
stratification columns — one row per stratum, tiny regardless of source
|
|
441
|
+
size. Largest-remainder proportional allocation is computed on that tiny
|
|
442
|
+
table in numpy. Pass 2 ranks rows per stratum with
|
|
443
|
+
`row_number() OVER (PARTITION BY strata ORDER BY random())` and keeps the
|
|
444
|
+
first *allocation* rows of each, joining the allocation table with
|
|
445
|
+
`IS NOT DISTINCT FROM` so missing-value strata are sampled too. DuckDB
|
|
446
|
+
parallelizes the partitions and spills the window sort if needed.
|
|
447
|
+
- **Parquet projection pushdown.** Pass 1 and the stats queries touch only
|
|
448
|
+
the columns they reference, so a wide Parquet file is never read in full.
|
|
449
|
+
- **Two-phase narrow sampling.** The expensive phase (the per-stratum window
|
|
450
|
+
sort, or the reservoir buffer) runs over *only the stratification columns
|
|
451
|
+
plus a stable row id* — `file_row_number` for single-file Parquet, a
|
|
452
|
+
positional id for DataFrames — and a second pass fetches just the winning
|
|
453
|
+
rows with every column. The sort never carries the wide payload: measured
|
|
454
|
+
2.5× on a 400-column Parquet file and 9.5× for wide DataFrames (whose
|
|
455
|
+
payload never enters the SQL engine at all — winners come back as
|
|
456
|
+
dtype-preserving pandas slices). CSV/JSON and multi-file Parquet globs
|
|
457
|
+
keep the single-pass shape, which is the correct one there (text must be
|
|
458
|
+
re-parsed per scan, and per-file row numbers aren't a global id).
|
|
459
|
+
- **Determinism engineering.** DuckDB's `GROUP BY` output order is
|
|
460
|
+
nondeterministic, so the stratum order is pinned with `ORDER BY … NULLS
|
|
461
|
+
LAST` before allocation (otherwise remainder ties break differently run to
|
|
462
|
+
run); seeded stratified runs drop to a single thread because `random()`
|
|
463
|
+
ordering is only reproducible that way, then restore the thread count.
|
|
464
|
+
- **Row-count caching.** `count(*)` runs once per source per engine session
|
|
465
|
+
instead of once per operation.
|
|
466
|
+
- **Vectorized anonymizers.** The column is dictionary-encoded once with
|
|
467
|
+
`pd.factorize`, each *unique* value gets one replacement, and the result is
|
|
468
|
+
assembled by a fancy-index gather — the in-process equivalent of a native
|
|
469
|
+
join against a mapping table. Cost scales with unique values plus one
|
|
470
|
+
vectorized pass, never a Python loop over rows: sequential IDs are an
|
|
471
|
+
`np.arange`, numeric/datetime jitter are single vectorized RNG draws.
|
|
472
|
+
|
|
473
|
+
### Thousands of columns
|
|
474
|
+
|
|
475
|
+
- **One scan for all scalar stats.** `DuckDBEngine.stats()` computes count,
|
|
476
|
+
distinct, min/max/mean/std, and median for *every column in a single
|
|
477
|
+
aggregate query* — one streaming pass over the data regardless of how many
|
|
478
|
+
columns there are.
|
|
479
|
+
- **Sketches instead of sorts.** Distinct counts use HyperLogLog
|
|
480
|
+
(`approx_count_distinct`) and medians use `approx_quantile` — streaming
|
|
481
|
+
approximations with fixed memory per column, no per-column sort or full
|
|
482
|
+
hash table. Exact mode (`approximate=False`) exists for small data, and
|
|
483
|
+
approximate results are flagged on the stats object.
|
|
484
|
+
- **`distributions=False`** skips the per-column histogram/top-k passes
|
|
485
|
+
entirely, so very wide tables get exactly one scan (this is what the CLI's
|
|
486
|
+
`--suggest` uses to pick anonymizer types).
|
|
487
|
+
- **Bounded stratification search.** Candidate columns are screened in one
|
|
488
|
+
aggregate pass (HLL cardinality + average text length), then a greedy
|
|
489
|
+
fewest-categories-first selection keeps the *joint* stratum count at or
|
|
490
|
+
below the sample size — so the group count stays bounded no matter how
|
|
491
|
+
many columns the file has.
|
|
492
|
+
- **Pandas-path trims.** Numeric columns skip the top-values stringification
|
|
493
|
+
(~1.9× faster stats), report histograms skip near-unique columns instead
|
|
494
|
+
of hashing millions of ids for a meaningless top-8, and the TUI computes
|
|
495
|
+
stats in a worker thread so the UI never freezes on load.
|
|
496
|
+
|
|
497
|
+
### Correctness under scale
|
|
498
|
+
|
|
499
|
+
Details that only bite on real data, all regression-tested: DuckDB treats NaN
|
|
500
|
+
as a value rather than NULL, so every NaN-sensitive aggregate is filtered
|
|
501
|
+
(`FILTER (WHERE NOT isnan(…))`); missing-value strata survive the allocation
|
|
502
|
+
join via `IS NOT DISTINCT FROM`; HyperLogLog estimates are clamped to each
|
|
503
|
+
column's non-null count; and columns-oriented JSON (the pandas `to_json()`
|
|
504
|
+
default, which SQL engines parse as one giant row) is detected and refused
|
|
505
|
+
with guidance.
|
|
506
|
+
|
|
507
|
+
Known trade-offs: CSV sources are re-parsed per query (the streaming design —
|
|
508
|
+
convert to Parquet for repeated work), and a seeded stratified run gives up
|
|
509
|
+
multi-threading for reproducibility (unseeded runs use all cores).
|
|
510
|
+
|
|
397
511
|
## Supported formats
|
|
398
512
|
|
|
399
513
|
| Format | Extensions |
|
|
@@ -420,7 +534,8 @@ Logging is controlled by `DATA_SAMPLER_LOG` (`quiet`/`info`/`verbose`) and
|
|
|
420
534
|
`DATA_SAMPLER_LOG_FILE`. See `ROADMAP.md` for planned work and
|
|
421
535
|
`TROUBLESHOOTING.md` for known failure modes.
|
|
422
536
|
|
|
423
|
-
|
|
537
|
+
Releases go to [PyPI](https://pypi.org/project/data-sampler/) via a
|
|
538
|
+
human-triggered, test-gated GitHub Actions workflow — see `RELEASING.md`.
|
|
424
539
|
|
|
425
540
|
---
|
|
426
541
|
|
|
@@ -1,22 +1,50 @@
|
|
|
1
1
|
# data-sampler
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
anonymization and a colorful terminal UI.
|
|
3
|
+
[](https://pypi.org/project/data-sampler/)
|
|
4
|
+
[](https://pypi.org/project/data-sampler/)
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
**Hand someone data that looks and behaves like your production data, isn't
|
|
7
|
+
your production data, and provably kept its statistical variety — in one
|
|
8
|
+
command.**
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
data-sampler customers.xlsx 500 --suggest
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Use it to:
|
|
15
|
+
|
|
16
|
+
- **Share a realistic slice with a vendor or contractor** — names, ids,
|
|
17
|
+
emails, salaries, and dates anonymized, but every distribution, duplicate,
|
|
18
|
+
and group structure intact.
|
|
19
|
+
- **Attach a repro to a bug report** without leaking the real records that
|
|
20
|
+
trigger it.
|
|
21
|
+
- **Cut a 2 GB export down to 500 rows** for a demo or prototype that still
|
|
22
|
+
behaves like the real thing.
|
|
23
|
+
- **Build test fixtures that mirror production skew** instead of uniform toy
|
|
24
|
+
data.
|
|
25
|
+
- **Give a class or workshop realistic data** without a data-sharing
|
|
26
|
+
agreement.
|
|
27
|
+
- **Pull 10,000 representative rows from a 100M-row Parquet file** without
|
|
28
|
+
loading it — the optional DuckDB engine samples out-of-core, in parallel.
|
|
29
|
+
- **Prove the sample is representative**: every run produces a side-by-side
|
|
30
|
+
source-vs-sample distribution report, per column.
|
|
31
|
+
|
|
32
|
+
How: stratified sampling preserves the statistical variety of your data
|
|
33
|
+
(strata are detected automatically), and every anonymizer maps each unique
|
|
34
|
+
original value to exactly one replacement — so repeated values stay repeated
|
|
35
|
+
and the joint distributions survive anonymization. Everything ships as a
|
|
36
|
+
single Python package: a colorful terminal UI for non-programmers, a headless
|
|
37
|
+
CLI, and a plain Python API.
|
|
10
38
|
|
|
11
39
|
## Install
|
|
12
40
|
|
|
13
41
|
```sh
|
|
14
|
-
pip install data-sampler
|
|
15
|
-
#
|
|
16
|
-
pip install -e ".[dev]"
|
|
42
|
+
pip install data-sampler # from PyPI
|
|
43
|
+
pip install "data-sampler[large]" # + the out-of-core DuckDB engine for huge files
|
|
17
44
|
```
|
|
18
45
|
|
|
19
|
-
Requires Python 3.10+.
|
|
46
|
+
Requires Python 3.10+. For development, clone the repo and
|
|
47
|
+
`pip install -e ".[dev]"`.
|
|
20
48
|
|
|
21
49
|
## Terminal UI
|
|
22
50
|
|
|
@@ -339,6 +367,92 @@ The pandas path on the same file: 5.6 s total while materializing a ~0.9 GB
|
|
|
339
367
|
frame in RAM — the engine's reservoir sampling is ~50× faster and never
|
|
340
368
|
materializes the source at all.
|
|
341
369
|
|
|
370
|
+
## How it scales: the algorithms
|
|
371
|
+
|
|
372
|
+
Every trick used to handle millions-to-billions of rows and thousands of
|
|
373
|
+
columns, in one place.
|
|
374
|
+
|
|
375
|
+
### Millions (to billions) of rows
|
|
376
|
+
|
|
377
|
+
- **Out-of-core execution.** With the DuckDB engine, loading, stratification,
|
|
378
|
+
and sampling run inside a vectorized, multi-threaded SQL engine with a
|
|
379
|
+
memory limit and a temp directory — it spills to disk instead of OOM-ing,
|
|
380
|
+
and only the resulting sample ever becomes a DataFrame.
|
|
381
|
+
- **Reservoir sampling** for the random case: a single streaming pass with
|
|
382
|
+
O(sample size) memory, an exact row count, and `REPEATABLE(seed)`
|
|
383
|
+
reproducibility that is independent of the file format and thread count.
|
|
384
|
+
- **Two-pass stratified sampling.** Pass 1 is a `GROUP BY` over the
|
|
385
|
+
stratification columns — one row per stratum, tiny regardless of source
|
|
386
|
+
size. Largest-remainder proportional allocation is computed on that tiny
|
|
387
|
+
table in numpy. Pass 2 ranks rows per stratum with
|
|
388
|
+
`row_number() OVER (PARTITION BY strata ORDER BY random())` and keeps the
|
|
389
|
+
first *allocation* rows of each, joining the allocation table with
|
|
390
|
+
`IS NOT DISTINCT FROM` so missing-value strata are sampled too. DuckDB
|
|
391
|
+
parallelizes the partitions and spills the window sort if needed.
|
|
392
|
+
- **Parquet projection pushdown.** Pass 1 and the stats queries touch only
|
|
393
|
+
the columns they reference, so a wide Parquet file is never read in full.
|
|
394
|
+
- **Two-phase narrow sampling.** The expensive phase (the per-stratum window
|
|
395
|
+
sort, or the reservoir buffer) runs over *only the stratification columns
|
|
396
|
+
plus a stable row id* — `file_row_number` for single-file Parquet, a
|
|
397
|
+
positional id for DataFrames — and a second pass fetches just the winning
|
|
398
|
+
rows with every column. The sort never carries the wide payload: measured
|
|
399
|
+
2.5× on a 400-column Parquet file and 9.5× for wide DataFrames (whose
|
|
400
|
+
payload never enters the SQL engine at all — winners come back as
|
|
401
|
+
dtype-preserving pandas slices). CSV/JSON and multi-file Parquet globs
|
|
402
|
+
keep the single-pass shape, which is the correct one there (text must be
|
|
403
|
+
re-parsed per scan, and per-file row numbers aren't a global id).
|
|
404
|
+
- **Determinism engineering.** DuckDB's `GROUP BY` output order is
|
|
405
|
+
nondeterministic, so the stratum order is pinned with `ORDER BY … NULLS
|
|
406
|
+
LAST` before allocation (otherwise remainder ties break differently run to
|
|
407
|
+
run); seeded stratified runs drop to a single thread because `random()`
|
|
408
|
+
ordering is only reproducible that way, then restore the thread count.
|
|
409
|
+
- **Row-count caching.** `count(*)` runs once per source per engine session
|
|
410
|
+
instead of once per operation.
|
|
411
|
+
- **Vectorized anonymizers.** The column is dictionary-encoded once with
|
|
412
|
+
`pd.factorize`, each *unique* value gets one replacement, and the result is
|
|
413
|
+
assembled by a fancy-index gather — the in-process equivalent of a native
|
|
414
|
+
join against a mapping table. Cost scales with unique values plus one
|
|
415
|
+
vectorized pass, never a Python loop over rows: sequential IDs are an
|
|
416
|
+
`np.arange`, numeric/datetime jitter are single vectorized RNG draws.
|
|
417
|
+
|
|
418
|
+
### Thousands of columns
|
|
419
|
+
|
|
420
|
+
- **One scan for all scalar stats.** `DuckDBEngine.stats()` computes count,
|
|
421
|
+
distinct, min/max/mean/std, and median for *every column in a single
|
|
422
|
+
aggregate query* — one streaming pass over the data regardless of how many
|
|
423
|
+
columns there are.
|
|
424
|
+
- **Sketches instead of sorts.** Distinct counts use HyperLogLog
|
|
425
|
+
(`approx_count_distinct`) and medians use `approx_quantile` — streaming
|
|
426
|
+
approximations with fixed memory per column, no per-column sort or full
|
|
427
|
+
hash table. Exact mode (`approximate=False`) exists for small data, and
|
|
428
|
+
approximate results are flagged on the stats object.
|
|
429
|
+
- **`distributions=False`** skips the per-column histogram/top-k passes
|
|
430
|
+
entirely, so very wide tables get exactly one scan (this is what the CLI's
|
|
431
|
+
`--suggest` uses to pick anonymizer types).
|
|
432
|
+
- **Bounded stratification search.** Candidate columns are screened in one
|
|
433
|
+
aggregate pass (HLL cardinality + average text length), then a greedy
|
|
434
|
+
fewest-categories-first selection keeps the *joint* stratum count at or
|
|
435
|
+
below the sample size — so the group count stays bounded no matter how
|
|
436
|
+
many columns the file has.
|
|
437
|
+
- **Pandas-path trims.** Numeric columns skip the top-values stringification
|
|
438
|
+
(~1.9× faster stats), report histograms skip near-unique columns instead
|
|
439
|
+
of hashing millions of ids for a meaningless top-8, and the TUI computes
|
|
440
|
+
stats in a worker thread so the UI never freezes on load.
|
|
441
|
+
|
|
442
|
+
### Correctness under scale
|
|
443
|
+
|
|
444
|
+
Details that only bite on real data, all regression-tested: DuckDB treats NaN
|
|
445
|
+
as a value rather than NULL, so every NaN-sensitive aggregate is filtered
|
|
446
|
+
(`FILTER (WHERE NOT isnan(…))`); missing-value strata survive the allocation
|
|
447
|
+
join via `IS NOT DISTINCT FROM`; HyperLogLog estimates are clamped to each
|
|
448
|
+
column's non-null count; and columns-oriented JSON (the pandas `to_json()`
|
|
449
|
+
default, which SQL engines parse as one giant row) is detected and refused
|
|
450
|
+
with guidance.
|
|
451
|
+
|
|
452
|
+
Known trade-offs: CSV sources are re-parsed per query (the streaming design —
|
|
453
|
+
convert to Parquet for repeated work), and a seeded stratified run gives up
|
|
454
|
+
multi-threading for reproducibility (unseeded runs use all cores).
|
|
455
|
+
|
|
342
456
|
## Supported formats
|
|
343
457
|
|
|
344
458
|
| Format | Extensions |
|
|
@@ -365,7 +479,8 @@ Logging is controlled by `DATA_SAMPLER_LOG` (`quiet`/`info`/`verbose`) and
|
|
|
365
479
|
`DATA_SAMPLER_LOG_FILE`. See `ROADMAP.md` for planned work and
|
|
366
480
|
`TROUBLESHOOTING.md` for known failure modes.
|
|
367
481
|
|
|
368
|
-
|
|
482
|
+
Releases go to [PyPI](https://pypi.org/project/data-sampler/) via a
|
|
483
|
+
human-triggered, test-gated GitHub Actions workflow — see `RELEASING.md`.
|
|
369
484
|
|
|
370
485
|
---
|
|
371
486
|
|
|
@@ -5,23 +5,19 @@ human publishes a GitHub Release (or manually dispatches the workflow). The
|
|
|
5
5
|
workflow uses **PyPI Trusted Publishing** (OIDC), so no API token is stored in
|
|
6
6
|
the repo, in CI secrets, or anywhere else.
|
|
7
7
|
|
|
8
|
-
## One-time setup (PyPI side)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
click-to-approve on every release.
|
|
22
|
-
|
|
23
|
-
After the first successful publish, the pending publisher becomes the project's
|
|
24
|
-
trusted publisher automatically.
|
|
8
|
+
## One-time setup (PyPI side) — DONE (v3.2.1, 2026-07-23)
|
|
9
|
+
|
|
10
|
+
Completed: the PyPI account exists, and the pending publisher (project
|
|
11
|
+
`data-sampler`, owner `aaronified`, repo `data-sampler`, workflow
|
|
12
|
+
`release.yml`, environment `pypi`) became the project's permanent **trusted
|
|
13
|
+
publisher** with the first publish. Nothing to repeat per release.
|
|
14
|
+
|
|
15
|
+
If it ever needs re-registering (new repo/owner/workflow name), manage it at
|
|
16
|
+
<https://pypi.org/manage/project/data-sampler/settings/publishing/>.
|
|
17
|
+
|
|
18
|
+
Still recommended: in GitHub → Settings → Environments → `pypi`, add yourself
|
|
19
|
+
as a required reviewer so every publish waits for your click-to-approve
|
|
20
|
+
(currently publishes run unattended once tests pass).
|
|
25
21
|
|
|
26
22
|
## Per release
|
|
27
23
|
|
|
@@ -89,9 +89,19 @@ its own commit.
|
|
|
89
89
|
the per-column passes for a single cheap scalar pass over very wide inputs.
|
|
90
90
|
`approx_count_distinct` also powers the engine's stratification-column selection.
|
|
91
91
|
|
|
92
|
+
## v3.3
|
|
93
|
+
|
|
94
|
+
- [x] **Block P7 — two-phase narrow sampling.** Rank/reservoir over only the
|
|
95
|
+
stratification columns + a stable row id (single-file Parquet
|
|
96
|
+
`file_row_number`; DataFrame positional id), then fetch the winning full
|
|
97
|
+
rows. 2.5× on wide Parquet, 9.5× on wide DataFrames. CSV/JSON and Parquet
|
|
98
|
+
globs keep the single-pass shape (per-file row ids are not global).
|
|
99
|
+
|
|
92
100
|
## Later
|
|
93
101
|
|
|
94
102
|
- Optional PyInstaller EXE build of the TUI (replaces the old Tkinter EXE).
|
|
103
|
+
- Composite filename+row-number id to extend narrow sampling to Parquet globs.
|
|
104
|
+
- `--columns` output projection (sample only a subset of columns).
|
|
95
105
|
- Rust/Polars-on-Arrow native engine (pyo3) as an alternative to DuckDB.
|
|
96
106
|
- GPU acceleration (RAPIDS cuDF) for the aggregation-heavy paths.
|
|
97
107
|
- Distributed backend (Dask / Ray Data / Spark) for multi-machine scale.
|
|
@@ -232,6 +232,63 @@ all were fixed except one accepted divergence. Highlights worth remembering:
|
|
|
232
232
|
*future* messages; a synchronous re-entrancy flag does not protect against
|
|
233
233
|
them. Guard by making re-syncs conditional, not by flags alone.
|
|
234
234
|
|
|
235
|
+
## Parquet file_row_number is per-file, not global (caught in verification)
|
|
236
|
+
|
|
237
|
+
- **Symptom:** the first draft of two-phase narrow sampling used
|
|
238
|
+
`read_parquet(..., file_row_number=true)` as the row id for the winner
|
|
239
|
+
fetch. On a multi-file glob (`data/*.parquet`) the verifier's probe showed a
|
|
240
|
+
request for 1,200 rows silently returning 3,600 with hundreds of duplicates
|
|
241
|
+
— each winner's row number matched one row in *every* file.
|
|
242
|
+
- **Cause:** DuckDB numbers `file_row_number` per file; it is only a global
|
|
243
|
+
row id for a single physical file. The fetch join fanned out across files.
|
|
244
|
+
- **Fix:** `_narrow_scan` gates the Parquet narrow shape on `Path.is_file()`;
|
|
245
|
+
globs/multi-file datasets take the full-width single-pass shape, which never
|
|
246
|
+
relies on row numbers. Regression test:
|
|
247
|
+
`test_narrow_glob_multi_file_parquet_falls_back_and_stays_exact`. A
|
|
248
|
+
composite `filename + file_row_number` id could lift this restriction later.
|
|
249
|
+
- **Lesson:** any "stable row id" assumption must be validated against
|
|
250
|
+
multi-file sources — the single-file case hides the fan-out completely.
|
|
251
|
+
|
|
252
|
+
## CI-only TUI failure #2: stale Changed messages (v3.3.0 release run)
|
|
253
|
+
|
|
254
|
+
- **Symptom:** the v3.3.0 publish was blocked by the same Windows-CI test one
|
|
255
|
+
assertion further along: the Select edit survived (v3.2.1's fix held) but
|
|
256
|
+
the follow-up Input edit vanished (`KeyError: 'start'`).
|
|
257
|
+
- **Cause:** third mechanism in the same family. Every Textual widget runs
|
|
258
|
+
its own message pump, so a stale `Select.Changed("none")` (mount-time echo
|
|
259
|
+
or superseded edit) can be *delivered* after the user's edits were applied.
|
|
260
|
+
The handler rolled `cfg.kind` back to `none` — wiping `options` — before
|
|
261
|
+
the Input edit's assertion.
|
|
262
|
+
- **Fix:** a general staleness guard (`_is_stale`) on every Changed handler
|
|
263
|
+
(Select/Input/Switch): a message whose value no longer matches its widget's
|
|
264
|
+
*current* value is provably stale and dropped — the message carrying the
|
|
265
|
+
current value is either this one or still in flight. Subsumes the earlier
|
|
266
|
+
duplicate-highlight class too. Regression test delivers stale messages
|
|
267
|
+
directly (`test_stale_changed_messages_do_not_clobber_config`).
|
|
268
|
+
- **Lesson (general):** in Textual, never apply a Changed message's payload
|
|
269
|
+
without checking it still matches the widget — cross-widget delivery order
|
|
270
|
+
is unspecified, and only loaded machines (CI) expose it.
|
|
271
|
+
|
|
272
|
+
## CI-only TUI failure #3: message-delivery lag (v3.3.1 release run)
|
|
273
|
+
|
|
274
|
+
- **Symptom:** with both real races fixed, the release CI failed once more —
|
|
275
|
+
this time inside the new regression test itself, before any stale message
|
|
276
|
+
was delivered: `options["start"]` was asserted one `pilot.pause()` after
|
|
277
|
+
setting the Input, and on a slow runner the `Changed` had not yet been
|
|
278
|
+
*delivered* to the screen.
|
|
279
|
+
- **Cause:** not an app bug. `pilot.pause()` drains the app-level queue, but
|
|
280
|
+
every Textual widget runs its own message pump — cross-widget delivery can
|
|
281
|
+
outlive a single pause. App state is eventually consistent (and the
|
|
282
|
+
`_is_stale` guard keeps it *correct* whenever it settles); only the test's
|
|
283
|
+
assert-after-one-pause pattern was racy.
|
|
284
|
+
- **Fix:** a `wait_until(pilot, predicate)` test helper that polls until the
|
|
285
|
+
state converges (bounded, fails loudly on timeout), used for every
|
|
286
|
+
cross-widget assertion. Tests-only change — the packaged artifact is
|
|
287
|
+
unaffected.
|
|
288
|
+
- **Lesson:** the full pattern for Textual testing: *wait_for_screen* for
|
|
289
|
+
navigation, *wait_until* for cross-widget state, and never assert
|
|
290
|
+
immediately after a single pause.
|
|
291
|
+
|
|
235
292
|
## Template
|
|
236
293
|
|
|
237
294
|
- **Symptom:** what was observed.
|
|
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "data-sampler"
|
|
7
|
-
version = "3.
|
|
8
|
-
description = "
|
|
7
|
+
version = "3.3.1"
|
|
8
|
+
description = "Hand someone data that looks and behaves like your production data, isn't your production data, and provably kept its statistical variety — stratified sampling + consistent anonymization, with a terminal UI."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
11
11
|
requires-python = ">=3.10"
|
|
@@ -434,6 +434,72 @@ class DuckDBEngine:
|
|
|
434
434
|
|
|
435
435
|
# ── sampling ──────────────────────────────────────────────────────────────
|
|
436
436
|
|
|
437
|
+
# column names the two-phase narrow shape needs for itself; a source that
|
|
438
|
+
# already uses one of these falls back to the single-pass full-width shape
|
|
439
|
+
_NARROW_RESERVED = frozenset({"file_row_number", "_ds_rid", "_ds_rowid", "_rn"})
|
|
440
|
+
|
|
441
|
+
def _narrow_scan(self, source, strat_cols: list[str]):
|
|
442
|
+
"""Set up a two-phase "narrow" sample: rank/sample over only
|
|
443
|
+
``strat_cols`` plus a stable row id, then fetch the winning full rows.
|
|
444
|
+
|
|
445
|
+
Sorting or reservoir-buffering the full row payload is the dominant
|
|
446
|
+
cost on wide tables; this shape makes phase 1 width-independent.
|
|
447
|
+
Returns ``(scan_sql, rid_expr, fetch)`` where ``fetch(row_ids)``
|
|
448
|
+
materializes the winners with every column — or ``None`` when the
|
|
449
|
+
source has no cheap stable row id (CSV/TSV/JSON must re-parse the
|
|
450
|
+
whole text per scan, so single-pass full-width is already the optimal
|
|
451
|
+
shape there) or a column name collides with the shape's internals.
|
|
452
|
+
"""
|
|
453
|
+
if isinstance(source, pd.DataFrame):
|
|
454
|
+
if self._NARROW_RESERVED & set(map(str, source.columns)):
|
|
455
|
+
return None
|
|
456
|
+
# only the stratification columns + a positional id enter DuckDB;
|
|
457
|
+
# the wide payload never leaves pandas
|
|
458
|
+
if strat_cols:
|
|
459
|
+
narrow = source.loc[:, list(strat_cols)].reset_index(drop=True).copy()
|
|
460
|
+
else:
|
|
461
|
+
narrow = pd.DataFrame()
|
|
462
|
+
narrow["_ds_rid"] = np.arange(len(source), dtype=np.int64)
|
|
463
|
+
self._reg += 1
|
|
464
|
+
name = f"_narrow_{self._reg}"
|
|
465
|
+
self.con.register(name, narrow)
|
|
466
|
+
|
|
467
|
+
def fetch(row_ids: np.ndarray) -> pd.DataFrame:
|
|
468
|
+
# positional take preserves the original dtypes exactly
|
|
469
|
+
return source.take(np.asarray(row_ids)).reset_index(drop=True)
|
|
470
|
+
|
|
471
|
+
return name, _quote_ident("_ds_rid"), fetch
|
|
472
|
+
|
|
473
|
+
p = Path(source)
|
|
474
|
+
if p.suffix.lower() != ".parquet":
|
|
475
|
+
return None
|
|
476
|
+
# file_row_number is numbered PER FILE: it is a valid global row id
|
|
477
|
+
# only for a single physical file. Globs / multi-file datasets
|
|
478
|
+
# (data/*.parquet) must use the full-width fallback, which never
|
|
479
|
+
# relies on it — a per-file id would fan each winner out to every
|
|
480
|
+
# file sharing that row number and silently corrupt the sample.
|
|
481
|
+
# (A composite filename+row id could lift this later.)
|
|
482
|
+
if not p.is_file():
|
|
483
|
+
return None
|
|
484
|
+
if self._NARROW_RESERVED & set(map(str, self.columns(source))):
|
|
485
|
+
return None
|
|
486
|
+
scan = f"read_parquet({_quote_str(str(p))}, file_row_number=true)"
|
|
487
|
+
|
|
488
|
+
def fetch(row_ids: np.ndarray) -> pd.DataFrame:
|
|
489
|
+
self._reg += 1
|
|
490
|
+
rid_tbl = f"_rids_{self._reg}"
|
|
491
|
+
self.con.register(
|
|
492
|
+
rid_tbl, pd.DataFrame({"rid": np.asarray(row_ids, dtype=np.int64)})
|
|
493
|
+
)
|
|
494
|
+
# ORDER BY the row id: deterministic output order for seeded runs
|
|
495
|
+
return self.con.execute(
|
|
496
|
+
f"SELECT s.* EXCLUDE (file_row_number) FROM {scan} s "
|
|
497
|
+
f"JOIN {rid_tbl} r ON s.file_row_number = r.rid "
|
|
498
|
+
f"ORDER BY s.file_row_number"
|
|
499
|
+
).df()
|
|
500
|
+
|
|
501
|
+
return scan, "file_row_number", fetch
|
|
502
|
+
|
|
437
503
|
def sample(
|
|
438
504
|
self,
|
|
439
505
|
source,
|
|
@@ -469,29 +535,40 @@ class DuckDBEngine:
|
|
|
469
535
|
self._set_seed(seed)
|
|
470
536
|
|
|
471
537
|
if use_random:
|
|
472
|
-
return self._reservoir(src, count, seed)
|
|
538
|
+
return self._reservoir(source, src, count, seed)
|
|
473
539
|
|
|
474
540
|
if strat_cols is None:
|
|
475
541
|
strat_cols = self.find_stratification_columns(source, count, exclude_columns)
|
|
476
542
|
if not strat_cols:
|
|
477
|
-
result = self._reservoir(src, count, seed)
|
|
543
|
+
result = self._reservoir(source, src, count, seed)
|
|
478
544
|
result.notes = ["No suitable stratification columns found; using reservoir sampling."]
|
|
479
545
|
return result
|
|
480
546
|
|
|
481
|
-
return self._stratified(src, count, strat_cols, seed)
|
|
547
|
+
return self._stratified(source, src, count, strat_cols, seed)
|
|
482
548
|
|
|
483
|
-
def _reservoir(self, src: str, count: int, seed: int | None) -> SampleResult:
|
|
549
|
+
def _reservoir(self, source, src: str, count: int, seed: int | None) -> SampleResult:
|
|
484
550
|
rep = f" REPEATABLE ({int(seed)})" if seed is not None else ""
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
551
|
+
narrow = self._narrow_scan(source, [])
|
|
552
|
+
if narrow is not None:
|
|
553
|
+
# phase 1: reservoir over just the row id (width-independent);
|
|
554
|
+
# phase 2: fetch the winners' full rows
|
|
555
|
+
scan, rid_expr, fetch = narrow
|
|
556
|
+
row_ids = self.con.execute(
|
|
557
|
+
f"SELECT {rid_expr} AS rid FROM {scan} "
|
|
558
|
+
f"USING SAMPLE reservoir({int(count)} ROWS){rep}"
|
|
559
|
+
).df()["rid"].to_numpy()
|
|
560
|
+
data = fetch(row_ids)
|
|
561
|
+
else:
|
|
562
|
+
data = self.con.execute(
|
|
563
|
+
f"SELECT * FROM {src} USING SAMPLE reservoir({int(count)} ROWS){rep}"
|
|
564
|
+
).df()
|
|
488
565
|
return SampleResult(
|
|
489
566
|
data=data, method="random", requested=count,
|
|
490
567
|
notes=["Mode: DuckDB reservoir sampling (out-of-core)"],
|
|
491
568
|
)
|
|
492
569
|
|
|
493
570
|
def _stratified(
|
|
494
|
-
self, src: str, count: int, strat_cols: list[str], seed: int | None
|
|
571
|
+
self, source, src: str, count: int, strat_cols: list[str], seed: int | None
|
|
495
572
|
) -> SampleResult:
|
|
496
573
|
cols_sql = ", ".join(_quote_ident(c) for c in strat_cols)
|
|
497
574
|
# pass 1: stratum sizes (small — one row per stratum). ORDER BY pins
|
|
@@ -515,18 +592,37 @@ class DuckDBEngine:
|
|
|
515
592
|
f"r.{_quote_ident(c)} IS NOT DISTINCT FROM a.{_quote_ident(c)}"
|
|
516
593
|
for c in strat_cols
|
|
517
594
|
)
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
595
|
+
narrow = self._narrow_scan(source, strat_cols)
|
|
596
|
+
if narrow is not None:
|
|
597
|
+
# two-phase: rank only the strat columns + row id (the window sort
|
|
598
|
+
# never touches the wide payload), then fetch the winners' rows
|
|
599
|
+
scan, rid_expr, fetch = narrow
|
|
600
|
+
query = f"""
|
|
601
|
+
WITH ranked AS (
|
|
602
|
+
SELECT {rid_expr} AS _ds_rowid, {cols_sql}, row_number() OVER (
|
|
603
|
+
PARTITION BY {cols_sql} ORDER BY random()
|
|
604
|
+
) AS _rn
|
|
605
|
+
FROM {scan}
|
|
606
|
+
)
|
|
607
|
+
SELECT r._ds_rowid
|
|
608
|
+
FROM ranked r
|
|
609
|
+
JOIN {alloc_tbl} a ON {join_on}
|
|
610
|
+
WHERE r._rn <= a._alloc
|
|
611
|
+
"""
|
|
612
|
+
else:
|
|
613
|
+
fetch = None
|
|
614
|
+
query = f"""
|
|
615
|
+
WITH ranked AS (
|
|
616
|
+
SELECT *, row_number() OVER (
|
|
617
|
+
PARTITION BY {cols_sql} ORDER BY random()
|
|
618
|
+
) AS _rn
|
|
619
|
+
FROM {src}
|
|
620
|
+
)
|
|
621
|
+
SELECT r.* EXCLUDE (_rn)
|
|
622
|
+
FROM ranked r
|
|
623
|
+
JOIN {alloc_tbl} a ON {join_on}
|
|
624
|
+
WHERE r._rn <= a._alloc
|
|
625
|
+
"""
|
|
530
626
|
# DuckDB's random() ordering is only reproducible single-threaded, so a
|
|
531
627
|
# seeded run goes single-threaded for determinism; unseeded runs keep all
|
|
532
628
|
# cores (the distribution is preserved either way).
|
|
@@ -535,7 +631,11 @@ class DuckDBEngine:
|
|
|
535
631
|
self.con.execute("PRAGMA threads=1")
|
|
536
632
|
self._set_seed(seed)
|
|
537
633
|
try:
|
|
538
|
-
|
|
634
|
+
result_df = self.con.execute(query).df()
|
|
635
|
+
if fetch is not None:
|
|
636
|
+
data = fetch(result_df["_ds_rowid"].to_numpy())
|
|
637
|
+
else:
|
|
638
|
+
data = result_df
|
|
539
639
|
finally:
|
|
540
640
|
if reproducible:
|
|
541
641
|
self.con.execute(f"PRAGMA threads={self.threads}")
|
|
@@ -527,9 +527,25 @@ class ColumnsScreen(Screen):
|
|
|
527
527
|
self._show_detail(self.selected)
|
|
528
528
|
self._sync_config_panel(self.selected)
|
|
529
529
|
|
|
530
|
+
@staticmethod
|
|
531
|
+
def _is_stale(event_value, widget_value) -> bool:
|
|
532
|
+
"""Whether a Changed message no longer reflects its widget's value.
|
|
533
|
+
|
|
534
|
+
Every widget runs its own message pump, so a Changed can be delivered
|
|
535
|
+
AFTER the value it reports was superseded (mount-time echoes, rapid
|
|
536
|
+
edits, panel syncs). Applying it would roll the config back — the
|
|
537
|
+
exact class of race that reset user edits on slow CI machines. A
|
|
538
|
+
message whose value differs from the widget's current value is
|
|
539
|
+
provably stale and safe to drop: the message carrying the current
|
|
540
|
+
value is either this one or still on its way.
|
|
541
|
+
"""
|
|
542
|
+
return str(event_value) != str(widget_value)
|
|
543
|
+
|
|
530
544
|
def on_select_changed(self, event: Select.Changed) -> None:
|
|
531
545
|
if self._syncing or self.selected is None:
|
|
532
546
|
return
|
|
547
|
+
if self._is_stale(event.value, event.select.value):
|
|
548
|
+
return
|
|
533
549
|
cfg = self.configs[self.selected]
|
|
534
550
|
if event.select.id == "anon-kind":
|
|
535
551
|
if str(event.value) == cfg.kind:
|
|
@@ -545,12 +561,16 @@ class ColumnsScreen(Screen):
|
|
|
545
561
|
if event.switch.id == "skip-strat":
|
|
546
562
|
if self._syncing or self.selected is None:
|
|
547
563
|
return
|
|
564
|
+
if self._is_stale(event.value, event.switch.value):
|
|
565
|
+
return
|
|
548
566
|
self.configs[self.selected].skip_strat = event.value
|
|
549
567
|
self._refresh_row(self.selected)
|
|
550
568
|
|
|
551
569
|
def on_input_changed(self, event: Input.Changed) -> None:
|
|
552
570
|
if self._syncing or self.selected is None:
|
|
553
571
|
return
|
|
572
|
+
if self._is_stale(event.value, event.input.value):
|
|
573
|
+
return
|
|
554
574
|
mapping = self._OPT_IDS.get(event.input.id or "")
|
|
555
575
|
if mapping is None:
|
|
556
576
|
return
|
|
@@ -339,6 +339,134 @@ def test_row_count_cached_per_file(tmp_path, big_df):
|
|
|
339
339
|
assert len(result.data) == 50
|
|
340
340
|
|
|
341
341
|
|
|
342
|
+
# ── two-phase narrow sampling (rank narrow, fetch winners) ───────────────────
|
|
343
|
+
|
|
344
|
+
def test_narrow_parquet_stratified_schema_count_distribution(tmp_path, engine, big_df):
|
|
345
|
+
p = tmp_path / "narrow.parquet"
|
|
346
|
+
big_df.to_parquet(p, index=False)
|
|
347
|
+
result = engine.sample(str(p), 500, strat_cols=["region"], seed=2)
|
|
348
|
+
assert len(result.data) == 500
|
|
349
|
+
# full schema, original order, no file_row_number/_ds_rowid leak
|
|
350
|
+
assert list(result.data.columns) == list(big_df.columns)
|
|
351
|
+
src_p = big_df["region"].value_counts(normalize=True)
|
|
352
|
+
smp_p = result.data["region"].value_counts(normalize=True)
|
|
353
|
+
for cat in src_p.index:
|
|
354
|
+
assert abs(src_p[cat] - smp_p.get(cat, 0)) < 0.05
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def test_narrow_parquet_seeded_reproducible_across_engines(tmp_path, big_df):
|
|
358
|
+
p = tmp_path / "repro.parquet"
|
|
359
|
+
big_df.to_parquet(p, index=False)
|
|
360
|
+
with DuckDBEngine(threads=4) as e1:
|
|
361
|
+
s1 = e1.sample(str(p), 300, strat_cols=["region", "tier"], seed=9)
|
|
362
|
+
r1 = e1.sample(str(p), 300, use_random=True, seed=9)
|
|
363
|
+
with DuckDBEngine(threads=4) as e2:
|
|
364
|
+
s2 = e2.sample(str(p), 300, strat_cols=["region", "tier"], seed=9)
|
|
365
|
+
r2 = e2.sample(str(p), 300, use_random=True, seed=9)
|
|
366
|
+
# stratified: identical rows AND order (fetch is ORDER BY row id)
|
|
367
|
+
assert s1.data["id"].tolist() == s2.data["id"].tolist()
|
|
368
|
+
assert sorted(r1.data["id"].tolist()) == sorted(r2.data["id"].tolist())
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def test_narrow_nan_stratum_survives_both_sources(tmp_path, engine, big_df):
|
|
372
|
+
df = big_df.copy()
|
|
373
|
+
df.loc[df.index[:2000], "region"] = None # 10% missing stratum
|
|
374
|
+
p = tmp_path / "nanstrat.parquet"
|
|
375
|
+
df.to_parquet(p, index=False)
|
|
376
|
+
for source in (str(p), df):
|
|
377
|
+
result = engine.sample(source, 1000, strat_cols=["region"], seed=1)
|
|
378
|
+
assert len(result.data) == 1000
|
|
379
|
+
# the missing-value stratum is sampled proportionally (~100 rows)
|
|
380
|
+
nan_rows = int(result.data["region"].isna().sum())
|
|
381
|
+
assert 60 <= nan_rows <= 140
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def test_narrow_guard_reserved_column_names_fall_back(tmp_path, engine, big_df):
|
|
385
|
+
# a real column named file_row_number must not break Parquet sampling
|
|
386
|
+
decoy = big_df.copy()
|
|
387
|
+
decoy["file_row_number"] = 1
|
|
388
|
+
p = tmp_path / "decoy.parquet"
|
|
389
|
+
decoy.to_parquet(p, index=False)
|
|
390
|
+
result = engine.sample(str(p), 100, strat_cols=["region"], seed=1)
|
|
391
|
+
assert len(result.data) == 100
|
|
392
|
+
assert "file_row_number" in result.data.columns
|
|
393
|
+
assert (result.data["file_row_number"] == 1).all()
|
|
394
|
+
# same for a DataFrame column named _ds_rid
|
|
395
|
+
decoy2 = big_df.copy()
|
|
396
|
+
decoy2["_ds_rid"] = 7
|
|
397
|
+
result2 = engine.sample(decoy2, 100, strat_cols=["region"], seed=1)
|
|
398
|
+
assert len(result2.data) == 100
|
|
399
|
+
assert (result2.data["_ds_rid"] == 7).all()
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def test_narrow_hostile_strat_column_name(engine):
|
|
403
|
+
hostile = 'my col"; DROP TABLE x;--'
|
|
404
|
+
df = pd.DataFrame({hostile: (["a", "b", "c"] * 400)[:1000], "v": range(1000)})
|
|
405
|
+
result = engine.sample(df, 100, strat_cols=[hostile], seed=1)
|
|
406
|
+
assert len(result.data) == 100
|
|
407
|
+
assert set(result.data.columns) == {hostile, "v"}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def test_narrow_dataframe_preserves_dtypes(engine):
|
|
411
|
+
# the pandas take() fetch returns slices of the original frame, so exotic
|
|
412
|
+
# dtypes (nullable string, categorical) survive exactly
|
|
413
|
+
df = pd.DataFrame(
|
|
414
|
+
{
|
|
415
|
+
"s": pd.Series((["x", "y", None] * 400)[:1000], dtype="string"),
|
|
416
|
+
"c": pd.Categorical((["g", "h"] * 500)[:1000]),
|
|
417
|
+
"grp": (["a", "b"] * 500)[:1000],
|
|
418
|
+
}
|
|
419
|
+
)
|
|
420
|
+
result = engine.sample(df, 100, strat_cols=["grp"], seed=3)
|
|
421
|
+
assert isinstance(result.data["s"].dtype, pd.StringDtype)
|
|
422
|
+
assert isinstance(result.data["c"].dtype, pd.CategoricalDtype)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def test_narrow_wide_parquet_correctness(tmp_path, engine):
|
|
426
|
+
# 120 payload columns: all must come back, in order, exact count
|
|
427
|
+
rng = np.random.default_rng(4)
|
|
428
|
+
n = 5000
|
|
429
|
+
wide = pd.DataFrame({f"c{i:03d}": rng.normal(size=n) for i in range(120)})
|
|
430
|
+
wide.insert(0, "grp", rng.choice(["a", "b", "c", "d"], n))
|
|
431
|
+
p = tmp_path / "wide.parquet"
|
|
432
|
+
wide.to_parquet(p, index=False)
|
|
433
|
+
result = engine.sample(str(p), 200, strat_cols=["grp"], seed=5)
|
|
434
|
+
assert len(result.data) == 200
|
|
435
|
+
assert list(result.data.columns) == list(wide.columns)
|
|
436
|
+
reservoir = engine.sample(str(p), 200, use_random=True, seed=5)
|
|
437
|
+
assert len(reservoir.data) == 200
|
|
438
|
+
assert list(reservoir.data.columns) == list(wide.columns)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def test_narrow_glob_multi_file_parquet_falls_back_and_stays_exact(tmp_path, engine, big_df):
|
|
442
|
+
"""Regression (caught in verification): file_row_number is per-FILE, so the
|
|
443
|
+
narrow shape on a multi-file glob would fan winners out across files and
|
|
444
|
+
silently inflate/duplicate the sample. Globs must take the full-width
|
|
445
|
+
fallback and return exact, duplicate-free counts."""
|
|
446
|
+
for i in range(3):
|
|
447
|
+
big_df.iloc[i * 5000 : (i + 1) * 5000].to_parquet(
|
|
448
|
+
tmp_path / f"part{i}.parquet", index=False
|
|
449
|
+
)
|
|
450
|
+
glob = str(tmp_path / "*.parquet")
|
|
451
|
+
assert engine._narrow_scan(glob, ["region"]) is None # gate: not a single file
|
|
452
|
+
strat = engine.sample(glob, 1200, strat_cols=["region"], seed=1)
|
|
453
|
+
assert len(strat.data) == 1200
|
|
454
|
+
assert not strat.data["id"].duplicated().any()
|
|
455
|
+
res = engine.sample(glob, 1200, use_random=True, seed=1)
|
|
456
|
+
assert len(res.data) == 1200
|
|
457
|
+
assert not res.data["id"].duplicated().any()
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def test_narrow_csv_keeps_single_pass_and_works(tmp_path, engine, big_df):
|
|
461
|
+
# CSV has no stable row id: the full-width single-pass shape still applies
|
|
462
|
+
p = tmp_path / "plain.csv"
|
|
463
|
+
big_df.to_csv(p, index=False)
|
|
464
|
+
assert engine._narrow_scan(str(p), ["region"]) is None
|
|
465
|
+
result = engine.sample(str(p), 300, strat_cols=["region"], seed=1)
|
|
466
|
+
assert len(result.data) == 300
|
|
467
|
+
assert set(result.data.columns) == set(big_df.columns)
|
|
468
|
+
|
|
469
|
+
|
|
342
470
|
# ── module-level helpers ──────────────────────────────────────────────────────
|
|
343
471
|
|
|
344
472
|
def test_should_use_engine_for_parquet(tmp_path, big_df):
|
|
@@ -40,6 +40,22 @@ async def wait_for_screen(app, pilot, screen_type, tries=100):
|
|
|
40
40
|
raise AssertionError(f"never reached {screen_type.__name__}; on {type(app.screen).__name__}")
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
async def wait_until(pilot, predicate, what="condition", tries=150):
|
|
44
|
+
"""Wait for cross-widget state to converge before asserting.
|
|
45
|
+
|
|
46
|
+
Every Textual widget runs its own message pump, so a single
|
|
47
|
+
``pilot.pause()`` does not guarantee a posted Changed message has been
|
|
48
|
+
DELIVERED to the screen yet — assert-after-one-pause is a timing lottery
|
|
49
|
+
that only loses on slow CI runners. The app state is eventually
|
|
50
|
+
consistent; tests must wait for it.
|
|
51
|
+
"""
|
|
52
|
+
for _ in range(tries):
|
|
53
|
+
if predicate():
|
|
54
|
+
return
|
|
55
|
+
await pilot.pause(0.05)
|
|
56
|
+
raise AssertionError(f"never converged: {what}")
|
|
57
|
+
|
|
58
|
+
|
|
43
59
|
def test_file_screen_rejects_missing_file(tmp_path):
|
|
44
60
|
async def go():
|
|
45
61
|
app = DataSamplerApp()
|
|
@@ -80,11 +96,15 @@ def test_full_flow_sample_and_anonymize(csv_file, tmp_path):
|
|
|
80
96
|
# configure: anonymize the highlighted column (first = "id")
|
|
81
97
|
assert screen.selected == "id"
|
|
82
98
|
screen.query_one("#anon-kind", Select).value = "sequential_id"
|
|
83
|
-
await
|
|
84
|
-
|
|
99
|
+
await wait_until(
|
|
100
|
+
pilot, lambda: screen.configs["id"].kind == "sequential_id",
|
|
101
|
+
"anon kind applied",
|
|
102
|
+
)
|
|
85
103
|
screen.query_one("#opt-seq-start", Input).value = "5000"
|
|
86
|
-
await
|
|
87
|
-
|
|
104
|
+
await wait_until(
|
|
105
|
+
pilot, lambda: screen.configs["id"].options.get("start") == "5000",
|
|
106
|
+
"start option applied",
|
|
107
|
+
)
|
|
88
108
|
# skip a column from stratification
|
|
89
109
|
screen.configs["region"].skip_strat = True
|
|
90
110
|
# run
|
|
@@ -139,14 +159,54 @@ def test_duplicate_row_highlight_does_not_clobber_pending_edit(csv_file):
|
|
|
139
159
|
# simulate the late duplicate mount-time highlight arriving first
|
|
140
160
|
fake = SimpleNamespace(row_key=SimpleNamespace(value="id"))
|
|
141
161
|
screen.on_data_table_row_highlighted(fake)
|
|
142
|
-
await
|
|
143
|
-
|
|
162
|
+
await wait_until(
|
|
163
|
+
pilot, lambda: screen.configs["id"].kind == "sequential_id",
|
|
164
|
+
"kind survives duplicate highlight",
|
|
165
|
+
)
|
|
144
166
|
# and the widget was not reset underneath the user
|
|
145
167
|
assert screen.query_one("#anon-kind", Select).value == "sequential_id"
|
|
146
168
|
|
|
147
169
|
run(go())
|
|
148
170
|
|
|
149
171
|
|
|
172
|
+
def test_stale_changed_messages_do_not_clobber_config(csv_file):
|
|
173
|
+
"""Regression: the CI-only race that blocked the v3.3.0 release.
|
|
174
|
+
|
|
175
|
+
Widgets have independent message pumps, so a stale Select.Changed("none")
|
|
176
|
+
(mount-time echo or superseded edit) can be delivered AFTER the user set
|
|
177
|
+
"sequential_id" and AFTER their option edits. Applying it resets the kind
|
|
178
|
+
and wipes the options (KeyError: 'start' on CI). A Changed whose value no
|
|
179
|
+
longer matches the widget's current value must be dropped as stale.
|
|
180
|
+
"""
|
|
181
|
+
async def go():
|
|
182
|
+
app = DataSamplerApp(path=str(csv_file))
|
|
183
|
+
async with app.run_test(size=(140, 45)) as pilot:
|
|
184
|
+
screen = await wait_for_screen(app, pilot, ColumnsScreen)
|
|
185
|
+
assert screen.selected == "id"
|
|
186
|
+
sel = screen.query_one("#anon-kind", Select)
|
|
187
|
+
sel.value = "sequential_id"
|
|
188
|
+
await wait_until(
|
|
189
|
+
pilot, lambda: screen.configs["id"].kind == "sequential_id",
|
|
190
|
+
"anon kind applied",
|
|
191
|
+
)
|
|
192
|
+
start_input = screen.query_one("#opt-seq-start", Input)
|
|
193
|
+
start_input.value = "5000"
|
|
194
|
+
await wait_until(
|
|
195
|
+
pilot, lambda: screen.configs["id"].options.get("start") == "5000",
|
|
196
|
+
"start option applied",
|
|
197
|
+
)
|
|
198
|
+
# deliver a stale Changed("none"): widget shows "sequential_id",
|
|
199
|
+
# so the handler must drop it instead of resetting the config
|
|
200
|
+
screen.on_select_changed(Select.Changed(sel, "none"))
|
|
201
|
+
assert screen.configs["id"].kind == "sequential_id"
|
|
202
|
+
assert screen.configs["id"].options["start"] == "5000"
|
|
203
|
+
# stale Input.Changed likewise (widget holds "5000", message "1")
|
|
204
|
+
screen.on_input_changed(Input.Changed(start_input, "1"))
|
|
205
|
+
assert screen.configs["id"].options["start"] == "5000"
|
|
206
|
+
|
|
207
|
+
run(go())
|
|
208
|
+
|
|
209
|
+
|
|
150
210
|
def test_invalid_count_notifies_instead_of_running(csv_file):
|
|
151
211
|
async def go():
|
|
152
212
|
app = DataSamplerApp(path=str(csv_file))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|