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.
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/PKG-INFO +28 -15
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/README.md +27 -14
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/pyproject.toml +1 -1
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/pyproject.toml.orig +1 -1
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/__init__.py +1 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/base.py +2 -0
- dataframe_toolkit-0.8.0/src/dftk/commands/cleannames_cmd.py +184 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/fit_cmd.py +21 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/query_cmd.py +21 -1
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/common/io.py +162 -29
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/__init__.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/cli.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/annotate_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/binx_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/chi_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/clean_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/concat_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/corr_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/dataset_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/describe_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/eval_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/func_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/help.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/hist_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/info_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/interp_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/line_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/melt_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/merge_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/pivot_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/print_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/randvar_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/sample_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/scale_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/scat_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/segid_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/split_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/stat_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/test_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/transpose_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/commands/wstat_cmd.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/common/__init__.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/common/copula.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/common/plot.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/common/seed.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/common/seq.py +0 -0
- {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.8.0}/src/dftk/common/stats.py +0 -0
- {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.
|
|
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
|
|
113
|
-
|
|
114
|
-
| `eval`
|
|
115
|
-
| `query`
|
|
116
|
-
| `merge`
|
|
117
|
-
| `concat`
|
|
118
|
-
| `melt`
|
|
119
|
-
| `pivot`
|
|
120
|
-
| `func`
|
|
121
|
-
| `scale`
|
|
122
|
-
| `interp`
|
|
123
|
-
| `binx`
|
|
124
|
-
| `transpose`
|
|
125
|
-
| `segid`
|
|
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
|
|
84
|
-
|
|
85
|
-
| `eval`
|
|
86
|
-
| `query`
|
|
87
|
-
| `merge`
|
|
88
|
-
| `concat`
|
|
89
|
-
| `melt`
|
|
90
|
-
| `pivot`
|
|
91
|
-
| `func`
|
|
92
|
-
| `scale`
|
|
93
|
-
| `interp`
|
|
94
|
-
| `binx`
|
|
95
|
-
| `transpose`
|
|
96
|
-
| `segid`
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
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 =
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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
|
-
|
|
538
|
-
|
|
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(
|
|
671
|
+
df[c] = df[c].astype(dtype)
|
|
542
672
|
except Exception:
|
|
543
|
-
df[c] = df[c].astype(float).astype(
|
|
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
|
-
|
|
801
|
+
_write_tsv(
|
|
802
|
+
df,
|
|
670
803
|
dest,
|
|
671
804
|
sep="\t",
|
|
672
805
|
index=False,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|