dataframe-toolkit 0.7.2__tar.gz → 0.8.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 (48) hide show
  1. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/PKG-INFO +28 -15
  2. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/README.md +27 -14
  3. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/pyproject.toml +1 -1
  4. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/pyproject.toml.orig +1 -1
  5. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/__init__.py +1 -0
  6. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/base.py +2 -0
  7. dataframe_toolkit-0.8.0/src/dftk/commands/cleannames_cmd.py +184 -0
  8. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/fit_cmd.py +21 -0
  9. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/query_cmd.py +21 -1
  10. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/common/io.py +162 -29
  11. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/__init__.py +0 -0
  12. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/cli.py +0 -0
  13. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/annotate_cmd.py +0 -0
  14. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/binx_cmd.py +0 -0
  15. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/chi_cmd.py +0 -0
  16. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/clean_cmd.py +0 -0
  17. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/concat_cmd.py +0 -0
  18. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/corr_cmd.py +0 -0
  19. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/dataset_cmd.py +0 -0
  20. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/describe_cmd.py +0 -0
  21. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/eval_cmd.py +0 -0
  22. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/func_cmd.py +0 -0
  23. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/help.py +0 -0
  24. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/hist_cmd.py +0 -0
  25. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/info_cmd.py +0 -0
  26. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/interp_cmd.py +0 -0
  27. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/line_cmd.py +0 -0
  28. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/melt_cmd.py +0 -0
  29. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/merge_cmd.py +0 -0
  30. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/pivot_cmd.py +0 -0
  31. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/print_cmd.py +0 -0
  32. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/randvar_cmd.py +0 -0
  33. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/sample_cmd.py +0 -0
  34. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/scale_cmd.py +0 -0
  35. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/scat_cmd.py +0 -0
  36. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/segid_cmd.py +0 -0
  37. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/split_cmd.py +0 -0
  38. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/stat_cmd.py +0 -0
  39. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/test_cmd.py +0 -0
  40. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/transpose_cmd.py +0 -0
  41. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/wstat_cmd.py +0 -0
  42. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/common/__init__.py +0 -0
  43. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/common/copula.py +0 -0
  44. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/common/plot.py +0 -0
  45. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/common/seed.py +0 -0
  46. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/common/seq.py +0 -0
  47. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/common/stats.py +0 -0
  48. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.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.7.2
3
+ Version: 0.8.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
@@ -109,20 +109,21 @@ dftk dataset tips -o \
109
109
 
110
110
  ### Data transformation
111
111
 
112
- | Command | Description |
113
- |-------------|---------------------------------------------------------------------------------------|
114
- | `eval` | Add or modify columns: eval expressions, string/path functions, statistical ops |
115
- | `query` | Filter rows with pandas query expressions or SQL (`--sql` via DuckDB) |
116
- | `merge` | Join two tables on key columns (inner/left/right/outer) |
117
- | `concat` | Concatenate two or more tables row-wise |
118
- | `melt` | Reshape wide-to-long (`pd.melt`) |
119
- | `pivot` | Reshape long-to-wide with per-cell aggregation |
120
- | `func` | Column transforms: cumsum, group mean/sum/min/max/count/median/std, rank, qcut:N |
121
- | `scale` | Normalise: z-score, min-shift, sum/max/mean scaling, Blom rank, regression residuals |
122
- | `interp` | Interpolate values from a reference curve into a table (1-D lookup) |
123
- | `binx` | Assign bin indices to a column based on explicit or generated edges |
124
- | `transpose` | Flip rows and columns; original column names land in a new `--keycol` column |
125
- | `segid` | Assign a segment ID that increments on each value change in a column |
112
+ | Command | Description |
113
+ |--------------|---------------------------------------------------------------------------------------|
114
+ | `eval` | Add or modify columns: eval expressions, string/path functions, statistical ops |
115
+ | `query` | Filter rows with pandas query expressions or SQL (`--sql` via DuckDB) |
116
+ | `merge` | Join two tables on key columns (inner/left/right/outer) |
117
+ | `concat` | Concatenate two or more tables row-wise |
118
+ | `melt` | Reshape wide-to-long (`pd.melt`) |
119
+ | `pivot` | Reshape long-to-wide with per-cell aggregation |
120
+ | `func` | Column transforms: cumsum, group mean/sum/min/max/count/median/std, rank, qcut:N |
121
+ | `scale` | Normalise: z-score, min-shift, sum/max/mean scaling, Blom rank, regression residuals |
122
+ | `interp` | Interpolate values from a reference curve into a table (1-D lookup) |
123
+ | `binx` | Assign bin indices to a column based on explicit or generated edges |
124
+ | `transpose` | Flip rows and columns; original column names land in a new `--keycol` column |
125
+ | `segid` | Assign a segment ID that increments on each value change in a column |
126
+ | `cleannames` | Rename columns to lowercase identifiers (`Sample ID` → `sample_id`), e.g. for SQL |
126
127
 
127
128
  ### Statistics
128
129
 
@@ -173,6 +174,18 @@ dftk dataset iris -o \
173
174
  | dftk fit ... -f "sepal_length_scaled ~ petal_length" -g species
174
175
  ```
175
176
 
177
+ ### Clean up spreadsheet headers
178
+
179
+ Headers such as `Country Name` or `1960` need quoting in SQL. `cleannames` turns them into lowercase identifiers (`country_name`, `x1960`) and keeps the original names in the parquet metadata (`dftk annotate FILE`):
180
+
181
+ ```bash
182
+ dftk dataset fertility -o \
183
+ | dftk cleannames ... -o \
184
+ | dftk query ... --sql "SELECT country_name, x1960, x2000 FROM data LIMIT 3"
185
+ ```
186
+
187
+ For your own export, `dftk cleannames data.tsv --backend duckdb -o data.parquet` also keeps integer columns with empty cells as integers.
188
+
176
189
  ### Group summary then plot
177
190
 
178
191
  ```bash
@@ -80,20 +80,21 @@ dftk dataset tips -o \
80
80
 
81
81
  ### Data transformation
82
82
 
83
- | Command | Description |
84
- |-------------|---------------------------------------------------------------------------------------|
85
- | `eval` | Add or modify columns: eval expressions, string/path functions, statistical ops |
86
- | `query` | Filter rows with pandas query expressions or SQL (`--sql` via DuckDB) |
87
- | `merge` | Join two tables on key columns (inner/left/right/outer) |
88
- | `concat` | Concatenate two or more tables row-wise |
89
- | `melt` | Reshape wide-to-long (`pd.melt`) |
90
- | `pivot` | Reshape long-to-wide with per-cell aggregation |
91
- | `func` | Column transforms: cumsum, group mean/sum/min/max/count/median/std, rank, qcut:N |
92
- | `scale` | Normalise: z-score, min-shift, sum/max/mean scaling, Blom rank, regression residuals |
93
- | `interp` | Interpolate values from a reference curve into a table (1-D lookup) |
94
- | `binx` | Assign bin indices to a column based on explicit or generated edges |
95
- | `transpose` | Flip rows and columns; original column names land in a new `--keycol` column |
96
- | `segid` | Assign a segment ID that increments on each value change in a column |
83
+ | Command | Description |
84
+ |--------------|---------------------------------------------------------------------------------------|
85
+ | `eval` | Add or modify columns: eval expressions, string/path functions, statistical ops |
86
+ | `query` | Filter rows with pandas query expressions or SQL (`--sql` via DuckDB) |
87
+ | `merge` | Join two tables on key columns (inner/left/right/outer) |
88
+ | `concat` | Concatenate two or more tables row-wise |
89
+ | `melt` | Reshape wide-to-long (`pd.melt`) |
90
+ | `pivot` | Reshape long-to-wide with per-cell aggregation |
91
+ | `func` | Column transforms: cumsum, group mean/sum/min/max/count/median/std, rank, qcut:N |
92
+ | `scale` | Normalise: z-score, min-shift, sum/max/mean scaling, Blom rank, regression residuals |
93
+ | `interp` | Interpolate values from a reference curve into a table (1-D lookup) |
94
+ | `binx` | Assign bin indices to a column based on explicit or generated edges |
95
+ | `transpose` | Flip rows and columns; original column names land in a new `--keycol` column |
96
+ | `segid` | Assign a segment ID that increments on each value change in a column |
97
+ | `cleannames` | Rename columns to lowercase identifiers (`Sample ID` → `sample_id`), e.g. for SQL |
97
98
 
98
99
  ### Statistics
99
100
 
@@ -144,6 +145,18 @@ dftk dataset iris -o \
144
145
  | dftk fit ... -f "sepal_length_scaled ~ petal_length" -g species
145
146
  ```
146
147
 
148
+ ### Clean up spreadsheet headers
149
+
150
+ Headers such as `Country Name` or `1960` need quoting in SQL. `cleannames` turns them into lowercase identifiers (`country_name`, `x1960`) and keeps the original names in the parquet metadata (`dftk annotate FILE`):
151
+
152
+ ```bash
153
+ dftk dataset fertility -o \
154
+ | dftk cleannames ... -o \
155
+ | dftk query ... --sql "SELECT country_name, x1960, x2000 FROM data LIMIT 3"
156
+ ```
157
+
158
+ For your own export, `dftk cleannames data.tsv --backend duckdb -o data.parquet` also keeps integer columns with empty cells as integers.
159
+
147
160
  ### Group summary then plot
148
161
 
149
162
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dataframe-toolkit"
3
- version = "0.7.2"
3
+ version = "0.8.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.7.2"
3
+ version = "0.8.0"
4
4
  description = "A command-line toolkit for DataFrame analysis and manipulation"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -37,6 +37,7 @@ COMMAND_GROUPS: list[tuple[str, list[tuple[str, str]]]] = [
37
37
  ("binx", "binx_cmd:BinxCommand"),
38
38
  ("transpose", "transpose_cmd:TransposeCommand"),
39
39
  ("segid", "segid_cmd:SegidCommand"),
40
+ ("cleannames", "cleannames_cmd:CleannamesCommand"),
40
41
  ],
41
42
  ),
42
43
  (
@@ -71,6 +71,7 @@ class BaseCommand(ABC):
71
71
  tabular data and produces tabular output.
72
72
  """
73
73
  from dftk.common.io import io
74
+
74
75
  io.parser_read(parser)
75
76
  io.parser_output(parser)
76
77
 
@@ -82,4 +83,5 @@ class BaseCommand(ABC):
82
83
  exposed.
83
84
  """
84
85
  from dftk.common.io import io
86
+
85
87
  io.parser_read(parser)
@@ -0,0 +1,184 @@
1
+ """
2
+ dftk.commands.cleannames_cmd — dftk cleannames subcommand.
3
+
4
+ Rename columns to lowercase ASCII identifiers (snake_case), so they can be
5
+ used unquoted in query --sql, eval -f and fit formulas. Typical input is a
6
+ spreadsheet export with headers such as "Sample ID", "GC %" or "Hæð (cm)".
7
+
8
+ Unlike R janitor's clean_names, camelCase is not split ("sampleID" becomes
9
+ "sampleid", not "sample_id"): splitting would also turn "HbA1c" into
10
+ "hb_a1c" and "pH" into "p_h". "%" and "#" become "pct" and "n".
11
+
12
+ The original names are kept in the parquet metadata (key original_names, a
13
+ JSON object cleaned → original; see dftk annotate) and, with --mapping, in a
14
+ TSV file.
15
+ """
16
+
17
+ import argparse
18
+ import json
19
+ import logging
20
+ import re
21
+ import unicodedata
22
+
23
+ import pandas as pd
24
+
25
+ from dftk.commands.base import BaseCommand
26
+ from dftk.common.io import io
27
+
28
+ _EPILOG = """\
29
+ RULES
30
+ -----
31
+ Sample ID -> sample_id lowercase; spaces and symbols become _
32
+ Age (years) -> age_years trimmed; one _ per run, none at the ends
33
+ GC % -> gc_pct % becomes pct
34
+ Count # -> count_n # becomes n
35
+ 1960 -> x1960 a leading digit gets an x
36
+ Hæð (µg) -> haed_ug letters become plain ASCII
37
+ sampleID -> sampleid camelCase is not split
38
+ --- -> colN nothing left: col + its position (1-based)
39
+ GC % , gc_pct -> gc_pct, gc_pct_2 clashes get _2, _3, ...
40
+
41
+ Duplicate and empty headers are already renamed when the file is read
42
+ (Count #.1, Unnamed: 7), so they come out as count_n_1 and unnamed_7.
43
+ Names that are SQL reserved words (group, order, end, ...) are kept, with a
44
+ warning: quote them in query --sql ("group").
45
+
46
+ EXAMPLES
47
+ --------
48
+ dftk cleannames data.tsv
49
+
50
+ Convert a spreadsheet export to parquet with clean names, keeping integer
51
+ columns with empty cells as integers:
52
+
53
+ dftk cleannames data.tsv --backend duckdb -o data.parquet
54
+
55
+ The World Bank fertility table has headers such as "Country Name" and one
56
+ column per year (1960, 1961, ...); after cleaning they can be used
57
+ unquoted in SQL:
58
+
59
+ dftk dataset fertility -o \\
60
+ | dftk cleannames ... -o \\
61
+ | dftk query ... --sql "SELECT country_name, x1960, x2000 FROM data LIMIT 3"
62
+
63
+ Write the old and new names to a TSV file, e.g. for a data dictionary:
64
+
65
+ dftk dataset fertility -o \\
66
+ | dftk cleannames ... --mapping names.tsv -o fertility.parquet
67
+ """
68
+
69
+ # Letters that Unicode does not decompose into ASCII letter + accent
70
+ _TRANSLIT = str.maketrans(
71
+ {
72
+ "æ": "ae",
73
+ "ð": "d",
74
+ "đ": "d",
75
+ "þ": "th",
76
+ "ø": "o",
77
+ "œ": "oe",
78
+ "ß": "ss",
79
+ "ł": "l",
80
+ "ı": "i",
81
+ "µ": "u", # micro sign, as in µg
82
+ "μ": "u", # Greek mu
83
+ }
84
+ )
85
+ _SYMBOLS = {"%": " pct ", "#": " n "}
86
+
87
+
88
+ def clean_name(name) -> str:
89
+ """Return *name* as a lowercase ASCII identifier ("" if nothing is left)."""
90
+ s = str(name).strip()
91
+ for symbol, word in _SYMBOLS.items():
92
+ s = s.replace(symbol, word)
93
+ s = s.lower().translate(_TRANSLIT)
94
+ s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode()
95
+ s = re.sub(r"[^a-z0-9]+", "_", s).strip("_")
96
+ if s[:1].isdigit():
97
+ s = "x" + s
98
+ return s
99
+
100
+
101
+ def clean_names(names: list) -> list[str]:
102
+ """Clean every name; empty results become colN, clashes get _2, _3, …"""
103
+ cleaned = [clean_name(n) or f"col{i + 1}" for i, n in enumerate(names)]
104
+ taken = set(cleaned) # a new name must not clash with any cleaned name
105
+ out: list[str] = []
106
+ for name in cleaned:
107
+ new, k = name, 1
108
+ while new in out or (new != name and new in taken):
109
+ k += 1
110
+ new = f"{name}_{k}"
111
+ out.append(new)
112
+ return out
113
+
114
+
115
+ def _sql_keywords(names: list[str]) -> list[str]:
116
+ """Return the names that DuckDB SQL only accepts quoted."""
117
+ try:
118
+ import duckdb
119
+ except ImportError:
120
+ return []
121
+ rows = duckdb.sql(
122
+ "SELECT keyword_name FROM duckdb_keywords() "
123
+ "WHERE keyword_category IN ('reserved', 'type_function')"
124
+ ).fetchall()
125
+ keywords = {r[0] for r in rows}
126
+ return [n for n in names if n in keywords]
127
+
128
+
129
+ class CleannamesCommand(BaseCommand):
130
+ @property
131
+ def name(self) -> str:
132
+ return "cleannames"
133
+
134
+ @property
135
+ def help(self) -> str:
136
+ return "Rename columns to lowercase identifiers (sample_id, gc_pct)"
137
+
138
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
139
+ parser.formatter_class = argparse.RawDescriptionHelpFormatter
140
+ parser.epilog = _EPILOG
141
+
142
+ self.add_io_arguments(parser)
143
+
144
+ g = parser.add_argument_group("cleannames options")
145
+ g.add_argument(
146
+ "--mapping",
147
+ metavar="FILE",
148
+ default=None,
149
+ help="Also write the names to FILE as TSV (columns original, cleaned).",
150
+ )
151
+
152
+ def execute(self, args: argparse.Namespace) -> None:
153
+ df = io.read(args)
154
+ original = [str(c) for c in df.columns]
155
+ cleaned = clean_names(original)
156
+ df.columns = cleaned
157
+
158
+ # Carry names from an earlier cleannames run, so the first originals
159
+ # (e.g. spreadsheet headers) are kept when a file is cleaned again
160
+ meta = dict(df.attrs.get("_parquet_meta", {}))
161
+ earlier = json.loads(meta.get("original_names", "{}"))
162
+ renamed = {}
163
+ for old, new in zip(original, cleaned, strict=True):
164
+ first = earlier.get(old, old)
165
+ if first != new:
166
+ renamed[new] = first
167
+ if renamed or earlier:
168
+ meta["original_names"] = json.dumps(renamed, ensure_ascii=False)
169
+ df.attrs["_parquet_meta"] = meta
170
+
171
+ if args.mapping is not None:
172
+ pd.DataFrame({"original": original, "cleaned": cleaned}).to_csv(
173
+ args.mapping, sep="\t", index=False
174
+ )
175
+
176
+ keywords = _sql_keywords(cleaned)
177
+ if keywords:
178
+ quoted = ", ".join(f'"{k}"' for k in keywords)
179
+ logging.warning(
180
+ f"cleannames: {', '.join(keywords)} are SQL reserved words; "
181
+ f"quote them in query --sql ({quoted})"
182
+ )
183
+
184
+ io.printdf(df, args)
@@ -73,12 +73,33 @@ def weighted_model(df: pd.DataFrame, args: argparse.Namespace):
73
73
  )
74
74
 
75
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
+
76
96
  def regress_it(df: pd.DataFrame, args: argparse.Namespace):
77
97
  """Fit the appropriate model and return the fitted result object.
78
98
 
79
99
  A formula that cannot be evaluated (unknown column or function) raises
80
100
  ValueError, which the CLI reports as an input error.
81
101
  """
102
+ df = _nullable_to_float(df)
82
103
  try:
83
104
  if getattr(args, "weights", None) is not None:
84
105
  mod = weighted_model(df, args)
@@ -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
  # ---------------------------------------------------------------------------
@@ -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,90 @@ 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 _unique_column_names(names: list) -> list:
171
+ """Name empty and duplicate headers as pandas' default reader does.
172
+
173
+ The pyarrow reader keeps ``""`` and repeated names as they are, which
174
+ breaks commands that look columns up by name. An empty header becomes
175
+ ``Unnamed: N`` (0-based position) and a repeat ``name.1``, ``name.2``, …
176
+ """
177
+ names = [f"Unnamed: {i}" if n == "" else n for i, n in enumerate(names)]
178
+ if len(set(names)) == len(names):
179
+ return names
180
+ taken = set(names) # a new name must not clash with any header, later ones too
181
+ out: list = []
182
+ for n in names:
183
+ new, k = n, 0
184
+ while new in out or (new != n and new in taken):
185
+ k += 1
186
+ new = f"{n}.{k}"
187
+ out.append(new)
188
+ return out
189
+
190
+
191
+ def _write_tsv(df: pd.DataFrame, dest, **to_csv_kwargs) -> None:
192
+ """``df.to_csv(dest, ...)``, also for one column with empty cells.
193
+
194
+ With quoting off, Python's csv module refuses to write a row that is a
195
+ single empty field ("single empty field record must be quoted"). For a
196
+ one-column table the row is written with a constant second column, which
197
+ is then cut off, leaving an empty line: TSV for one missing value.
198
+ """
199
+ if len(df.columns) != 1:
200
+ df.to_csv(dest, **to_csv_kwargs)
201
+ return
202
+ pad = "_pad" if df.columns[0] != "_pad" else "_pad2"
203
+ text = df.assign(**{pad: "x"}).to_csv(None, lineterminator="\n", **to_csv_kwargs)
204
+ # Values cannot contain a tab (unescaped, the csv module refuses them), so
205
+ # the last tab on each line is the one before the padding column
206
+ text = "".join(line.rsplit("\t", 1)[0] + "\n" for line in text.splitlines())
207
+ if isinstance(dest, str):
208
+ with open(dest, "w", encoding="utf-8", newline="") as fh:
209
+ fh.write(text)
210
+ else:
211
+ dest.write(text)
212
+
213
+
119
214
  # ---------------------------------------------------------------------------
120
215
  # Utility helpers
121
216
  # ---------------------------------------------------------------------------
@@ -242,30 +337,48 @@ class io:
242
337
 
243
338
  # -- helpers -------------------------------------------------------
244
339
 
245
- def _rename_if_noheader(df: pd.DataFrame) -> pd.DataFrame:
340
+ def _name_columns(df: pd.DataFrame) -> pd.DataFrame:
246
341
  if noheader:
247
342
  df.columns = [f"V{i + 1}" for i in range(len(df.columns))]
343
+ else:
344
+ df.columns = _unique_column_names(list(df.columns))
248
345
  return df
249
346
 
347
+ def _pandas_csv(source, **extra) -> pd.DataFrame:
348
+ return pd.read_csv(
349
+ source, sep=sep, header=header, dtype=dtype, engine="pyarrow", **extra
350
+ )
351
+
352
+ def _with_blank_lines(df: pd.DataFrame, reread) -> pd.DataFrame:
353
+ # In a one-column file an empty line is a missing value, not a
354
+ # line to skip (dftk writes such files; DuckDB reads them this way)
355
+ if len(df.columns) != 1:
356
+ return df
357
+ full = reread()
358
+ return full if list(full.columns) == list(df.columns) else df
359
+
250
360
  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",
361
+ df = _pandas_csv(_io.BytesIO(data))
362
+ df = _with_blank_lines(
363
+ df, lambda: _pandas_csv(_io.BytesIO(data), skip_blank_lines=False)
257
364
  )
258
- return _rename_if_noheader(df)
365
+ return _name_columns(df)
366
+
367
+ def _csv_with_duckdb(path: str) -> pd.DataFrame:
368
+ # DuckDB keeps integer columns with blanks as Int64 (pandas: float64)
369
+ try:
370
+ df = duckdb.read_csv(path, sep=sep, header=not noheader).df()
371
+ except Exception as exc:
372
+ logging.error(f"DuckDB failed to read {path!r}: {exc}")
373
+ raise
374
+ return _name_columns(df)
259
375
 
260
376
  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",
377
+ df = _pandas_csv(path)
378
+ df = _with_blank_lines(
379
+ df, lambda: _pandas_csv(path, skip_blank_lines=False)
267
380
  )
268
- return _rename_if_noheader(df)
381
+ return _name_columns(df)
269
382
 
270
383
  # -- read ----------------------------------------------------------
271
384
 
@@ -290,15 +403,22 @@ class io:
290
403
  df = pd.read_parquet(filename)
291
404
  df.attrs["_parquet_meta"] = _read_parquet_meta(filename)
292
405
  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
406
+ df = _csv_with_duckdb(filename)
299
407
  else:
300
408
  df = _csv_from_file(filename)
301
409
 
410
+ 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)
421
+
302
422
  else:
303
423
  # stdin — read TSV directly
304
424
  df = _csv_from_bytes(sys.stdin.buffer.read())
@@ -385,7 +505,12 @@ class io:
385
505
  )
386
506
  g.add_argument(
387
507
  "--cast",
388
- help="Cast column to dtype (COL:TYPE). Glob patterns supported.",
508
+ help=(
509
+ "Cast column to dtype (COL:TYPE). TYPE is one of object, int64, "
510
+ "Int64, float64, bool, datetime64, category (a prefix is enough, "
511
+ "e.g. int, Int, cat). Int64 is an integer column that allows "
512
+ "empty cells. Glob patterns supported."
513
+ ),
389
514
  nargs="*",
390
515
  default=None,
391
516
  metavar="COL:TYPE",
@@ -471,7 +596,11 @@ class io:
471
596
  )
472
597
  na_group.add_argument(
473
598
  "--dropna",
474
- help="Drop rows containing any NA value before output.",
599
+ help=(
600
+ "Drop rows containing any NA value from the output. Applied "
601
+ "after the command has run, so it does not change what the "
602
+ "command sees (use --prequery for that)."
603
+ ),
475
604
  action="store_true",
476
605
  )
477
606
 
@@ -530,17 +659,18 @@ class io:
530
659
  raise
531
660
 
532
661
  # Cast columns
533
- _DTYPES = ["object", "int64", "float64", "bool", "datetime64", "category"]
534
662
  for item in getattr(args, "cast", None) or []:
535
663
  try:
536
664
  col, coltype = item.split(":")
537
- coltype = fnmatch.filter(_DTYPES, f"{coltype}*")[0]
538
- np_type = getattr(np, coltype)
665
+ # Case-sensitive: "int" is int64, "Int" is the nullable Int64
666
+ pattern = f"{coltype}*"
667
+ matches = [t for t in _CAST_TYPES if fnmatch.fnmatchcase(t, pattern)]
668
+ dtype = _CAST_TYPES[matches[0]]
539
669
  for c in fnmatch.filter(df.columns, col):
540
670
  try:
541
- df[c] = df[c].astype(np_type)
671
+ df[c] = df[c].astype(dtype)
542
672
  except Exception:
543
- df[c] = df[c].astype(float).astype(np_type)
673
+ df[c] = df[c].astype(float).astype(dtype)
544
674
  except Exception:
545
675
  logging.warning(f"--cast: failed for {item!r}")
546
676
 
@@ -665,8 +795,11 @@ class io:
665
795
 
666
796
  else:
667
797
  # TSV — stdout or named file
798
+ if digits is not None:
799
+ _warn_digits_exponent(df, digits)
668
800
  dest = output if output is not None else sys.stdout
669
- df.to_csv(
801
+ _write_tsv(
802
+ df,
670
803
  dest,
671
804
  sep="\t",
672
805
  index=False,