data-sampler 3.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data_sampler/engine.py ADDED
@@ -0,0 +1,628 @@
1
+ """DuckDB-backed out-of-core engine for large inputs.
2
+
3
+ Optional — install the extra: ``pip install "data-sampler[large]"``.
4
+
5
+ The pandas path (:mod:`data_sampler.sampling`) loads the whole file into memory
6
+ and is the default for small/medium data. This engine instead pushes loading,
7
+ stratification, and sampling **into DuckDB**: vectorized, multi-threaded, and
8
+ able to spill to disk, so inputs far larger than RAM can be sampled without ever
9
+ materializing them in pandas. Only the resulting sample (``count`` rows) comes
10
+ back as a DataFrame.
11
+
12
+ Highlights:
13
+
14
+ - **Parallelism** — ``PRAGMA threads`` uses all cores by default.
15
+ - **Out-of-core** — a memory limit plus a temp directory let DuckDB spill.
16
+ - **Native readers** — CSV/TSV/JSON and, especially, Parquet are read directly
17
+ with projection pushdown (only the needed columns are scanned).
18
+ - **Streaming sampling** — reservoir sampling for the random case (exact count,
19
+ single pass) and window-based proportional sampling for the stratified case.
20
+
21
+ This module imports :mod:`duckdb` lazily, so importing it never fails just
22
+ because the optional dependency is absent — only *using* the engine does, with a
23
+ clear message.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import tempfile
30
+ from pathlib import Path
31
+ from typing import Iterable
32
+
33
+ import numpy as np
34
+ import pandas as pd
35
+
36
+ from ._logging import get_logger
37
+ from .sampling import SampleResult
38
+ from .stats import ColumnStats, _fmt_num
39
+
40
+ log = get_logger(__name__)
41
+
42
+ # Above this row count the engine is a good idea and materializing the whole
43
+ # thing in pandas risks exhausting memory (used for auto-selection + warnings).
44
+ LARGE_ROW_THRESHOLD = 5_000_000
45
+
46
+ # extensions DuckDB can read natively (no pandas round-trip)
47
+ _NATIVE_READERS = (".csv", ".tsv", ".json", ".parquet")
48
+
49
+
50
+ class DuckDBUnavailable(RuntimeError):
51
+ """Raised when the DuckDB engine is used without the optional dependency."""
52
+
53
+
54
+ def _require_duckdb():
55
+ try:
56
+ import duckdb # noqa: PLC0415
57
+ except ImportError as exc: # pragma: no cover - exercised without the extra
58
+ raise DuckDBUnavailable(
59
+ "The DuckDB engine needs the optional 'large' extra. Install it with:"
60
+ " pip install \"data-sampler[large]\""
61
+ ) from exc
62
+ return duckdb
63
+
64
+
65
+ def _quote_ident(name: str) -> str:
66
+ """Quote a SQL identifier (column name), escaping embedded quotes."""
67
+ return '"' + str(name).replace('"', '""') + '"'
68
+
69
+
70
+ def _quote_str(value: str) -> str:
71
+ return "'" + str(value).replace("'", "''") + "'"
72
+
73
+
74
+ def _to_float(v) -> float | None:
75
+ """Coerce a DuckDB scalar to a finite float, or None (NULL/NaN/inf)."""
76
+ if v is None:
77
+ return None
78
+ try:
79
+ f = float(v)
80
+ except (TypeError, ValueError):
81
+ return None
82
+ return f if np.isfinite(f) else None
83
+
84
+
85
+ def _duckdb_kind(dtype: str) -> str:
86
+ """Map a DuckDB column type name to a :class:`ColumnStats` kind."""
87
+ d = dtype.lower()
88
+ # nested/wrapped types first: INTEGER[], DECIMAL(6,2)[], STRUCT(...), MAP(...)
89
+ # would otherwise match the scalar numeric prefixes and crash the numeric
90
+ # aggregates ("Unimplemented type for cast (INTEGER[] -> DOUBLE)")
91
+ if d.endswith("]") or d.startswith(("struct", "map", "list", "union", "array", "json")):
92
+ return "other"
93
+ if d.startswith("bool"):
94
+ return "boolean"
95
+ if d.startswith(("timestamp", "date")):
96
+ return "datetime"
97
+ # TIME (no date part) and INTERVAL (timedelta) cannot be datetime-jittered
98
+ # or averaged; "interval" must also never match the "int" numeric prefix
99
+ if d.startswith(("time", "interval")):
100
+ return "other"
101
+ if d.startswith((
102
+ "tinyint", "smallint", "integer", "int", "bigint", "hugeint",
103
+ "utinyint", "usmallint", "uinteger", "ubigint", "uhugeint",
104
+ "decimal", "double", "float", "real", "numeric",
105
+ )):
106
+ return "numeric"
107
+ if d.startswith(("varchar", "char", "text", "string", "uuid", "bit", "blob", "enum")):
108
+ return "categorical" # refined to "text" by average length
109
+ return "other"
110
+
111
+
112
+ def _proportional_allocation(sizes: np.ndarray, count: int) -> np.ndarray:
113
+ """Largest-remainder proportional allocation summing exactly to ``count``.
114
+
115
+ Mirrors :func:`data_sampler.sampling.stratified_sample` so the DuckDB path
116
+ allocates strata identically to the pandas path.
117
+ """
118
+ sizes = np.asarray(sizes, dtype=float)
119
+ total = sizes.sum()
120
+ if total <= 0:
121
+ return np.zeros(len(sizes), dtype=np.int64)
122
+ exact = sizes / total * count
123
+ alloc = np.floor(exact).astype(np.int64)
124
+ shortfall = int(count - alloc.sum())
125
+ if shortfall > 0:
126
+ # hand the remaining slots to the largest fractional remainders
127
+ order = np.argsort(-(exact - alloc), kind="stable")
128
+ alloc[order[:shortfall]] += 1
129
+ # never allocate a stratum more rows than it has
130
+ return np.minimum(alloc, sizes.astype(np.int64))
131
+
132
+
133
+ class DuckDBEngine:
134
+ """A configured DuckDB connection with sampling/stats helpers.
135
+
136
+ ``threads`` defaults to all CPU cores; ``memory_limit`` (e.g. ``"8GB"``)
137
+ caps RAM so DuckDB spills to ``temp_directory`` instead of OOM-ing.
138
+ Use as a context manager to close the connection.
139
+ """
140
+
141
+ def __init__(
142
+ self,
143
+ threads: int | None = None,
144
+ memory_limit: str | None = None,
145
+ temp_directory: str | None = None,
146
+ ):
147
+ duckdb = _require_duckdb()
148
+ self.con = duckdb.connect(":memory:")
149
+ self.threads = int(threads) if threads else (os.cpu_count() or 4)
150
+ self.con.execute(f"PRAGMA threads={self.threads}")
151
+ if memory_limit:
152
+ self.con.execute(f"PRAGMA memory_limit={_quote_str(memory_limit)}")
153
+ self.con.execute(
154
+ f"PRAGMA temp_directory={_quote_str(temp_directory or tempfile.gettempdir())}"
155
+ )
156
+ self._reg = 0
157
+ # per-source row-count cache: one engine session assumes its file
158
+ # sources do not change underneath it, so count(*) runs once per file
159
+ # instead of once per operation (row_count/sample/strat-selection/stats)
160
+ self._count_cache: dict[str, int] = {}
161
+ log.info("DuckDBEngine ready (threads=%d, memory_limit=%s)", self.threads, memory_limit)
162
+
163
+ # ── lifecycle ─────────────────────────────────────────────────────────────
164
+
165
+ def close(self) -> None:
166
+ self.con.close()
167
+
168
+ def __enter__(self) -> "DuckDBEngine":
169
+ return self
170
+
171
+ def __exit__(self, *exc) -> None:
172
+ self.close()
173
+
174
+ # ── source resolution ─────────────────────────────────────────────────────
175
+
176
+ def _source_sql(self, source) -> str:
177
+ """Return a SQL table expression for ``source``.
178
+
179
+ ``source`` may be a path (CSV/TSV/JSON/Parquet, read natively) or a
180
+ pandas DataFrame (registered zero-copy via Arrow).
181
+ """
182
+ if isinstance(source, pd.DataFrame):
183
+ self._reg += 1
184
+ name = f"_src_df_{self._reg}"
185
+ self.con.register(name, source)
186
+ return name
187
+ p = Path(source)
188
+ ext = p.suffix.lower()
189
+ path = _quote_str(str(p))
190
+ if ext == ".parquet":
191
+ return f"read_parquet({path})"
192
+ if ext == ".csv":
193
+ return f"read_csv({path}, header=true, auto_detect=true)"
194
+ if ext == ".tsv":
195
+ return f"read_csv({path}, header=true, auto_detect=true, delim='\t')"
196
+ if ext == ".json":
197
+ expr = f"read_json_auto({path})"
198
+ # DuckDB parses a columns-oriented JSON document — the DEFAULT
199
+ # output of pandas DataFrame.to_json() — as ONE row of index-keyed
200
+ # STRUCTs, which would silently corrupt the sample. Detect that
201
+ # shape (1 row, all STRUCT/MAP columns) and refuse with guidance.
202
+ info = self.con.execute(f"DESCRIBE SELECT * FROM {expr}").fetchall()
203
+ if info and all(
204
+ str(r[1]).upper().startswith(("STRUCT", "MAP")) for r in info
205
+ ):
206
+ n = self.con.execute(
207
+ f"SELECT count(*) FROM (SELECT * FROM {expr} LIMIT 2) t"
208
+ ).fetchone()[0]
209
+ if int(n) == 1:
210
+ raise ValueError(
211
+ f"{p.name} looks columns-oriented (the pandas to_json "
212
+ "default); the DuckDB engine reads records-oriented or "
213
+ "newline-delimited JSON — re-save with "
214
+ "orient='records' (or lines=True), or use --engine pandas"
215
+ )
216
+ return expr
217
+ raise ValueError(
218
+ f"DuckDB engine cannot read '{ext}' natively; supported: "
219
+ f"{', '.join(_NATIVE_READERS)} (or pass a pandas DataFrame)"
220
+ )
221
+
222
+ # ── introspection ─────────────────────────────────────────────────────────
223
+
224
+ def _count(self, source, src_sql: str) -> int:
225
+ """Row count of ``source``, cached per file (free for DataFrames)."""
226
+ if isinstance(source, pd.DataFrame):
227
+ return len(source)
228
+ if src_sql not in self._count_cache:
229
+ self._count_cache[src_sql] = int(
230
+ self.con.execute(f"SELECT count(*) FROM {src_sql}").fetchone()[0]
231
+ )
232
+ return self._count_cache[src_sql]
233
+
234
+ def row_count(self, source) -> int:
235
+ return self._count(source, self._source_sql(source))
236
+
237
+ def columns(self, source) -> list[str]:
238
+ src = self._source_sql(source)
239
+ rel = self.con.execute(f"SELECT * FROM {src} LIMIT 0")
240
+ return [d[0] for d in rel.description]
241
+
242
+ def _set_seed(self, seed: int | None) -> None:
243
+ if seed is not None:
244
+ # setseed wants a value in [-1, 1]; map the int deterministically
245
+ self.con.execute(f"SELECT setseed({(int(seed) % 1000) / 1000.0})")
246
+
247
+ # ── stratification-column selection ───────────────────────────────────────
248
+
249
+ def find_stratification_columns(
250
+ self, source, sample_count: int, exclude: Iterable[str] = ()
251
+ ) -> list[str]:
252
+ """Pick low-cardinality columns to stratify on, using approximate
253
+ distinct counts (HyperLogLog) so it stays cheap on huge inputs."""
254
+ exclude = set(exclude)
255
+ src = self._source_sql(source)
256
+ n_rows = self._count(source, src)
257
+ info = self.con.execute(f"DESCRIBE SELECT * FROM {src}").fetchall()
258
+ # classify via _duckdb_kind so type rules match stats() (and never
259
+ # match INTERVAL against the "int" numeric prefix)
260
+ cols = [
261
+ (r[0], _duckdb_kind(str(r[1]))) for r in info if r[0] not in exclude
262
+ ]
263
+ if not cols:
264
+ return []
265
+
266
+ # one pass: approx distinct per column (HLL) + avg text length for
267
+ # string-ish columns (VARCHAR and ENUM both take this rule)
268
+ parts = []
269
+ for name, kind in cols:
270
+ q = _quote_ident(name)
271
+ parts.append(f"approx_count_distinct({q}) AS {_quote_ident(name + '::nd')}")
272
+ if kind == "categorical":
273
+ parts.append(
274
+ f"avg(length(CAST({q} AS VARCHAR))) AS {_quote_ident(name + '::len')}"
275
+ )
276
+ row = self.con.execute(f"SELECT {', '.join(parts)} FROM {src}").fetchdf().iloc[0]
277
+
278
+ candidates: list[tuple[str, int]] = []
279
+ for name, kind in cols:
280
+ n_unique = int(row[name + "::nd"] or 0)
281
+ if n_unique < 2 or n_unique > min(100, n_rows * 0.5):
282
+ continue
283
+ if kind == "numeric" and n_unique > min(20, n_rows * 0.3):
284
+ continue
285
+ if (name + "::len") in row and (row[name + "::len"] or 0) > 50:
286
+ continue
287
+ candidates.append((name, n_unique))
288
+
289
+ candidates.sort(key=lambda x: x[1]) # fewest categories first
290
+ selected: list[str] = []
291
+ combo = 1
292
+ for name, n_unique in candidates:
293
+ if combo * n_unique > sample_count:
294
+ break
295
+ combo *= n_unique
296
+ selected.append(name)
297
+ log.debug("engine stratification columns: %s", selected)
298
+ return selected
299
+
300
+ # ── approximate stats ─────────────────────────────────────────────────────
301
+
302
+ def stats(
303
+ self,
304
+ source,
305
+ columns: Iterable[str] | None = None,
306
+ approximate: bool = True,
307
+ histogram_bins: int = 10,
308
+ top: int = 8,
309
+ distributions: bool = True,
310
+ ) -> list[ColumnStats]:
311
+ """Per-column :class:`ColumnStats`, computed in DuckDB.
312
+
313
+ With ``approximate`` (default) distinct counts use HyperLogLog
314
+ (``approx_count_distinct``) and the median uses ``approx_quantile`` — both
315
+ streaming, so column stats stay cheap over billions of rows. Set
316
+ ``approximate=False`` for exact counts/quantiles on small inputs.
317
+
318
+ ``distributions`` controls whether per-column histograms / top-values are
319
+ computed (one extra streaming pass per column). Turn it off for a single
320
+ cheap scalar pass across very wide (thousands-of-columns) inputs.
321
+ """
322
+ con = self.con
323
+ src = self._source_sql(source)
324
+ total = self._count(source, src)
325
+ info = con.execute(f"DESCRIBE SELECT * FROM {src}").fetchall()
326
+ col_types = {r[0]: str(r[1]) for r in info}
327
+ if columns is None:
328
+ cols = list(col_types)
329
+ else:
330
+ cols = [str(c) for c in columns]
331
+ unknown = [c for c in cols if c not in col_types]
332
+ if unknown:
333
+ raise KeyError(f"Column(s) not found in source: {', '.join(unknown)}")
334
+ if not cols:
335
+ return []
336
+
337
+ # Pass A: all scalar aggregates for every column in ONE query
338
+ parts: list[str] = []
339
+ for i, c in enumerate(cols):
340
+ q = _quote_ident(c)
341
+ a = f"c{i}"
342
+ kind = _duckdb_kind(col_types[c])
343
+ parts.append(f"count({q}) AS {a}_cnt")
344
+ parts.append(
345
+ (f"approx_count_distinct({q})" if approximate else f"count(DISTINCT {q})")
346
+ + f" AS {a}_nd"
347
+ )
348
+ if kind == "numeric":
349
+ # NaN is a VALUE to DuckDB, not NULL: unfiltered, it poisons
350
+ # min/max and makes stddev_samp raise "out of range" — filter
351
+ # every NaN-sensitive aggregate down to finite values
352
+ fin = f"FILTER (WHERE NOT isnan({q}::DOUBLE))"
353
+ parts += [
354
+ f"min({q}::DOUBLE) {fin} AS {a}_min",
355
+ f"max({q}::DOUBLE) {fin} AS {a}_max",
356
+ f"avg({q}::DOUBLE) {fin} AS {a}_avg",
357
+ f"stddev_samp({q}::DOUBLE) {fin} AS {a}_std",
358
+ (f"approx_quantile({q}::DOUBLE, 0.5)" if approximate
359
+ else f"quantile_cont({q}::DOUBLE, 0.5)") + f" {fin} AS {a}_med",
360
+ ]
361
+ elif kind == "categorical":
362
+ parts.append(f"avg(length(CAST({q} AS VARCHAR))) AS {a}_len")
363
+ row = con.execute(f"SELECT {', '.join(parts)} FROM {src}").fetchdf().iloc[0]
364
+
365
+ out: list[ColumnStats] = []
366
+ for i, c in enumerate(cols):
367
+ a = f"c{i}"
368
+ kind = _duckdb_kind(col_types[c])
369
+ count = int(row[f"{a}_cnt"] or 0)
370
+ missing = total - count
371
+ # clamp: HLL can overestimate distinct counts past the row count,
372
+ # which would push unique_pct over 100% and skew suggest_type
373
+ unique = min(int(row[f"{a}_nd"] or 0), count)
374
+ if kind == "categorical" and (row.get(f"{a}_len") or 0) > 50:
375
+ kind = "text"
376
+
377
+ cs = ColumnStats(
378
+ name=str(c), dtype=col_types[c], kind=kind, count=count,
379
+ missing=missing, missing_pct=missing / total * 100 if total else 0.0,
380
+ unique=unique, unique_pct=unique / count * 100 if count else 0.0,
381
+ approximate=approximate,
382
+ )
383
+ # stratifiable flag, mirroring stats.is_stratifiable's rules
384
+ strat = 2 <= unique <= min(100, total * 0.5)
385
+ if kind == "numeric" and unique > min(20, total * 0.3):
386
+ strat = False
387
+ if kind == "text":
388
+ strat = False
389
+ cs.stratifiable = strat
390
+
391
+ if kind == "numeric" and count > 0:
392
+ cs.min = _to_float(row.get(f"{a}_min"))
393
+ cs.max = _to_float(row.get(f"{a}_max"))
394
+ cs.mean = _to_float(row.get(f"{a}_avg"))
395
+ cs.std = _to_float(row.get(f"{a}_std")) or 0.0
396
+ cs.median = _to_float(row.get(f"{a}_med"))
397
+
398
+ if distributions and count > 0:
399
+ self._fill_distribution(cs, src, c, kind, histogram_bins, top)
400
+ out.append(cs)
401
+ return out
402
+
403
+ def _fill_distribution(self, cs, src, col, kind, bins, top) -> None:
404
+ con = self.con
405
+ q = _quote_ident(col)
406
+ if kind == "numeric" and cs.min is not None and cs.max is not None and cs.min < cs.max:
407
+ lo, hi = float(cs.min), float(cs.max)
408
+ width = hi - lo
409
+ # equal-width bins via floor (width_bucket isn't available in
410
+ # DuckDB); clamp to [0, bins-1] so the max value lands in the last
411
+ # bin, and drop NaN explicitly (NaN is a value, not NULL, in DuckDB)
412
+ rows = con.execute(
413
+ f"SELECT least({bins - 1}, greatest(0, CAST(floor("
414
+ f"({q}::DOUBLE - {lo!r}) / {width!r} * {bins}) AS INTEGER))) AS b, "
415
+ f"count(*) AS n FROM {src} "
416
+ f"WHERE {q} IS NOT NULL AND NOT isnan({q}::DOUBLE) GROUP BY b"
417
+ ).fetchall()
418
+ counts = [0] * bins
419
+ for b, n in rows:
420
+ counts[int(b)] += int(n)
421
+ cs.histogram = counts
422
+ edges = [lo + (hi - lo) * k / bins for k in range(bins + 1)]
423
+ cs.histogram_labels = [
424
+ f"{_fmt_num(edges[k])} – {_fmt_num(edges[k + 1])}" for k in range(bins)
425
+ ]
426
+ elif kind in ("categorical", "boolean", "text", "datetime", "other"):
427
+ rows = con.execute(
428
+ f"SELECT CAST({q} AS VARCHAR) AS v, count(*) AS n FROM {src} "
429
+ f"WHERE {q} IS NOT NULL GROUP BY v ORDER BY n DESC, v LIMIT {top}"
430
+ ).fetchall()
431
+ cs.top_values = [(str(v), int(n)) for v, n in rows]
432
+ cs.histogram = [int(n) for _, n in rows]
433
+ cs.histogram_labels = [str(v) for v, _ in rows]
434
+
435
+ # ── sampling ──────────────────────────────────────────────────────────────
436
+
437
+ def sample(
438
+ self,
439
+ source,
440
+ count: int,
441
+ use_random: bool = False,
442
+ exclude_columns: Iterable[str] = (),
443
+ strat_cols: list[str] | None = None,
444
+ seed: int | None = None,
445
+ ) -> SampleResult:
446
+ """Draw ``count`` rows out-of-core, returning a :class:`SampleResult`.
447
+
448
+ Stratifies automatically (unless ``use_random``) on suitable columns;
449
+ pass ``strat_cols`` to force a set. Only the sample is materialized.
450
+ """
451
+ src = self._source_sql(source)
452
+ total = self._count(source, src)
453
+ log.info("engine sampling %d of %d rows (random=%s)", count, total, use_random)
454
+
455
+ if count >= total:
456
+ notes = [f"Requested {count} rows but source has {total}. Returning all rows."]
457
+ if total >= LARGE_ROW_THRESHOLD:
458
+ # the whole point of this engine is to never materialize the
459
+ # source — warn loudly before honoring an all-rows request
460
+ warn = (
461
+ f"Materializing all {total:,} rows into memory defeats "
462
+ "out-of-core sampling and may exhaust RAM — request fewer rows."
463
+ )
464
+ log.warning(warn)
465
+ notes.append(f"WARNING: {warn}")
466
+ data = self.con.execute(f"SELECT * FROM {src}").df()
467
+ return SampleResult(data=data, method="all", requested=count, notes=notes)
468
+
469
+ self._set_seed(seed)
470
+
471
+ if use_random:
472
+ return self._reservoir(src, count, seed)
473
+
474
+ if strat_cols is None:
475
+ strat_cols = self.find_stratification_columns(source, count, exclude_columns)
476
+ if not strat_cols:
477
+ result = self._reservoir(src, count, seed)
478
+ result.notes = ["No suitable stratification columns found; using reservoir sampling."]
479
+ return result
480
+
481
+ return self._stratified(src, count, strat_cols, seed)
482
+
483
+ def _reservoir(self, src: str, count: int, seed: int | None) -> SampleResult:
484
+ 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()
488
+ return SampleResult(
489
+ data=data, method="random", requested=count,
490
+ notes=["Mode: DuckDB reservoir sampling (out-of-core)"],
491
+ )
492
+
493
+ def _stratified(
494
+ self, src: str, count: int, strat_cols: list[str], seed: int | None
495
+ ) -> SampleResult:
496
+ cols_sql = ", ".join(_quote_ident(c) for c in strat_cols)
497
+ # pass 1: stratum sizes (small — one row per stratum). ORDER BY pins
498
+ # the stratum order: DuckDB's GROUP BY row order is nondeterministic,
499
+ # and _proportional_allocation breaks remainder ties by position, so
500
+ # an unpinned order would make even seeded runs nondeterministic.
501
+ group_df = self.con.execute(
502
+ f"SELECT {cols_sql}, count(*) AS _n FROM {src} "
503
+ f"GROUP BY {cols_sql} ORDER BY {cols_sql} NULLS LAST"
504
+ ).df()
505
+ allocations = _proportional_allocation(group_df["_n"].to_numpy(), count)
506
+ group_df["_alloc"] = allocations
507
+
508
+ # pass 2: proportional per-stratum sampling. row_number over a random
509
+ # order per partition, keep the first _alloc of each stratum. DuckDB
510
+ # parallelizes the partitions and spills if needed.
511
+ alloc_tbl = f"_alloc_{self._reg}"
512
+ self._reg += 1
513
+ self.con.register(alloc_tbl, group_df)
514
+ join_on = " AND ".join(
515
+ f"r.{_quote_ident(c)} IS NOT DISTINCT FROM a.{_quote_ident(c)}"
516
+ for c in strat_cols
517
+ )
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
+ """
530
+ # DuckDB's random() ordering is only reproducible single-threaded, so a
531
+ # seeded run goes single-threaded for determinism; unseeded runs keep all
532
+ # cores (the distribution is preserved either way).
533
+ reproducible = seed is not None
534
+ if reproducible:
535
+ self.con.execute("PRAGMA threads=1")
536
+ self._set_seed(seed)
537
+ try:
538
+ data = self.con.execute(query).df()
539
+ finally:
540
+ if reproducible:
541
+ self.con.execute(f"PRAGMA threads={self.threads}")
542
+
543
+ group_sizes = group_df.set_index(strat_cols)["_n"]
544
+ alloc_series = group_df.set_index(strat_cols)["_alloc"]
545
+ return SampleResult(
546
+ data=data, method="stratified", requested=count,
547
+ strat_cols=list(strat_cols), group_sizes=group_sizes, allocations=alloc_series,
548
+ notes=[f"Stratifying on columns (DuckDB, out-of-core): {list(strat_cols)}"],
549
+ )
550
+
551
+
552
+ # ── module-level conveniences ──────────────────────────────────────────────────
553
+
554
+
555
+ def duckdb_available() -> bool:
556
+ """Whether the optional DuckDB dependency is importable."""
557
+ try:
558
+ import duckdb # noqa: F401, PLC0415
559
+ return True
560
+ except ImportError:
561
+ return False
562
+
563
+
564
+ def should_use_engine(source, row_threshold: int = LARGE_ROW_THRESHOLD) -> bool:
565
+ """Heuristic for auto-selecting the DuckDB engine: Parquet inputs (always a
566
+ win via pushdown) or files large enough that pandas would strain."""
567
+ if not duckdb_available():
568
+ return False
569
+ if isinstance(source, pd.DataFrame):
570
+ return len(source) >= row_threshold
571
+ ext = Path(source).suffix.lower()
572
+ if ext == ".parquet":
573
+ return True
574
+ if ext not in _NATIVE_READERS:
575
+ return False # e.g. Excel: DuckDB cannot read it natively
576
+ try:
577
+ # rough gate on file size (bytes) as a proxy for row count
578
+ return os.path.getsize(source) >= row_threshold * 20
579
+ except OSError:
580
+ return False
581
+
582
+
583
+ def large_materialization_warning(
584
+ n_rows: int, n_cols: int, row_threshold: int = LARGE_ROW_THRESHOLD
585
+ ) -> str | None:
586
+ """A warning to show before loading a big source fully into pandas.
587
+
588
+ Parquet compresses hard on disk, so a modest file can explode in memory —
589
+ surface the speedbump and point at the out-of-core engine.
590
+ """
591
+ if n_rows >= row_threshold:
592
+ return (
593
+ f"{n_rows:,} rows x {n_cols} columns is large: loading it fully into "
594
+ "pandas may exhaust memory (Parquet especially expands well beyond its "
595
+ "on-disk size). The DuckDB engine samples it out-of-core without "
596
+ "materializing the whole dataset."
597
+ )
598
+ return None
599
+
600
+
601
+ def sample(
602
+ source,
603
+ count: int,
604
+ use_random: bool = False,
605
+ exclude_columns: Iterable[str] = (),
606
+ strat_cols: list[str] | None = None,
607
+ seed: int | None = None,
608
+ threads: int | None = None,
609
+ memory_limit: str | None = None,
610
+ ) -> SampleResult:
611
+ """Convenience: open a :class:`DuckDBEngine`, sample ``source``, close it."""
612
+ with DuckDBEngine(threads=threads, memory_limit=memory_limit) as engine:
613
+ return engine.sample(
614
+ source, count, use_random=use_random,
615
+ exclude_columns=exclude_columns, strat_cols=strat_cols, seed=seed,
616
+ )
617
+
618
+
619
+ def stats(
620
+ source,
621
+ columns: Iterable[str] | None = None,
622
+ approximate: bool = True,
623
+ threads: int | None = None,
624
+ memory_limit: str | None = None,
625
+ ) -> list[ColumnStats]:
626
+ """Convenience: open a :class:`DuckDBEngine`, compute stats, close it."""
627
+ with DuckDBEngine(threads=threads, memory_limit=memory_limit) as engine:
628
+ return engine.stats(source, columns=columns, approximate=approximate)