From f2aea1b108f46b812740647aa6624d759e2af015 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 26 Feb 2026 20:56:22 -0800 Subject: [PATCH] --csv, --tsv, --nl, -t/--table output formats, closes #31 --- README.md | 6 ++- dclient/cli.py | 99 ++++++++++++++++++++++++++++++++++++++++++++--- docs/inserting.md | 4 +- docs/queries.md | 49 +++++++++++++++++++++++ 4 files changed, 150 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 96b7764..75225c6 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Much of the functionality requires Datasette 1.0a2 or higher. ## Things you can do with dclient -- Run SQL queries against Datasette and return the results as JSON +- Run SQL queries against Datasette and return the results as JSON, CSV, TSV, or an ASCII table - Introspect databases, tables, plugins, and schema - Run queries against authenticated Datasette instances - Create aliases and set default instances/databases for convenient access @@ -43,6 +43,10 @@ Or be explicit: ```bash dclient query fixtures "select * from facetable limit 1" -i latest ``` +Output as a table with `-t`, or use `--csv`, `--tsv`, `--nl`: +```bash +dclient "select pk, state from facetable limit 3" -t +``` ## Introspection diff --git a/dclient/cli.py b/dclient/cli.py index fcc8047..36e2380 100644 --- a/dclient/cli.py +++ b/dclient/cli.py @@ -1,6 +1,8 @@ import click from click_default_group import DefaultGroup +import csv import httpx +import io import json import os import pathlib @@ -123,6 +125,83 @@ def _resolve_token(token, url, auth_file, config_file): return os.environ.get("DATASETTE_TOKEN") +def _output_rows(rows, fmt, columns=None): + """Output rows in the specified format. fmt is one of 'json', 'csv', 'tsv', 'nl', 'table'.""" + if fmt == "csv": + _output_csv(rows, columns) + elif fmt == "tsv": + _output_csv(rows, columns, delimiter="\t") + elif fmt == "nl": + for row in rows: + click.echo(json.dumps(row, default=str)) + elif fmt == "table": + _output_table(rows, columns) + else: + click.echo(json.dumps(rows, indent=2, default=str)) + + +def _output_csv(rows, columns=None, delimiter=","): + if not rows and not columns: + return + if columns is None: + columns = list(rows[0].keys()) if rows else [] + buf = io.StringIO() + writer = csv.writer(buf, delimiter=delimiter) + writer.writerow(columns) + for row in rows: + writer.writerow(str(row.get(col, "")) for col in columns) + click.echo(buf.getvalue(), nl=False) + + +def _output_table(rows, columns=None): + if not rows and not columns: + return + if columns is None: + columns = list(rows[0].keys()) if rows else [] + if not columns: + return + # Calculate column widths + widths = {col: len(str(col)) for col in columns} + for row in rows: + for col in columns: + widths[col] = max(widths[col], len(str(row.get(col, "")))) + # Header + header = " ".join(str(col).ljust(widths[col]) for col in columns) + click.echo(header) + # Separator + sep = " ".join("-" * widths[col] for col in columns) + click.echo(sep) + # Rows + for row in rows: + line = " ".join(str(row.get(col, "")).ljust(widths[col]) for col in columns) + click.echo(line) + + +def _determine_output_format(fmt_csv, fmt_tsv, fmt_nl, fmt_table): + if fmt_csv: + return "csv" + if fmt_tsv: + return "tsv" + if fmt_nl: + return "nl" + if fmt_table: + return "table" + return "json" + + +def output_format_options(f): + """Decorator that adds --csv, --tsv, --nl, --table options to a command.""" + f = click.option( + "fmt_table", "--table", "-t", is_flag=True, help="Output as ASCII table" + )(f) + f = click.option( + "fmt_nl", "--nl", is_flag=True, help="Output as newline-delimited JSON" + )(f) + f = click.option("fmt_tsv", "--tsv", is_flag=True, help="Output as TSV")(f) + f = click.option("fmt_csv", "--csv", is_flag=True, help="Output as CSV")(f) + return f + + @click.group(cls=DefaultGroup, default="default_query", default_if_no_args=False) @click.version_option() def cli(): @@ -286,7 +365,8 @@ def tables(instance, database, views, views_only, hidden, _json, token): @click.option("-i", "--instance", default=None, help="Datasette instance URL or alias") @click.option("--token", help="API token") @click.option("-v", "--verbose", is_flag=True, help="Verbose output: show HTTP request") -def query(database, sql, instance, token, verbose): +@output_format_options +def query(database, sql, instance, token, verbose, fmt_csv, fmt_tsv, fmt_nl, fmt_table): """ Run a SQL query against a Datasette database @@ -346,7 +426,10 @@ def query(database, sql, instance, token, verbose): bits = [json.dumps(data)] raise click.ClickException(": ".join(bits)) - click.echo(json.dumps(response.json()["rows"], indent=2)) + rows = response.json()["rows"] + columns = response.json().get("columns") + fmt = _determine_output_format(fmt_csv, fmt_tsv, fmt_nl, fmt_table) + _output_rows(rows, fmt, columns) def _do_insert( @@ -499,7 +582,7 @@ _insert_options = [ click.option( "--interval", type=float, default=10, help="Send batch at least every X seconds" ), - click.option("--token", "-t", help="API token"), + click.option("--token", help="API token"), click.option("--silent", is_flag=True, help="Don't output progress"), click.option( "-v", @@ -744,7 +827,10 @@ def actor(instance, token): @click.option("-d", "--database", default=None, help="Database name") @click.option("--token", help="API token") @click.option("-v", "--verbose", is_flag=True, help="Verbose output: show HTTP request") -def default_query(sql, instance, database, token, verbose): +@output_format_options +def default_query( + sql, instance, database, token, verbose, fmt_csv, fmt_tsv, fmt_nl, fmt_table +): """Run a SQL query using default instance and database.""" config_dir = get_config_dir() url = _resolve_instance(instance, config_dir / "config.json") @@ -807,7 +893,10 @@ def default_query(sql, instance, database, token, verbose): bits = [json.dumps(data)] raise click.ClickException(": ".join(bits)) - click.echo(json.dumps(response.json()["rows"], indent=2)) + rows = response.json()["rows"] + columns = response.json().get("columns") + fmt = _determine_output_format(fmt_csv, fmt_tsv, fmt_nl, fmt_table) + _output_rows(rows, fmt, columns) @cli.command() diff --git a/docs/inserting.md b/docs/inserting.md index 17d81a7..4ac7d4c 100644 --- a/docs/inserting.md +++ b/docs/inserting.md @@ -132,7 +132,7 @@ Options: table --batch-size INTEGER Send rows in batches of this size --interval FLOAT Send batch at least every X seconds - -t, --token TEXT API token + --token TEXT API token --silent Don't output progress -v, --verbose Verbose output: show HTTP request and response --replace Replace rows with a matching primary key @@ -174,7 +174,7 @@ Options: table --batch-size INTEGER Send rows in batches of this size --interval FLOAT Send batch at least every X seconds - -t, --token TEXT API token + --token TEXT API token --silent Don't output progress -v, --verbose Verbose output: show HTTP request and response --help Show this message and exit. diff --git a/docs/queries.md b/docs/queries.md index 900f0ca..2db5cdb 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -36,6 +36,51 @@ You can override just the database with `-d`: dclient "select * from counters" -d counters ``` +## Output formats + +By default, results are returned as JSON. Use these flags to change the output format: + +- `--csv` — CSV +- `--tsv` — TSV +- `-t` / `--table` — ASCII table +- `--nl` — newline-delimited JSON (one JSON object per line) + +These flags work with both `dclient query` and the bare SQL shortcut. + +CSV output: + +```bash +dclient query fixtures "select * from facetable limit 2" -i latest --csv +``` +``` +pk,created,planet_int,on_earth,state,_city_id,_neighborhood +1,2019-01-14 08:00:00,1,1,CA,1,Mission +2,2019-01-15 08:00:00,1,1,CA,1,Dogpatch +``` + +ASCII table output: + +```bash +dclient query fixtures "select pk, state, _neighborhood from facetable limit 3" -i latest -t +``` +``` +pk state _neighborhood +-- ----- ------------- +1 CA Mission +2 CA Dogpatch +3 CA SOMA +``` + +Newline-delimited JSON, useful for piping into `jq` or other line-oriented tools: + +```bash +dclient query fixtures "select pk, state from facetable limit 2" -i latest --nl +``` +``` +{"pk": 1, "state": "CA"} +{"pk": 2, "state": "CA"} +``` + ## dclient query --help