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.
Files changed (40) hide show
  1. {data_sampler-3.2.1 → data_sampler-3.3.1}/CHANGELOG.md +28 -0
  2. {data_sampler-3.2.1 → data_sampler-3.3.1}/PKG-INFO +128 -13
  3. {data_sampler-3.2.1 → data_sampler-3.3.1}/README.md +126 -11
  4. {data_sampler-3.2.1 → data_sampler-3.3.1}/RELEASING.md +13 -17
  5. {data_sampler-3.2.1 → data_sampler-3.3.1}/ROADMAP.md +10 -0
  6. {data_sampler-3.2.1 → data_sampler-3.3.1}/TROUBLESHOOTING.md +57 -0
  7. {data_sampler-3.2.1 → data_sampler-3.3.1}/pyproject.toml +2 -2
  8. {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/__init__.py +1 -1
  9. {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/engine.py +121 -21
  10. {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/tui/app.py +20 -0
  11. {data_sampler-3.2.1 → data_sampler-3.3.1}/tests/test_engine.py +128 -0
  12. {data_sampler-3.2.1 → data_sampler-3.3.1}/tests/test_tui.py +66 -6
  13. {data_sampler-3.2.1 → data_sampler-3.3.1}/.gitattributes +0 -0
  14. {data_sampler-3.2.1 → data_sampler-3.3.1}/.github/workflows/release.yml +0 -0
  15. {data_sampler-3.2.1 → data_sampler-3.3.1}/.gitignore +0 -0
  16. {data_sampler-3.2.1 → data_sampler-3.3.1}/LICENSE +0 -0
  17. {data_sampler-3.2.1 → data_sampler-3.3.1}/examples/employees.csv +0 -0
  18. {data_sampler-3.2.1 → data_sampler-3.3.1}/examples/using_data_sampler.ipynb +0 -0
  19. {data_sampler-3.2.1 → data_sampler-3.3.1}/requirements.txt +0 -0
  20. {data_sampler-3.2.1 → data_sampler-3.3.1}/scripts/run-tui.bat +0 -0
  21. {data_sampler-3.2.1 → data_sampler-3.3.1}/scripts/run-tui.sh +0 -0
  22. {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/__main__.py +0 -0
  23. {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/_logging.py +0 -0
  24. {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/_names.py +0 -0
  25. {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/anonymize.py +0 -0
  26. {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/cli.py +0 -0
  27. {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/io.py +0 -0
  28. {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/report.py +0 -0
  29. {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/sampling.py +0 -0
  30. {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/stats.py +0 -0
  31. {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/tui/__init__.py +0 -0
  32. {data_sampler-3.2.1 → data_sampler-3.3.1}/src/data_sampler/workflow.py +0 -0
  33. {data_sampler-3.2.1 → data_sampler-3.3.1}/tests/conftest.py +0 -0
  34. {data_sampler-3.2.1 → data_sampler-3.3.1}/tests/test_anonymize.py +0 -0
  35. {data_sampler-3.2.1 → data_sampler-3.3.1}/tests/test_cli.py +0 -0
  36. {data_sampler-3.2.1 → data_sampler-3.3.1}/tests/test_io.py +0 -0
  37. {data_sampler-3.2.1 → data_sampler-3.3.1}/tests/test_report.py +0 -0
  38. {data_sampler-3.2.1 → data_sampler-3.3.1}/tests/test_sampling.py +0 -0
  39. {data_sampler-3.2.1 → data_sampler-3.3.1}/tests/test_stats.py +0 -0
  40. {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.2.1
4
- Summary: Representative (stratified) sampling and column anonymization for tabular data files, with a terminal UI.
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
- Creates representative samples from data files, using stratified sampling to
59
- preserve the statistical variety of your data — with optional per-column
60
- anonymization and a colorful terminal UI.
58
+ [![PyPI](https://img.shields.io/pypi/v/data-sampler)](https://pypi.org/project/data-sampler/)
59
+ [![Python](https://img.shields.io/pypi/pyversions/data-sampler)](https://pypi.org/project/data-sampler/)
61
60
 
62
- Everything ships as a single Python package: launch the TUI with one
63
- function call or command, or use the sampling/anonymization functions
64
- directly from Python.
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 # once released on PyPI
70
- # from a clone, today:
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
- PyPI releases are manual and happen only after extensive testing.
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
- Creates representative samples from data files, using stratified sampling to
4
- preserve the statistical variety of your data — with optional per-column
5
- anonymization and a colorful terminal UI.
3
+ [![PyPI](https://img.shields.io/pypi/v/data-sampler)](https://pypi.org/project/data-sampler/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/data-sampler)](https://pypi.org/project/data-sampler/)
6
5
 
7
- Everything ships as a single Python package: launch the TUI with one
8
- function call or command, or use the sampling/anonymization functions
9
- directly from Python.
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 # once released on PyPI
15
- # from a clone, today:
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
- PyPI releases are manual and happen only after extensive testing.
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
- 1. Create a PyPI account at <https://pypi.org/account/register/> and enable 2FA.
11
- 2. Because `data-sampler` has no release on PyPI yet, register a **pending
12
- publisher**: <https://pypi.org/manage/account/publishing/> "Add a new
13
- pending publisher" with:
14
- - PyPI project name: `data-sampler`
15
- - Owner: `aaronified`
16
- - Repository: `data-sampler`
17
- - Workflow name: `release.yml`
18
- - Environment name: `pypi`
19
- 3. (Recommended) In GitHub: Settings Environments create `pypi` and add
20
- yourself as a required reviewer. The publish job then waits for your
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.2.1"
8
- description = "Representative (stratified) sampling and column anonymization for tabular data files, with a terminal UI."
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"
@@ -16,7 +16,7 @@ Public API::
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- __version__ = "3.2.1"
19
+ __version__ = "3.3.1"
20
20
 
21
21
  from .io import SUPPORTED_EXTENSIONS, list_sheets, load_file, save_output
22
22
  from .report import (
@@ -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
- data = self.con.execute(
486
- f"SELECT * FROM {src} USING SAMPLE reservoir({int(count)} ROWS){rep}"
487
- ).df()
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
- query = f"""
519
- WITH ranked AS (
520
- SELECT *, row_number() OVER (
521
- PARTITION BY {cols_sql} ORDER BY random()
522
- ) AS _rn
523
- FROM {src}
524
- )
525
- SELECT r.* EXCLUDE (_rn)
526
- FROM ranked r
527
- JOIN {alloc_tbl} a ON {join_on}
528
- WHERE r._rn <= a._alloc
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
- data = self.con.execute(query).df()
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 pilot.pause()
84
- assert screen.configs["id"].kind == "sequential_id"
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 pilot.pause()
87
- assert screen.configs["id"].options["start"] == "5000"
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 pilot.pause()
143
- assert screen.configs["id"].kind == "sequential_id"
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