dataframe-toolkit 0.10.0__tar.gz → 0.11.1__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.1}/PKG-INFO +34 -1
  2. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/README.md +33 -0
  3. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/pyproject.toml +1 -1
  4. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/pyproject.toml.orig +1 -1
  5. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/__init__.py +1 -0
  6. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/eval_cmd.py +189 -26
  7. dataframe_toolkit-0.11.1/src/dftk/commands/implode_cmd.py +194 -0
  8. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/__init__.py +0 -0
  9. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/cli.py +0 -0
  10. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/annotate_cmd.py +0 -0
  11. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/base.py +0 -0
  12. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/binx_cmd.py +0 -0
  13. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/chi_cmd.py +0 -0
  14. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/clean_cmd.py +0 -0
  15. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/cleannames_cmd.py +0 -0
  16. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/concat_cmd.py +0 -0
  17. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/corr_cmd.py +0 -0
  18. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/dataset_cmd.py +0 -0
  19. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/describe_cmd.py +0 -0
  20. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/explode_cmd.py +0 -0
  21. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/fit_cmd.py +0 -0
  22. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/func_cmd.py +0 -0
  23. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/help.py +0 -0
  24. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/hist_cmd.py +0 -0
  25. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/info_cmd.py +0 -0
  26. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/interp_cmd.py +0 -0
  27. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/line_cmd.py +0 -0
  28. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/melt_cmd.py +0 -0
  29. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/merge_cmd.py +0 -0
  30. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/pivot_cmd.py +0 -0
  31. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/print_cmd.py +0 -0
  32. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/query_cmd.py +0 -0
  33. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/randvar_cmd.py +0 -0
  34. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/sample_cmd.py +0 -0
  35. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/scale_cmd.py +0 -0
  36. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/scat_cmd.py +0 -0
  37. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/segid_cmd.py +0 -0
  38. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/split_cmd.py +0 -0
  39. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/stat_cmd.py +0 -0
  40. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/test_cmd.py +0 -0
  41. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/transpose_cmd.py +0 -0
  42. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/commands/wstat_cmd.py +0 -0
  43. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/common/__init__.py +0 -0
  44. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/common/copula.py +0 -0
  45. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/common/io.py +0 -0
  46. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/common/plot.py +0 -0
  47. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/common/seed.py +0 -0
  48. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/common/seq.py +0 -0
  49. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/src/dftk/common/stats.py +0 -0
  50. {dataframe_toolkit-0.10.0 → dataframe_toolkit-0.11.1}/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.1
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.1"
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.1"
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
 
@@ -34,6 +35,12 @@ try:
34
35
  except ImportError:
35
36
  _HAS_LEVENSHTEIN = False
36
37
 
38
+
39
+ class _FormulaMistake(ValueError):
40
+ """A mistake in a formula whatever the column types (an unknown name,
41
+ bad syntax, no assignment): reported even on an empty table."""
42
+
43
+
37
44
  _EPILOG = """\
38
45
  FORMULA EXPRESSIONS (-f)
39
46
  -------------------------
@@ -44,6 +51,88 @@ arithmetic and boolean expressions directly on column names:
44
51
  dftk dataset tips -o | dftk eval ... -f "bill_and_tip = total_bill + tip * 2"
45
52
  dftk dataset tips -o | dftk eval ... -f "good_tip = (tip > 3) & (total_bill < 30)"
46
53
 
54
+ Column methods are pandas Series methods, written COL.method(...); text
55
+ methods are under .str (name.str.lower(), not name.lower() or tolower()):
56
+
57
+ x = x.abs() x = x.round(1) x = x.clip(lower=0) y = name.str.upper()
58
+
59
+
60
+ FILLING MISSING VALUES (-f)
61
+ ---------------------------
62
+ fillna() fills the empty cells of a column; the other cells are kept:
63
+
64
+ dftk eval data.tsv -f "x = x.fillna(y)" from another column
65
+ -f "x = x.fillna(0)" -f "sex = sex.fillna('unknown')" a fixed value
66
+ -f "x = x.fillna(y).fillna(0)" y first, then 0
67
+ -f "x_filled = x.fillna(y)" keep x, add a new column
68
+ -f "missing = x.isna()" mark the empty cells
69
+
70
+ Defaults from another table: a column that is in both files gets the
71
+ suffix _r from merge, so fill from it and drop it. Here the defaults are
72
+ each species' median body mass:
73
+
74
+ dftk dataset penguins -o | dftk pivot ... -i species -v body_mass_g \\
75
+ -f median --move median_body_mass_g:body_mass_g -o defaults.parquet
76
+ dftk dataset penguins -o \\
77
+ | dftk merge ... -r defaults.parquet -k species -t left -o \\
78
+ | dftk eval ... -f "body_mass_g = body_mass_g.fillna(body_mass_g_r)" \\
79
+ --drop body_mass_g_r
80
+
81
+ The group mean (or 'median', 'min', ...), in one step:
82
+
83
+ dftk dataset penguins -o | dftk eval ... \\
84
+ -f "mean = body_mass_g.groupby(species).transform('mean')" \\
85
+ -f "body_mass_g = body_mass_g.fillna(mean)" --drop mean
86
+
87
+ From neighbouring rows (the co2 dataset is a weekly series with gaps):
88
+
89
+ dftk dataset co2 -o | dftk eval ... -f "ffill = co2.ffill()" \\
90
+ -f "bfill = co2.bfill()" -f "linear = co2.interpolate()"
91
+
92
+ ffill() the last value above; the first rows stay empty
93
+ bfill() the next value below; the last rows stay empty
94
+ ffill(limit=1) fill at most one row after each value
95
+ interpolate() a straight line between the values above and below, by
96
+ row position: rows must be evenly spaced (for x-based
97
+ lookup from a reference curve, see dftk interp)
98
+
99
+ Row order matters: these methods use the rows in the order they are in.
100
+ Sort first, in a step of its own (--sortasc is applied to the output, so
101
+ in the same command it comes after the fill):
102
+
103
+ dftk eval data.tsv --sortasc site day -o | dftk eval ... -f "..."
104
+
105
+ Several series in one table (sites, countries, samples) must be filled
106
+ within each group, or values are taken from the neighbouring group:
107
+
108
+ -f "x = x.groupby(site).ffill()"
109
+ -f "x = x.groupby(site).bfill()"
110
+ -f "x = x.groupby(site).ffill().fillna(x.groupby(site).bfill())"
111
+
112
+ A group's rows don't have to be next to each other, but within each group
113
+ they must be in order. In healthexp, rows are sorted by Year with the
114
+ countries mixed together, so a grouped ffill is right as it is:
115
+
116
+ dftk dataset healthexp -o \\
117
+ | dftk eval ... -f "le = Life_Expectancy.where(Year % 10 != 5)" -o \\
118
+ | dftk eval ... -f "le_filled = le.groupby(Country).ffill()"
119
+
120
+ interpolate() has no grouped form. Sort so that each group's rows are
121
+ together; then gaps inside a group are right, but a gap at the start or
122
+ end of a group is filled across the boundary. To leave those empty, keep
123
+ only values with something before and after them in the same group:
124
+
125
+ dftk dataset healthexp -o \\
126
+ | dftk eval ... -f "le = Life_Expectancy.where(Year % 10 != 5)" \\
127
+ --sortasc Country Year -o \\
128
+ | dftk eval ... -f "before = le.groupby(Country).ffill().notna()" \\
129
+ -f "after = le.groupby(Country).bfill().notna()" \\
130
+ -f "le_linear = le.interpolate().where(before & after)" \\
131
+ --drop before after
132
+
133
+ Only empty cells are filled; missing rows (a year with no row at all) are
134
+ not added.
135
+
47
136
  If df.eval() fails (e.g. for function calls), the expression is retried
48
137
  as a special function call using the syntax:
49
138
 
@@ -177,7 +266,7 @@ def _parse_formula(formula: str) -> tuple[str, str, list[str], str]:
177
266
  func, right = (x.strip() for x in right.split("(", 1))
178
267
  body, _ = (x.strip() for x in right.rsplit(")", 1))
179
268
  except Exception:
180
- raise ValueError(
269
+ raise _FormulaMistake(
181
270
  f"Bad formula syntax: {formula!r} Expected: dest = func(col1, col2, ...)"
182
271
  ) from None
183
272
  cols = [x.strip() for x in body.split(",")]
@@ -334,7 +423,7 @@ def _special_function(formula: str, df_columns: list[str]):
334
423
  dest, func_name, cols, body = _parse_formula(formula)
335
424
 
336
425
  if func_name not in _VALID_SPECIAL:
337
- raise ValueError(
426
+ raise _FormulaMistake(
338
427
  f"Function {func_name!r} is not available as a "
339
428
  "special function. See dftk eval --help."
340
429
  )
@@ -516,6 +605,97 @@ def _string_function(formula: str, joinsep: str):
516
605
  # ---------------------------------------------------------------------------
517
606
 
518
607
 
608
+ # A message pandas gives for a method that no column has, whatever its dtype
609
+ _NO_METHOD = re.compile(r"'Series' object has no attribute '(\w+)'")
610
+
611
+ # dest = col.method(...): never a special function
612
+ _METHOD_CALL = re.compile(r"\s*\w+\s*=\s*[A-Za-z_]\w*\.[A-Za-z_]")
613
+
614
+
615
+ def _formula_error(formula: str, exc: Exception) -> str:
616
+ """Return a short message for a mistake pandas found in an -f formula."""
617
+ msg = str(exc)
618
+ m = _NO_METHOD.search(msg)
619
+ if m:
620
+ name = m.group(1)
621
+ col = re.search(rf"([A-Za-z_]\w*)\.{name}\b", formula)
622
+ col = col.group(1) if col else "COL"
623
+ if hasattr(pd.Series.str, name):
624
+ hint = f"Text methods are under .str: {col}.str.{name}()."
625
+ else:
626
+ hint = f"Text methods are under .str, e.g. {col}.str.lower()."
627
+ return (
628
+ f"-f {formula!r}: column has no method {name!r}. {hint} See dftk eval -h."
629
+ )
630
+ return f"-f {formula!r}: {msg}"
631
+
632
+
633
+ def _apply_special_function(df: pd.DataFrame, formula: str) -> None:
634
+ """Evaluate a special-function formula and store its column in *df*."""
635
+ dest, func, cols = _special_function(formula, list(df.columns))
636
+ missing = [c for c in cols if c not in df.columns]
637
+ if missing:
638
+ hint = ""
639
+ if any(re.fullmatch(r"[-+]?[\d.]+(e[-+]?\d+)?", c) for c in missing):
640
+ hint = (
641
+ " For a fixed value, add it as a column first: "
642
+ "-c 'p = 0.5' -f 'z = func(x, p)'."
643
+ )
644
+ raise _FormulaMistake(f"-f {formula!r}: column(s) not found: {missing}.{hint}")
645
+ if len(df) == 0:
646
+ df[dest] = True
647
+ elif isinstance(func, _WhereFunc):
648
+ df[dest] = func(df)
649
+ elif isinstance(func, _FrameFunc):
650
+ # Frame-level functions (idxmax/idxmin) need column labels
651
+ df[dest] = func(df[cols])
652
+ elif len(cols) == 1:
653
+ # Element-wise on a single column (e.g. np.sign, os.path.*)
654
+ df[dest] = df[cols[0]].apply(func)
655
+ else:
656
+ # Multi-column row function: pass numpy array so positional
657
+ # indexing (row[0], row[1], ...) works in pandas 2.x
658
+ df[dest] = df[cols].apply(lambda row, f=func: f(row.to_numpy()), axis=1)
659
+
660
+
661
+ def _eval_formula(df: pd.DataFrame, formula: str) -> pd.DataFrame:
662
+ """Evaluate one -f formula: pandas df.eval(), else a special function."""
663
+ try:
664
+ # Not inplace: that writes into the existing column, which must keep
665
+ # its dtype, so x = x.fillna(y) or x = x.isna() failed whenever the
666
+ # result had another dtype
667
+ result = df.eval(formula)
668
+ except NameError as exc:
669
+ # An unknown column (pandas' UndefinedVariableError)
670
+ raise _FormulaMistake(_formula_error(formula, exc)) from exc
671
+ except (AttributeError, TypeError, KeyError) as exc:
672
+ # Raised while pandas evaluated the user's expression, so most likely
673
+ # a mistake in it (wrong method, arguments, or .str on numbers)
674
+ cls = _FormulaMistake if _NO_METHOD.search(str(exc)) else ValueError
675
+ raise cls(_formula_error(formula, exc)) from exc
676
+ except (ValueError, SyntaxError) as eval_exc:
677
+ if _METHOD_CALL.match(formula):
678
+ raise ValueError(_formula_error(formula, eval_exc)) from eval_exc
679
+ logger.debug(f"df.eval failed for {formula!r}, trying special_function")
680
+ try:
681
+ _apply_special_function(df, formula)
682
+ except ValueError as exc:
683
+ name = re.search(r"=\s*(\w+)\s*\(", formula)
684
+ if name and name.group(1) in _VALID_SPECIAL:
685
+ raise # a known special function: pandas' error is beside the point
686
+ raise type(exc)(f"{exc} (pandas df.eval: {eval_exc})") from exc
687
+ except Exception:
688
+ logger.error(f"Could not evaluate formula: {formula!r}")
689
+ raise
690
+ return df
691
+ if not isinstance(result, pd.DataFrame):
692
+ # No "dest =": df.eval returned the values instead of a table
693
+ raise _FormulaMistake(
694
+ f"-f {formula!r} has no assignment; write it as 'name = {formula}'"
695
+ )
696
+ return result
697
+
698
+
519
699
  def _eval(df: pd.DataFrame, args: argparse.Namespace) -> pd.DataFrame:
520
700
  joinsep: str = getattr(args, "joinsep", "")
521
701
  intbool: bool = getattr(args, "intbool", False)
@@ -536,31 +716,14 @@ def _eval(df: pd.DataFrame, args: argparse.Namespace) -> pd.DataFrame:
536
716
  # -f: pandas eval with special-function fallback
537
717
  for formula in getattr(args, "formula", []):
538
718
  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)
557
- 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}")
719
+ df = _eval_formula(df, formula)
720
+ except Exception as exc:
721
+ # An empty table has no column types (pandas reads them as
722
+ # float64), so on one only mistakes that don't depend on types
723
+ # are reported; for the rest the column is just added
724
+ if len(df) != 0 or isinstance(exc, _FormulaMistake):
563
725
  raise
726
+ df[formula.split("=", 1)[0].strip()] = True
564
727
 
565
728
  # -s: string / row functions
566
729
  for strfunc in getattr(args, "strfunc", []):
@@ -0,0 +1,194 @@
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 numpy as np
13
+ import pandas as pd
14
+
15
+ from dftk.commands.base import BaseCommand
16
+ from dftk.common.io import check_cols, io
17
+
18
+ _EPILOG = """\
19
+ EXAMPLE
20
+ -------
21
+ gene go tissue
22
+ BRCA1 GO:1 breast
23
+ BRCA1 GO:2 breast
24
+ TP53 GO:3 colon
25
+
26
+ dftk implode data.tsv -c go
27
+
28
+ gene go tissue
29
+ BRCA1 GO:1,GO:2 breast
30
+ TP53 GO:3 colon
31
+
32
+ By default rows are grouped by all the other columns, so rows are only
33
+ joined when everything else in them is the same. With -i, rows are grouped
34
+ by those columns only and the other columns are dropped. Rows with an
35
+ empty key form a group of their own.
36
+
37
+ Values are joined in the order of the rows; --sortvalues sorts them and
38
+ --unique drops repeats. Empty values are left out, and a group with none
39
+ gets an empty cell. Whole numbers are written without ".0" when every
40
+ value in the column is one (pandas reads integers with gaps as floats).
41
+
42
+ Several columns are joined row by row, so their n-th items still belong
43
+ together; --unique and --sortvalues then work on whole rows.
44
+
45
+ Runnable version: each species' islands, one cell per species:
46
+
47
+ dftk dataset penguins -o \\
48
+ | dftk implode ... -c island -i species --unique --sortvalues
49
+
50
+ Numbers, with another separator: the party sizes seen on each day:
51
+
52
+ dftk dataset tips -o \\
53
+ | dftk implode ... -c size -i day --unique --sortvalues --sep '|'
54
+
55
+ Undoing explode: --poscol puts the items back in their original order,
56
+ here after shuffling the rows, and is dropped from the output:
57
+
58
+ dftk dataset penguins -o \\
59
+ | dftk implode ... -c island -i species --unique -o \\
60
+ | dftk explode ... -c island --poscol n -o \\
61
+ | dftk sample ... -f 1 --randomseed 1 -o \\
62
+ | dftk implode ... -c island --poscol n
63
+ """
64
+
65
+
66
+ def _as_text(s: pd.Series) -> pd.Series:
67
+ """Return *s* as strings, keeping missing values missing."""
68
+ values = s.dropna()
69
+ if (
70
+ pd.api.types.is_float_dtype(s)
71
+ and len(values)
72
+ and np.isfinite(values).all()
73
+ and (values == values.round()).all()
74
+ ):
75
+ # Integers with gaps are read as floats: write 10, not 10.0
76
+ s = s.astype("Int64")
77
+ text = s.astype(object).where(s.notna())
78
+ text = text.map(lambda v: v if pd.isna(v) else str(v))
79
+ return text.where(text != "")
80
+
81
+
82
+ def _implode(df: pd.DataFrame, args: argparse.Namespace) -> pd.DataFrame:
83
+ cols = args.cols
84
+ poscol = args.poscol
85
+ if len(set(cols)) != len(cols):
86
+ raise ValueError(f"-c/--cols lists a column more than once: {cols}")
87
+ if poscol is not None and poscol in cols:
88
+ raise ValueError(f"--poscol {poscol!r} is also in -c/--cols")
89
+
90
+ if args.indexcols is not None:
91
+ keys = list(args.indexcols)
92
+ overlap = [c for c in keys if c in cols or c == poscol]
93
+ if overlap:
94
+ raise ValueError(
95
+ f"-i/--indexcols overlaps -c/--cols or --poscol: {overlap}"
96
+ )
97
+ else:
98
+ keys = [c for c in df.columns if c not in cols and c != poscol]
99
+
100
+ order = [c for c in df.columns if c in keys or c in cols]
101
+ work = df[keys + cols + ([poscol] if poscol else [])].reset_index(drop=True)
102
+ gid = "_gid" # helper column for the group number; never one of the user's
103
+ while gid in work.columns:
104
+ gid = "_" + gid
105
+ if keys:
106
+ work[gid] = work.groupby(keys, sort=False, dropna=False).ngroup()
107
+ else:
108
+ work[gid] = 0
109
+
110
+ if args.sortvalues:
111
+ work = work.sort_values([gid, *cols], kind="stable", na_position="last")
112
+ elif poscol:
113
+ work = work.sort_values([gid, poscol], kind="stable")
114
+ else:
115
+ work = work.sort_values(gid, kind="stable")
116
+ if args.unique:
117
+ work = work.drop_duplicates(subset=[gid, *cols])
118
+
119
+ for col in cols:
120
+ work[col] = _as_text(work[col])
121
+ grouped = work.groupby(gid, sort=True)
122
+ joined = grouped[cols].agg(lambda s: args.sep.join(s.dropna()))
123
+ joined = joined.where(joined != "")
124
+ result = grouped[keys].first().join(joined) if keys else joined
125
+ return result.reset_index(drop=True)[order]
126
+
127
+
128
+ class ImplodeCommand(BaseCommand):
129
+ @property
130
+ def name(self) -> str:
131
+ return "implode"
132
+
133
+ @property
134
+ def help(self) -> str:
135
+ return "Join a column's values within groups of rows into one cell"
136
+
137
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
138
+ parser.formatter_class = argparse.RawDescriptionHelpFormatter
139
+ parser.epilog = _EPILOG
140
+
141
+ self.add_io_arguments(parser)
142
+
143
+ g = parser.add_argument_group("implode options")
144
+ g.add_argument(
145
+ "-c",
146
+ "--cols",
147
+ nargs="+",
148
+ required=True,
149
+ metavar="COL",
150
+ help="Column(s) whose values are joined. Several columns are "
151
+ "joined in the same row order.",
152
+ )
153
+ g.add_argument(
154
+ "-i",
155
+ "--indexcols",
156
+ nargs="+",
157
+ default=None,
158
+ metavar="COL",
159
+ help="Group by these columns and drop the others "
160
+ "(default: group by all columns not in -c or --poscol).",
161
+ )
162
+ g.add_argument(
163
+ "--sep",
164
+ default=",",
165
+ metavar="SEP",
166
+ help="Separator between joined values (default: ,).",
167
+ )
168
+ g.add_argument(
169
+ "--poscol",
170
+ default=None,
171
+ metavar="NAME",
172
+ help="Join the values in the order of column NAME (e.g. the "
173
+ "positions from explode --poscol) and drop it.",
174
+ )
175
+ g.add_argument(
176
+ "--unique",
177
+ action="store_true",
178
+ help="Drop repeated values (with several -c columns, repeated rows).",
179
+ )
180
+ g.add_argument(
181
+ "--sortvalues",
182
+ action="store_true",
183
+ help="Sort the values in each cell (with several -c columns, "
184
+ "sort whole rows by them).",
185
+ )
186
+
187
+ def execute(self, args: argparse.Namespace) -> None:
188
+ df = io.read(args)
189
+ check_cols(df, args.cols, "-c/--cols")
190
+ check_cols(df, args.indexcols, "-i/--indexcols")
191
+ if args.poscol is not None:
192
+ check_cols(df, [args.poscol], "--poscol")
193
+ result = _implode(df, args)
194
+ io.printdf(result, args)