dataframe-toolkit 0.14.0__tar.gz → 0.15.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/PKG-INFO +51 -3
  2. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/README.md +50 -2
  3. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/pyproject.toml +1 -1
  4. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/pyproject.toml.orig +1 -1
  5. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/print_cmd.py +8 -2
  6. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/query_cmd.py +17 -1
  7. dataframe_toolkit-0.15.0/src/dftk/common/docformats.py +246 -0
  8. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/common/io.py +46 -5
  9. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/skills/dftk/SKILL.md +6 -0
  10. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/__init__.py +0 -0
  11. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/cli.py +0 -0
  12. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/__init__.py +0 -0
  13. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/annotate_cmd.py +0 -0
  14. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/base.py +0 -0
  15. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/binx_cmd.py +0 -0
  16. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/chi_cmd.py +0 -0
  17. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/clean_cmd.py +0 -0
  18. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/cleannames_cmd.py +0 -0
  19. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/concat_cmd.py +0 -0
  20. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/corr_cmd.py +0 -0
  21. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/dataset_cmd.py +0 -0
  22. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/describe_cmd.py +0 -0
  23. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/eval_cmd.py +0 -0
  24. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/explode_cmd.py +0 -0
  25. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/fit_cmd.py +0 -0
  26. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/func_cmd.py +0 -0
  27. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/help.py +0 -0
  28. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/hist_cmd.py +0 -0
  29. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/implode_cmd.py +0 -0
  30. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/info_cmd.py +0 -0
  31. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/interp_cmd.py +0 -0
  32. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/line_cmd.py +0 -0
  33. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/melt_cmd.py +0 -0
  34. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/merge_cmd.py +0 -0
  35. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/pivot_cmd.py +0 -0
  36. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/randvar_cmd.py +0 -0
  37. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/sample_cmd.py +0 -0
  38. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/scale_cmd.py +0 -0
  39. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/scat_cmd.py +0 -0
  40. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/segid_cmd.py +0 -0
  41. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/skill_cmd.py +0 -0
  42. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/split_cmd.py +0 -0
  43. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/stat_cmd.py +0 -0
  44. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/test_cmd.py +0 -0
  45. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/transpose_cmd.py +0 -0
  46. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/wstat_cmd.py +0 -0
  47. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/common/__init__.py +0 -0
  48. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/common/copula.py +0 -0
  49. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/common/evalfuncs.py +0 -0
  50. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/common/plot.py +0 -0
  51. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/common/seed.py +0 -0
  52. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/common/seq.py +0 -0
  53. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/common/stats.py +0 -0
  54. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/io.py +0 -0
  55. {dataframe_toolkit-0.14.0 → dataframe_toolkit-0.15.0}/src/dftk/skills/dftk/references/snakemake.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: dataframe-toolkit
3
- Version: 0.14.0
3
+ Version: 0.15.0
4
4
  Summary: A command-line toolkit for DataFrame analysis and manipulation
5
5
  Keywords: dataframe,statistics,data analysis,CLI,TSV,pandas
6
6
  Author: Gunnar Pálsson
@@ -166,7 +166,7 @@ publication-quality output.
166
166
  | `sample` | Random row sampling (with or without replacement, by count or fraction) |
167
167
  | `split` | Split a dataframe into one file per group |
168
168
  | `annotate` | Read and write provenance metadata (genome, source, …) in parquet files |
169
- | `print` | Read any dftk input (TSV, parquet, stdin) and write TSV |
169
+ | `print` | Read any dftk input (TSV, parquet, stdin) and write TSV, JSON, Markdown or LaTeX |
170
170
  | `clean` | Remove leftover temp parquet pipe files from interrupted pipelines |
171
171
  | `help` | List all subcommands or show full help for one: `dftk help stat` |
172
172
 
@@ -195,7 +195,7 @@ For your own export, `dftk cleannames data.tsv --backend duckdb -o data.parquet`
195
195
 
196
196
  ### JSON files
197
197
 
198
- DuckDB reads JSON itself, so `query --sql` takes a JSON file in `FROM` (a file of records, `[{...}, {...}]`, or JSON Lines, one object per line; `'logs/*.jsonl'` reads several). When the SQL doesn't use the `data` table, dftk reads no input. Nested objects and lists would end up as one text cell each, so flatten them in the same query: `pos.*` makes a column of each field, `len()` counts a list and `unnest()` gives one row per item:
198
+ DuckDB reads JSON itself, so `query --sql` takes a JSON file in `FROM` (a file of records, `[{...}, {...}]`, or JSON Lines, one object per line; `'logs/*.jsonl'` reads several). When the SQL doesn't use the `data` table, dftk reads no input. In TSV a nested object or list is shown as one line of JSON (`["x","y"]`), which later commands can't compute on, so flatten it in the same query: `pos.*` makes a column of each field, `len()` counts a list and `unnest()` gives one row per item:
199
199
 
200
200
  ```bash
201
201
  printf '%s\n' '{"id": 1, "pos": {"chr": "1", "start": 100}, "tags": ["x", "y"]}' \
@@ -210,6 +210,32 @@ dftk query --sql "SELECT id, unnest(tags) AS tag FROM 'records.jsonl'" -o \
210
210
  | dftk pivot -i tag -v id -f count
211
211
  ```
212
212
 
213
+ A field that is a string in some records and a list in others (a variant catalog's `ReferenceRegion`, say) gets DuckDB type JSON; TSV shows its strings without quotes and its lists as JSON. `json_type()` tells them apart, so a list can become one row per item:
214
+
215
+ ```bash
216
+ printf '%s\n' '{"locus": "AFF2", "region": "chrX:1-60"}' \
217
+ '{"locus": "ATXN7", "region": ["chr3:1-30", "chr3:30-42"]}' \
218
+ > catalog.jsonl
219
+ dftk query --sql "
220
+ SELECT locus,
221
+ unnest(CASE WHEN json_type(region) = 'ARRAY' THEN region->>'\$[*]'
222
+ ELSE [region->>'\$'] END) AS region
223
+ FROM 'catalog.jsonl'"
224
+ # locus region
225
+ # AFF2 chrX:1-60
226
+ # ATXN7 chr3:1-30
227
+ # ATXN7 chr3:30-42
228
+ ```
229
+
230
+ To get JSON back, write `-o FILE.json` or `.jsonl`, or `--format json` for stdout (see [Output formats](#output-formats)). Nested values and mixed fields are written as JSON again; a key missing from a record is written as `null`:
231
+
232
+ ```bash
233
+ dftk query --sql "SELECT * FROM 'catalog.jsonl' WHERE locus LIKE 'ATXN%'" --format json
234
+ # [
235
+ # {"locus": "ATXN7", "region": ["chr3:1-30", "chr3:30-42"]}
236
+ # ]
237
+ ```
238
+
213
239
  ### Large tables: hexbin instead of points
214
240
 
215
241
  With tens of thousands of points a scatter plot is a solid blob. `--hexbin` counts points per hexagon instead (`--hexlog` for a log colour scale), and `--fit lowess` adds a smooth curve:
@@ -420,6 +446,28 @@ Older scripts give `...` as the file to read a parquet path from stdin; that
420
446
  still works. In zsh with Oh My Zsh an unquoted `...` becomes `../..`, so
421
447
  leave it out (or use `-`) in scripts you share.
422
448
 
449
+ ### Output formats
450
+
451
+ Without `-o`, output is TSV on stdout. `-o` alone, or `-o FILE.parquet`, writes parquet and prints the path for the next command; `-o FILE` writes TSV, except for these extensions:
452
+
453
+ | `-o` | Writes |
454
+ |------|--------|
455
+ | `FILE.json` | JSON array of records |
456
+ | `FILE.jsonl` | JSON Lines, one record per line |
457
+ | `FILE.md` | Markdown table (numeric columns right-aligned) |
458
+ | `FILE.tex` | bare LaTeX `tabular` with booktabs rules |
459
+
460
+ `--format tsv|json|jsonl|md|tex` writes the same formats to stdout, or overrides the extension. `--digits N` applies to all of them. The `.tex` file is only the tabular, with special characters (`_ & % $ # { } ~ ^ \ | < >`) escaped; it needs `\usepackage{booktabs}`, and goes inside your own `table` environment with `\input{tips_by_day.tex}`. Add `\usepackage[T1]{fontenc}` (pdfLaTeX's default font has no straight `"`, which JSON lists contain):
461
+
462
+ ```bash
463
+ dftk dataset tips -o | dftk pivot -i day -v tip -f mean count --digits 3 --format md
464
+ # | day | mean_tip | count_tip |
465
+ # | --- | ---: | ---: |
466
+ # | Thur | 2.77 | 62 |
467
+ # ...
468
+ dftk dataset tips -o | dftk pivot -i day -v tip -f mean count --digits 3 -o tips_by_day.tex
469
+ ```
470
+
423
471
  ### Comment lines and preambles
424
472
 
425
473
  dftk has no comment or skip-rows option: `#` is ordinary data, so headers
@@ -136,7 +136,7 @@ publication-quality output.
136
136
  | `sample` | Random row sampling (with or without replacement, by count or fraction) |
137
137
  | `split` | Split a dataframe into one file per group |
138
138
  | `annotate` | Read and write provenance metadata (genome, source, …) in parquet files |
139
- | `print` | Read any dftk input (TSV, parquet, stdin) and write TSV |
139
+ | `print` | Read any dftk input (TSV, parquet, stdin) and write TSV, JSON, Markdown or LaTeX |
140
140
  | `clean` | Remove leftover temp parquet pipe files from interrupted pipelines |
141
141
  | `help` | List all subcommands or show full help for one: `dftk help stat` |
142
142
 
@@ -165,7 +165,7 @@ For your own export, `dftk cleannames data.tsv --backend duckdb -o data.parquet`
165
165
 
166
166
  ### JSON files
167
167
 
168
- DuckDB reads JSON itself, so `query --sql` takes a JSON file in `FROM` (a file of records, `[{...}, {...}]`, or JSON Lines, one object per line; `'logs/*.jsonl'` reads several). When the SQL doesn't use the `data` table, dftk reads no input. Nested objects and lists would end up as one text cell each, so flatten them in the same query: `pos.*` makes a column of each field, `len()` counts a list and `unnest()` gives one row per item:
168
+ DuckDB reads JSON itself, so `query --sql` takes a JSON file in `FROM` (a file of records, `[{...}, {...}]`, or JSON Lines, one object per line; `'logs/*.jsonl'` reads several). When the SQL doesn't use the `data` table, dftk reads no input. In TSV a nested object or list is shown as one line of JSON (`["x","y"]`), which later commands can't compute on, so flatten it in the same query: `pos.*` makes a column of each field, `len()` counts a list and `unnest()` gives one row per item:
169
169
 
170
170
  ```bash
171
171
  printf '%s\n' '{"id": 1, "pos": {"chr": "1", "start": 100}, "tags": ["x", "y"]}' \
@@ -180,6 +180,32 @@ dftk query --sql "SELECT id, unnest(tags) AS tag FROM 'records.jsonl'" -o \
180
180
  | dftk pivot -i tag -v id -f count
181
181
  ```
182
182
 
183
+ A field that is a string in some records and a list in others (a variant catalog's `ReferenceRegion`, say) gets DuckDB type JSON; TSV shows its strings without quotes and its lists as JSON. `json_type()` tells them apart, so a list can become one row per item:
184
+
185
+ ```bash
186
+ printf '%s\n' '{"locus": "AFF2", "region": "chrX:1-60"}' \
187
+ '{"locus": "ATXN7", "region": ["chr3:1-30", "chr3:30-42"]}' \
188
+ > catalog.jsonl
189
+ dftk query --sql "
190
+ SELECT locus,
191
+ unnest(CASE WHEN json_type(region) = 'ARRAY' THEN region->>'\$[*]'
192
+ ELSE [region->>'\$'] END) AS region
193
+ FROM 'catalog.jsonl'"
194
+ # locus region
195
+ # AFF2 chrX:1-60
196
+ # ATXN7 chr3:1-30
197
+ # ATXN7 chr3:30-42
198
+ ```
199
+
200
+ To get JSON back, write `-o FILE.json` or `.jsonl`, or `--format json` for stdout (see [Output formats](#output-formats)). Nested values and mixed fields are written as JSON again; a key missing from a record is written as `null`:
201
+
202
+ ```bash
203
+ dftk query --sql "SELECT * FROM 'catalog.jsonl' WHERE locus LIKE 'ATXN%'" --format json
204
+ # [
205
+ # {"locus": "ATXN7", "region": ["chr3:1-30", "chr3:30-42"]}
206
+ # ]
207
+ ```
208
+
183
209
  ### Large tables: hexbin instead of points
184
210
 
185
211
  With tens of thousands of points a scatter plot is a solid blob. `--hexbin` counts points per hexagon instead (`--hexlog` for a log colour scale), and `--fit lowess` adds a smooth curve:
@@ -390,6 +416,28 @@ Older scripts give `...` as the file to read a parquet path from stdin; that
390
416
  still works. In zsh with Oh My Zsh an unquoted `...` becomes `../..`, so
391
417
  leave it out (or use `-`) in scripts you share.
392
418
 
419
+ ### Output formats
420
+
421
+ Without `-o`, output is TSV on stdout. `-o` alone, or `-o FILE.parquet`, writes parquet and prints the path for the next command; `-o FILE` writes TSV, except for these extensions:
422
+
423
+ | `-o` | Writes |
424
+ |------|--------|
425
+ | `FILE.json` | JSON array of records |
426
+ | `FILE.jsonl` | JSON Lines, one record per line |
427
+ | `FILE.md` | Markdown table (numeric columns right-aligned) |
428
+ | `FILE.tex` | bare LaTeX `tabular` with booktabs rules |
429
+
430
+ `--format tsv|json|jsonl|md|tex` writes the same formats to stdout, or overrides the extension. `--digits N` applies to all of them. The `.tex` file is only the tabular, with special characters (`_ & % $ # { } ~ ^ \ | < >`) escaped; it needs `\usepackage{booktabs}`, and goes inside your own `table` environment with `\input{tips_by_day.tex}`. Add `\usepackage[T1]{fontenc}` (pdfLaTeX's default font has no straight `"`, which JSON lists contain):
431
+
432
+ ```bash
433
+ dftk dataset tips -o | dftk pivot -i day -v tip -f mean count --digits 3 --format md
434
+ # | day | mean_tip | count_tip |
435
+ # | --- | ---: | ---: |
436
+ # | Thur | 2.77 | 62 |
437
+ # ...
438
+ dftk dataset tips -o | dftk pivot -i day -v tip -f mean count --digits 3 -o tips_by_day.tex
439
+ ```
440
+
393
441
  ### Comment lines and preambles
394
442
 
395
443
  dftk has no comment or skip-rows option: `#` is ordinary data, so headers
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dataframe-toolkit"
3
- version = "0.14.0"
3
+ version = "0.15.0"
4
4
  description = "A command-line toolkit for DataFrame analysis and manipulation"
5
5
  readme = "README.md"
6
6
  keywords = [
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dataframe-toolkit"
3
- version = "0.14.0"
3
+ version = "0.15.0"
4
4
  description = "A command-line toolkit for DataFrame analysis and manipulation"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -2,7 +2,8 @@
2
2
  dftk.commands.print_cmd — the ``dftk print`` subcommand.
3
3
 
4
4
  Reads any dftk-compatible input (TSV file, TSV on stdin, or a parquet pipe
5
- path on stdin) and writes TSV to stdout. Acts as the terminal stage of a
5
+ path on stdin) and writes TSV to stdout, or JSON, a Markdown table or a
6
+ LaTeX tabular with --format json|jsonl|md|tex. Acts as the terminal stage of a
6
7
  dftk pipeline:
7
8
 
8
9
  dftk stat -g grp -s val -o \\
@@ -12,6 +13,11 @@ dftk pipeline:
12
13
  With --noheader and --move it names the columns of a headerless file:
13
14
 
14
15
  dftk print data.txt --noheader --move V1:chr V2:begin V3:end
16
+
17
+ A table for a document:
18
+
19
+ dftk dataset tips -o | dftk pivot -i day -v tip -f mean -o \\
20
+ | dftk print --digits 3 --format md
15
21
  """
16
22
 
17
23
  import argparse
@@ -24,7 +30,7 @@ class PrintCommand(BaseCommand):
24
30
  """Read any dftk input and write TSV to stdout."""
25
31
 
26
32
  name = "print"
27
- help = "Convert any input (TSV or parquet pipe) to TSV on stdout"
33
+ help = "Write any input (TSV or parquet pipe) as TSV, JSON, Markdown or LaTeX"
28
34
 
29
35
  def add_arguments(self, parser: argparse.ArgumentParser) -> None:
30
36
  # The standard output options (--move, --select, --removeheader, …)
@@ -33,6 +33,7 @@ import os
33
33
  import pandas as pd
34
34
 
35
35
  from dftk.commands.base import BaseCommand
36
+ from dftk.common import docformats
36
37
  from dftk.common.io import io
37
38
 
38
39
  try:
@@ -100,7 +101,11 @@ def _sql_query(args: argparse.Namespace, sql: str, table: str) -> pd.DataFrame:
100
101
  df = result.df()
101
102
  except Exception as exc:
102
103
  raise ValueError(f"DuckDB SQL failed: {exc}") from exc
103
- return _hugeint_to_int64(df, description)
104
+ df = _hugeint_to_int64(df, description)
105
+ docformats.mark_json_columns(
106
+ df, [d[0] for d in description or [] if str(d[1]) == "JSON"]
107
+ )
108
+ return df
104
109
 
105
110
 
106
111
  def _uses_table(sql: str, table: str) -> bool:
@@ -256,6 +261,17 @@ column of each field, len() counts a list, unnest() gives a row per item:
256
261
  "SELECT * EXCLUDE (pos, tags), pos.* FROM 'records.jsonl'" -o \\
257
262
  | dftk stat -c start
258
263
 
264
+ In TSV output a list or object that is left nested is shown as one line
265
+ of JSON (["x","y"]). A field that is a string in some records and a list
266
+ in others gets DuckDB type JSON; its plain strings are shown without
267
+ quotes. To get JSON back, give -o FILE.json (or .jsonl) or --format json;
268
+ nested values, and such mixed fields, are written as JSON again, and a key
269
+ missing from a record is written as null:
270
+
271
+ dftk query --sql "SELECT * FROM 'records.jsonl' WHERE pos.chr = '1'" \\
272
+ --format json
273
+ dftk query --sql "SELECT id, tags FROM 'records.jsonl'" -o ids.jsonl
274
+
259
275
  OUT-OF-CORE LARGE FILES
260
276
  ------------------------
261
277
  When the input is a named .parquet file, DuckDB queries it natively without
@@ -0,0 +1,246 @@
1
+ """
2
+ dftk.common.docformats — JSON, Markdown and LaTeX output for io.printdf.
3
+
4
+ Nested values (lists, structs, maps) come from DuckDB as numpy arrays and
5
+ dicts. TSV, Markdown and LaTeX show them as one line of JSON text; JSON
6
+ output writes them as JSON.
7
+
8
+ A DuckDB column of type JSON (from a JSON file whose field is a string in one
9
+ record and a list in another) reaches pandas as JSON text, e.g.
10
+ '"chrX:1-2"' and '["chr3:1-2","chr3:2-3"]'. query_cmd lists such columns in
11
+ the parquet metadata key JSON_COLUMNS_KEY, so they survive -o pipes; the text
12
+ view drops the quotes around a plain string and JSON output parses them.
13
+ """
14
+
15
+ import contextlib
16
+ import decimal
17
+ import json
18
+ import math
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+
23
+ JSON_COLUMNS_KEY = "dftk_json_columns"
24
+
25
+ # Output format by file extension; --format names the same formats
26
+ EXTENSIONS = {".json": "json", ".jsonl": "jsonl", ".md": "md", ".tex": "tex"}
27
+ FORMATS = ("tsv", "json", "jsonl", "md", "tex")
28
+
29
+
30
+ def json_columns(df: pd.DataFrame) -> list[str]:
31
+ """Columns of *df* that hold DuckDB JSON text (see module docstring)."""
32
+ names = df.attrs.get("_parquet_meta", {}).get(JSON_COLUMNS_KEY, "")
33
+ return [c for c in names.split(",") if c in df.columns]
34
+
35
+
36
+ def mark_json_columns(df: pd.DataFrame, columns: list[str]) -> None:
37
+ """Record *columns* as JSON text in df.attrs (carried into parquet)."""
38
+ meta = dict(df.attrs.get("_parquet_meta", {}))
39
+ if columns:
40
+ meta[JSON_COLUMNS_KEY] = ",".join(columns)
41
+ else:
42
+ meta.pop(JSON_COLUMNS_KEY, None)
43
+ df.attrs["_parquet_meta"] = meta
44
+
45
+
46
+ def rename_json_columns(df: pd.DataFrame, mapping: dict[str, str]) -> None:
47
+ """Follow a --move rename in the JSON column list."""
48
+ names = df.attrs.get("_parquet_meta", {}).get(JSON_COLUMNS_KEY)
49
+ if names:
50
+ mark_json_columns(df, [mapping.get(c, c) for c in names.split(",")])
51
+
52
+
53
+ def _plain(value):
54
+ """json.dumps fallback for numpy, Decimal and other non-JSON values."""
55
+ if isinstance(value, np.ndarray): # MaskedArray.tolist() gives None
56
+ return value.tolist()
57
+ if isinstance(value, np.generic):
58
+ return value.item()
59
+ if isinstance(value, decimal.Decimal):
60
+ return float(value)
61
+ return str(value)
62
+
63
+
64
+ def _is_nested(value) -> bool:
65
+ return isinstance(value, (np.ndarray, list, tuple, dict))
66
+
67
+
68
+ def _dumps(value) -> str:
69
+ return json.dumps(value, default=_plain, ensure_ascii=False, separators=(",", ":"))
70
+
71
+
72
+ def nested_as_text(df: pd.DataFrame) -> pd.DataFrame:
73
+ """Return *df* with nested values and JSON-column text as one-line text."""
74
+ jcols = set(json_columns(df))
75
+ out = None
76
+ for col in df.columns:
77
+ dtype = df[col].dtype
78
+ if not (
79
+ pd.api.types.is_object_dtype(dtype) or pd.api.types.is_string_dtype(dtype)
80
+ ):
81
+ continue
82
+ if col in jcols:
83
+ new = df[col].map(_json_text_cell)
84
+ elif df[col].map(_is_nested).any():
85
+ new = df[col].map(lambda v: _dumps(v) if _is_nested(v) else v)
86
+ else:
87
+ continue
88
+ if out is None:
89
+ out = df.copy()
90
+ out[col] = new
91
+ return df if out is None else out
92
+
93
+
94
+ def _json_text_cell(value):
95
+ """'"chrX:1-2"' → 'chrX:1-2'; a list or object stays JSON text."""
96
+ if not isinstance(value, str):
97
+ return value
98
+ try:
99
+ parsed = json.loads(value)
100
+ except ValueError:
101
+ return value
102
+ return parsed if isinstance(parsed, str) else value
103
+
104
+
105
+ def _json_value(value, digits: int | None, is_json_text: bool):
106
+ """A cell as a JSON-ready Python value."""
107
+ if is_json_text and isinstance(value, str):
108
+ try:
109
+ return json.loads(value)
110
+ except ValueError:
111
+ return value
112
+ if _is_nested(value):
113
+ return json.loads(_dumps(value))
114
+ if value is None or value is pd.NA or value is pd.NaT:
115
+ return None
116
+ if isinstance(value, np.generic):
117
+ value = value.item()
118
+ if isinstance(value, float):
119
+ if math.isnan(value) or math.isinf(value):
120
+ return None
121
+ if digits is not None:
122
+ return float(f"{value:.{digits}g}")
123
+ return value
124
+ if isinstance(value, (int, bool, str)):
125
+ return value
126
+ return _plain(value)
127
+
128
+
129
+ def write_json(df: pd.DataFrame, dest, lines: bool, digits: int | None) -> None:
130
+ """Write *df* as an array of records, or one record per line (*lines*).
131
+
132
+ A missing value is written as null; the key is kept.
133
+ """
134
+ jcols = set(json_columns(df))
135
+ cols = list(df.columns)
136
+ records = (
137
+ {
138
+ col: _json_value(value, digits, col in jcols)
139
+ for col, value in zip(cols, row, strict=True)
140
+ }
141
+ for row in df.itertuples(index=False, name=None)
142
+ )
143
+ with _open(dest) as fh:
144
+ if lines:
145
+ for record in records:
146
+ fh.write(json.dumps(record, ensure_ascii=False) + "\n")
147
+ else:
148
+ items = [json.dumps(r, ensure_ascii=False) for r in records]
149
+ fh.write("[\n" + ",\n".join(" " + s for s in items) + "\n]\n")
150
+
151
+
152
+ def _cell_text(value, float_format: str | None) -> str:
153
+ if value is None or value is pd.NA or value is pd.NaT:
154
+ return ""
155
+ if isinstance(value, (float, np.floating)):
156
+ if math.isnan(value):
157
+ return ""
158
+ return float_format % value if float_format else str(float(value))
159
+ return str(value)
160
+
161
+
162
+ def _is_numeric(series: pd.Series) -> bool:
163
+ return pd.api.types.is_numeric_dtype(series) and not pd.api.types.is_bool_dtype(
164
+ series
165
+ )
166
+
167
+
168
+ def _table(df: pd.DataFrame, float_format: str | None, escape):
169
+ """Escaped header and rows of *df* (nested values as JSON text)."""
170
+ df = nested_as_text(df)
171
+ header = [escape(str(c)) for c in df.columns]
172
+ rows = [
173
+ [escape(_cell_text(v, float_format)) for v in row]
174
+ for row in df.itertuples(index=False, name=None)
175
+ ]
176
+ numeric = [_is_numeric(df[c]) for c in df.columns]
177
+ return header, rows, numeric
178
+
179
+
180
+ def _escape_markdown(text: str) -> str:
181
+ return text.replace("\\", "\\\\").replace("|", "\\|").replace("\n", " ")
182
+
183
+
184
+ def write_markdown(
185
+ df: pd.DataFrame, dest, float_format: str | None, header: bool
186
+ ) -> None:
187
+ """Write *df* as a pipe table; numeric columns are right-aligned.
188
+
189
+ A Markdown table always has a header row; with --removeheader it is empty.
190
+ """
191
+ names, rows, numeric = _table(df, float_format, _escape_markdown)
192
+ if not header:
193
+ names = [""] * len(names)
194
+ rule = ["---:" if num else "---" for num in numeric]
195
+ with _open(dest) as fh:
196
+ for cells in [names, rule, *rows]:
197
+ fh.write("| " + " | ".join(cells) + " |\n")
198
+
199
+
200
+ _LATEX_SPECIAL = {
201
+ "\\": r"\textbackslash{}",
202
+ "&": r"\&",
203
+ "%": r"\%",
204
+ "$": r"\$",
205
+ "#": r"\#",
206
+ "_": r"\_",
207
+ "{": r"\{",
208
+ "}": r"\}",
209
+ "~": r"\textasciitilde{}",
210
+ "^": r"\textasciicircum{}",
211
+ # The default OT1 font prints these as an em dash and as inverted ! and ?
212
+ "|": r"\textbar{}",
213
+ "<": r"\textless{}",
214
+ ">": r"\textgreater{}",
215
+ "\n": " ",
216
+ }
217
+
218
+
219
+ def _escape_latex(text: str) -> str:
220
+ return "".join(_LATEX_SPECIAL.get(ch, ch) for ch in text)
221
+
222
+
223
+ def write_latex(df: pd.DataFrame, dest, float_format: str | None, header: bool) -> None:
224
+ """Write *df* as a bare booktabs tabular (needs \\usepackage{booktabs}).
225
+
226
+ Numeric columns are right-aligned (r), others left (l). Wrap it in your
227
+ own table environment with \\input{file.tex}. A " (in JSON lists, say)
228
+ and ~ ^ print as intended with \\usepackage[T1]{fontenc}; the default
229
+ OT1 font has no straight double quote.
230
+ """
231
+ names, rows, numeric = _table(df, float_format, _escape_latex)
232
+ spec = "".join("r" if num else "l" for num in numeric)
233
+ with _open(dest) as fh:
234
+ fh.write(f"\\begin{{tabular}}{{{spec}}}\n\\toprule\n")
235
+ if header:
236
+ fh.write(" & ".join(names) + " \\\\\n\\midrule\n")
237
+ for cells in rows:
238
+ fh.write(" & ".join(cells) + " \\\\\n")
239
+ fh.write("\\bottomrule\n\\end{tabular}\n")
240
+
241
+
242
+ def _open(dest):
243
+ """Open *dest* for writing if it is a path; use it as is if a stream."""
244
+ if isinstance(dest, str):
245
+ return open(dest, "w", encoding="utf-8", newline="")
246
+ return contextlib.nullcontext(dest)
@@ -10,6 +10,10 @@ Ported from df/src/df/dfio.py with the following changes:
10
10
  -o FILE.parquet → named parquet written to FILE, path printed to stdout
11
11
  (NOT auto-deleted — reusable later in the same pipeline)
12
12
  -o FILE → TSV written to FILE, nothing on stdout
13
+ -o FILE.json / .jsonl / .md / .tex
14
+ → JSON, JSON Lines, Markdown or LaTeX table written to
15
+ FILE (common/docformats.py); --format sends the same
16
+ formats to stdout
13
17
 
14
18
  • Parquet pipes: when stdin (no DATAFILE, or -) or a pipe given as
15
19
  DATAFILE (<(...)) holds just the path of an existing .parquet file, that
@@ -18,7 +22,7 @@ Ported from df/src/df/dfio.py with the following changes:
18
22
  they can be referenced again downstream. The older ``...`` DATAFILE
19
23
  marker still works and requires a parquet path.
20
24
 
21
- • Removed --sqlout and --latex (niche output formats).
25
+ • Removed --sqlout. --latex became -o FILE.tex / --format tex.
22
26
  • Removed --experimental (unused).
23
27
  • parser_printdf kept as an alias for parser_output for compatibility.
24
28
  """
@@ -36,6 +40,8 @@ import tempfile
36
40
  import numpy as np
37
41
  import pandas as pd
38
42
 
43
+ from dftk.common import docformats
44
+
39
45
  try:
40
46
  import duckdb
41
47
  except ImportError:
@@ -529,12 +535,27 @@ class io:
529
535
  "path to stdout (auto-deleted by the next reader). "
530
536
  "Use -o FILE.parquet to write a named parquet and print its path to "
531
537
  "stdout (not auto-deleted — reusable later in the same pipeline). "
532
- "Use -o FILE (no .parquet) to write TSV to FILE with no stdout output."
538
+ "Use -o FILE (no .parquet) to write TSV to FILE with no stdout output; "
539
+ "FILE.json, .jsonl, .md or .tex writes JSON, JSON Lines, a "
540
+ "Markdown table or a LaTeX tabular instead."
541
+ ),
542
+ )
543
+ g.add_argument(
544
+ "--format",
545
+ choices=docformats.FORMATS,
546
+ default=None,
547
+ help=(
548
+ "Output format for stdout or -o FILE, instead of the format "
549
+ "the file extension gives: tsv, json (array of records), "
550
+ "jsonl (one record per line), md (Markdown table), tex "
551
+ "(bare booktabs tabular: \\usepackage{booktabs} and [T1]{fontenc}, "
552
+ "then \\input the file). Not with parquet output."
533
553
  ),
534
554
  )
535
555
  g.add_argument(
536
556
  "--digits",
537
- help="Significant floating-point digits in TSV output",
557
+ help="Significant floating-point digits in TSV, JSON, Markdown and "
558
+ "LaTeX output",
538
559
  type=int,
539
560
  default=None,
540
561
  )
@@ -688,6 +709,7 @@ class io:
688
709
  try:
689
710
  old, new = item.split(":")
690
711
  df = df.rename(columns={old: new})
712
+ docformats.rename_json_columns(df, {old: new})
691
713
  except Exception:
692
714
  logging.warning(f"--move: failed to rename from {item!r}")
693
715
 
@@ -836,12 +858,22 @@ class io:
836
858
 
837
859
  # Header: written unless --removeheader (--noheader is about the input)
838
860
  header = not getattr(args, "removeheader", False)
839
- if not header and (output == "" or str(output).endswith(".parquet")):
861
+ to_parquet = output == "" or str(output).endswith(".parquet")
862
+ if not header and to_parquet:
840
863
  logging.warning(
841
864
  "--removeheader has no effect on parquet output (-o); parquet "
842
865
  "always keeps column names"
843
866
  )
844
867
 
868
+ fmt: str | None = getattr(args, "format", None)
869
+ if fmt is not None and to_parquet:
870
+ raise ValueError(
871
+ f"--format {fmt} can't be combined with parquet output (-o, "
872
+ "-o FILE.parquet): give -o FILE or leave -o out"
873
+ )
874
+ if fmt is None and output:
875
+ fmt = docformats.EXTENSIONS.get(os.path.splitext(output)[1].lower())
876
+
845
877
  if output == "":
846
878
  # Pipe mode — write temp parquet, print path
847
879
  os.makedirs(DFTK_TMPDIR, exist_ok=True)
@@ -857,13 +889,22 @@ class io:
857
889
  _write_parquet(df, output, meta=meta or None)
858
890
  print(output)
859
891
 
892
+ elif fmt in ("json", "jsonl"):
893
+ dest = output if output is not None else sys.stdout
894
+ docformats.write_json(df, dest, lines=fmt == "jsonl", digits=digits)
895
+
896
+ elif fmt in ("md", "tex"):
897
+ dest = output if output is not None else sys.stdout
898
+ write = docformats.write_markdown if fmt == "md" else docformats.write_latex
899
+ write(df, dest, float_format=float_format, header=header)
900
+
860
901
  else:
861
902
  # TSV — stdout or named file
862
903
  if digits is not None:
863
904
  _warn_digits_exponent(df, digits)
864
905
  dest = output if output is not None else sys.stdout
865
906
  _write_tsv(
866
- df,
907
+ docformats.nested_as_text(df),
867
908
  dest,
868
909
  sep="\t",
869
910
  index=False,
@@ -87,6 +87,12 @@ columns), `--drop COL…`, `--move OLD:NEW…` (rename), `--postquery EXPR`,
87
87
  `--dropna`, `--na_rep VAL`, `--removeheader`, and
88
88
  `--meta KEY=VALUE` (provenance, see below).
89
89
 
90
+ Output format: TSV unless `-o FILE` ends in `.json` (array of records),
91
+ `.jsonl`, `.md` (Markdown table) or `.tex` (bare booktabs tabular, for
92
+ `\input` in a LaTeX table); `--format tsv|json|jsonl|md|tex` does the same
93
+ on stdout. Lists and nested values are one line of JSON in TSV and stay
94
+ nested in JSON output.
95
+
90
96
  ## Choosing a command
91
97
 
92
98
  Look at a table first: