dataframe-toolkit 0.7.2__tar.gz → 0.7.3__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 (47) hide show
  1. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/PKG-INFO +1 -1
  2. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/pyproject.toml +1 -1
  3. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/pyproject.toml.orig +1 -1
  4. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/fit_cmd.py +21 -0
  5. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/query_cmd.py +21 -1
  6. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/common/io.py +136 -26
  7. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/README.md +0 -0
  8. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/__init__.py +0 -0
  9. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/cli.py +0 -0
  10. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/__init__.py +0 -0
  11. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/annotate_cmd.py +0 -0
  12. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/base.py +0 -0
  13. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/binx_cmd.py +0 -0
  14. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/chi_cmd.py +0 -0
  15. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/clean_cmd.py +0 -0
  16. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/concat_cmd.py +0 -0
  17. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/corr_cmd.py +0 -0
  18. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/dataset_cmd.py +0 -0
  19. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/describe_cmd.py +0 -0
  20. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/eval_cmd.py +0 -0
  21. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/func_cmd.py +0 -0
  22. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/help.py +0 -0
  23. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/hist_cmd.py +0 -0
  24. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/info_cmd.py +0 -0
  25. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/interp_cmd.py +0 -0
  26. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/line_cmd.py +0 -0
  27. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/melt_cmd.py +0 -0
  28. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/merge_cmd.py +0 -0
  29. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/pivot_cmd.py +0 -0
  30. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/print_cmd.py +0 -0
  31. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/randvar_cmd.py +0 -0
  32. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/sample_cmd.py +0 -0
  33. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/scale_cmd.py +0 -0
  34. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/scat_cmd.py +0 -0
  35. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/segid_cmd.py +0 -0
  36. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/split_cmd.py +0 -0
  37. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/stat_cmd.py +0 -0
  38. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/test_cmd.py +0 -0
  39. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/transpose_cmd.py +0 -0
  40. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/commands/wstat_cmd.py +0 -0
  41. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/common/__init__.py +0 -0
  42. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/common/copula.py +0 -0
  43. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/common/plot.py +0 -0
  44. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/common/seed.py +0 -0
  45. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/common/seq.py +0 -0
  46. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/common/stats.py +0 -0
  47. {dataframe_toolkit-0.7.2 → dataframe_toolkit-0.7.3}/src/dftk/io.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: dataframe-toolkit
3
- Version: 0.7.2
3
+ Version: 0.7.3
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dataframe-toolkit"
3
- version = "0.7.2"
3
+ version = "0.7.3"
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.7.2"
3
+ version = "0.7.3"
4
4
  description = "A command-line toolkit for DataFrame analysis and manipulation"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -73,12 +73,33 @@ def weighted_model(df: pd.DataFrame, args: argparse.Namespace):
73
73
  )
74
74
 
75
75
 
76
+ def _nullable_to_float(df: pd.DataFrame) -> pd.DataFrame:
77
+ """Return *df* with nullable numeric columns (e.g. Int64) as float64.
78
+
79
+ patsy finds missing values with np.isnan, which fails on pd.NA
80
+ ("boolean value of NA is ambiguous"). As float64 they become NaN, and
81
+ statsmodels drops incomplete rows as it does for any other float column.
82
+ Nullable booleans are left alone: patsy treats them as categorical.
83
+ """
84
+ cols = [
85
+ c
86
+ for c in df.columns
87
+ if pd.api.types.is_extension_array_dtype(df[c])
88
+ and pd.api.types.is_numeric_dtype(df[c])
89
+ and not pd.api.types.is_bool_dtype(df[c])
90
+ ]
91
+ if not cols:
92
+ return df
93
+ return df.astype({c: "float64" for c in cols})
94
+
95
+
76
96
  def regress_it(df: pd.DataFrame, args: argparse.Namespace):
77
97
  """Fit the appropriate model and return the fitted result object.
78
98
 
79
99
  A formula that cannot be evaluated (unknown column or function) raises
80
100
  ValueError, which the CLI reports as an input error.
81
101
  """
102
+ df = _nullable_to_float(df)
82
103
  try:
83
104
  if getattr(args, "weights", None) is not None:
84
105
  mod = weighted_model(df, args)
@@ -91,9 +91,29 @@ def _sql_query(args: argparse.Namespace, sql: str, table: str) -> pd.DataFrame:
91
91
  conn.register(table, df)
92
92
 
93
93
  try:
94
- return conn.execute(sql).df()
94
+ result = conn.execute(sql)
95
+ description = result.description
96
+ df = result.df()
95
97
  except Exception as exc:
96
98
  raise ValueError(f"DuckDB SQL failed: {exc}") from exc
99
+ return _hugeint_to_int64(df, description)
100
+
101
+
102
+ def _hugeint_to_int64(df: pd.DataFrame, description) -> pd.DataFrame:
103
+ """Return 128-bit integer result columns as Int64 instead of float64.
104
+
105
+ DuckDB's SUM of an integer column is a HUGEINT, which pandas has no type
106
+ for, so .df() gives float64 (19.0). Below 2**53 that float is exact and
107
+ is converted back; from 2**53 on the float may already be rounded, so it
108
+ stays float.
109
+ """
110
+ for i, (_, type_code, *_) in enumerate(description or []):
111
+ if str(type_code) not in ("HUGEINT", "UHUGEINT"):
112
+ continue
113
+ col = df.iloc[:, i]
114
+ if (col.dropna().abs() < 2**53).all():
115
+ df.isetitem(i, col.astype("Int64"))
116
+ return df
97
117
 
98
118
 
99
119
  # ---------------------------------------------------------------------------
@@ -48,6 +48,17 @@ logging.getLogger("matplotlib.font_manager").setLevel(logging.WARNING)
48
48
  # Parquet-pipe temp directory
49
49
  # ---------------------------------------------------------------------------
50
50
 
51
+ # --cast TYPE names → dtypes
52
+ _CAST_TYPES: dict[str, object] = {
53
+ "object": object,
54
+ "int64": "int64",
55
+ "Int64": "Int64",
56
+ "float64": "float64",
57
+ "bool": "bool",
58
+ "datetime64": "datetime64[ns]",
59
+ "category": "category",
60
+ }
61
+
51
62
  DFTK_TMPDIR: str = os.environ.get(
52
63
  "DFTK_TMPDIR",
53
64
  os.path.join(tempfile.gettempdir(), "dftk"),
@@ -116,6 +127,69 @@ def _write_pipe_parquet(df: pd.DataFrame) -> str:
116
127
  return path
117
128
 
118
129
 
130
+ def _warn_digits_exponent(df: pd.DataFrame, digits: int) -> None:
131
+ """Warn if ``--digits`` would print whole numbers in exponent form.
132
+
133
+ An integer column with empty cells is read as float, and ``%.Ng`` then
134
+ writes e.g. 248956422 as 2.49e+08. The output is left as it is; the
135
+ warning suggests --cast COL:Int64 or --round instead.
136
+ """
137
+ import shlex
138
+
139
+ cols: list[str] = []
140
+ example = None
141
+ for c in df.columns:
142
+ if not pd.api.types.is_float_dtype(df[c]):
143
+ continue
144
+ s = df[c].dropna()
145
+ s = s[np.isfinite(s)]
146
+ hit = s[(s == s.round()) & (s.abs() >= 10**digits)]
147
+ if len(hit):
148
+ cols.append(str(c))
149
+ if example is None:
150
+ example = hit.iloc[0]
151
+ if not cols:
152
+ return
153
+
154
+ shown = cols[:3]
155
+ names = ", ".join(repr(c) for c in shown)
156
+ if len(cols) > len(shown):
157
+ names += f" and {len(cols) - len(shown)} more"
158
+ casts = " ".join(shlex.quote(f"{c}:Int64") for c in shown)
159
+ if len(cols) > len(shown):
160
+ casts += " ..."
161
+ logging.warning(
162
+ f"--digits {digits} writes whole numbers in column(s) {names} in "
163
+ f"exponent form (e.g. {int(example)} -> {example:.{digits}g}).\n"
164
+ f" If these columns hold integers: --cast {casts}\n"
165
+ f" To round without exponent form: --round {digits} (decimal places) "
166
+ f"instead of --digits {digits}"
167
+ )
168
+
169
+
170
+ def _write_tsv(df: pd.DataFrame, dest, **to_csv_kwargs) -> None:
171
+ """``df.to_csv(dest, ...)``, also for one column with empty cells.
172
+
173
+ With quoting off, Python's csv module refuses to write a row that is a
174
+ single empty field ("single empty field record must be quoted"). For a
175
+ one-column table the row is written with a constant second column, which
176
+ is then cut off, leaving an empty line: TSV for one missing value.
177
+ """
178
+ if len(df.columns) != 1:
179
+ df.to_csv(dest, **to_csv_kwargs)
180
+ return
181
+ pad = "_pad" if df.columns[0] != "_pad" else "_pad2"
182
+ text = df.assign(**{pad: "x"}).to_csv(None, lineterminator="\n", **to_csv_kwargs)
183
+ # Values cannot contain a tab (unescaped, the csv module refuses them), so
184
+ # the last tab on each line is the one before the padding column
185
+ text = "".join(line.rsplit("\t", 1)[0] + "\n" for line in text.splitlines())
186
+ if isinstance(dest, str):
187
+ with open(dest, "w", encoding="utf-8", newline="") as fh:
188
+ fh.write(text)
189
+ else:
190
+ dest.write(text)
191
+
192
+
119
193
  # ---------------------------------------------------------------------------
120
194
  # Utility helpers
121
195
  # ---------------------------------------------------------------------------
@@ -247,23 +321,39 @@ class io:
247
321
  df.columns = [f"V{i + 1}" for i in range(len(df.columns))]
248
322
  return df
249
323
 
324
+ def _pandas_csv(source, **extra) -> pd.DataFrame:
325
+ return pd.read_csv(
326
+ source, sep=sep, header=header, dtype=dtype, engine="pyarrow", **extra
327
+ )
328
+
329
+ def _with_blank_lines(df: pd.DataFrame, reread) -> pd.DataFrame:
330
+ # In a one-column file an empty line is a missing value, not a
331
+ # line to skip (dftk writes such files; DuckDB reads them this way)
332
+ if len(df.columns) != 1:
333
+ return df
334
+ full = reread()
335
+ return full if list(full.columns) == list(df.columns) else df
336
+
250
337
  def _csv_from_bytes(data: bytes) -> pd.DataFrame:
251
- df = pd.read_csv(
252
- _io.BytesIO(data),
253
- sep=sep,
254
- header=header,
255
- dtype=dtype,
256
- engine="pyarrow",
338
+ df = _pandas_csv(_io.BytesIO(data))
339
+ df = _with_blank_lines(
340
+ df, lambda: _pandas_csv(_io.BytesIO(data), skip_blank_lines=False)
257
341
  )
258
342
  return _rename_if_noheader(df)
259
343
 
344
+ def _csv_with_duckdb(path: str) -> pd.DataFrame:
345
+ # DuckDB keeps integer columns with blanks as Int64 (pandas: float64)
346
+ try:
347
+ df = duckdb.read_csv(path, sep=sep, header=not noheader).df()
348
+ except Exception as exc:
349
+ logging.error(f"DuckDB failed to read {path!r}: {exc}")
350
+ raise
351
+ return _rename_if_noheader(df)
352
+
260
353
  def _csv_from_file(path: str) -> pd.DataFrame:
261
- df = pd.read_csv(
262
- path,
263
- sep=sep,
264
- header=header,
265
- dtype=dtype,
266
- engine="pyarrow",
354
+ df = _pandas_csv(path)
355
+ df = _with_blank_lines(
356
+ df, lambda: _pandas_csv(path, skip_blank_lines=False)
267
357
  )
268
358
  return _rename_if_noheader(df)
269
359
 
@@ -290,15 +380,22 @@ class io:
290
380
  df = pd.read_parquet(filename)
291
381
  df.attrs["_parquet_meta"] = _read_parquet_meta(filename)
292
382
  elif backend == "duckdb" and duckdb is not None:
293
- try:
294
- df = duckdb.read_csv(filename, sep=sep, header=not noheader).df()
295
- df = _rename_if_noheader(df)
296
- except Exception as exc:
297
- logging.error(f"DuckDB failed to read {filename!r}: {exc}")
298
- raise
383
+ df = _csv_with_duckdb(filename)
299
384
  else:
300
385
  df = _csv_from_file(filename)
301
386
 
387
+ elif backend == "duckdb" and duckdb is not None:
388
+ # stdin with DuckDB: it reads files, not buffers (that needs fsspec),
389
+ # so spool stdin to a temp file and read it like a named file
390
+ os.makedirs(DFTK_TMPDIR, exist_ok=True)
391
+ fd, path = tempfile.mkstemp(suffix=".tsv", dir=DFTK_TMPDIR)
392
+ try:
393
+ with os.fdopen(fd, "wb") as fh:
394
+ fh.write(sys.stdin.buffer.read())
395
+ df = _csv_with_duckdb(path)
396
+ finally:
397
+ os.unlink(path)
398
+
302
399
  else:
303
400
  # stdin — read TSV directly
304
401
  df = _csv_from_bytes(sys.stdin.buffer.read())
@@ -385,7 +482,12 @@ class io:
385
482
  )
386
483
  g.add_argument(
387
484
  "--cast",
388
- help="Cast column to dtype (COL:TYPE). Glob patterns supported.",
485
+ help=(
486
+ "Cast column to dtype (COL:TYPE). TYPE is one of object, int64, "
487
+ "Int64, float64, bool, datetime64, category (a prefix is enough, "
488
+ "e.g. int, Int, cat). Int64 is an integer column that allows "
489
+ "empty cells. Glob patterns supported."
490
+ ),
389
491
  nargs="*",
390
492
  default=None,
391
493
  metavar="COL:TYPE",
@@ -471,7 +573,11 @@ class io:
471
573
  )
472
574
  na_group.add_argument(
473
575
  "--dropna",
474
- help="Drop rows containing any NA value before output.",
576
+ help=(
577
+ "Drop rows containing any NA value from the output. Applied "
578
+ "after the command has run, so it does not change what the "
579
+ "command sees (use --prequery for that)."
580
+ ),
475
581
  action="store_true",
476
582
  )
477
583
 
@@ -530,17 +636,18 @@ class io:
530
636
  raise
531
637
 
532
638
  # Cast columns
533
- _DTYPES = ["object", "int64", "float64", "bool", "datetime64", "category"]
534
639
  for item in getattr(args, "cast", None) or []:
535
640
  try:
536
641
  col, coltype = item.split(":")
537
- coltype = fnmatch.filter(_DTYPES, f"{coltype}*")[0]
538
- np_type = getattr(np, coltype)
642
+ # Case-sensitive: "int" is int64, "Int" is the nullable Int64
643
+ pattern = f"{coltype}*"
644
+ matches = [t for t in _CAST_TYPES if fnmatch.fnmatchcase(t, pattern)]
645
+ dtype = _CAST_TYPES[matches[0]]
539
646
  for c in fnmatch.filter(df.columns, col):
540
647
  try:
541
- df[c] = df[c].astype(np_type)
648
+ df[c] = df[c].astype(dtype)
542
649
  except Exception:
543
- df[c] = df[c].astype(float).astype(np_type)
650
+ df[c] = df[c].astype(float).astype(dtype)
544
651
  except Exception:
545
652
  logging.warning(f"--cast: failed for {item!r}")
546
653
 
@@ -665,8 +772,11 @@ class io:
665
772
 
666
773
  else:
667
774
  # TSV — stdout or named file
775
+ if digits is not None:
776
+ _warn_digits_exponent(df, digits)
668
777
  dest = output if output is not None else sys.stdout
669
- df.to_csv(
778
+ _write_tsv(
779
+ df,
670
780
  dest,
671
781
  sep="\t",
672
782
  index=False,