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
dftk/common/io.py
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dftk.common.io — core I/O module for the dftk toolkit.
|
|
3
|
+
|
|
4
|
+
Ported from df/src/df/dfio.py with the following changes:
|
|
5
|
+
|
|
6
|
+
• -o/--output replaces --ofstream, with three modes:
|
|
7
|
+
absent → TSV written to stdout
|
|
8
|
+
-o → temp parquet in DFTK_TMPDIR, path printed to stdout
|
|
9
|
+
(auto-deleted by the next reader in the pipeline)
|
|
10
|
+
-o FILE.parquet → named parquet written to FILE, path printed to stdout
|
|
11
|
+
(NOT auto-deleted — reusable later in the same pipeline)
|
|
12
|
+
-o FILE → TSV written to FILE, nothing on stdout
|
|
13
|
+
|
|
14
|
+
• Parquet-pipe via ``...`` DATAFILE: use ``...`` as the DATAFILE argument to
|
|
15
|
+
read a parquet path from the first line of stdin and load that file.
|
|
16
|
+
Temp files (inside DFTK_TMPDIR) are deleted after reading; named files
|
|
17
|
+
are left intact so they can be referenced again downstream.
|
|
18
|
+
|
|
19
|
+
• Removed --sqlout and --latex (niche output formats).
|
|
20
|
+
• Removed --experimental (unused).
|
|
21
|
+
• parser_printdf kept as an alias for parser_output for compatibility.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import csv
|
|
26
|
+
import fnmatch
|
|
27
|
+
import io as _io
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import tempfile
|
|
32
|
+
|
|
33
|
+
import numpy as np
|
|
34
|
+
import pandas as pd
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
import duckdb
|
|
38
|
+
except ImportError:
|
|
39
|
+
duckdb = None
|
|
40
|
+
|
|
41
|
+
logging.basicConfig(
|
|
42
|
+
format="%(asctime)s %(module)s %(levelname)s at line %(lineno)d: %(message)s",
|
|
43
|
+
level=logging.INFO,
|
|
44
|
+
)
|
|
45
|
+
logging.getLogger("matplotlib.font_manager").setLevel(logging.WARNING)
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Parquet-pipe temp directory
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
DFTK_TMPDIR: str = os.environ.get(
|
|
52
|
+
"DFTK_TMPDIR",
|
|
53
|
+
os.path.join(tempfile.gettempdir(), "dftk"),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_pipe_path(path: str) -> bool:
|
|
58
|
+
"""Return True if *path* looks like a parquet pipe path arriving on stdin.
|
|
59
|
+
|
|
60
|
+
Any existing .parquet file qualifies — both temp files in DFTK_TMPDIR
|
|
61
|
+
and named files written by a previous ``-o FILE.parquet`` invocation.
|
|
62
|
+
Only temp files (those inside DFTK_TMPDIR) are auto-deleted after reading.
|
|
63
|
+
"""
|
|
64
|
+
return path.endswith(".parquet") and os.path.isfile(path)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_temp_pipe_path(path: str) -> bool:
|
|
68
|
+
"""Return True if *path* is a dftk-created temp parquet that should be deleted."""
|
|
69
|
+
return (
|
|
70
|
+
path.endswith(".parquet")
|
|
71
|
+
and os.path.isfile(path)
|
|
72
|
+
and os.path.abspath(os.path.dirname(path)) == os.path.abspath(DFTK_TMPDIR)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _read_parquet_meta(path: str) -> dict[str, str]:
|
|
77
|
+
"""Return custom (non-pandas) metadata from a parquet file's schema."""
|
|
78
|
+
import pyarrow.parquet as pq
|
|
79
|
+
|
|
80
|
+
schema = pq.read_schema(path)
|
|
81
|
+
raw = schema.metadata or {}
|
|
82
|
+
return {k.decode(): v.decode() for k, v in raw.items() if k != b"pandas"}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _write_parquet(
|
|
86
|
+
df: pd.DataFrame, path: str, meta: dict[str, str] | None = None
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Write *df* to *path* as parquet, embedding custom *meta* annotations.
|
|
89
|
+
|
|
90
|
+
Any metadata already carried in ``df.attrs["_parquet_meta"]`` is merged
|
|
91
|
+
with *meta* (explicit *meta* values take precedence).
|
|
92
|
+
"""
|
|
93
|
+
import pyarrow as pa
|
|
94
|
+
import pyarrow.parquet as pq
|
|
95
|
+
|
|
96
|
+
table = pa.Table.from_pandas(df, preserve_index=False)
|
|
97
|
+
# Start from whatever the pandas→arrow conversion embedded
|
|
98
|
+
existing: dict[bytes, bytes] = dict(table.schema.metadata or {})
|
|
99
|
+
# Merge attrs-carried provenance then explicit --meta overrides
|
|
100
|
+
carried: dict[str, str] = df.attrs.get("_parquet_meta", {})
|
|
101
|
+
merged = {
|
|
102
|
+
**existing,
|
|
103
|
+
**{k.encode(): v.encode() for k, v in carried.items()},
|
|
104
|
+
**{k.encode(): v.encode() for k, v in (meta or {}).items()},
|
|
105
|
+
}
|
|
106
|
+
table = table.replace_schema_metadata(merged)
|
|
107
|
+
pq.write_table(table, path)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _write_pipe_parquet(df: pd.DataFrame) -> str:
|
|
111
|
+
"""Write *df* to a temp parquet file in DFTK_TMPDIR and return the path."""
|
|
112
|
+
os.makedirs(DFTK_TMPDIR, exist_ok=True)
|
|
113
|
+
fd, path = tempfile.mkstemp(suffix=".parquet", dir=DFTK_TMPDIR)
|
|
114
|
+
os.close(fd)
|
|
115
|
+
_write_parquet(df, path)
|
|
116
|
+
return path
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Utility helpers
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def check_cols(
|
|
125
|
+
df: pd.DataFrame,
|
|
126
|
+
cols: list[str] | None,
|
|
127
|
+
context: str = "",
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Raise *ValueError* if any column in *cols* is absent from *df*.
|
|
130
|
+
|
|
131
|
+
Parameters
|
|
132
|
+
----------
|
|
133
|
+
df : DataFrame to validate against.
|
|
134
|
+
cols : Column names to check. None or empty → no-op.
|
|
135
|
+
context : Short label shown in the error, e.g. ``"-c/--cols"``.
|
|
136
|
+
"""
|
|
137
|
+
if not cols:
|
|
138
|
+
return
|
|
139
|
+
missing = [c for c in cols if c not in df.columns]
|
|
140
|
+
if missing:
|
|
141
|
+
label = f" ({context})" if context else ""
|
|
142
|
+
available = list(df.columns)
|
|
143
|
+
raise ValueError(
|
|
144
|
+
f"Column(s) not found{label}: {missing}. Available columns: {available}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def globnames(names: list[str], columns: list[str]) -> list[list[str]]:
|
|
149
|
+
"""Shell-style wildcard match of *names* against *columns*."""
|
|
150
|
+
result = []
|
|
151
|
+
for name in names:
|
|
152
|
+
result.append(fnmatch.filter(columns, name))
|
|
153
|
+
return list(dict.fromkeys(result))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# I/O class
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class io:
|
|
162
|
+
"""Namespace for parser factories and read/write helpers."""
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------ #
|
|
165
|
+
# Input #
|
|
166
|
+
# ------------------------------------------------------------------ #
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def parser_read(
|
|
170
|
+
parser: argparse.ArgumentParser | None = None,
|
|
171
|
+
) -> argparse.ArgumentParser:
|
|
172
|
+
if parser is None:
|
|
173
|
+
parser = argparse.ArgumentParser(description="Read data into a dataframe")
|
|
174
|
+
|
|
175
|
+
g = parser.add_argument_group("data input")
|
|
176
|
+
g.add_argument(
|
|
177
|
+
"DATAFILE",
|
|
178
|
+
help=(
|
|
179
|
+
"Input file (tab-separated, or .parquet). "
|
|
180
|
+
"Omit or use - to read TSV from stdin. "
|
|
181
|
+
"Use ... to read a parquet path from stdin "
|
|
182
|
+
"(written by a previous -o command)."
|
|
183
|
+
),
|
|
184
|
+
nargs="?",
|
|
185
|
+
default=None,
|
|
186
|
+
)
|
|
187
|
+
g.add_argument(
|
|
188
|
+
"--backend",
|
|
189
|
+
help="Backend for reading data (default: pandas)",
|
|
190
|
+
choices=["pandas", "duckdb"],
|
|
191
|
+
default="pandas",
|
|
192
|
+
)
|
|
193
|
+
g.add_argument(
|
|
194
|
+
"--noheader",
|
|
195
|
+
help="File has no header row; columns are named V1, V2, …",
|
|
196
|
+
action="store_true",
|
|
197
|
+
)
|
|
198
|
+
g.add_argument(
|
|
199
|
+
"--nrows",
|
|
200
|
+
help="Maximum number of rows to read",
|
|
201
|
+
type=int,
|
|
202
|
+
default=None,
|
|
203
|
+
)
|
|
204
|
+
g.add_argument(
|
|
205
|
+
"--delimiter",
|
|
206
|
+
help="Column delimiter (default: tab)",
|
|
207
|
+
default=None,
|
|
208
|
+
)
|
|
209
|
+
g.add_argument(
|
|
210
|
+
"--readasobject",
|
|
211
|
+
help=(
|
|
212
|
+
"Read named columns as object dtype. "
|
|
213
|
+
"Omit column names to apply to all columns."
|
|
214
|
+
),
|
|
215
|
+
nargs="*",
|
|
216
|
+
default=None,
|
|
217
|
+
metavar="COL",
|
|
218
|
+
)
|
|
219
|
+
g.add_argument(
|
|
220
|
+
"--prequery",
|
|
221
|
+
help=(
|
|
222
|
+
"Pandas query expression applied immediately after reading. "
|
|
223
|
+
"Repeatable."
|
|
224
|
+
),
|
|
225
|
+
default=[],
|
|
226
|
+
action="append",
|
|
227
|
+
metavar="EXPR",
|
|
228
|
+
)
|
|
229
|
+
return parser
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def read(args: argparse.Namespace) -> pd.DataFrame:
|
|
233
|
+
"""Read a dataframe from the source described in *args*."""
|
|
234
|
+
|
|
235
|
+
filename: str | None = getattr(args, "DATAFILE", None)
|
|
236
|
+
backend: str = getattr(args, "backend", "pandas")
|
|
237
|
+
sep: str = getattr(args, "delimiter", None) or "\t"
|
|
238
|
+
noheader: bool = getattr(args, "noheader", False)
|
|
239
|
+
header: int | None = None if noheader else 0
|
|
240
|
+
nrows: int | None = getattr(args, "nrows", None)
|
|
241
|
+
readasobject = getattr(args, "readasobject", None)
|
|
242
|
+
prequery: list[str] = getattr(args, "prequery", [])
|
|
243
|
+
|
|
244
|
+
dtype = None
|
|
245
|
+
if readasobject is not None:
|
|
246
|
+
dtype = (
|
|
247
|
+
object if len(readasobject) == 0 else {c: object for c in readasobject}
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# -- helpers -------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
def _rename_if_noheader(df: pd.DataFrame) -> pd.DataFrame:
|
|
253
|
+
if noheader:
|
|
254
|
+
df.columns = [f"V{i + 1}" for i in range(len(df.columns))]
|
|
255
|
+
return df
|
|
256
|
+
|
|
257
|
+
def _csv_from_bytes(data: bytes) -> pd.DataFrame:
|
|
258
|
+
df = pd.read_csv(
|
|
259
|
+
_io.BytesIO(data),
|
|
260
|
+
sep=sep,
|
|
261
|
+
header=header,
|
|
262
|
+
nrows=nrows,
|
|
263
|
+
dtype=dtype,
|
|
264
|
+
engine="pyarrow",
|
|
265
|
+
)
|
|
266
|
+
return _rename_if_noheader(df)
|
|
267
|
+
|
|
268
|
+
def _csv_from_file(path: str) -> pd.DataFrame:
|
|
269
|
+
df = pd.read_csv(
|
|
270
|
+
path,
|
|
271
|
+
sep=sep,
|
|
272
|
+
header=header,
|
|
273
|
+
nrows=nrows,
|
|
274
|
+
dtype=dtype,
|
|
275
|
+
engine="pyarrow",
|
|
276
|
+
)
|
|
277
|
+
return _rename_if_noheader(df)
|
|
278
|
+
|
|
279
|
+
# -- read ----------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
if filename == "...":
|
|
282
|
+
# Parquet-pipe mode: read a path from stdin, load that parquet file.
|
|
283
|
+
pipe_path = sys.stdin.readline().rstrip("\r\n")
|
|
284
|
+
if not pipe_path.endswith(".parquet") or not os.path.isfile(pipe_path):
|
|
285
|
+
raise ValueError(
|
|
286
|
+
f"'...' expects a .parquet path on stdin, got {pipe_path!r}"
|
|
287
|
+
)
|
|
288
|
+
df = pd.read_parquet(pipe_path)
|
|
289
|
+
df.attrs["_parquet_meta"] = _read_parquet_meta(pipe_path)
|
|
290
|
+
if _is_temp_pipe_path(pipe_path):
|
|
291
|
+
try:
|
|
292
|
+
os.unlink(pipe_path)
|
|
293
|
+
except OSError as exc:
|
|
294
|
+
logging.warning(f"Could not remove pipe file {pipe_path!r}: {exc}")
|
|
295
|
+
|
|
296
|
+
elif filename is not None and filename != "-":
|
|
297
|
+
# Named file
|
|
298
|
+
if filename.endswith(".parquet"):
|
|
299
|
+
df = pd.read_parquet(filename)
|
|
300
|
+
df.attrs["_parquet_meta"] = _read_parquet_meta(filename)
|
|
301
|
+
elif backend == "duckdb" and duckdb is not None:
|
|
302
|
+
try:
|
|
303
|
+
df = duckdb.query(
|
|
304
|
+
"SELECT * FROM read_csv_auto($1, delim=$2)", [filename, sep]
|
|
305
|
+
).to_df()
|
|
306
|
+
except Exception as exc:
|
|
307
|
+
logging.error(f"DuckDB failed to read {filename!r}: {exc}")
|
|
308
|
+
raise
|
|
309
|
+
else:
|
|
310
|
+
df = _csv_from_file(filename)
|
|
311
|
+
|
|
312
|
+
else:
|
|
313
|
+
# stdin — read TSV directly
|
|
314
|
+
df = _csv_from_bytes(sys.stdin.buffer.read())
|
|
315
|
+
|
|
316
|
+
# -- prequery ------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
for query in prequery:
|
|
319
|
+
try:
|
|
320
|
+
df = df.query(query)
|
|
321
|
+
except Exception as exc:
|
|
322
|
+
logging.error(f"--prequery failed ({query!r}): {exc}")
|
|
323
|
+
raise
|
|
324
|
+
|
|
325
|
+
return df
|
|
326
|
+
|
|
327
|
+
# ------------------------------------------------------------------ #
|
|
328
|
+
# Output #
|
|
329
|
+
# ------------------------------------------------------------------ #
|
|
330
|
+
|
|
331
|
+
@staticmethod
|
|
332
|
+
def parser_output(
|
|
333
|
+
parser: argparse.ArgumentParser | None = None,
|
|
334
|
+
) -> argparse.ArgumentParser:
|
|
335
|
+
"""Add output options to *parser*.
|
|
336
|
+
|
|
337
|
+
The key option is ``-o / --output`` with three modes:
|
|
338
|
+
|
|
339
|
+
* absent → TSV to stdout
|
|
340
|
+
* ``-o`` → write temp parquet, print path to stdout (pipe mode)
|
|
341
|
+
* ``-o FILE`` → write to FILE (.parquet if extension matches, else TSV)
|
|
342
|
+
"""
|
|
343
|
+
if parser is None:
|
|
344
|
+
parser = argparse.ArgumentParser()
|
|
345
|
+
|
|
346
|
+
g = parser.add_argument_group("data output")
|
|
347
|
+
|
|
348
|
+
g.add_argument(
|
|
349
|
+
"-o",
|
|
350
|
+
"--output",
|
|
351
|
+
nargs="?",
|
|
352
|
+
const="", # -o with no argument → pipe mode (empty string sentinel)
|
|
353
|
+
default=None, # absent → TSV to stdout
|
|
354
|
+
metavar="FILE",
|
|
355
|
+
help=(
|
|
356
|
+
"Output destination. "
|
|
357
|
+
"Omit for TSV on stdout. "
|
|
358
|
+
"Use -o alone to write a temp parquet to DFTK_TMPDIR and print its "
|
|
359
|
+
"path to stdout (auto-deleted by the next reader). "
|
|
360
|
+
"Use -o FILE.parquet to write a named parquet and print its path to "
|
|
361
|
+
"stdout (not auto-deleted — reusable later in the same pipeline). "
|
|
362
|
+
"Use -o FILE (no .parquet) to write TSV to FILE with no stdout output."
|
|
363
|
+
),
|
|
364
|
+
)
|
|
365
|
+
g.add_argument(
|
|
366
|
+
"--digits",
|
|
367
|
+
help="Significant floating-point digits in TSV output",
|
|
368
|
+
type=int,
|
|
369
|
+
default=None,
|
|
370
|
+
)
|
|
371
|
+
g.add_argument(
|
|
372
|
+
"--drop",
|
|
373
|
+
help="Columns to drop before output. Glob patterns supported.",
|
|
374
|
+
nargs="*",
|
|
375
|
+
default=None,
|
|
376
|
+
metavar="COL",
|
|
377
|
+
)
|
|
378
|
+
g.add_argument(
|
|
379
|
+
"--move",
|
|
380
|
+
help="Rename columns (OLD:NEW). Applied before --select.",
|
|
381
|
+
nargs="*",
|
|
382
|
+
default=None,
|
|
383
|
+
metavar="OLD:NEW",
|
|
384
|
+
)
|
|
385
|
+
g.add_argument(
|
|
386
|
+
"--select",
|
|
387
|
+
help=(
|
|
388
|
+
"Select and reorder output columns. Glob patterns supported. "
|
|
389
|
+
"Append + to include all remaining columns after the listed ones. "
|
|
390
|
+
"A single FIRST:LAST argument selects a positional range (1-based)."
|
|
391
|
+
),
|
|
392
|
+
nargs="*",
|
|
393
|
+
default=None,
|
|
394
|
+
metavar="COL",
|
|
395
|
+
)
|
|
396
|
+
g.add_argument(
|
|
397
|
+
"--cast",
|
|
398
|
+
help="Cast column to dtype (COL:TYPE). Glob patterns supported.",
|
|
399
|
+
nargs="*",
|
|
400
|
+
default=None,
|
|
401
|
+
metavar="COL:TYPE",
|
|
402
|
+
)
|
|
403
|
+
g.add_argument(
|
|
404
|
+
"--round",
|
|
405
|
+
help=(
|
|
406
|
+
"Round columns to N decimal places (COL:N). "
|
|
407
|
+
"A bare integer N rounds all float columns."
|
|
408
|
+
),
|
|
409
|
+
nargs="*",
|
|
410
|
+
default=None,
|
|
411
|
+
metavar="COL:N",
|
|
412
|
+
)
|
|
413
|
+
g.add_argument(
|
|
414
|
+
"--removeheader",
|
|
415
|
+
help="Suppress the header row in TSV output.",
|
|
416
|
+
action="store_true",
|
|
417
|
+
)
|
|
418
|
+
g.add_argument(
|
|
419
|
+
"--deduplicate",
|
|
420
|
+
help="Remove duplicate rows, optionally limited to named columns.",
|
|
421
|
+
nargs="*",
|
|
422
|
+
default=None,
|
|
423
|
+
metavar="COL",
|
|
424
|
+
)
|
|
425
|
+
g.add_argument(
|
|
426
|
+
"--postquery",
|
|
427
|
+
help="Pandas query expression applied after processing. Repeatable.",
|
|
428
|
+
default=[],
|
|
429
|
+
action="append",
|
|
430
|
+
metavar="EXPR",
|
|
431
|
+
)
|
|
432
|
+
g.add_argument(
|
|
433
|
+
"--meta",
|
|
434
|
+
help=(
|
|
435
|
+
"Embed provenance metadata in parquet output (KEY=VALUE). "
|
|
436
|
+
"Repeatable. Ignored for TSV output."
|
|
437
|
+
),
|
|
438
|
+
default=[],
|
|
439
|
+
action="append",
|
|
440
|
+
metavar="KEY=VALUE",
|
|
441
|
+
)
|
|
442
|
+
g.add_argument(
|
|
443
|
+
"--errortag",
|
|
444
|
+
help="Tag included in error log messages (useful when pipelining).",
|
|
445
|
+
default="-",
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
sort_group = g.add_mutually_exclusive_group()
|
|
449
|
+
sort_group.add_argument(
|
|
450
|
+
"--sortasc",
|
|
451
|
+
help="Sort ascending by named columns.",
|
|
452
|
+
nargs="*",
|
|
453
|
+
default=None,
|
|
454
|
+
metavar="COL",
|
|
455
|
+
)
|
|
456
|
+
sort_group.add_argument(
|
|
457
|
+
"--sortdesc",
|
|
458
|
+
help="Sort descending by named columns.",
|
|
459
|
+
nargs="*",
|
|
460
|
+
default=None,
|
|
461
|
+
metavar="COL",
|
|
462
|
+
)
|
|
463
|
+
sort_group.add_argument(
|
|
464
|
+
"--sort",
|
|
465
|
+
help="Sort by multiple columns with mixed directions (COL:a or COL:d).",
|
|
466
|
+
nargs="*",
|
|
467
|
+
default=None,
|
|
468
|
+
metavar="COL:DIR",
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
na_group = g.add_mutually_exclusive_group()
|
|
472
|
+
na_group.add_argument(
|
|
473
|
+
"--na_rep",
|
|
474
|
+
help=(
|
|
475
|
+
"Replace NA values in output. "
|
|
476
|
+
"A single value applies globally; COL:VAL targets a specific column."
|
|
477
|
+
),
|
|
478
|
+
nargs="*",
|
|
479
|
+
default=None,
|
|
480
|
+
metavar="VAL",
|
|
481
|
+
)
|
|
482
|
+
na_group.add_argument(
|
|
483
|
+
"--dropna",
|
|
484
|
+
help="Drop rows containing any NA value before output.",
|
|
485
|
+
action="store_true",
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
return parser
|
|
489
|
+
|
|
490
|
+
# Alias so existing code that calls io.parser_printdf() keeps working.
|
|
491
|
+
parser_printdf = parser_output
|
|
492
|
+
|
|
493
|
+
@staticmethod
|
|
494
|
+
def printdf(df: pd.DataFrame, args: argparse.Namespace) -> None:
|
|
495
|
+
"""Apply output transformations from *args* and write *df*."""
|
|
496
|
+
|
|
497
|
+
pd.options.mode.chained_assignment = None
|
|
498
|
+
|
|
499
|
+
# ---------------------------------------------------------------- #
|
|
500
|
+
# Pre-output transformations (order matches original dfio.py) #
|
|
501
|
+
# ---------------------------------------------------------------- #
|
|
502
|
+
|
|
503
|
+
if getattr(args, "dropna", False):
|
|
504
|
+
df = df.dropna(axis=0, how="any")
|
|
505
|
+
|
|
506
|
+
# Rename columns
|
|
507
|
+
for item in getattr(args, "move", None) or []:
|
|
508
|
+
try:
|
|
509
|
+
old, new = item.split(":")
|
|
510
|
+
df = df.rename(columns={old: new})
|
|
511
|
+
except Exception:
|
|
512
|
+
logging.warning(f"--move: failed to rename from {item!r}")
|
|
513
|
+
|
|
514
|
+
# Fill NA
|
|
515
|
+
na_rep = getattr(args, "na_rep", None)
|
|
516
|
+
if na_rep is not None:
|
|
517
|
+
if len(na_rep) == 1 and ":" not in na_rep[0]:
|
|
518
|
+
try:
|
|
519
|
+
val = np.float64(na_rep[0])
|
|
520
|
+
except Exception:
|
|
521
|
+
val = na_rep[0]
|
|
522
|
+
df = df.fillna(value=val)
|
|
523
|
+
else:
|
|
524
|
+
na_dict: dict = {}
|
|
525
|
+
for item in na_rep:
|
|
526
|
+
col, raw = item.split(":")
|
|
527
|
+
try:
|
|
528
|
+
raw = np.float64(raw)
|
|
529
|
+
except Exception:
|
|
530
|
+
pass
|
|
531
|
+
na_dict[col] = raw
|
|
532
|
+
df = df.fillna(value=na_dict)
|
|
533
|
+
|
|
534
|
+
# Post-query filter
|
|
535
|
+
for query in getattr(args, "postquery", []):
|
|
536
|
+
try:
|
|
537
|
+
df = df.query(query)
|
|
538
|
+
except Exception as exc:
|
|
539
|
+
logging.error(f"--postquery failed ({query!r}): {exc}")
|
|
540
|
+
raise
|
|
541
|
+
|
|
542
|
+
# Cast columns
|
|
543
|
+
_DTYPES = ["object", "int64", "float64", "bool", "datetime64", "category"]
|
|
544
|
+
for item in getattr(args, "cast", None) or []:
|
|
545
|
+
try:
|
|
546
|
+
col, coltype = item.split(":")
|
|
547
|
+
coltype = fnmatch.filter(_DTYPES, f"{coltype}*")[0]
|
|
548
|
+
np_type = getattr(np, coltype)
|
|
549
|
+
for c in fnmatch.filter(df.columns, col):
|
|
550
|
+
try:
|
|
551
|
+
df[c] = df[c].astype(np_type)
|
|
552
|
+
except Exception:
|
|
553
|
+
df[c] = df[c].astype(float).astype(np_type)
|
|
554
|
+
except Exception:
|
|
555
|
+
logging.warning(f"--cast: failed for {item!r}")
|
|
556
|
+
|
|
557
|
+
# Sort
|
|
558
|
+
sortcols: list[str] = []
|
|
559
|
+
sortdirs: list[bool] = []
|
|
560
|
+
if getattr(args, "sortasc", None) is not None:
|
|
561
|
+
sortcols += args.sortasc
|
|
562
|
+
sortdirs += [True] * len(args.sortasc)
|
|
563
|
+
if getattr(args, "sortdesc", None) is not None:
|
|
564
|
+
sortcols += args.sortdesc
|
|
565
|
+
sortdirs += [False] * len(args.sortdesc)
|
|
566
|
+
for item in getattr(args, "sort", None) or []:
|
|
567
|
+
col, direction = item.split(":")
|
|
568
|
+
sortcols.append(col)
|
|
569
|
+
sortdirs.append(direction.lower() != "d")
|
|
570
|
+
if sortcols:
|
|
571
|
+
try:
|
|
572
|
+
df = df.sort_values(by=sortcols, ascending=sortdirs)
|
|
573
|
+
except Exception as exc:
|
|
574
|
+
logging.warning(f"--sort failed: {exc}")
|
|
575
|
+
|
|
576
|
+
# Drop columns
|
|
577
|
+
for pattern in getattr(args, "drop", None) or []:
|
|
578
|
+
for col in fnmatch.filter(df.columns, pattern):
|
|
579
|
+
try:
|
|
580
|
+
df = df.drop(columns=[col])
|
|
581
|
+
except KeyError:
|
|
582
|
+
pass
|
|
583
|
+
|
|
584
|
+
# Select / reorder columns.
|
|
585
|
+
# IMPORTANT: this block intentionally runs against the *output* DataFrame
|
|
586
|
+
# that was passed to printdf (i.e. after the command has generated any new
|
|
587
|
+
# columns such as "mean" from stat or "y" from eval). Never call
|
|
588
|
+
# check_cols() on --select / --move arguments against the
|
|
589
|
+
# *input* DataFrame inside a command, because those columns may not exist
|
|
590
|
+
# there yet.
|
|
591
|
+
select = getattr(args, "select", None)
|
|
592
|
+
if select is not None:
|
|
593
|
+
if len(select) == 1:
|
|
594
|
+
try:
|
|
595
|
+
first, last = select[0].split(":")
|
|
596
|
+
select = list(df.columns[int(first) - 1 : int(last)])
|
|
597
|
+
except Exception:
|
|
598
|
+
pass
|
|
599
|
+
try:
|
|
600
|
+
selcols: list[str] = []
|
|
601
|
+
_GLOB_CHARS = frozenset("*?[")
|
|
602
|
+
for item in select:
|
|
603
|
+
if item == "+":
|
|
604
|
+
continue
|
|
605
|
+
matched = fnmatch.filter(df.columns, item)
|
|
606
|
+
# Warn when a literal name (no glob chars) matches nothing
|
|
607
|
+
if not matched and not (_GLOB_CHARS & set(item)):
|
|
608
|
+
logging.warning(
|
|
609
|
+
f"--select: column {item!r} not found in DataFrame "
|
|
610
|
+
f"(available: {list(df.columns)})"
|
|
611
|
+
)
|
|
612
|
+
selcols += matched
|
|
613
|
+
if select[-1] == "+":
|
|
614
|
+
selcols += [c for c in df.columns if c not in selcols]
|
|
615
|
+
df = df[selcols].reset_index(drop=True)
|
|
616
|
+
except Exception as exc:
|
|
617
|
+
logging.error(f"--select failed: {exc}")
|
|
618
|
+
|
|
619
|
+
# Round decimal places
|
|
620
|
+
for item in getattr(args, "round", None) or []:
|
|
621
|
+
try:
|
|
622
|
+
col, n = item.split(":")
|
|
623
|
+
for c in fnmatch.filter(df.columns, col):
|
|
624
|
+
df[c] = df[c].round(int(n))
|
|
625
|
+
except Exception:
|
|
626
|
+
try:
|
|
627
|
+
n = int(item)
|
|
628
|
+
for c in df.columns:
|
|
629
|
+
if df[c].dtype == np.float64:
|
|
630
|
+
df[c] = df[c].round(n)
|
|
631
|
+
except Exception:
|
|
632
|
+
pass
|
|
633
|
+
|
|
634
|
+
# Deduplicate
|
|
635
|
+
dedup = getattr(args, "deduplicate", None)
|
|
636
|
+
if dedup is not None:
|
|
637
|
+
df = df.drop_duplicates(subset=dedup if dedup else None)
|
|
638
|
+
|
|
639
|
+
# ---------------------------------------------------------------- #
|
|
640
|
+
# Output routing #
|
|
641
|
+
# ---------------------------------------------------------------- #
|
|
642
|
+
|
|
643
|
+
output: str | None = getattr(args, "output", None)
|
|
644
|
+
digits: int | None = getattr(args, "digits", None)
|
|
645
|
+
float_format = f"%.{digits}g" if digits is not None else None
|
|
646
|
+
|
|
647
|
+
# Parse --meta KEY=VALUE pairs
|
|
648
|
+
meta: dict[str, str] = {}
|
|
649
|
+
for item in getattr(args, "meta", []):
|
|
650
|
+
try:
|
|
651
|
+
k, v = item.split("=", 1)
|
|
652
|
+
meta[k.strip()] = v.strip()
|
|
653
|
+
except ValueError:
|
|
654
|
+
logging.warning(f"--meta: expected KEY=VALUE, got {item!r}")
|
|
655
|
+
|
|
656
|
+
# Header: suppressed if the input had no header, or --removeheader set
|
|
657
|
+
header = not getattr(args, "noheader", False)
|
|
658
|
+
if header and getattr(args, "removeheader", False):
|
|
659
|
+
header = False
|
|
660
|
+
|
|
661
|
+
if output == "":
|
|
662
|
+
# Pipe mode — write temp parquet, print path
|
|
663
|
+
os.makedirs(DFTK_TMPDIR, exist_ok=True)
|
|
664
|
+
fd, path = tempfile.mkstemp(suffix=".parquet", dir=DFTK_TMPDIR)
|
|
665
|
+
os.close(fd)
|
|
666
|
+
_write_parquet(df, path, meta=meta or None)
|
|
667
|
+
print(path)
|
|
668
|
+
|
|
669
|
+
elif output is not None and output.endswith(".parquet"):
|
|
670
|
+
# Named parquet — write file and print path so the pipe continues.
|
|
671
|
+
# The file is NOT auto-deleted by downstream readers (only DFTK_TMPDIR
|
|
672
|
+
# temp files are deleted), so it can be referenced later in a pipeline.
|
|
673
|
+
_write_parquet(df, output, meta=meta or None)
|
|
674
|
+
print(output)
|
|
675
|
+
|
|
676
|
+
else:
|
|
677
|
+
# TSV — stdout or named file
|
|
678
|
+
dest = output if output is not None else sys.stdout
|
|
679
|
+
try:
|
|
680
|
+
df.to_csv(
|
|
681
|
+
dest,
|
|
682
|
+
sep="\t",
|
|
683
|
+
index=False,
|
|
684
|
+
float_format=float_format,
|
|
685
|
+
header=header,
|
|
686
|
+
quoting=csv.QUOTE_NONE,
|
|
687
|
+
doublequote=False,
|
|
688
|
+
)
|
|
689
|
+
except (OSError, BrokenPipeError):
|
|
690
|
+
sys.stderr.close()
|