dataframe-toolkit 0.9.0__tar.gz → 0.10.0__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 (49) hide show
  1. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/PKG-INFO +17 -1
  2. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/README.md +16 -0
  3. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/pyproject.toml +1 -1
  4. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/pyproject.toml.orig +1 -1
  5. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/__init__.py +1 -0
  6. dataframe_toolkit-0.10.0/src/dftk/commands/explode_cmd.py +180 -0
  7. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/merge_cmd.py +48 -0
  8. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/common/io.py +27 -13
  9. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/__init__.py +0 -0
  10. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/cli.py +0 -0
  11. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/annotate_cmd.py +0 -0
  12. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/base.py +0 -0
  13. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/binx_cmd.py +0 -0
  14. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/chi_cmd.py +0 -0
  15. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/clean_cmd.py +0 -0
  16. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/cleannames_cmd.py +0 -0
  17. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/concat_cmd.py +0 -0
  18. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/corr_cmd.py +0 -0
  19. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/dataset_cmd.py +0 -0
  20. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/describe_cmd.py +0 -0
  21. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/eval_cmd.py +0 -0
  22. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/fit_cmd.py +0 -0
  23. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/func_cmd.py +0 -0
  24. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/help.py +0 -0
  25. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/hist_cmd.py +0 -0
  26. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/info_cmd.py +0 -0
  27. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/interp_cmd.py +0 -0
  28. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/line_cmd.py +0 -0
  29. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/melt_cmd.py +0 -0
  30. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/pivot_cmd.py +0 -0
  31. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/print_cmd.py +0 -0
  32. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/query_cmd.py +0 -0
  33. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/randvar_cmd.py +0 -0
  34. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/sample_cmd.py +0 -0
  35. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/scale_cmd.py +0 -0
  36. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/scat_cmd.py +0 -0
  37. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/segid_cmd.py +0 -0
  38. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/split_cmd.py +0 -0
  39. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/stat_cmd.py +0 -0
  40. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/test_cmd.py +0 -0
  41. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/transpose_cmd.py +0 -0
  42. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/commands/wstat_cmd.py +0 -0
  43. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/common/__init__.py +0 -0
  44. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/common/copula.py +0 -0
  45. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/common/plot.py +0 -0
  46. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/common/seed.py +0 -0
  47. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/common/seq.py +0 -0
  48. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/common/stats.py +0 -0
  49. {dataframe_toolkit-0.9.0 → dataframe_toolkit-0.10.0}/src/dftk/io.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: dataframe-toolkit
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: A command-line toolkit for DataFrame analysis and manipulation
5
5
  Keywords: dataframe,statistics,data analysis,CLI,TSV,pandas
6
6
  Author: Gunnar Pálsson
@@ -116,6 +116,7 @@ dftk dataset tips -o \
116
116
  | `merge` | Join two tables on key columns (inner/left/right/outer) |
117
117
  | `concat` | Concatenate two or more tables row-wise |
118
118
  | `melt` | Reshape wide-to-long (`pd.melt`) |
119
+ | `explode` | Split separated values in a cell (`a,b`) into one row per value |
119
120
  | `pivot` | Reshape long-to-wide with per-cell aggregation |
120
121
  | `func` | Column transforms: cumsum, group mean/sum/min/max/count/median/std, rank, qcut:N |
121
122
  | `scale` | Normalise: z-score, min-shift, sum/max/mean scaling, Blom rank, regression residuals |
@@ -251,6 +252,21 @@ All commands accept:
251
252
  > parquet and prints *its path* to stdout — `-` would try to parse that path
252
253
  > as TSV and fail.
253
254
 
255
+ ### Comment lines and preambles
256
+
257
+ dftk has no comment or skip-rows option: `#` is ordinary data, so headers
258
+ like `Count #` and values like `A #1` are read as they are. Drop comment
259
+ lines or a preamble in the shell instead:
260
+
261
+ ```bash
262
+ grep -v '^#' data.tsv | dftk print # drop every line starting with #
263
+ tail -n +3 data.tsv | dftk print # skip a 2-line preamble
264
+ dftk print <(grep -v '^#' data.tsv) # the same, as a filename
265
+ ```
266
+
267
+ With `--backend duckdb`, a whole-line `#` comment below the header is an
268
+ error, and `#` lines above the header are skipped.
269
+
254
270
  Standard output options (available on all tabular commands):
255
271
 
256
272
  - `-o` / `--output` — controls where output goes; takes an *optional* value:
@@ -87,6 +87,7 @@ dftk dataset tips -o \
87
87
  | `merge` | Join two tables on key columns (inner/left/right/outer) |
88
88
  | `concat` | Concatenate two or more tables row-wise |
89
89
  | `melt` | Reshape wide-to-long (`pd.melt`) |
90
+ | `explode` | Split separated values in a cell (`a,b`) into one row per value |
90
91
  | `pivot` | Reshape long-to-wide with per-cell aggregation |
91
92
  | `func` | Column transforms: cumsum, group mean/sum/min/max/count/median/std, rank, qcut:N |
92
93
  | `scale` | Normalise: z-score, min-shift, sum/max/mean scaling, Blom rank, regression residuals |
@@ -222,6 +223,21 @@ All commands accept:
222
223
  > parquet and prints *its path* to stdout — `-` would try to parse that path
223
224
  > as TSV and fail.
224
225
 
226
+ ### Comment lines and preambles
227
+
228
+ dftk has no comment or skip-rows option: `#` is ordinary data, so headers
229
+ like `Count #` and values like `A #1` are read as they are. Drop comment
230
+ lines or a preamble in the shell instead:
231
+
232
+ ```bash
233
+ grep -v '^#' data.tsv | dftk print # drop every line starting with #
234
+ tail -n +3 data.tsv | dftk print # skip a 2-line preamble
235
+ dftk print <(grep -v '^#' data.tsv) # the same, as a filename
236
+ ```
237
+
238
+ With `--backend duckdb`, a whole-line `#` comment below the header is an
239
+ error, and `#` lines above the header are skipped.
240
+
225
241
  Standard output options (available on all tabular commands):
226
242
 
227
243
  - `-o` / `--output` — controls where output goes; takes an *optional* value:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dataframe-toolkit"
3
- version = "0.9.0"
3
+ version = "0.10.0"
4
4
  description = "A command-line toolkit for DataFrame analysis and manipulation"
5
5
  readme = "README.md"
6
6
  keywords = [
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dataframe-toolkit"
3
- version = "0.9.0"
3
+ version = "0.10.0"
4
4
  description = "A command-line toolkit for DataFrame analysis and manipulation"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -30,6 +30,7 @@ COMMAND_GROUPS: list[tuple[str, list[tuple[str, str]]]] = [
30
30
  ("merge", "merge_cmd:MergeCommand"),
31
31
  ("concat", "concat_cmd:ConcatCommand"),
32
32
  ("melt", "melt_cmd:MeltCommand"),
33
+ ("explode", "explode_cmd:ExplodeCommand"),
33
34
  ("pivot", "pivot_cmd:PivotCommand"),
34
35
  ("func", "func_cmd:FuncCommand"),
35
36
  ("scale", "scale_cmd:ScaleCommand"),
@@ -0,0 +1,180 @@
1
+ """
2
+ dftk.commands.explode_cmd — dftk explode subcommand.
3
+
4
+ Split a column of separated values (e.g. "GO:1, GO:2") into one row per
5
+ value, repeating the other columns on each new row (pandas str.split +
6
+ explode). Several columns are split in parallel: their n-th items end up
7
+ on the same row, so every row must have the same number of items in them.
8
+ """
9
+
10
+ import argparse
11
+
12
+ import pandas as pd
13
+
14
+ from dftk.commands.base import BaseCommand
15
+ from dftk.common.io import check_cols, io
16
+
17
+ _EPILOG = """\
18
+ EXAMPLE
19
+ -------
20
+ gene go tissue
21
+ BRCA1 GO:1, GO:2 breast
22
+ TP53 GO:3 colon
23
+
24
+ dftk explode data.tsv -c go
25
+
26
+ gene go tissue
27
+ BRCA1 GO:1 breast
28
+ BRCA1 GO:2 breast
29
+ TP53 GO:3 colon
30
+
31
+ Spaces around each item are removed. An empty cell stays as one row with
32
+ an empty value; --dropempty removes such rows. If all the new values in a
33
+ split column are numbers, the column becomes numeric (integers stay
34
+ integers); columns named in --readasobject stay text.
35
+
36
+ Several columns are split in parallel, item by item. For every
37
+ combination instead, run explode once per column:
38
+
39
+ id a b
40
+ 1 x,y 10,20
41
+
42
+ dftk explode data.tsv -c a b -> (1, x, 10), (1, y, 20)
43
+
44
+ Runnable version: collect each species' islands into one comma-separated
45
+ cell with SQL, then split them back into rows, numbering each item:
46
+
47
+ dftk dataset penguins -o \\
48
+ | dftk query ... -o --sql "SELECT species,
49
+ string_agg(DISTINCT island, ',' ORDER BY island) AS islands
50
+ FROM data GROUP BY species ORDER BY species" \\
51
+ | dftk explode ... -c islands --poscol n
52
+
53
+ Another separator, keeping only some columns:
54
+
55
+ dftk dataset tips -o \\
56
+ | dftk query ... -o --sql "SELECT day,
57
+ string_agg(DISTINCT size::VARCHAR, '|' ORDER BY size::VARCHAR) AS sizes
58
+ FROM data GROUP BY day ORDER BY day" \\
59
+ | dftk explode ... -c sizes --sep '|' --select sizes day
60
+ """
61
+
62
+
63
+ def _split_cell(value, sep: str):
64
+ if pd.isna(value):
65
+ return value
66
+ return [item.strip() for item in str(value).split(sep)]
67
+
68
+
69
+ def _to_numeric(s: pd.Series) -> pd.Series:
70
+ """Return *s* as integers or floats if every non-empty value is one."""
71
+ values = s.dropna()
72
+ if values.empty:
73
+ return s
74
+ text = values.astype(str)
75
+ if text.str.fullmatch(r"[+-]?\d+").all():
76
+ return s.astype("Int64") if s.isna().any() else s.astype("int64")
77
+ try:
78
+ return pd.to_numeric(s)
79
+ except (ValueError, TypeError):
80
+ return s
81
+
82
+
83
+ def _explode(df: pd.DataFrame, args: argparse.Namespace) -> pd.DataFrame:
84
+ cols = args.cols
85
+ if not args.sep:
86
+ raise ValueError("--sep must not be empty")
87
+ if len(set(cols)) != len(cols):
88
+ raise ValueError(f"-c/--cols lists a column more than once: {cols}")
89
+ if args.poscol is not None and args.poscol in df.columns:
90
+ raise ValueError(f"--poscol {args.poscol!r} is already a column")
91
+
92
+ df = df.reset_index(drop=True)
93
+ for col in cols:
94
+ df[col] = df[col].map(lambda v: _split_cell(v, args.sep)).astype(object)
95
+
96
+ if len(cols) > 1:
97
+ counts = pd.DataFrame(
98
+ {c: df[c].map(lambda v: len(v) if isinstance(v, list) else 1) for c in cols}
99
+ )
100
+ bad = counts.index[counts.nunique(axis=1) > 1]
101
+ if len(bad):
102
+ shown = "; ".join(
103
+ f"row {i + 1} ("
104
+ + ", ".join(f"{c}: {counts.at[i, c]}" for c in cols)
105
+ + ")"
106
+ for i in bad[:5]
107
+ )
108
+ more = f" and {len(bad) - 5} more" if len(bad) > 5 else ""
109
+ raise ValueError(
110
+ f"Rows with different numbers of items in -c/--cols: {shown}{more}. "
111
+ "Run explode once per column for every combination."
112
+ )
113
+
114
+ result = df.explode(cols)
115
+ if args.poscol is not None:
116
+ result[args.poscol] = result.groupby(level=0).cumcount() + 1
117
+ result = result.reset_index(drop=True)
118
+
119
+ for col in cols:
120
+ result[col] = result[col].replace("", pd.NA)
121
+ if args.dropempty:
122
+ result = result[result[cols].notna().any(axis=1)].reset_index(drop=True)
123
+
124
+ readasobject = getattr(args, "readasobject", None)
125
+ for col in cols:
126
+ if readasobject is not None and (not readasobject or col in readasobject):
127
+ continue
128
+ result[col] = _to_numeric(result[col])
129
+ return result
130
+
131
+
132
+ class ExplodeCommand(BaseCommand):
133
+ @property
134
+ def name(self) -> str:
135
+ return "explode"
136
+
137
+ @property
138
+ def help(self) -> str:
139
+ return "Split separated values in a column into one row per value"
140
+
141
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
142
+ parser.formatter_class = argparse.RawDescriptionHelpFormatter
143
+ parser.epilog = _EPILOG
144
+
145
+ self.add_io_arguments(parser)
146
+
147
+ g = parser.add_argument_group("explode options")
148
+ g.add_argument(
149
+ "-c",
150
+ "--cols",
151
+ nargs="+",
152
+ required=True,
153
+ metavar="COL",
154
+ help="Column(s) to split. Several columns are split in parallel "
155
+ "and must have the same number of items on each row.",
156
+ )
157
+ g.add_argument(
158
+ "--sep",
159
+ default=",",
160
+ metavar="SEP",
161
+ help="Separator between items, a plain string, not a regex (default: ,).",
162
+ )
163
+ g.add_argument(
164
+ "--poscol",
165
+ default=None,
166
+ metavar="NAME",
167
+ help="Add a column NAME with each item's position in its cell (1, 2, ...).",
168
+ )
169
+ g.add_argument(
170
+ "--dropempty",
171
+ action="store_true",
172
+ help="Drop rows whose split columns are all empty "
173
+ "(default: keep them, so no input row is lost).",
174
+ )
175
+
176
+ def execute(self, args: argparse.Namespace) -> None:
177
+ df = io.read(args)
178
+ check_cols(df, args.cols, "-c/--cols")
179
+ result = _explode(df, args)
180
+ io.printdf(result, args)
@@ -17,6 +17,8 @@ Key features
17
17
  * --select [:right:] expands to all right-file columns, suppressing ``_l``.
18
18
  * Both input files can be TSV or Parquet (.parquet extension auto-detected).
19
19
  The left file defaults to stdin, making it parquet-pipe friendly.
20
+ * Integer columns that a left, right or outer join leaves gaps in become
21
+ nullable (Int64) instead of float64.
20
22
  """
21
23
 
22
24
  import argparse
@@ -107,6 +109,35 @@ def _add_cross_col(
107
109
  args.drop = drop
108
110
 
109
111
 
112
+ def _gap_sides(args: argparse.Namespace) -> tuple[bool, bool]:
113
+ """Return (left, right): which side's columns can get missing values.
114
+
115
+ --only runs an outer join before filtering, so both sides count.
116
+ """
117
+ return args.type in ("right", "outer"), args.type in ("left", "outer")
118
+
119
+
120
+ def _nullable_ints(df: pd.DataFrame, keep: list[str]) -> pd.DataFrame:
121
+ """Return *df* with numpy integer columns (except *keep*) as Int64 etc.
122
+
123
+ A plain int64 column cannot hold a missing value, so pandas turns it into
124
+ float64 when a join leaves rows unmatched; the nullable type keeps the
125
+ integers.
126
+ """
127
+ cols = [
128
+ c
129
+ for c in df.columns
130
+ if c not in keep
131
+ and pd.api.types.is_integer_dtype(df[c])
132
+ and not isinstance(df[c].dtype, pd.api.extensions.ExtensionDtype)
133
+ ]
134
+ if not cols:
135
+ return df
136
+ # int32 -> Int32, uint8 -> UInt8, ...
137
+ dtypes = {c: df[c].dtype.name.capitalize().replace("Uint", "UInt") for c in cols}
138
+ return df.astype(dtypes)
139
+
140
+
110
141
  def _filter_only(
111
142
  df: pd.DataFrame,
112
143
  df_left: pd.DataFrame,
@@ -152,6 +183,15 @@ def _do_merge(
152
183
 
153
184
  suffixes = _expand_select(list(df_left.columns), list(df_right.columns), args)
154
185
 
186
+ # Keep integer columns integers when the join leaves gaps in them. The
187
+ # -k keys are filled from both sides, so they never get gaps.
188
+ keys = args.keys or []
189
+ left_gaps, right_gaps = _gap_sides(args)
190
+ if left_gaps:
191
+ df_left = _nullable_ints(df_left, keys)
192
+ if right_gaps:
193
+ df_right = _nullable_ints(df_right, keys)
194
+
155
195
  if args.keys is not None:
156
196
  df_merged = pd.merge(
157
197
  df_left,
@@ -278,6 +318,14 @@ would be read as if it were the data.
278
318
  Named-file self-merge (no stdin required):
279
319
 
280
320
  dftk merge penguins.tsv -r penguins_adelie.tsv -k species --only left
321
+
322
+ INTEGER COLUMNS
323
+ ---------------
324
+ A left, right or outer join leaves the other side's columns empty in
325
+ unmatched rows. Integer columns there are made nullable (Int64), so they
326
+ stay integers (100, not 100.0); the -k key columns are not changed. Read
327
+ TSV input with --backend duckdb to keep integer columns that already have
328
+ empty cells as integers too.
281
329
  """
282
330
 
283
331
 
@@ -27,6 +27,7 @@ import fnmatch
27
27
  import io as _io
28
28
  import logging
29
29
  import os
30
+ import shutil
30
31
  import sys
31
32
  import tempfile
32
33
 
@@ -365,14 +366,30 @@ class io:
365
366
  return _name_columns(df)
366
367
 
367
368
  def _csv_with_duckdb(path: str) -> pd.DataFrame:
368
- # DuckDB keeps integer columns with blanks as Int64 (pandas: float64)
369
+ # DuckDB keeps integer columns with blanks as Int64 (pandas: float64).
370
+ # comment="" stops it guessing "#" as a comment character: after one
371
+ # whole-line "#" comment it cuts every line at "#", headers included
369
372
  try:
370
- df = duckdb.read_csv(path, sep=sep, header=not noheader).df()
373
+ df = duckdb.read_csv(
374
+ path, sep=sep, header=not noheader, comment=""
375
+ ).df()
371
376
  except Exception as exc:
372
377
  logging.error(f"DuckDB failed to read {path!r}: {exc}")
373
378
  raise
374
379
  return _name_columns(df)
375
380
 
381
+ def _spooled_csv_with_duckdb(stream) -> pd.DataFrame:
382
+ # DuckDB reads its input twice (type sniffing, then data), so a
383
+ # stream must be copied to a temp file first
384
+ os.makedirs(DFTK_TMPDIR, exist_ok=True)
385
+ fd, path = tempfile.mkstemp(suffix=".tsv", dir=DFTK_TMPDIR)
386
+ try:
387
+ with os.fdopen(fd, "wb") as fh:
388
+ shutil.copyfileobj(stream, fh)
389
+ return _csv_with_duckdb(path)
390
+ finally:
391
+ os.unlink(path)
392
+
376
393
  def _csv_from_file(path: str) -> pd.DataFrame:
377
394
  df = _pandas_csv(path)
378
395
  df = _with_blank_lines(
@@ -403,21 +420,18 @@ class io:
403
420
  df = pd.read_parquet(filename)
404
421
  df.attrs["_parquet_meta"] = _read_parquet_meta(filename)
405
422
  elif backend == "duckdb" and duckdb is not None:
406
- df = _csv_with_duckdb(filename)
423
+ if os.path.exists(filename) and not os.path.isfile(filename):
424
+ # A pipe, e.g. <(grep -v '^#' file): one read would use it up
425
+ with open(filename, "rb") as fh:
426
+ df = _spooled_csv_with_duckdb(fh)
427
+ else:
428
+ df = _csv_with_duckdb(filename)
407
429
  else:
408
430
  df = _csv_from_file(filename)
409
431
 
410
432
  elif backend == "duckdb" and duckdb is not None:
411
- # stdin with DuckDB: it reads files, not buffers (that needs fsspec),
412
- # so spool stdin to a temp file and read it like a named file
413
- os.makedirs(DFTK_TMPDIR, exist_ok=True)
414
- fd, path = tempfile.mkstemp(suffix=".tsv", dir=DFTK_TMPDIR)
415
- try:
416
- with os.fdopen(fd, "wb") as fh:
417
- fh.write(sys.stdin.buffer.read())
418
- df = _csv_with_duckdb(path)
419
- finally:
420
- os.unlink(path)
433
+ # stdin with DuckDB: it reads files, not buffers (that needs fsspec)
434
+ df = _spooled_csv_with_duckdb(sys.stdin.buffer)
421
435
 
422
436
  else:
423
437
  # stdin — read TSV directly