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