dataframe-toolkit 0.4.1__py3-none-any.whl
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.4.1.dist-info/METADATA +230 -0
- dataframe_toolkit-0.4.1.dist-info/RECORD +42 -0
- dataframe_toolkit-0.4.1.dist-info/WHEEL +4 -0
- dataframe_toolkit-0.4.1.dist-info/entry_points.txt +3 -0
- dftk/__init__.py +4 -0
- dftk/cli.py +72 -0
- dftk/commands/__init__.py +100 -0
- dftk/commands/annotate_cmd.py +165 -0
- dftk/commands/base.py +85 -0
- dftk/commands/binx_cmd.py +130 -0
- dftk/commands/clean_cmd.py +51 -0
- dftk/commands/concat_cmd.py +165 -0
- dftk/commands/corr_cmd.py +281 -0
- dftk/commands/dataset_cmd.py +181 -0
- dftk/commands/describe_cmd.py +360 -0
- dftk/commands/eval_cmd.py +657 -0
- dftk/commands/fit_cmd.py +265 -0
- dftk/commands/func_cmd.py +186 -0
- dftk/commands/help.py +63 -0
- dftk/commands/hist_cmd.py +321 -0
- dftk/commands/interp_cmd.py +271 -0
- dftk/commands/line_cmd.py +320 -0
- dftk/commands/melt_cmd.py +78 -0
- dftk/commands/merge_cmd.py +373 -0
- dftk/commands/pivot_cmd.py +438 -0
- dftk/commands/print_cmd.py +36 -0
- dftk/commands/query_cmd.py +230 -0
- dftk/commands/randvar_cmd.py +199 -0
- dftk/commands/sample_cmd.py +126 -0
- dftk/commands/scale_cmd.py +299 -0
- dftk/commands/scat_cmd.py +278 -0
- dftk/commands/split_cmd.py +130 -0
- dftk/commands/stat_cmd.py +289 -0
- dftk/commands/test_cmd.py +235 -0
- dftk/commands/wstat_cmd.py +163 -0
- dftk/common/__init__.py +8 -0
- dftk/common/io.py +690 -0
- dftk/common/plot.py +566 -0
- dftk/common/seed.py +25 -0
- dftk/common/seq.py +150 -0
- dftk/common/stats.py +214 -0
- dftk/io.py +10 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dftk.commands.merge_cmd — dftk merge subcommand.
|
|
3
|
+
|
|
4
|
+
Port of dfmerg.py, adapted for the BaseCommand pattern and the dftk
|
|
5
|
+
common.io module.
|
|
6
|
+
|
|
7
|
+
Key features
|
|
8
|
+
------------
|
|
9
|
+
* Inner, left, right, outer, and cross joins (pandas merge under the hood).
|
|
10
|
+
* Common-key merge (-k) or mapped-key merge (-lo / -ro).
|
|
11
|
+
* Duplicate column names automatically receive a ``_r`` suffix on the right
|
|
12
|
+
side (or ``_l`` on the left when --only right is active).
|
|
13
|
+
* --only left/right performs an outer join and returns only rows that appear
|
|
14
|
+
exclusively on the specified side (anti-join).
|
|
15
|
+
* --select [:left:] expands to all left-file columns, suppressing the ``_r``
|
|
16
|
+
suffix on any duplicates.
|
|
17
|
+
* --select [:right:] expands to all right-file columns, suppressing ``_l``.
|
|
18
|
+
* Both input files can be TSV or Parquet (.parquet extension auto-detected).
|
|
19
|
+
The left file defaults to stdin, making it parquet-pipe friendly.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import logging
|
|
24
|
+
import uuid
|
|
25
|
+
|
|
26
|
+
import pandas as pd
|
|
27
|
+
|
|
28
|
+
from dftk.commands.base import BaseCommand
|
|
29
|
+
from dftk.common.io import check_cols, io
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Core helpers
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _set_suffixes(args: argparse.Namespace) -> None:
|
|
40
|
+
"""Configure merge suffix and indicator settings on *args*."""
|
|
41
|
+
args.suffixes = ("", "_r")
|
|
42
|
+
args.indicator = False
|
|
43
|
+
|
|
44
|
+
if args.type == "cross":
|
|
45
|
+
args.only = None # cross join does not support --only
|
|
46
|
+
|
|
47
|
+
if args.only is not None:
|
|
48
|
+
args.type = "outer"
|
|
49
|
+
args.indicator = True
|
|
50
|
+
args.filtertag = f"{args.only}_only"
|
|
51
|
+
if args.only == "right":
|
|
52
|
+
args.suffixes = ("_l", "")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _expand_select(
|
|
56
|
+
left_cols: list[str],
|
|
57
|
+
right_cols: list[str],
|
|
58
|
+
args: argparse.Namespace,
|
|
59
|
+
) -> tuple[str, str]:
|
|
60
|
+
"""Expand ``[:left:]`` / ``[:right:]`` tokens in ``args.select``.
|
|
61
|
+
|
|
62
|
+
Mutates ``args.select`` in-place, replacing the token with the actual
|
|
63
|
+
column names from that side. Returns the ``(left_suffix, right_suffix)``
|
|
64
|
+
tuple to use for the merge so that the expanded column names never carry
|
|
65
|
+
an unwanted suffix.
|
|
66
|
+
|
|
67
|
+
If no token is present, returns ``args.suffixes`` unchanged.
|
|
68
|
+
"""
|
|
69
|
+
select: list[str] | None = getattr(args, "select", None)
|
|
70
|
+
if select is None:
|
|
71
|
+
return args.suffixes
|
|
72
|
+
|
|
73
|
+
if "[:left:]" in select and "[:right:]" in select:
|
|
74
|
+
raise ValueError("Cannot use both [:left:] and [:right:] in --select")
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
idx = select.index("[:left:]")
|
|
78
|
+
select[idx : idx + 1] = list(left_cols)
|
|
79
|
+
return ("", "_r")
|
|
80
|
+
except ValueError:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
idx = select.index("[:right:]")
|
|
85
|
+
select[idx : idx + 1] = list(right_cols)
|
|
86
|
+
return ("_l", "")
|
|
87
|
+
except ValueError:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
return args.suffixes
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _add_cross_col(
|
|
94
|
+
df_left: pd.DataFrame,
|
|
95
|
+
df_right: pd.DataFrame,
|
|
96
|
+
args: argparse.Namespace,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Add a temporary UUID key column to both frames for a cross join."""
|
|
99
|
+
colname = uuid.uuid1().hex
|
|
100
|
+
df_left[colname] = 1
|
|
101
|
+
df_right[colname] = 1
|
|
102
|
+
args.type = "inner"
|
|
103
|
+
args.keys = [colname]
|
|
104
|
+
# Schedule the helper column for removal via io.printdf --drop
|
|
105
|
+
drop = list(getattr(args, "drop", None) or [])
|
|
106
|
+
drop.append(colname)
|
|
107
|
+
args.drop = drop
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _filter_only(
|
|
111
|
+
df: pd.DataFrame,
|
|
112
|
+
df_left: pd.DataFrame,
|
|
113
|
+
df_right: pd.DataFrame,
|
|
114
|
+
args: argparse.Namespace,
|
|
115
|
+
) -> pd.DataFrame:
|
|
116
|
+
"""For ``--only`` mode: filter to rows exclusive to one side.
|
|
117
|
+
|
|
118
|
+
The pandas ``_merge`` indicator column is removed. If ``--select`` was
|
|
119
|
+
not provided, the output is restricted to columns from the chosen side.
|
|
120
|
+
"""
|
|
121
|
+
if args.only is None:
|
|
122
|
+
return df
|
|
123
|
+
|
|
124
|
+
df = df[df["_merge"] == args.filtertag].copy()
|
|
125
|
+
df = df.drop(columns=["_merge"])
|
|
126
|
+
|
|
127
|
+
# Default column selection: columns that came from the chosen side
|
|
128
|
+
if getattr(args, "select", None) is None:
|
|
129
|
+
if args.only == "left":
|
|
130
|
+
args.select = [c for c in df_left.columns if c in df.columns]
|
|
131
|
+
else:
|
|
132
|
+
args.select = [c for c in df_right.columns if c in df.columns]
|
|
133
|
+
|
|
134
|
+
return df
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _do_merge(
|
|
138
|
+
df_left: pd.DataFrame,
|
|
139
|
+
df_right: pd.DataFrame,
|
|
140
|
+
args: argparse.Namespace,
|
|
141
|
+
) -> pd.DataFrame:
|
|
142
|
+
"""Perform the merge and return the combined DataFrame."""
|
|
143
|
+
is_cross = args.type == "cross"
|
|
144
|
+
if is_cross:
|
|
145
|
+
_add_cross_col(df_left, df_right, args)
|
|
146
|
+
elif args.keys is not None:
|
|
147
|
+
check_cols(df_left, args.keys, "-k (left file)")
|
|
148
|
+
check_cols(df_right, args.keys, "-k (right file)")
|
|
149
|
+
elif args.left_on is not None or args.right_on is not None:
|
|
150
|
+
check_cols(df_left, args.left_on, "-lo/--left_on")
|
|
151
|
+
check_cols(df_right, args.right_on, "-ro/--right_on")
|
|
152
|
+
|
|
153
|
+
suffixes = _expand_select(list(df_left.columns), list(df_right.columns), args)
|
|
154
|
+
|
|
155
|
+
if args.keys is not None:
|
|
156
|
+
df_merged = pd.merge(
|
|
157
|
+
df_left,
|
|
158
|
+
df_right,
|
|
159
|
+
on=args.keys,
|
|
160
|
+
how=args.type,
|
|
161
|
+
indicator=args.indicator,
|
|
162
|
+
suffixes=suffixes,
|
|
163
|
+
)
|
|
164
|
+
elif args.left_on is not None and args.right_on is not None:
|
|
165
|
+
df_merged = pd.merge(
|
|
166
|
+
df_left,
|
|
167
|
+
df_right,
|
|
168
|
+
left_on=args.left_on,
|
|
169
|
+
right_on=args.right_on,
|
|
170
|
+
how=args.type,
|
|
171
|
+
indicator=args.indicator,
|
|
172
|
+
suffixes=suffixes,
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
raise ValueError(
|
|
176
|
+
"Specify -k/--keys for common columns, "
|
|
177
|
+
"or both -lo and -ro for mapped columns."
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
df_merged = _filter_only(df_merged, df_left, df_right, args)
|
|
181
|
+
|
|
182
|
+
# Safety: drop _merge indicator if somehow still present
|
|
183
|
+
if "_merge" in df_merged.columns:
|
|
184
|
+
df_merged = df_merged.drop(columns=["_merge"])
|
|
185
|
+
|
|
186
|
+
return df_merged
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
# Command class
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
_EPILOG = """\
|
|
194
|
+
MERGE MODES
|
|
195
|
+
-----------
|
|
196
|
+
Common-key merge (key column has the same name in both files):
|
|
197
|
+
|
|
198
|
+
dftk merge left.tsv -r right.tsv -k id
|
|
199
|
+
cat left.tsv | dftk merge -r right.tsv -k id -o | dftk stat -c value
|
|
200
|
+
|
|
201
|
+
Mapped-key merge (key columns have different names):
|
|
202
|
+
|
|
203
|
+
dftk merge left.tsv -r right.tsv -lo person_id -ro id
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
JOIN TYPES (-t / --type)
|
|
207
|
+
--------------------------
|
|
208
|
+
inner (default) Only rows with matching keys in both files.
|
|
209
|
+
left All left rows; NaN where right has no match.
|
|
210
|
+
right All right rows; NaN where left has no match.
|
|
211
|
+
outer All rows from both files; NaN where no match.
|
|
212
|
+
cross Cartesian product (no keys required or allowed).
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
ANTI-JOIN (--only)
|
|
216
|
+
--------------------
|
|
217
|
+
Return rows that appear exclusively in one file:
|
|
218
|
+
|
|
219
|
+
dftk merge left.tsv -r right.tsv -k id --only left
|
|
220
|
+
dftk merge left.tsv -r right.tsv -k id --only right
|
|
221
|
+
|
|
222
|
+
Internally this runs an outer join and filters rows where the pandas
|
|
223
|
+
_merge indicator equals "left_only" or "right_only".
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
COLUMN SELECTION FROM ONE SIDE (--select)
|
|
227
|
+
------------------------------------------
|
|
228
|
+
When both files share column names beyond the key, the right-side copy
|
|
229
|
+
receives a ``_r`` suffix by default. To output only one side's columns
|
|
230
|
+
without any suffix pollution, use the special tokens:
|
|
231
|
+
|
|
232
|
+
--select [:left:] All left-file columns (no _r suffix needed).
|
|
233
|
+
--select [:right:] All right-file columns (no _l suffix needed).
|
|
234
|
+
|
|
235
|
+
Tokens can be mixed with explicit names:
|
|
236
|
+
|
|
237
|
+
--select id score [:left:]
|
|
238
|
+
|
|
239
|
+
These tokens are expanded before the merge, so the resulting column list
|
|
240
|
+
is what you see in the output.
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
PARQUET INPUT AND SELF-MERGE
|
|
244
|
+
-----------------------------
|
|
245
|
+
Both files accept .parquet paths; the extension is detected automatically.
|
|
246
|
+
The left file defaults to stdin, making it parquet-pipe friendly.
|
|
247
|
+
|
|
248
|
+
Self-merge — join a dataframe with itself — is supported with -r - :
|
|
249
|
+
|
|
250
|
+
cat data.tsv | dftk eval "y = sqrt(x)" -o | dftk merge -l - -r - -k x
|
|
251
|
+
|
|
252
|
+
When both -l and -r are -, stdin is read once and df_right is a copy of
|
|
253
|
+
df_left. This works naturally with the parquet-pipe: the previous command
|
|
254
|
+
writes a temp parquet and prints its path; dftk merge reads that path,
|
|
255
|
+
loads the file, and reuses it for both sides.
|
|
256
|
+
|
|
257
|
+
Named-file self-merge (no stdin required):
|
|
258
|
+
|
|
259
|
+
dftk merge data.tsv -r data.tsv -k id --only left
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class MergeCommand(BaseCommand):
|
|
264
|
+
name = "merge"
|
|
265
|
+
help = "Join two dataframes on common or mapped keys."
|
|
266
|
+
|
|
267
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
268
|
+
parser.formatter_class = argparse.RawDescriptionHelpFormatter
|
|
269
|
+
parser.epilog = _EPILOG
|
|
270
|
+
|
|
271
|
+
# Left file via the standard DATAFILE positional (stdin-friendly).
|
|
272
|
+
io.parser_read(parser)
|
|
273
|
+
io.parser_output(parser)
|
|
274
|
+
|
|
275
|
+
g = parser.add_argument_group("merge options")
|
|
276
|
+
g.add_argument(
|
|
277
|
+
"-l",
|
|
278
|
+
"--leftfile",
|
|
279
|
+
default=None,
|
|
280
|
+
metavar="FILE",
|
|
281
|
+
help=(
|
|
282
|
+
"Left-hand input file (TSV or .parquet), or - for stdin. "
|
|
283
|
+
"Overrides the positional DATAFILE argument when provided."
|
|
284
|
+
),
|
|
285
|
+
)
|
|
286
|
+
g.add_argument(
|
|
287
|
+
"-r",
|
|
288
|
+
"--rightfile",
|
|
289
|
+
default=None,
|
|
290
|
+
metavar="FILE",
|
|
291
|
+
help=(
|
|
292
|
+
"Right-hand input file (TSV or .parquet), or - for stdin. "
|
|
293
|
+
"When - is used and the left file is also stdin, the left "
|
|
294
|
+
"DataFrame is reused as-is (self-join / self-merge)."
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
g.add_argument(
|
|
298
|
+
"-k",
|
|
299
|
+
"--keys",
|
|
300
|
+
nargs="+",
|
|
301
|
+
default=None,
|
|
302
|
+
metavar="COL",
|
|
303
|
+
help="Column name(s) shared by both files on which to merge.",
|
|
304
|
+
)
|
|
305
|
+
g.add_argument(
|
|
306
|
+
"-lo",
|
|
307
|
+
"--left_on",
|
|
308
|
+
nargs="+",
|
|
309
|
+
default=None,
|
|
310
|
+
metavar="COL",
|
|
311
|
+
help="Left-file key column(s) when names differ from the right file.",
|
|
312
|
+
)
|
|
313
|
+
g.add_argument(
|
|
314
|
+
"-ro",
|
|
315
|
+
"--right_on",
|
|
316
|
+
nargs="+",
|
|
317
|
+
default=None,
|
|
318
|
+
metavar="COL",
|
|
319
|
+
help="Right-file key column(s) when names differ from the left file.",
|
|
320
|
+
)
|
|
321
|
+
g.add_argument(
|
|
322
|
+
"-t",
|
|
323
|
+
"--type",
|
|
324
|
+
choices=["inner", "left", "right", "outer", "cross"],
|
|
325
|
+
default="inner",
|
|
326
|
+
help="Join type (default: inner).",
|
|
327
|
+
)
|
|
328
|
+
g.add_argument(
|
|
329
|
+
"--only",
|
|
330
|
+
choices=["left", "right"],
|
|
331
|
+
default=None,
|
|
332
|
+
help=(
|
|
333
|
+
"Return rows exclusive to one side (anti-join). Implies --type outer."
|
|
334
|
+
),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
def execute(self, args: argparse.Namespace) -> None:
|
|
338
|
+
if args.rightfile is None:
|
|
339
|
+
raise ValueError("Specify -r FILE (or -r - for self-join).")
|
|
340
|
+
|
|
341
|
+
# -l overrides the positional DATAFILE when provided
|
|
342
|
+
if args.leftfile is not None:
|
|
343
|
+
args.DATAFILE = args.leftfile
|
|
344
|
+
|
|
345
|
+
left_was_stdin = args.DATAFILE in (None, "-")
|
|
346
|
+
|
|
347
|
+
# Read left file (positional DATAFILE, -l, or stdin)
|
|
348
|
+
try:
|
|
349
|
+
df_left = io.read(args)
|
|
350
|
+
except Exception:
|
|
351
|
+
logger.error("Failed to read left (primary) input file")
|
|
352
|
+
raise
|
|
353
|
+
|
|
354
|
+
_set_suffixes(args)
|
|
355
|
+
|
|
356
|
+
# Self-join: -r - reuses the left DataFrame (stdin already consumed)
|
|
357
|
+
if args.rightfile == "-":
|
|
358
|
+
if not left_was_stdin:
|
|
359
|
+
logger.warning(
|
|
360
|
+
"-r - used but left file was not stdin; "
|
|
361
|
+
"treating as self-join (copy of left)."
|
|
362
|
+
)
|
|
363
|
+
df_right = df_left.copy()
|
|
364
|
+
else:
|
|
365
|
+
args.DATAFILE = args.rightfile
|
|
366
|
+
try:
|
|
367
|
+
df_right = io.read(args)
|
|
368
|
+
except Exception:
|
|
369
|
+
logger.error(f"Failed to read right file: {args.rightfile!r}")
|
|
370
|
+
raise
|
|
371
|
+
|
|
372
|
+
df_merged = _do_merge(df_left, df_right, args)
|
|
373
|
+
io.printdf(df_merged, args)
|