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.
@@ -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)