dataframe-toolkit 0.10.0__tar.gz → 0.11.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.
Files changed (50) hide show
  1. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/PKG-INFO +34 -1
  2. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/README.md +33 -0
  3. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/pyproject.toml +1 -1
  4. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/pyproject.toml.orig +1 -1
  5. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/__init__.py +1 -0
  6. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/eval_cmd.py +148 -23
  7. dataframe_toolkit-0.11.0/src/dftk/commands/implode_cmd.py +189 -0
  8. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/__init__.py +0 -0
  9. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/cli.py +0 -0
  10. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/annotate_cmd.py +0 -0
  11. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/base.py +0 -0
  12. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/binx_cmd.py +0 -0
  13. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/chi_cmd.py +0 -0
  14. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/clean_cmd.py +0 -0
  15. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/cleannames_cmd.py +0 -0
  16. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/concat_cmd.py +0 -0
  17. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/corr_cmd.py +0 -0
  18. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/dataset_cmd.py +0 -0
  19. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/describe_cmd.py +0 -0
  20. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/explode_cmd.py +0 -0
  21. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/fit_cmd.py +0 -0
  22. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/func_cmd.py +0 -0
  23. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/help.py +0 -0
  24. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/hist_cmd.py +0 -0
  25. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/info_cmd.py +0 -0
  26. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/interp_cmd.py +0 -0
  27. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/line_cmd.py +0 -0
  28. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/melt_cmd.py +0 -0
  29. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/merge_cmd.py +0 -0
  30. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/pivot_cmd.py +0 -0
  31. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/print_cmd.py +0 -0
  32. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/query_cmd.py +0 -0
  33. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/randvar_cmd.py +0 -0
  34. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/sample_cmd.py +0 -0
  35. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/scale_cmd.py +0 -0
  36. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/scat_cmd.py +0 -0
  37. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/segid_cmd.py +0 -0
  38. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/split_cmd.py +0 -0
  39. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/stat_cmd.py +0 -0
  40. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/test_cmd.py +0 -0
  41. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/transpose_cmd.py +0 -0
  42. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/commands/wstat_cmd.py +0 -0
  43. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/common/__init__.py +0 -0
  44. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/common/copula.py +0 -0
  45. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/common/io.py +0 -0
  46. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/common/plot.py +0 -0
  47. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/common/seed.py +0 -0
  48. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/common/seq.py +0 -0
  49. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.0}/src/dftk/common/stats.py +0 -0
  50. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.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.10.0
3
+ Version: 0.11.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
@@ -117,6 +117,7 @@ dftk dataset tips -o \
117
117
  | `concat` | Concatenate two or more tables row-wise |
118
118
  | `melt` | Reshape wide-to-long (`pd.melt`) |
119
119
  | `explode` | Split separated values in a cell (`a,b`) into one row per value |
120
+ | `implode` | Join a column's values within groups into one cell, the inverse of `explode` |
120
121
  | `pivot` | Reshape long-to-wide with per-cell aggregation |
121
122
  | `func` | Column transforms: cumsum, group mean/sum/min/max/count/median/std, rank, qcut:N |
122
123
  | `scale` | Normalise: z-score, min-shift, sum/max/mean scaling, Blom rank, regression residuals |
@@ -223,6 +224,38 @@ printf "sample\tfluor\ns1\t60\ns2\t110\n" \
223
224
  | dftk interp - --ref stdcurve.tsv -x fluor --refx fluor -v conc -d conc_ng_ul
224
225
  ```
225
226
 
227
+ ### Filling missing values
228
+
229
+ `eval -f` takes pandas column methods (`COL.method(...)`), and `fillna`,
230
+ `ffill`, `bfill` and `interpolate` fill empty cells. Defaults from another
231
+ table, after a merge (a column in both files gets the suffix `_r`):
232
+
233
+ ```bash
234
+ dftk merge data.tsv -r defaults.tsv -k type -t left -o \
235
+ | dftk eval ... -f "depth = depth.fillna(depth_r)" --drop depth_r
236
+ ```
237
+
238
+ The group mean, a fixed value, or the last value above (`ffill`) within
239
+ each group:
240
+
241
+ ```bash
242
+ dftk dataset penguins -o | dftk eval ... \
243
+ -f "mean = body_mass_g.groupby(species).transform('mean')" \
244
+ -f "body_mass_g = body_mass_g.fillna(mean)" --drop mean \
245
+ -f "sex = sex.fillna('unknown')"
246
+
247
+ dftk dataset healthexp -o \
248
+ | dftk eval ... -f "le = Life_Expectancy.where(Year % 10 != 5)" -o \
249
+ | dftk eval ... -f "le_filled = le.groupby(Country).ffill()"
250
+ ```
251
+
252
+ `ffill`, `bfill` and `interpolate` use the rows in the order they are in:
253
+ sort in a step of its own first (`dftk eval data.tsv --sortasc site day -o
254
+ | dftk eval ... -f ...`), and use `groupby(...)` when the table holds
255
+ several series, or values are taken from the neighbouring group.
256
+ `interpolate()` has no grouped form and works by row position. `dftk eval
257
+ -h` covers these cases, with examples.
258
+
226
259
  ### Bootstrap confidence intervals
227
260
 
228
261
  Bootstrap mode repeats the full stat computation N times on resampled data
@@ -88,6 +88,7 @@ dftk dataset tips -o \
88
88
  | `concat` | Concatenate two or more tables row-wise |
89
89
  | `melt` | Reshape wide-to-long (`pd.melt`) |
90
90
  | `explode` | Split separated values in a cell (`a,b`) into one row per value |
91
+ | `implode` | Join a column's values within groups into one cell, the inverse of `explode` |
91
92
  | `pivot` | Reshape long-to-wide with per-cell aggregation |
92
93
  | `func` | Column transforms: cumsum, group mean/sum/min/max/count/median/std, rank, qcut:N |
93
94
  | `scale` | Normalise: z-score, min-shift, sum/max/mean scaling, Blom rank, regression residuals |
@@ -194,6 +195,38 @@ printf "sample\tfluor\ns1\t60\ns2\t110\n" \
194
195
  | dftk interp - --ref stdcurve.tsv -x fluor --refx fluor -v conc -d conc_ng_ul
195
196
  ```
196
197
 
198
+ ### Filling missing values
199
+
200
+ `eval -f` takes pandas column methods (`COL.method(...)`), and `fillna`,
201
+ `ffill`, `bfill` and `interpolate` fill empty cells. Defaults from another
202
+ table, after a merge (a column in both files gets the suffix `_r`):
203
+
204
+ ```bash
205
+ dftk merge data.tsv -r defaults.tsv -k type -t left -o \
206
+ | dftk eval ... -f "depth = depth.fillna(depth_r)" --drop depth_r
207
+ ```
208
+
209
+ The group mean, a fixed value, or the last value above (`ffill`) within
210
+ each group:
211
+
212
+ ```bash
213
+ dftk dataset penguins -o | dftk eval ... \
214
+ -f "mean = body_mass_g.groupby(species).transform('mean')" \
215
+ -f "body_mass_g = body_mass_g.fillna(mean)" --drop mean \
216
+ -f "sex = sex.fillna('unknown')"
217
+
218
+ dftk dataset healthexp -o \
219
+ | dftk eval ... -f "le = Life_Expectancy.where(Year % 10 != 5)" -o \
220
+ | dftk eval ... -f "le_filled = le.groupby(Country).ffill()"
221
+ ```
222
+
223
+ `ffill`, `bfill` and `interpolate` use the rows in the order they are in:
224
+ sort in a step of its own first (`dftk eval data.tsv --sortasc site day -o
225
+ | dftk eval ... -f ...`), and use `groupby(...)` when the table holds
226
+ several series, or values are taken from the neighbouring group.
227
+ `interpolate()` has no grouped form and works by row position. `dftk eval
228
+ -h` covers these cases, with examples.
229
+
197
230
  ### Bootstrap confidence intervals
198
231
 
199
232
  Bootstrap mode repeats the full stat computation N times on resampled data
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dataframe-toolkit"
3
- version = "0.10.0"
3
+ version = "0.11.0"
4
4
  description = "A command-line toolkit for DataFrame analysis and manipulation"
5
5
  readme = "README.md"
6
6
  keywords = [
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dataframe-toolkit"
3
- version = "0.10.0"
3
+ version = "0.11.0"
4
4
  description = "A command-line toolkit for DataFrame analysis and manipulation"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -31,6 +31,7 @@ COMMAND_GROUPS: list[tuple[str, list[tuple[str, str]]]] = [
31
31
  ("concat", "concat_cmd:ConcatCommand"),
32
32
  ("melt", "melt_cmd:MeltCommand"),
33
33
  ("explode", "explode_cmd:ExplodeCommand"),
34
+ ("implode", "implode_cmd:ImplodeCommand"),
34
35
  ("pivot", "pivot_cmd:PivotCommand"),
35
36
  ("func", "func_cmd:FuncCommand"),
36
37
  ("scale", "scale_cmd:ScaleCommand"),
@@ -14,6 +14,7 @@ import fnmatch
14
14
  import hashlib
15
15
  import logging
16
16
  import os
17
+ import re
17
18
  from functools import partial
18
19
  from pathlib import Path
19
20
 
@@ -44,6 +45,88 @@ arithmetic and boolean expressions directly on column names:
44
45
  dftk dataset tips -o | dftk eval ... -f "bill_and_tip = total_bill + tip * 2"
45
46
  dftk dataset tips -o | dftk eval ... -f "good_tip = (tip > 3) & (total_bill < 30)"
46
47
 
48
+ Column methods are pandas Series methods, written COL.method(...); text
49
+ methods are under .str (name.str.lower(), not name.lower() or tolower()):
50
+
51
+ x = x.abs() x = x.round(1) x = x.clip(lower=0) y = name.str.upper()
52
+
53
+
54
+ FILLING MISSING VALUES (-f)
55
+ ---------------------------
56
+ fillna() fills the empty cells of a column; the other cells are kept:
57
+
58
+ dftk eval data.tsv -f "x = x.fillna(y)" from another column
59
+ -f "x = x.fillna(0)" -f "sex = sex.fillna('unknown')" a fixed value
60
+ -f "x = x.fillna(y).fillna(0)" y first, then 0
61
+ -f "x_filled = x.fillna(y)" keep x, add a new column
62
+ -f "missing = x.isna()" mark the empty cells
63
+
64
+ Defaults from another table: a column that is in both files gets the
65
+ suffix _r from merge, so fill from it and drop it. Here the defaults are
66
+ each species' median body mass:
67
+
68
+ dftk dataset penguins -o | dftk pivot ... -i species -v body_mass_g \\
69
+ -f median --move median_body_mass_g:body_mass_g -o defaults.parquet
70
+ dftk dataset penguins -o \\
71
+ | dftk merge ... -r defaults.parquet -k species -t left -o \\
72
+ | dftk eval ... -f "body_mass_g = body_mass_g.fillna(body_mass_g_r)" \\
73
+ --drop body_mass_g_r
74
+
75
+ The group mean (or 'median', 'min', ...), in one step:
76
+
77
+ dftk dataset penguins -o | dftk eval ... \\
78
+ -f "mean = body_mass_g.groupby(species).transform('mean')" \\
79
+ -f "body_mass_g = body_mass_g.fillna(mean)" --drop mean
80
+
81
+ From neighbouring rows (the co2 dataset is a weekly series with gaps):
82
+
83
+ dftk dataset co2 -o | dftk eval ... -f "ffill = co2.ffill()" \\
84
+ -f "bfill = co2.bfill()" -f "linear = co2.interpolate()"
85
+
86
+ ffill() the last value above; the first rows stay empty
87
+ bfill() the next value below; the last rows stay empty
88
+ ffill(limit=1) fill at most one row after each value
89
+ interpolate() a straight line between the values above and below, by
90
+ row position: rows must be evenly spaced (for x-based
91
+ lookup from a reference curve, see dftk interp)
92
+
93
+ Row order matters: these methods use the rows in the order they are in.
94
+ Sort first, in a step of its own (--sortasc is applied to the output, so
95
+ in the same command it comes after the fill):
96
+
97
+ dftk eval data.tsv --sortasc site day -o | dftk eval ... -f "..."
98
+
99
+ Several series in one table (sites, countries, samples) must be filled
100
+ within each group, or values are taken from the neighbouring group:
101
+
102
+ -f "x = x.groupby(site).ffill()"
103
+ -f "x = x.groupby(site).bfill()"
104
+ -f "x = x.groupby(site).ffill().fillna(x.groupby(site).bfill())"
105
+
106
+ A group's rows don't have to be next to each other, but within each group
107
+ they must be in order. In healthexp, rows are sorted by Year with the
108
+ countries mixed together, so a grouped ffill is right as it is:
109
+
110
+ dftk dataset healthexp -o \\
111
+ | dftk eval ... -f "le = Life_Expectancy.where(Year % 10 != 5)" -o \\
112
+ | dftk eval ... -f "le_filled = le.groupby(Country).ffill()"
113
+
114
+ interpolate() has no grouped form. Sort so that each group's rows are
115
+ together; then gaps inside a group are right, but a gap at the start or
116
+ end of a group is filled across the boundary. To leave those empty, keep
117
+ only values with something before and after them in the same group:
118
+
119
+ dftk dataset healthexp -o \\
120
+ | dftk eval ... -f "le = Life_Expectancy.where(Year % 10 != 5)" \\
121
+ --sortasc Country Year -o \\
122
+ | dftk eval ... -f "before = le.groupby(Country).ffill().notna()" \\
123
+ -f "after = le.groupby(Country).bfill().notna()" \\
124
+ -f "le_linear = le.interpolate().where(before & after)" \\
125
+ --drop before after
126
+
127
+ Only empty cells are filled; missing rows (a year with no row at all) are
128
+ not added.
129
+
47
130
  If df.eval() fails (e.g. for function calls), the expression is retried
48
131
  as a special function call using the syntax:
49
132
 
@@ -516,6 +599,68 @@ def _string_function(formula: str, joinsep: str):
516
599
  # ---------------------------------------------------------------------------
517
600
 
518
601
 
602
+ def _formula_error(formula: str, exc: Exception) -> str:
603
+ """Return a short message for a mistake pandas found in an -f formula."""
604
+ msg = str(exc)
605
+ m = re.search(r"'Series' object has no attribute '(\w+)'", msg)
606
+ if m:
607
+ name = m.group(1)
608
+ col = re.search(rf"(\w+)\.{name}\b", formula)
609
+ col = col.group(1) if col else "COL"
610
+ if hasattr(pd.Series.str, name):
611
+ hint = f"Text methods are under .str: {col}.str.{name}()."
612
+ else:
613
+ hint = f"Text methods are under .str, e.g. {col}.str.lower()."
614
+ return (
615
+ f"-f {formula!r}: column has no method {name!r}. {hint} See dftk eval -h."
616
+ )
617
+ return f"-f {formula!r}: {msg}"
618
+
619
+
620
+ def _apply_special_function(df: pd.DataFrame, formula: str) -> None:
621
+ """Evaluate a special-function formula and store its column in *df*."""
622
+ dest, func, cols = _special_function(formula, list(df.columns))
623
+ if len(df) == 0:
624
+ df[dest] = True
625
+ elif isinstance(func, _WhereFunc):
626
+ df[dest] = func(df)
627
+ elif isinstance(func, _FrameFunc):
628
+ # Frame-level functions (idxmax/idxmin) need column labels
629
+ df[dest] = func(df[cols])
630
+ elif len(cols) == 1:
631
+ # Element-wise on a single column (e.g. np.sign, os.path.*)
632
+ df[dest] = df[cols[0]].apply(func)
633
+ else:
634
+ # Multi-column row function: pass numpy array so positional
635
+ # indexing (row[0], row[1], ...) works in pandas 2.x
636
+ df[dest] = df[cols].apply(lambda row, f=func: f(row.to_numpy()), axis=1)
637
+
638
+
639
+ def _eval_formula(df: pd.DataFrame, formula: str) -> pd.DataFrame:
640
+ """Evaluate one -f formula: pandas df.eval(), else a special function."""
641
+ try:
642
+ # Not inplace: that writes into the existing column, which must keep
643
+ # its dtype, so x = x.fillna(y) or x = x.isna() failed whenever the
644
+ # result had another dtype
645
+ return df.eval(formula)
646
+ except (AttributeError, TypeError, NameError, KeyError) as exc:
647
+ # pandas ran the expression: a mistake in it, not a dftk bug
648
+ raise ValueError(_formula_error(formula, exc)) from exc
649
+ except (ValueError, SyntaxError) as eval_exc:
650
+ if re.match(r"\s*\w+\s*=\s*\w+\.", formula):
651
+ # dest = col.method(...) is never a special function
652
+ raise ValueError(_formula_error(formula, eval_exc)) from eval_exc
653
+ logger.debug(f"df.eval failed for {formula!r}, trying special_function")
654
+ try:
655
+ _apply_special_function(df, formula)
656
+ except ValueError as exc:
657
+ raise ValueError(f"{exc} (pandas df.eval: {eval_exc})") from exc
658
+ except Exception:
659
+ logger.error(f"Could not evaluate formula: {formula!r}")
660
+ raise
661
+ return df
662
+
663
+
519
664
  def _eval(df: pd.DataFrame, args: argparse.Namespace) -> pd.DataFrame:
520
665
  joinsep: str = getattr(args, "joinsep", "")
521
666
  intbool: bool = getattr(args, "intbool", False)
@@ -536,31 +681,11 @@ def _eval(df: pd.DataFrame, args: argparse.Namespace) -> pd.DataFrame:
536
681
  # -f: pandas eval with special-function fallback
537
682
  for formula in getattr(args, "formula", []):
538
683
  try:
539
- df.eval(formula, inplace=True)
540
- except (ValueError, SyntaxError):
541
- logger.debug(f"df.eval failed for {formula!r}, trying special_function")
542
- dest, func, cols = _special_function(formula, list(df.columns))
543
- if len(df) == 0:
544
- df[dest] = True
545
- elif isinstance(func, _WhereFunc):
546
- df[dest] = func(df)
547
- elif isinstance(func, _FrameFunc):
548
- # Frame-level functions (idxmax/idxmin) need column labels
549
- df[dest] = func(df[cols])
550
- elif len(cols) == 1:
551
- # Element-wise on a single column (e.g. np.sign, os.path.*)
552
- df[dest] = df[cols[0]].apply(func)
553
- else:
554
- # Multi-column row function: pass numpy array so positional
555
- # indexing (row[0], row[1], ...) works in pandas 2.x
556
- df[dest] = df[cols].apply(lambda row, f=func: f(row.to_numpy()), axis=1)
684
+ df = _eval_formula(df, formula)
557
685
  except Exception:
558
- if len(df) == 0:
559
- dest = formula.split("=", 1)[0].strip()
560
- df[dest] = True
561
- else:
562
- logger.error(f"Could not evaluate formula: {formula!r}")
686
+ if len(df) != 0:
563
687
  raise
688
+ df[formula.split("=", 1)[0].strip()] = True
564
689
 
565
690
  # -s: string / row functions
566
691
  for strfunc in getattr(args, "strfunc", []):
@@ -0,0 +1,189 @@
1
+ """
2
+ dftk.commands.implode_cmd — dftk implode subcommand.
3
+
4
+ The inverse of explode: join the values of a column within each group of
5
+ rows into one cell ("GO:1,GO:2"). Several columns are joined in the same
6
+ row order, so explode -c a b followed by implode -c a b gives back the
7
+ original rows.
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 breast
22
+ BRCA1 GO:2 breast
23
+ TP53 GO:3 colon
24
+
25
+ dftk implode data.tsv -c go
26
+
27
+ gene go tissue
28
+ BRCA1 GO:1,GO:2 breast
29
+ TP53 GO:3 colon
30
+
31
+ By default rows are grouped by all the other columns, so rows are only
32
+ joined when everything else in them is the same. With -i, rows are grouped
33
+ by those columns only and the other columns are dropped. Rows with an
34
+ empty key form a group of their own.
35
+
36
+ Values are joined in the order of the rows; --sortvalues sorts them and
37
+ --unique drops repeats. Empty values are left out, and a group with none
38
+ gets an empty cell. Whole numbers are written without ".0" when every
39
+ value in the column is one (pandas reads integers with gaps as floats).
40
+
41
+ Several columns are joined row by row, so their n-th items still belong
42
+ together; --unique and --sortvalues then work on whole rows.
43
+
44
+ Runnable version: each species' islands, one cell per species:
45
+
46
+ dftk dataset penguins -o \\
47
+ | dftk implode ... -c island -i species --unique --sortvalues
48
+
49
+ Numbers, with another separator: the party sizes seen on each day:
50
+
51
+ dftk dataset tips -o \\
52
+ | dftk implode ... -c size -i day --unique --sortvalues --sep '|'
53
+
54
+ Undoing explode: --poscol puts the items back in their original order,
55
+ here after shuffling the rows, and is dropped from the output:
56
+
57
+ dftk dataset penguins -o \\
58
+ | dftk implode ... -c island -i species --unique -o \\
59
+ | dftk explode ... -c island --poscol n -o \\
60
+ | dftk sample ... -f 1 --randomseed 1 -o \\
61
+ | dftk implode ... -c island --poscol n
62
+ """
63
+
64
+
65
+ def _as_text(s: pd.Series) -> pd.Series:
66
+ """Return *s* as strings, keeping missing values missing."""
67
+ values = s.dropna()
68
+ if (
69
+ pd.api.types.is_float_dtype(s)
70
+ and len(values)
71
+ and (values == values.round()).all()
72
+ ):
73
+ # Integers with gaps are read as floats: write 10, not 10.0
74
+ s = s.astype("Int64")
75
+ text = s.astype(object).where(s.notna())
76
+ text = text.map(lambda v: v if pd.isna(v) else str(v))
77
+ return text.where(text != "")
78
+
79
+
80
+ def _implode(df: pd.DataFrame, args: argparse.Namespace) -> pd.DataFrame:
81
+ cols = args.cols
82
+ poscol = args.poscol
83
+ if len(set(cols)) != len(cols):
84
+ raise ValueError(f"-c/--cols lists a column more than once: {cols}")
85
+ if poscol is not None and poscol in cols:
86
+ raise ValueError(f"--poscol {poscol!r} is also in -c/--cols")
87
+
88
+ if args.indexcols is not None:
89
+ keys = list(args.indexcols)
90
+ overlap = [c for c in keys if c in cols or c == poscol]
91
+ if overlap:
92
+ raise ValueError(
93
+ f"-i/--indexcols overlaps -c/--cols or --poscol: {overlap}"
94
+ )
95
+ else:
96
+ keys = [c for c in df.columns if c not in cols and c != poscol]
97
+
98
+ order = [c for c in df.columns if c in keys or c in cols]
99
+ work = df[keys + cols + ([poscol] if poscol else [])].reset_index(drop=True)
100
+ if keys:
101
+ work["_gid"] = work.groupby(keys, sort=False, dropna=False).ngroup()
102
+ else:
103
+ work["_gid"] = 0
104
+
105
+ if args.sortvalues:
106
+ work = work.sort_values(["_gid", *cols], kind="stable", na_position="last")
107
+ elif poscol:
108
+ work = work.sort_values(["_gid", poscol], kind="stable")
109
+ else:
110
+ work = work.sort_values("_gid", kind="stable")
111
+ if args.unique:
112
+ work = work.drop_duplicates(subset=["_gid", *cols])
113
+
114
+ for col in cols:
115
+ work[col] = _as_text(work[col])
116
+ grouped = work.groupby("_gid", sort=True)
117
+ joined = grouped[cols].agg(lambda s: args.sep.join(s.dropna()))
118
+ joined = joined.where(joined != "")
119
+ result = grouped[keys].first().join(joined) if keys else joined
120
+ return result.reset_index(drop=True)[order]
121
+
122
+
123
+ class ImplodeCommand(BaseCommand):
124
+ @property
125
+ def name(self) -> str:
126
+ return "implode"
127
+
128
+ @property
129
+ def help(self) -> str:
130
+ return "Join a column's values within groups of rows into one cell"
131
+
132
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
133
+ parser.formatter_class = argparse.RawDescriptionHelpFormatter
134
+ parser.epilog = _EPILOG
135
+
136
+ self.add_io_arguments(parser)
137
+
138
+ g = parser.add_argument_group("implode options")
139
+ g.add_argument(
140
+ "-c",
141
+ "--cols",
142
+ nargs="+",
143
+ required=True,
144
+ metavar="COL",
145
+ help="Column(s) whose values are joined. Several columns are "
146
+ "joined in the same row order.",
147
+ )
148
+ g.add_argument(
149
+ "-i",
150
+ "--indexcols",
151
+ nargs="+",
152
+ default=None,
153
+ metavar="COL",
154
+ help="Group by these columns and drop the others "
155
+ "(default: group by all columns not in -c or --poscol).",
156
+ )
157
+ g.add_argument(
158
+ "--sep",
159
+ default=",",
160
+ metavar="SEP",
161
+ help="Separator between joined values (default: ,).",
162
+ )
163
+ g.add_argument(
164
+ "--poscol",
165
+ default=None,
166
+ metavar="NAME",
167
+ help="Join the values in the order of column NAME (e.g. the "
168
+ "positions from explode --poscol) and drop it.",
169
+ )
170
+ g.add_argument(
171
+ "--unique",
172
+ action="store_true",
173
+ help="Drop repeated values (with several -c columns, repeated rows).",
174
+ )
175
+ g.add_argument(
176
+ "--sortvalues",
177
+ action="store_true",
178
+ help="Sort the values in each cell (with several -c columns, "
179
+ "sort whole rows by them).",
180
+ )
181
+
182
+ def execute(self, args: argparse.Namespace) -> None:
183
+ df = io.read(args)
184
+ check_cols(df, args.cols, "-c/--cols")
185
+ check_cols(df, args.indexcols, "-i/--indexcols")
186
+ if args.poscol is not None:
187
+ check_cols(df, [args.poscol], "--poscol")
188
+ result = _implode(df, args)
189
+ io.printdf(result, args)