dataframe-toolkit 0.13.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.13.0 → dataframe_toolkit-0.15.0}/PKG-INFO +81 -2
  2. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/README.md +80 -1
  3. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/pyproject.toml +1 -1
  4. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/pyproject.toml.orig +1 -1
  5. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/print_cmd.py +8 -2
  6. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/query_cmd.py +78 -1
  7. dataframe_toolkit-0.15.0/src/dftk/common/docformats.py +246 -0
  8. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/common/io.py +46 -5
  9. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/skills/dftk/SKILL.md +7 -0
  10. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/__init__.py +0 -0
  11. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/cli.py +0 -0
  12. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/__init__.py +0 -0
  13. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/annotate_cmd.py +0 -0
  14. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/base.py +0 -0
  15. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/binx_cmd.py +0 -0
  16. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/chi_cmd.py +0 -0
  17. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/clean_cmd.py +0 -0
  18. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/cleannames_cmd.py +0 -0
  19. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/concat_cmd.py +0 -0
  20. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/corr_cmd.py +0 -0
  21. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/dataset_cmd.py +0 -0
  22. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/describe_cmd.py +0 -0
  23. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/eval_cmd.py +0 -0
  24. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/explode_cmd.py +0 -0
  25. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/fit_cmd.py +0 -0
  26. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/func_cmd.py +0 -0
  27. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/help.py +0 -0
  28. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/hist_cmd.py +0 -0
  29. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/implode_cmd.py +0 -0
  30. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/info_cmd.py +0 -0
  31. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/interp_cmd.py +0 -0
  32. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/line_cmd.py +0 -0
  33. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/melt_cmd.py +0 -0
  34. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/merge_cmd.py +0 -0
  35. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/pivot_cmd.py +0 -0
  36. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/randvar_cmd.py +0 -0
  37. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/sample_cmd.py +0 -0
  38. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/scale_cmd.py +0 -0
  39. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/scat_cmd.py +0 -0
  40. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/segid_cmd.py +0 -0
  41. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/skill_cmd.py +0 -0
  42. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/split_cmd.py +0 -0
  43. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/stat_cmd.py +0 -0
  44. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/test_cmd.py +0 -0
  45. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/transpose_cmd.py +0 -0
  46. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/commands/wstat_cmd.py +0 -0
  47. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/common/__init__.py +0 -0
  48. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/common/copula.py +0 -0
  49. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/common/evalfuncs.py +0 -0
  50. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/common/plot.py +0 -0
  51. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/common/seed.py +0 -0
  52. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/common/seq.py +0 -0
  53. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/common/stats.py +0 -0
  54. {dataframe_toolkit-0.13.0 → dataframe_toolkit-0.15.0}/src/dftk/io.py +0 -0
  55. {dataframe_toolkit-0.13.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.13.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
 
@@ -193,6 +193,49 @@ dftk dataset fertility -o \
193
193
 
194
194
  For your own export, `dftk cleannames data.tsv --backend duckdb -o data.parquet` also keeps integer columns with empty cells as integers.
195
195
 
196
+ ### JSON files
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. 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
+
200
+ ```bash
201
+ printf '%s\n' '{"id": 1, "pos": {"chr": "1", "start": 100}, "tags": ["x", "y"]}' \
202
+ '{"id": 2, "pos": {"chr": "2", "start": 200}, "tags": []}' \
203
+ > records.jsonl
204
+ dftk query --sql \
205
+ "SELECT * EXCLUDE (pos, tags), pos.*, len(tags) AS ntags FROM 'records.jsonl'"
206
+ # id chr start ntags
207
+ # 1 1 100 2
208
+ # 2 2 200 0
209
+ dftk query --sql "SELECT id, unnest(tags) AS tag FROM 'records.jsonl'" -o \
210
+ | dftk pivot -i tag -v id -f count
211
+ ```
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
+
196
239
  ### Large tables: hexbin instead of points
197
240
 
198
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:
@@ -390,6 +433,8 @@ All commands accept:
390
433
  a previous command's bare `-o` (that temp file is then read and deleted)
391
434
  - A pipe as the filename, e.g. `<(grep -v '^#' data.tsv)` or `<(dftk … -o)`,
392
435
  read the same way as stdin
436
+ - JSON through `query --sql "SELECT … FROM 'f.jsonl'"`, which needs no
437
+ other input (see [JSON files](#json-files))
393
438
 
394
439
  ```bash
395
440
  dftk dataset tips -o | dftk stat -c tip -g day # parquet path on stdin
@@ -401,6 +446,28 @@ Older scripts give `...` as the file to read a parquet path from stdin; that
401
446
  still works. In zsh with Oh My Zsh an unquoted `...` becomes `../..`, so
402
447
  leave it out (or use `-`) in scripts you share.
403
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
+
404
471
  ### Comment lines and preambles
405
472
 
406
473
  dftk has no comment or skip-rows option: `#` is ordinary data, so headers
@@ -427,6 +494,18 @@ dftk print data.txt --noheader --move V1:chr V2:begin V3:end -o named.tsv
427
494
  dftk eval data.txt --noheader -f "len = V3 - V2" --removeheader # no header out
428
495
  ```
429
496
 
497
+ In `merge`, `--noheader` applies to both files, so both sides are named
498
+ `V1 V2 …`. The key appears once and the right side's other columns get the
499
+ `_r` suffix. `--move` runs after the merge, so it renames the merged names:
500
+
501
+ ```bash
502
+ dftk merge -l left.txt -r right.txt -k V1 --noheader
503
+ # V1 V2 V3 V2_r V3_r
504
+ dftk merge -l left.txt -r right.txt -k V1 --noheader \
505
+ --move V1:id V2:n V3:s V2_r:n2 V3_r:s2
506
+ # id n s n2 s2
507
+ ```
508
+
430
509
  Parquet always has column names, so `--removeheader` with `-o` and
431
510
  `--noheader` on parquet input do nothing (dftk warns).
432
511
 
@@ -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
 
@@ -163,6 +163,49 @@ dftk dataset fertility -o \
163
163
 
164
164
  For your own export, `dftk cleannames data.tsv --backend duckdb -o data.parquet` also keeps integer columns with empty cells as integers.
165
165
 
166
+ ### JSON files
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. 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
+
170
+ ```bash
171
+ printf '%s\n' '{"id": 1, "pos": {"chr": "1", "start": 100}, "tags": ["x", "y"]}' \
172
+ '{"id": 2, "pos": {"chr": "2", "start": 200}, "tags": []}' \
173
+ > records.jsonl
174
+ dftk query --sql \
175
+ "SELECT * EXCLUDE (pos, tags), pos.*, len(tags) AS ntags FROM 'records.jsonl'"
176
+ # id chr start ntags
177
+ # 1 1 100 2
178
+ # 2 2 200 0
179
+ dftk query --sql "SELECT id, unnest(tags) AS tag FROM 'records.jsonl'" -o \
180
+ | dftk pivot -i tag -v id -f count
181
+ ```
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
+
166
209
  ### Large tables: hexbin instead of points
167
210
 
168
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:
@@ -360,6 +403,8 @@ All commands accept:
360
403
  a previous command's bare `-o` (that temp file is then read and deleted)
361
404
  - A pipe as the filename, e.g. `<(grep -v '^#' data.tsv)` or `<(dftk … -o)`,
362
405
  read the same way as stdin
406
+ - JSON through `query --sql "SELECT … FROM 'f.jsonl'"`, which needs no
407
+ other input (see [JSON files](#json-files))
363
408
 
364
409
  ```bash
365
410
  dftk dataset tips -o | dftk stat -c tip -g day # parquet path on stdin
@@ -371,6 +416,28 @@ Older scripts give `...` as the file to read a parquet path from stdin; that
371
416
  still works. In zsh with Oh My Zsh an unquoted `...` becomes `../..`, so
372
417
  leave it out (or use `-`) in scripts you share.
373
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
+
374
441
  ### Comment lines and preambles
375
442
 
376
443
  dftk has no comment or skip-rows option: `#` is ordinary data, so headers
@@ -397,6 +464,18 @@ dftk print data.txt --noheader --move V1:chr V2:begin V3:end -o named.tsv
397
464
  dftk eval data.txt --noheader -f "len = V3 - V2" --removeheader # no header out
398
465
  ```
399
466
 
467
+ In `merge`, `--noheader` applies to both files, so both sides are named
468
+ `V1 V2 …`. The key appears once and the right side's other columns get the
469
+ `_r` suffix. `--move` runs after the merge, so it renames the merged names:
470
+
471
+ ```bash
472
+ dftk merge -l left.txt -r right.txt -k V1 --noheader
473
+ # V1 V2 V3 V2_r V3_r
474
+ dftk merge -l left.txt -r right.txt -k V1 --noheader \
475
+ --move V1:id V2:n V3:s V2_r:n2 V3_r:s2
476
+ # id n s n2 s2
477
+ ```
478
+
400
479
  Parquet always has column names, so `--removeheader` with `-o` and
401
480
  `--noheader` on parquet input do nothing (dftk warns).
402
481
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dataframe-toolkit"
3
- version = "0.13.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.13.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, …)
@@ -26,12 +26,14 @@ sql mode (--sql)
26
26
  """
27
27
 
28
28
  import argparse
29
+ import json
29
30
  import logging
30
31
  import os
31
32
 
32
33
  import pandas as pd
33
34
 
34
35
  from dftk.commands.base import BaseCommand
36
+ from dftk.common import docformats
35
37
  from dftk.common.io import io
36
38
 
37
39
  try:
@@ -85,6 +87,9 @@ def _sql_query(args: argparse.Namespace, sql: str, table: str) -> pd.DataFrame:
85
87
  if direct_parquet:
86
88
  rel = conn.read_parquet(filename)
87
89
  conn.register(table, rel)
90
+ elif filename is None and not _uses_table(sql, table):
91
+ # SQL reads its own files (FROM 'f.jsonl'): don't wait for stdin.
92
+ pass
88
93
  else:
89
94
  # stdin, TSV files, or parquet-pipe: read into pandas first.
90
95
  df = io.read(args)
@@ -96,7 +101,42 @@ def _sql_query(args: argparse.Namespace, sql: str, table: str) -> pd.DataFrame:
96
101
  df = result.df()
97
102
  except Exception as exc:
98
103
  raise ValueError(f"DuckDB SQL failed: {exc}") from exc
99
- 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
109
+
110
+
111
+ def _uses_table(sql: str, table: str) -> bool:
112
+ """Return True unless DuckDB's parser says *sql* never names *table*.
113
+
114
+ Uses json_serialize_sql, which only parses (duckdb.get_table_names also
115
+ binds, and fails on valid SQL such as SELECT pos.*). On a parse error
116
+ the input is read as before and the query itself reports the error.
117
+ """
118
+ try:
119
+ tree = json.loads(
120
+ duckdb.execute("SELECT json_serialize_sql(?)", [sql]).fetchone()[0]
121
+ )
122
+ except Exception: # an old duckdb without json_serialize_sql
123
+ return True
124
+ if tree.get("error"):
125
+ return True
126
+ names: set[str] = set()
127
+
128
+ def walk(node) -> None:
129
+ if isinstance(node, dict):
130
+ if node.get("type") == "BASE_TABLE":
131
+ names.add(node.get("table_name", "").lower())
132
+ for value in node.values():
133
+ walk(value)
134
+ elif isinstance(node, list):
135
+ for value in node:
136
+ walk(value)
137
+
138
+ walk(tree)
139
+ return table.lower() in names
100
140
 
101
141
 
102
142
  def _hugeint_to_int64(df: pd.DataFrame, description) -> pd.DataFrame:
@@ -197,6 +237,41 @@ CTEs:
197
237
  )
198
238
  SELECT * FROM ranked WHERE rank <= 3"
199
239
 
240
+ JSON FILES
241
+ ----------
242
+ DuckDB reads JSON itself: name the file in FROM, in single quotes. A file
243
+ of records ([{...}, {...}]) and JSON Lines (one object per line) both
244
+ work, and 'logs/*.jsonl' reads several files as one table. When the SQL
245
+ doesn't use the data table, dftk reads no input, so nothing is piped in:
246
+
247
+ printf '%s\\n' '{"id": 1, "pos": {"chr": "1", "start": 100}, "tags": ["x", "y"]}' \\
248
+ '{"id": 2, "pos": {"chr": "2", "start": 200}, "tags": []}' \\
249
+ > records.jsonl
250
+ dftk query --sql "SELECT * FROM 'records.jsonl'"
251
+
252
+ Nested objects and lists come out as one text cell each, which later
253
+ commands can't compute on. Flatten them in the same query: pos.* makes a
254
+ column of each field, len() counts a list, unnest() gives a row per item:
255
+
256
+ dftk query --sql \\
257
+ "SELECT * EXCLUDE (pos, tags), pos.*, len(tags) AS ntags
258
+ FROM 'records.jsonl'"
259
+ dftk query --sql "SELECT id, unnest(tags) AS tag FROM 'records.jsonl'"
260
+ dftk query --sql \\
261
+ "SELECT * EXCLUDE (pos, tags), pos.* FROM 'records.jsonl'" -o \\
262
+ | dftk stat -c start
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
+
200
275
  OUT-OF-CORE LARGE FILES
201
276
  ------------------------
202
277
  When the input is a named .parquet file, DuckDB queries it natively without
@@ -254,6 +329,8 @@ class QueryCommand(BaseCommand):
254
329
  "Full DuckDB SQL query. The input is registered as a virtual "
255
330
  "table named 'data' (see --table). Named .parquet inputs are "
256
331
  "queried natively without full load into memory. "
332
+ "SQL that only reads its own files (FROM 'f.jsonl') "
333
+ "needs no input. "
257
334
  "Cannot be combined with -q. Requires duckdb."
258
335
  ),
259
336
  )
@@ -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:
@@ -104,6 +110,7 @@ Transform:
104
110
  | New column from a formula: `z = x / y`, `where(...)`, string, DNA and statistical functions | `eval -f`, `eval -c` (constant), `eval -s` (string) |
105
111
  | Fill empty cells: from another column, a group mean, the row above (`ffill`, per group with `groupby`) or by interpolation; sort first, fills follow row order | `eval -f "x = x.fillna(y)"`, `"x = x.groupby(g).ffill()"` (see `eval -h`) |
106
112
  | Filter rows (pandas expression) or run any DuckDB SQL (`FROM data`) | `query -q` / `query --sql` |
113
+ | Read JSON or JSON Lines (no input needed); flatten nested fields with `pos.*`, lists with `unnest()` | `query --sql "SELECT * EXCLUDE (pos), pos.* FROM 'f.jsonl'"` |
107
114
  | Join two tables, including anti-joins | `merge DATAFILE -r OTHER -k KEY [-t left] [--only left]` |
108
115
  | Stack tables row-wise | `concat A B C [--sourcecol src]` |
109
116
  | Wide to long / long to wide plus aggregation | `melt` / `pivot -i ROWS -g COLS -v VALS -f FUNCS` |