diff --git a/docs/changelog.rst b/docs/changelog.rst index e7b0ac6..15a085f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -48,7 +48,7 @@ The ``Database()`` constructor can now accept a ``pathlib.Path`` object in addit Two new commands: ``sqlite-utils csv`` and ``sqlite-utils json`` -These commands execute a SQL query and return the results as CSV or JSON. See :ref:`cli_csv` and :ref:`cli_json` for more details. +These commands execute a SQL query and return the results as CSV or JSON. See :ref:`cli_query_csv` and :ref:`cli_query_json` for more details. :: diff --git a/docs/cli.rst b/docs/cli.rst index 6461a44..2c7b5f3 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -6,56 +6,38 @@ The ``sqlite-utils`` command-line tool can be used to manipulate SQLite databases in a number of different ways. -.. _cli_csv: - -Running queries and returning CSV -================================= - -You can execute a SQL query against a database and get the results back as CSV like this:: - - $ sqlite-utils csv dogs.db "select * from dogs" - id,age,name - 1,4,Cleo - 2,2,Pancakes - -This will default to including the column names as a header row. To exclude the headers, use ``--no-headers``:: - - $ sqlite-utils csv dogs.db "select * from dogs" --no-headers - 1,4,Cleo - 2,2,Pancakes - -.. _cli_json: +.. _cli_query_json: Running queries and returning JSON ================================== You can execute a SQL query against a database and get the results back as JSON like this:: - $ sqlite-utils json dogs.db "select * from dogs" + $ sqlite-utils dogs.db "select * from dogs" [{"id": 1, "age": 4, "name": "Cleo"}, {"id": 2, "age": 2, "name": "Pancakes"}] Use ``--nl`` to get back newline-delimited JSON objects:: - $ sqlite-utils json --nl dogs.db "select * from dogs" + $ sqlite-utils dogs.db "select * from dogs" --nl {"id": 1, "age": 4, "name": "Cleo"} {"id": 2, "age": 2, "name": "Pancakes"} You can use ``--arrays`` to request ararys instead of objects:: - $ sqlite-utils json --arrays dogs.db "select * from dogs" + $ sqlite-utils dogs.db "select * from dogs" --arrays [[1, 4, "Cleo"], [2, 2, "Pancakes"]] You can also combine ``--arrays`` and ``--nl``:: - $ sqlite-utils json --arrays --nl dogs.db "select * from dogs" + $ sqlite-utils dogs.db "select * from dogs" --arrays --nl [1, 4, "Cleo"] [2, 2, "Pancakes"] If you want to pretty-print the output further, you can pipe it through ``python -mjson.tool``:: - $ sqlite-utils json dogs.db "select * from dogs" | python -mjson.tool + $ sqlite-utils dogs.db "select * from dogs" | python -mjson.tool [ { "id": 1, @@ -69,6 +51,24 @@ If you want to pretty-print the output further, you can pipe it through ``python } ] +.. _cli_query_csv: + +Running queries and returning CSV +================================= + +You can use the ``--csv`` option to return results as CSV:: + + $ sqlite-utils dogs.db "select * from dogs" --csv + id,age,name + 1,4,Cleo + 2,2,Pancakes + +This will default to including the column names as a header row. To exclude the headers, use ``--no-headers``:: + + $ sqlite-utils dogs.db "select * from dogs" --csv --no-headers + 1,4,Cleo + 2,2,Pancakes + Listing tables ============== diff --git a/setup.py b/setup.py index 78934de..5062f0f 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ setup( version=VERSION, license="Apache License, Version 2.0", packages=find_packages(), - install_requires=["click"], + install_requires=["click", "click-default-group"], setup_requires=["pytest-runner"], extras_require={"test": ["pytest", "black"]}, entry_points=""" diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index fb80987..e44d36f 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1,13 +1,14 @@ import click +from click_default_group import DefaultGroup import sqlite_utils import itertools import json import sys -import csv +import csv as csv_std import sqlite3 -@click.group() +@click.group(cls=DefaultGroup, default="query", default_if_no_args=True) @click.version_option() def cli(): "Commands for interacting with a SQLite database" @@ -103,6 +104,29 @@ def populate_fts(path, table, column): db[table].populate_fts(column) +def output_options(fn): + for decorator in reversed( + ( + click.option( + "--nl", + help="Output newline-delimited JSON", + is_flag=True, + default=False, + ), + click.option( + "--arrays", + help="Output rows as arrays instead of objects", + is_flag=True, + default=False, + ), + click.option("--csv", is_flag=True, help="Output CSV"), + click.option("--no-headers", is_flag=True, help="Omit CSV headers"), + ) + ): + fn = decorator(fn) + return fn + + def insert_upsert_options(fn): for decorator in reversed( ( @@ -133,7 +157,7 @@ def insert_upsert_implementation( click.echo("Use just one of --nl and --csv", err=True) return if csv: - reader = csv.reader(json_file) + reader = csv_std.reader(json_file) headers = next(reader) docs = (dict(zip(headers, row)) for row in reader) elif nl: @@ -176,34 +200,14 @@ def upsert(path, table, json_file, pk, nl, csv, batch_size): ) -@cli.command(name="csv") -@click.argument( - "path", - type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), - required=True, -) -@click.argument("sql") -@click.option( - "--no-headers", help="Exclude headers from CSV output", is_flag=True, default=False -) -def csv_cmd(path, sql, no_headers): - "Execute SQL query and return the results as CSV" - db = sqlite_utils.Database(path) - cursor = db.conn.execute(sql) - writer = csv.writer(sys.stdout) - if not no_headers: - writer.writerow([c[0] for c in cursor.description]) - for row in cursor: - writer.writerow(row) - - -@cli.command(name="json") +@cli.command() @click.argument( "path", type=click.Path(file_okay=True, dir_okay=False, allow_dash=False), required=True, ) @click.argument("sql") +@output_options @click.option("--nl", help="Output newline-delimited JSON", is_flag=True, default=False) @click.option( "--arrays", @@ -211,17 +215,29 @@ def csv_cmd(path, sql, no_headers): is_flag=True, default=False, ) -def json_cmd(path, sql, nl, arrays): +def query(path, sql, nl, arrays, csv, no_headers): "Execute SQL query and return the results as JSON" db = sqlite_utils.Database(path) cursor = iter(db.conn.execute(sql)) + headers = [c[0] for c in cursor.description] + if csv: + writer = csv_std.writer(sys.stdout) + if not no_headers: + writer.writerow([c[0] for c in cursor.description]) + for row in cursor: + writer.writerow(row) + else: + for line in output_rows(cursor, headers, nl, arrays): + click.echo(line) + + +def output_rows(iterator, headers, nl, arrays): # We have to iterate two-at-a-time so we can know if we # should output a trailing comma or if we have reached # the last row. - current_iter, next_iter = itertools.tee(cursor, 2) + current_iter, next_iter = itertools.tee(iterator, 2) next(next_iter, None) first = True - headers = [c[0] for c in cursor.description] for row, next_row in itertools.zip_longest(current_iter, next_iter): is_last = next_row is None data = row @@ -233,5 +249,5 @@ def json_cmd(path, sql, nl, arrays): maybecomma="," if (not nl and not is_last) else "", lastchar="]" if (is_last and not nl) else "", ) - click.echo(line) + yield line first = False diff --git a/tests/test_cli.py b/tests/test_cli.py index 76e1d9b..41c427c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -179,7 +179,7 @@ def test_upsert(db_path, tmpdir): ) -def test_csv(db_path): +def test_query_csv(db_path): db = Database(db_path) with db.conn: db["dogs"].insert_all( @@ -189,13 +189,13 @@ def test_csv(db_path): ] ) result = CliRunner().invoke( - cli.cli, ["csv", db_path, "select id, name, age from dogs"] + cli.cli, [db_path, "select id, name, age from dogs", "--csv"] ) assert 0 == result.exit_code assert "id,name,age\n1,Cleo,4\n2,Pancakes,2\n" == result.output # Test the no-headers option: result = CliRunner().invoke( - cli.cli, ["csv", db_path, "select id, name, age from dogs", "--no-headers"] + cli.cli, [db_path, "select id, name, age from dogs", "--no-headers", "--csv"] ) assert "1,Cleo,4\n2,Pancakes,2\n" == result.output @@ -225,7 +225,7 @@ _one_query = "select id, name, age from dogs where id = 1" (_one_query, ["--arrays", "--nl"], '[1, "Cleo", 4]'), ], ) -def test_json(db_path, sql, args, expected): +def test_query_json(db_path, sql, args, expected): db = Database(db_path) with db.conn: db["dogs"].insert_all( @@ -234,5 +234,5 @@ def test_json(db_path, sql, args, expected): {"id": 2, "age": 2, "name": "Pancakes"}, ] ) - result = CliRunner().invoke(cli.cli, ["json", db_path, sql] + args) + result = CliRunner().invoke(cli.cli, [db_path, sql] + args) assert expected == result.output.strip()