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