dataframe-toolkit 0.7.1__tar.gz → 0.7.3__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 (47) hide show
  1. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/PKG-INFO +16 -1
  2. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/README.md +14 -0
  3. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/pyproject.toml +2 -1
  4. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/pyproject.toml.orig +2 -1
  5. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/concat_cmd.py +13 -4
  6. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/fit_cmd.py +40 -5
  7. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/query_cmd.py +21 -1
  8. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/stat_cmd.py +3 -0
  9. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/common/io.py +136 -26
  10. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/__init__.py +0 -0
  11. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/cli.py +0 -0
  12. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/__init__.py +0 -0
  13. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/annotate_cmd.py +0 -0
  14. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/base.py +0 -0
  15. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/binx_cmd.py +0 -0
  16. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/chi_cmd.py +0 -0
  17. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/clean_cmd.py +0 -0
  18. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/corr_cmd.py +0 -0
  19. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/dataset_cmd.py +0 -0
  20. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/describe_cmd.py +0 -0
  21. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/eval_cmd.py +0 -0
  22. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/func_cmd.py +0 -0
  23. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/help.py +0 -0
  24. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/hist_cmd.py +0 -0
  25. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/info_cmd.py +0 -0
  26. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/interp_cmd.py +0 -0
  27. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/line_cmd.py +0 -0
  28. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/melt_cmd.py +0 -0
  29. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/merge_cmd.py +0 -0
  30. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/pivot_cmd.py +0 -0
  31. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/print_cmd.py +0 -0
  32. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/randvar_cmd.py +0 -0
  33. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/sample_cmd.py +0 -0
  34. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/scale_cmd.py +0 -0
  35. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/scat_cmd.py +0 -0
  36. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/segid_cmd.py +0 -0
  37. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/split_cmd.py +0 -0
  38. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/test_cmd.py +0 -0
  39. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/transpose_cmd.py +0 -0
  40. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/wstat_cmd.py +0 -0
  41. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/common/__init__.py +0 -0
  42. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/common/copula.py +0 -0
  43. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/common/plot.py +0 -0
  44. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/common/seed.py +0 -0
  45. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/common/seq.py +0 -0
  46. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/common/stats.py +0 -0
  47. {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/io.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: dataframe-toolkit
3
- Version: 0.7.1
3
+ Version: 0.7.3
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
@@ -17,6 +17,7 @@ Requires-Dist: numpy>=1.24
17
17
  Requires-Dist: pyarrow>=12.0
18
18
  Requires-Dist: scipy>=1.10
19
19
  Requires-Dist: statsmodels>=0.14
20
+ Requires-Dist: patsy>=0.5.6
20
21
  Requires-Dist: duckdb>=0.8
21
22
  Requires-Dist: matplotlib>=3.7
22
23
  Requires-Dist: seaborn>=0.12
@@ -73,6 +74,20 @@ cd dataframe-toolkit
73
74
  uv tool install .
74
75
  ```
75
76
 
77
+ ### Skill for coding agents
78
+
79
+ `skills/dftk/` is an agent skill: a short guide that coding agents such as
80
+ Codex and Claude Code load when a task involves dftk. It covers the piping
81
+ conventions, which command to use for what, and patterns for Snakemake and
82
+ Make. Copy it into your agent's skills directory, for example:
83
+
84
+ ```bash
85
+ cp -r skills/dftk ~/.codex/skills/ # Codex
86
+ cp -r skills/dftk ~/.claude/skills/ # Claude Code
87
+ ```
88
+
89
+ Copy it again after upgrading dftk so it matches the installed version.
90
+
76
91
  ## Quick example
77
92
 
78
93
  ```bash
@@ -45,6 +45,20 @@ cd dataframe-toolkit
45
45
  uv tool install .
46
46
  ```
47
47
 
48
+ ### Skill for coding agents
49
+
50
+ `skills/dftk/` is an agent skill: a short guide that coding agents such as
51
+ Codex and Claude Code load when a task involves dftk. It covers the piping
52
+ conventions, which command to use for what, and patterns for Snakemake and
53
+ Make. Copy it into your agent's skills directory, for example:
54
+
55
+ ```bash
56
+ cp -r skills/dftk ~/.codex/skills/ # Codex
57
+ cp -r skills/dftk ~/.claude/skills/ # Claude Code
58
+ ```
59
+
60
+ Copy it again after upgrading dftk so it matches the installed version.
61
+
48
62
  ## Quick example
49
63
 
50
64
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dataframe-toolkit"
3
- version = "0.7.1"
3
+ version = "0.7.3"
4
4
  description = "A command-line toolkit for DataFrame analysis and manipulation"
5
5
  readme = "README.md"
6
6
  keywords = [
@@ -26,6 +26,7 @@ dependencies = [
26
26
  "pyarrow>=12.0",
27
27
  "scipy>=1.10",
28
28
  "statsmodels>=0.14",
29
+ "patsy>=0.5.6",
29
30
  "duckdb>=0.8",
30
31
  "matplotlib>=3.7",
31
32
  "seaborn>=0.12",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dataframe-toolkit"
3
- version = "0.7.1"
3
+ version = "0.7.3"
4
4
  description = "A command-line toolkit for DataFrame analysis and manipulation"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -23,6 +23,7 @@ dependencies = [
23
23
  "pyarrow>=12.0",
24
24
  "scipy>=1.10",
25
25
  "statsmodels>=0.14",
26
+ "patsy>=0.5.6",
26
27
  "duckdb>=0.8",
27
28
  "matplotlib>=3.7",
28
29
  "seaborn>=0.12",
@@ -40,6 +40,8 @@ COLUMN ALIGNMENT
40
40
  ----------------
41
41
  Columns present in all files are aligned by name.
42
42
  Columns missing from some files are filled with NaN (or --fill value).
43
+ --fill applies only to those missing columns: a cell that is empty within
44
+ a file stays empty.
43
45
  Use --select to enforce a fixed output column set.
44
46
 
45
47
  EXAMPLES
@@ -79,7 +81,8 @@ Mismatched columns are outer-joined and filled (NaN by default, or --fill):
79
81
  metavar="VALUE",
80
82
  help=(
81
83
  "Fill value for columns absent in some files "
82
- "(default: NaN). Use 0 or '' for numeric/string defaults."
84
+ "(default: NaN). Use 0 or '' for numeric/string defaults. "
85
+ "Cells that are empty within a file stay empty."
83
86
  ),
84
87
  )
85
88
 
@@ -152,8 +155,6 @@ Mismatched columns are outer-joined and filled (NaN by default, or --fill):
152
155
  if not dfs:
153
156
  raise ValueError("concat: no data read from any input file")
154
157
 
155
- result = pd.concat(dfs, ignore_index=True)
156
-
157
158
  if args.fill is not None:
158
159
  fill: int | float | str
159
160
  try:
@@ -163,6 +164,14 @@ Mismatched columns are outer-joined and filled (NaN by default, or --fill):
163
164
  fill = float(args.fill)
164
165
  except ValueError:
165
166
  fill = args.fill
166
- result = result.fillna(fill)
167
+ # Add each file's missing columns before stacking, so --fill
168
+ # touches only those, not cells that were already empty in a file.
169
+ columns = list(dict.fromkeys(c for df in dfs for c in df.columns))
170
+ dfs = [
171
+ df.assign(**{c: fill for c in columns if c not in df.columns})[columns]
172
+ for df in dfs
173
+ ]
174
+
175
+ result = pd.concat(dfs, ignore_index=True)
167
176
 
168
177
  io.printdf(result, args)
@@ -34,10 +34,12 @@ tidy coefficient tables into one output dataframe.
34
34
  import argparse
35
35
  import logging
36
36
 
37
+ import numpy as np # noqa: F401 — for formulas such as "np.log(y) ~ x", see below
37
38
  import pandas as pd
38
39
  import statsmodels.api as sm
39
40
  import statsmodels.formula.api as smf
40
41
  import statsmodels.stats.anova as sa
42
+ from patsy import PatsyError
41
43
 
42
44
  from dftk.commands.base import BaseCommand
43
45
  from dftk.common.io import check_cols, io
@@ -50,6 +52,11 @@ logger = logging.getLogger(__name__)
50
52
  # ---------------------------------------------------------------------------
51
53
 
52
54
 
55
+ # Formulas are evaluated in the namespace of the function that builds the
56
+ # model (statsmodels' default eval_env), i.e. this module's globals — which
57
+ # is why numpy is imported above as np.
58
+
59
+
53
60
  def standard_model(df: pd.DataFrame, args: argparse.Namespace):
54
61
  """Return an OLS or RLM (robust) model, not yet fitted."""
55
62
  if getattr(args, "robust", False):
@@ -66,12 +73,40 @@ def weighted_model(df: pd.DataFrame, args: argparse.Namespace):
66
73
  )
67
74
 
68
75
 
76
+ def _nullable_to_float(df: pd.DataFrame) -> pd.DataFrame:
77
+ """Return *df* with nullable numeric columns (e.g. Int64) as float64.
78
+
79
+ patsy finds missing values with np.isnan, which fails on pd.NA
80
+ ("boolean value of NA is ambiguous"). As float64 they become NaN, and
81
+ statsmodels drops incomplete rows as it does for any other float column.
82
+ Nullable booleans are left alone: patsy treats them as categorical.
83
+ """
84
+ cols = [
85
+ c
86
+ for c in df.columns
87
+ if pd.api.types.is_extension_array_dtype(df[c])
88
+ and pd.api.types.is_numeric_dtype(df[c])
89
+ and not pd.api.types.is_bool_dtype(df[c])
90
+ ]
91
+ if not cols:
92
+ return df
93
+ return df.astype({c: "float64" for c in cols})
94
+
95
+
69
96
  def regress_it(df: pd.DataFrame, args: argparse.Namespace):
70
- """Fit the appropriate model and return the fitted result object."""
71
- if getattr(args, "weights", None) is not None:
72
- mod = weighted_model(df, args)
73
- else:
74
- mod = standard_model(df, args)
97
+ """Fit the appropriate model and return the fitted result object.
98
+
99
+ A formula that cannot be evaluated (unknown column or function) raises
100
+ ValueError, which the CLI reports as an input error.
101
+ """
102
+ df = _nullable_to_float(df)
103
+ try:
104
+ if getattr(args, "weights", None) is not None:
105
+ mod = weighted_model(df, args)
106
+ else:
107
+ mod = standard_model(df, args)
108
+ except PatsyError as exc:
109
+ raise ValueError(f"cannot evaluate formula {args.formula!r}: {exc}") from None
75
110
  return mod.fit()
76
111
 
77
112
 
@@ -91,9 +91,29 @@ def _sql_query(args: argparse.Namespace, sql: str, table: str) -> pd.DataFrame:
91
91
  conn.register(table, df)
92
92
 
93
93
  try:
94
- return conn.execute(sql).df()
94
+ result = conn.execute(sql)
95
+ description = result.description
96
+ df = result.df()
95
97
  except Exception as exc:
96
98
  raise ValueError(f"DuckDB SQL failed: {exc}") from exc
99
+ return _hugeint_to_int64(df, description)
100
+
101
+
102
+ def _hugeint_to_int64(df: pd.DataFrame, description) -> pd.DataFrame:
103
+ """Return 128-bit integer result columns as Int64 instead of float64.
104
+
105
+ DuckDB's SUM of an integer column is a HUGEINT, which pandas has no type
106
+ for, so .df() gives float64 (19.0). Below 2**53 that float is exact and
107
+ is converted back; from 2**53 on the float may already be rounded, so it
108
+ stays float.
109
+ """
110
+ for i, (_, type_code, *_) in enumerate(description or []):
111
+ if str(type_code) not in ("HUGEINT", "UHUGEINT"):
112
+ continue
113
+ col = df.iloc[:, i]
114
+ if (col.dropna().abs() < 2**53).all():
115
+ df.isetitem(i, col.astype("Int64"))
116
+ return df
97
117
 
98
118
 
99
119
  # ---------------------------------------------------------------------------
@@ -132,6 +132,9 @@ def _compute_stats(df: pd.DataFrame, args: argparse.Namespace) -> pd.DataFrame:
132
132
  stats = (
133
133
  df[cols].agg(agg_funcs).T.reset_index().rename(columns={"index": "name"})
134
134
  )
135
+ # Before the transpose each column held count and the float stats
136
+ # together, so count came out as float; a count is never missing.
137
+ stats["count"] = stats["count"].astype("int64")
135
138
  return stats[["name"] + _STAT_NAMES]
136
139
 
137
140
  else:
@@ -48,6 +48,17 @@ logging.getLogger("matplotlib.font_manager").setLevel(logging.WARNING)
48
48
  # Parquet-pipe temp directory
49
49
  # ---------------------------------------------------------------------------
50
50
 
51
+ # --cast TYPE names → dtypes
52
+ _CAST_TYPES: dict[str, object] = {
53
+ "object": object,
54
+ "int64": "int64",
55
+ "Int64": "Int64",
56
+ "float64": "float64",
57
+ "bool": "bool",
58
+ "datetime64": "datetime64[ns]",
59
+ "category": "category",
60
+ }
61
+
51
62
  DFTK_TMPDIR: str = os.environ.get(
52
63
  "DFTK_TMPDIR",
53
64
  os.path.join(tempfile.gettempdir(), "dftk"),
@@ -116,6 +127,69 @@ def _write_pipe_parquet(df: pd.DataFrame) -> str:
116
127
  return path
117
128
 
118
129
 
130
+ def _warn_digits_exponent(df: pd.DataFrame, digits: int) -> None:
131
+ """Warn if ``--digits`` would print whole numbers in exponent form.
132
+
133
+ An integer column with empty cells is read as float, and ``%.Ng`` then
134
+ writes e.g. 248956422 as 2.49e+08. The output is left as it is; the
135
+ warning suggests --cast COL:Int64 or --round instead.
136
+ """
137
+ import shlex
138
+
139
+ cols: list[str] = []
140
+ example = None
141
+ for c in df.columns:
142
+ if not pd.api.types.is_float_dtype(df[c]):
143
+ continue
144
+ s = df[c].dropna()
145
+ s = s[np.isfinite(s)]
146
+ hit = s[(s == s.round()) & (s.abs() >= 10**digits)]
147
+ if len(hit):
148
+ cols.append(str(c))
149
+ if example is None:
150
+ example = hit.iloc[0]
151
+ if not cols:
152
+ return
153
+
154
+ shown = cols[:3]
155
+ names = ", ".join(repr(c) for c in shown)
156
+ if len(cols) > len(shown):
157
+ names += f" and {len(cols) - len(shown)} more"
158
+ casts = " ".join(shlex.quote(f"{c}:Int64") for c in shown)
159
+ if len(cols) > len(shown):
160
+ casts += " ..."
161
+ logging.warning(
162
+ f"--digits {digits} writes whole numbers in column(s) {names} in "
163
+ f"exponent form (e.g. {int(example)} -> {example:.{digits}g}).\n"
164
+ f" If these columns hold integers: --cast {casts}\n"
165
+ f" To round without exponent form: --round {digits} (decimal places) "
166
+ f"instead of --digits {digits}"
167
+ )
168
+
169
+
170
+ def _write_tsv(df: pd.DataFrame, dest, **to_csv_kwargs) -> None:
171
+ """``df.to_csv(dest, ...)``, also for one column with empty cells.
172
+
173
+ With quoting off, Python's csv module refuses to write a row that is a
174
+ single empty field ("single empty field record must be quoted"). For a
175
+ one-column table the row is written with a constant second column, which
176
+ is then cut off, leaving an empty line: TSV for one missing value.
177
+ """
178
+ if len(df.columns) != 1:
179
+ df.to_csv(dest, **to_csv_kwargs)
180
+ return
181
+ pad = "_pad" if df.columns[0] != "_pad" else "_pad2"
182
+ text = df.assign(**{pad: "x"}).to_csv(None, lineterminator="\n", **to_csv_kwargs)
183
+ # Values cannot contain a tab (unescaped, the csv module refuses them), so
184
+ # the last tab on each line is the one before the padding column
185
+ text = "".join(line.rsplit("\t", 1)[0] + "\n" for line in text.splitlines())
186
+ if isinstance(dest, str):
187
+ with open(dest, "w", encoding="utf-8", newline="") as fh:
188
+ fh.write(text)
189
+ else:
190
+ dest.write(text)
191
+
192
+
119
193
  # ---------------------------------------------------------------------------
120
194
  # Utility helpers
121
195
  # ---------------------------------------------------------------------------
@@ -247,23 +321,39 @@ class io:
247
321
  df.columns = [f"V{i + 1}" for i in range(len(df.columns))]
248
322
  return df
249
323
 
324
+ def _pandas_csv(source, **extra) -> pd.DataFrame:
325
+ return pd.read_csv(
326
+ source, sep=sep, header=header, dtype=dtype, engine="pyarrow", **extra
327
+ )
328
+
329
+ def _with_blank_lines(df: pd.DataFrame, reread) -> pd.DataFrame:
330
+ # In a one-column file an empty line is a missing value, not a
331
+ # line to skip (dftk writes such files; DuckDB reads them this way)
332
+ if len(df.columns) != 1:
333
+ return df
334
+ full = reread()
335
+ return full if list(full.columns) == list(df.columns) else df
336
+
250
337
  def _csv_from_bytes(data: bytes) -> pd.DataFrame:
251
- df = pd.read_csv(
252
- _io.BytesIO(data),
253
- sep=sep,
254
- header=header,
255
- dtype=dtype,
256
- engine="pyarrow",
338
+ df = _pandas_csv(_io.BytesIO(data))
339
+ df = _with_blank_lines(
340
+ df, lambda: _pandas_csv(_io.BytesIO(data), skip_blank_lines=False)
257
341
  )
258
342
  return _rename_if_noheader(df)
259
343
 
344
+ def _csv_with_duckdb(path: str) -> pd.DataFrame:
345
+ # DuckDB keeps integer columns with blanks as Int64 (pandas: float64)
346
+ try:
347
+ df = duckdb.read_csv(path, sep=sep, header=not noheader).df()
348
+ except Exception as exc:
349
+ logging.error(f"DuckDB failed to read {path!r}: {exc}")
350
+ raise
351
+ return _rename_if_noheader(df)
352
+
260
353
  def _csv_from_file(path: str) -> pd.DataFrame:
261
- df = pd.read_csv(
262
- path,
263
- sep=sep,
264
- header=header,
265
- dtype=dtype,
266
- engine="pyarrow",
354
+ df = _pandas_csv(path)
355
+ df = _with_blank_lines(
356
+ df, lambda: _pandas_csv(path, skip_blank_lines=False)
267
357
  )
268
358
  return _rename_if_noheader(df)
269
359
 
@@ -290,15 +380,22 @@ class io:
290
380
  df = pd.read_parquet(filename)
291
381
  df.attrs["_parquet_meta"] = _read_parquet_meta(filename)
292
382
  elif backend == "duckdb" and duckdb is not None:
293
- try:
294
- df = duckdb.read_csv(filename, sep=sep, header=not noheader).df()
295
- df = _rename_if_noheader(df)
296
- except Exception as exc:
297
- logging.error(f"DuckDB failed to read {filename!r}: {exc}")
298
- raise
383
+ df = _csv_with_duckdb(filename)
299
384
  else:
300
385
  df = _csv_from_file(filename)
301
386
 
387
+ elif backend == "duckdb" and duckdb is not None:
388
+ # stdin with DuckDB: it reads files, not buffers (that needs fsspec),
389
+ # so spool stdin to a temp file and read it like a named file
390
+ os.makedirs(DFTK_TMPDIR, exist_ok=True)
391
+ fd, path = tempfile.mkstemp(suffix=".tsv", dir=DFTK_TMPDIR)
392
+ try:
393
+ with os.fdopen(fd, "wb") as fh:
394
+ fh.write(sys.stdin.buffer.read())
395
+ df = _csv_with_duckdb(path)
396
+ finally:
397
+ os.unlink(path)
398
+
302
399
  else:
303
400
  # stdin — read TSV directly
304
401
  df = _csv_from_bytes(sys.stdin.buffer.read())
@@ -385,7 +482,12 @@ class io:
385
482
  )
386
483
  g.add_argument(
387
484
  "--cast",
388
- help="Cast column to dtype (COL:TYPE). Glob patterns supported.",
485
+ help=(
486
+ "Cast column to dtype (COL:TYPE). TYPE is one of object, int64, "
487
+ "Int64, float64, bool, datetime64, category (a prefix is enough, "
488
+ "e.g. int, Int, cat). Int64 is an integer column that allows "
489
+ "empty cells. Glob patterns supported."
490
+ ),
389
491
  nargs="*",
390
492
  default=None,
391
493
  metavar="COL:TYPE",
@@ -471,7 +573,11 @@ class io:
471
573
  )
472
574
  na_group.add_argument(
473
575
  "--dropna",
474
- help="Drop rows containing any NA value before output.",
576
+ help=(
577
+ "Drop rows containing any NA value from the output. Applied "
578
+ "after the command has run, so it does not change what the "
579
+ "command sees (use --prequery for that)."
580
+ ),
475
581
  action="store_true",
476
582
  )
477
583
 
@@ -530,17 +636,18 @@ class io:
530
636
  raise
531
637
 
532
638
  # Cast columns
533
- _DTYPES = ["object", "int64", "float64", "bool", "datetime64", "category"]
534
639
  for item in getattr(args, "cast", None) or []:
535
640
  try:
536
641
  col, coltype = item.split(":")
537
- coltype = fnmatch.filter(_DTYPES, f"{coltype}*")[0]
538
- np_type = getattr(np, coltype)
642
+ # Case-sensitive: "int" is int64, "Int" is the nullable Int64
643
+ pattern = f"{coltype}*"
644
+ matches = [t for t in _CAST_TYPES if fnmatch.fnmatchcase(t, pattern)]
645
+ dtype = _CAST_TYPES[matches[0]]
539
646
  for c in fnmatch.filter(df.columns, col):
540
647
  try:
541
- df[c] = df[c].astype(np_type)
648
+ df[c] = df[c].astype(dtype)
542
649
  except Exception:
543
- df[c] = df[c].astype(float).astype(np_type)
650
+ df[c] = df[c].astype(float).astype(dtype)
544
651
  except Exception:
545
652
  logging.warning(f"--cast: failed for {item!r}")
546
653
 
@@ -665,8 +772,11 @@ class io:
665
772
 
666
773
  else:
667
774
  # TSV — stdout or named file
775
+ if digits is not None:
776
+ _warn_digits_exponent(df, digits)
668
777
  dest = output if output is not None else sys.stdout
669
- df.to_csv(
778
+ _write_tsv(
779
+ df,
670
780
  dest,
671
781
  sep="\t",
672
782
  index=False,