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.
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/PKG-INFO +16 -1
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/README.md +14 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/pyproject.toml +2 -1
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/pyproject.toml.orig +2 -1
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/concat_cmd.py +13 -4
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/fit_cmd.py +40 -5
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/query_cmd.py +21 -1
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/stat_cmd.py +3 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/common/io.py +136 -26
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/__init__.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/cli.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/__init__.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/annotate_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/base.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/binx_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/chi_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/clean_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/corr_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/dataset_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/describe_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/eval_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/func_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/help.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/hist_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/info_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/interp_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/line_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/melt_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/merge_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/pivot_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/print_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/randvar_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/sample_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/scale_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/scat_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/segid_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/split_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/test_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/transpose_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/commands/wstat_cmd.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/common/__init__.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/common/copula.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/common/plot.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/common/seed.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/common/seq.py +0 -0
- {dataframe_toolkit-0.7.1 → dataframe_toolkit-0.7.3}/src/dftk/common/stats.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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 =
|
|
252
|
-
|
|
253
|
-
|
|
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 =
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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
|
-
|
|
538
|
-
|
|
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(
|
|
648
|
+
df[c] = df[c].astype(dtype)
|
|
542
649
|
except Exception:
|
|
543
|
-
df[c] = df[c].astype(float).astype(
|
|
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
|
-
|
|
778
|
+
_write_tsv(
|
|
779
|
+
df,
|
|
670
780
|
dest,
|
|
671
781
|
sep="\t",
|
|
672
782
|
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
|