From cf565e3eb6d9126e2bcce4750e92679eee462e4b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 24 Feb 2026 14:59:23 -0800 Subject: [PATCH 01/17] Ran cog --- docs/authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/authentication.md b/docs/authentication.md index 4e5f22f..caadba7 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -161,7 +161,7 @@ cog.out( ) ]]] --> ``` -Usage: dclient actor [OPTIONS] URL_OR_ALIAS +Usage: dclient actor [OPTIONS] [URL_OR_ALIAS] Show the actor represented by an API token From 4df076d1f0962ff6e0ecffe47fa9bd5324e4ea54 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 26 Feb 2026 15:00:15 -0800 Subject: [PATCH 02/17] Initial oauth flow for use with datasette-oauth --- dclient/cli.py | 372 +++++++++++++++++++++++++++++++++----- tests/test_cli_auth.py | 137 ++++++++++++++ tests/test_commands_v2.py | 44 ++--- tests/test_config.py | 9 +- tests/test_env.py | 11 +- tests/test_migration.py | 24 +-- tests/test_query.py | 8 +- 7 files changed, 503 insertions(+), 102 deletions(-) diff --git a/dclient/cli.py b/dclient/cli.py index 48f249b..91ccec5 100644 --- a/dclient/cli.py +++ b/dclient/cli.py @@ -153,7 +153,9 @@ def get(path, instance, token): """ config_dir = get_config_dir() url = _resolve_instance(instance, config_dir / "config.json") - token = _resolve_token(token, url, config_dir / "auth.json", config_dir / "config.json") + token = _resolve_token( + token, url, config_dir / "auth.json", config_dir / "config.json" + ) full_url = url.rstrip("/") + "/" + path.lstrip("/") response = _make_request(url, token, "/" + path.lstrip("/")) if response.status_code != 200: @@ -180,7 +182,9 @@ def databases(instance, _json, token): """ config_dir = get_config_dir() url = _resolve_instance(instance, config_dir / "config.json") - token = _resolve_token(token, url, config_dir / "auth.json", config_dir / "config.json") + token = _resolve_token( + token, url, config_dir / "auth.json", config_dir / "config.json" + ) response = _make_request(url, token, "/.json") if response.status_code != 200: raise click.ClickException(f"{response.status_code} error") @@ -219,10 +223,21 @@ def tables(instance, database, views, views_only, hidden, _json, token): """ config_dir = get_config_dir() url = _resolve_instance(instance, config_dir / "config.json") - instance_alias = _instance_alias_for_url(url, config_dir / "config.json") if not (instance and (instance.startswith("http://") or instance.startswith("https://"))) else None - if instance and not (instance.startswith("http://") or instance.startswith("https://")): + instance_alias = ( + _instance_alias_for_url(url, config_dir / "config.json") + if not ( + instance + and (instance.startswith("http://") or instance.startswith("https://")) + ) + else None + ) + if instance and not ( + instance.startswith("http://") or instance.startswith("https://") + ): instance_alias = instance - token = _resolve_token(token, url, config_dir / "auth.json", config_dir / "config.json") + token = _resolve_token( + token, url, config_dir / "auth.json", config_dir / "config.json" + ) db = _resolve_database(database, instance_alias, config_dir / "config.json") response = _make_request(url, token, f"/{db}.json") if response.status_code != 200: @@ -277,7 +292,9 @@ def query(database, sql, instance, token, verbose): """ config_dir = get_config_dir() url = _resolve_instance(instance, config_dir / "config.json") - token = _resolve_token(token, url, config_dir / "auth.json", config_dir / "config.json") + token = _resolve_token( + token, url, config_dir / "auth.json", config_dir / "config.json" + ) query_url = url.rstrip("/") + "/" + database + ".json" headers = {} if token: @@ -285,7 +302,9 @@ def query(database, sql, instance, token, verbose): params = {"sql": sql, "_shape": "objects"} if verbose: click.echo(query_url + "?" + urllib.parse.urlencode(params), err=True) - response = httpx.get(query_url, params=params, headers=headers, follow_redirects=True) + response = httpx.get( + query_url, params=params, headers=headers, follow_redirects=True + ) if response.status_code != 200: try: @@ -322,14 +341,35 @@ def query(database, sql, instance, token, verbose): click.echo(json.dumps(response.json()["rows"], indent=2)) -def _do_insert(database, table, filepath, format_csv, format_tsv, format_json, - format_nl, encoding, no_detect_types, replace, ignore, create, - alter, pks, batch_size, interval, token, silent, verbose, - instance, endpoint="insert"): +def _do_insert( + database, + table, + filepath, + format_csv, + format_tsv, + format_json, + format_nl, + encoding, + no_detect_types, + replace, + ignore, + create, + alter, + pks, + batch_size, + interval, + token, + silent, + verbose, + instance, + endpoint="insert", +): """Shared implementation for insert and upsert commands.""" config_dir = get_config_dir() url = _resolve_instance(instance, config_dir / "config.json") - token = _resolve_token(token, url, config_dir / "auth.json", config_dir / "config.json") + token = _resolve_token( + token, url, config_dir / "auth.json", config_dir / "config.json" + ) format = None if format_csv: @@ -420,20 +460,29 @@ _insert_options = [ click.argument("database"), click.argument("table"), click.argument( - "filepath", type=click.Path("rb", readable=True, allow_dash=True, dir_okay=False) + "filepath", + type=click.Path("rb", readable=True, allow_dash=True, dir_okay=False), + ), + click.option( + "-i", "--instance", default=None, help="Datasette instance URL or alias" ), - click.option("-i", "--instance", default=None, help="Datasette instance URL or alias"), click.option("format_csv", "--csv", is_flag=True, help="Input is CSV"), click.option("format_tsv", "--tsv", is_flag=True, help="Input is TSV"), click.option("format_json", "--json", is_flag=True, help="Input is JSON"), - click.option("format_nl", "--nl", is_flag=True, help="Input is newline-delimited JSON"), + click.option( + "format_nl", "--nl", is_flag=True, help="Input is newline-delimited JSON" + ), click.option("--encoding", help="Character encoding for CSV/TSV"), click.option( "--no-detect-types", is_flag=True, help="Don't detect column types for CSV/TSV" ), - click.option("--alter", is_flag=True, help="Alter table to add any missing columns"), click.option( - "pks", "--pk", multiple=True, + "--alter", is_flag=True, help="Alter table to add any missing columns" + ), + click.option( + "pks", + "--pk", + multiple=True, help="Columns to use as the primary key when creating the table", ), click.option( @@ -445,7 +494,9 @@ _insert_options = [ click.option("--token", "-t", help="API token"), click.option("--silent", is_flag=True, help="Don't output progress"), click.option( - "-v", "--verbose", is_flag=True, + "-v", + "--verbose", + is_flag=True, help="Verbose output: show HTTP request and response", ), ] @@ -456,17 +507,39 @@ def _apply_options(options): for option in reversed(options): func = option(func) return func + return decorator @cli.command() @_apply_options(_insert_options) -@click.option("--replace", is_flag=True, help="Replace rows with a matching primary key") +@click.option( + "--replace", is_flag=True, help="Replace rows with a matching primary key" +) @click.option("--ignore", is_flag=True, help="Ignore rows with a matching primary key") @click.option("--create", is_flag=True, help="Create table if it does not exist") -def insert(database, table, filepath, instance, format_csv, format_tsv, format_json, - format_nl, encoding, no_detect_types, alter, pks, batch_size, interval, - token, silent, verbose, replace, ignore, create): +def insert( + database, + table, + filepath, + instance, + format_csv, + format_tsv, + format_json, + format_nl, + encoding, + no_detect_types, + alter, + pks, + batch_size, + interval, + token, + silent, + verbose, + replace, + ignore, + create, +): """ Insert data into a remote Datasette instance @@ -476,17 +549,52 @@ def insert(database, table, filepath, instance, format_csv, format_tsv, format_j dclient insert main mytable data.csv --csv -i myapp dclient insert main mytable data.csv --csv --create --pk id """ - _do_insert(database, table, filepath, format_csv, format_tsv, format_json, - format_nl, encoding, no_detect_types, replace, ignore, create, - alter, pks, batch_size, interval, token, silent, verbose, - instance, endpoint="insert") + _do_insert( + database, + table, + filepath, + format_csv, + format_tsv, + format_json, + format_nl, + encoding, + no_detect_types, + replace, + ignore, + create, + alter, + pks, + batch_size, + interval, + token, + silent, + verbose, + instance, + endpoint="insert", + ) @cli.command() @_apply_options(_insert_options) -def upsert(database, table, filepath, instance, format_csv, format_tsv, format_json, - format_nl, encoding, no_detect_types, alter, pks, batch_size, interval, - token, silent, verbose): +def upsert( + database, + table, + filepath, + instance, + format_csv, + format_tsv, + format_json, + format_nl, + encoding, + no_detect_types, + alter, + pks, + batch_size, + interval, + token, + silent, + verbose, +): """ Upsert data into a remote Datasette instance @@ -495,10 +603,29 @@ def upsert(database, table, filepath, instance, format_csv, format_tsv, format_j \b dclient upsert main mytable data.csv --csv -i myapp """ - _do_insert(database, table, filepath, format_csv, format_tsv, format_json, - format_nl, encoding, no_detect_types, False, False, False, - alter, pks, batch_size, interval, token, silent, verbose, - instance, endpoint="upsert") + _do_insert( + database, + table, + filepath, + format_csv, + format_tsv, + format_json, + format_nl, + encoding, + no_detect_types, + False, + False, + False, + alter, + pks, + batch_size, + interval, + token, + silent, + verbose, + instance, + endpoint="upsert", + ) @cli.command() @@ -520,10 +647,21 @@ def schema(table_name, instance, database, _json, token): """ config_dir = get_config_dir() url = _resolve_instance(instance, config_dir / "config.json") - instance_alias = _instance_alias_for_url(url, config_dir / "config.json") if not (instance and (instance.startswith("http://") or instance.startswith("https://"))) else None - if instance and not (instance.startswith("http://") or instance.startswith("https://")): + instance_alias = ( + _instance_alias_for_url(url, config_dir / "config.json") + if not ( + instance + and (instance.startswith("http://") or instance.startswith("https://")) + ) + else None + ) + if instance and not ( + instance.startswith("http://") or instance.startswith("https://") + ): instance_alias = instance - token = _resolve_token(token, url, config_dir / "auth.json", config_dir / "config.json") + token = _resolve_token( + token, url, config_dir / "auth.json", config_dir / "config.json" + ) db = _resolve_database(database, instance_alias, config_dir / "config.json") if table_name: response = _make_request(url, token, f"/{db}/{table_name}/-/schema.json") @@ -554,7 +692,9 @@ def plugins(instance, _json, token): """ config_dir = get_config_dir() url = _resolve_instance(instance, config_dir / "config.json") - token = _resolve_token(token, url, config_dir / "auth.json", config_dir / "config.json") + token = _resolve_token( + token, url, config_dir / "auth.json", config_dir / "config.json" + ) response = _make_request(url, token, "/-/plugins.json") if response.status_code != 200: raise click.ClickException(f"{response.status_code} error") @@ -582,7 +722,9 @@ def actor(instance, token): """ config_dir = get_config_dir() url = _resolve_instance(instance, config_dir / "config.json") - token = _resolve_token(token, url, config_dir / "auth.json", config_dir / "config.json") + token = _resolve_token( + token, url, config_dir / "auth.json", config_dir / "config.json" + ) response = _make_request(url, token, "/-/actor.json") response.raise_for_status() click.echo(json.dumps(response.json(), indent=4)) @@ -598,10 +740,21 @@ def default_query(sql, instance, database, token, verbose): """Run a SQL query using default instance and database.""" config_dir = get_config_dir() url = _resolve_instance(instance, config_dir / "config.json") - instance_alias = _instance_alias_for_url(url, config_dir / "config.json") if not (instance and (instance.startswith("http://") or instance.startswith("https://"))) else None - if instance and not (instance.startswith("http://") or instance.startswith("https://")): + instance_alias = ( + _instance_alias_for_url(url, config_dir / "config.json") + if not ( + instance + and (instance.startswith("http://") or instance.startswith("https://")) + ) + else None + ) + if instance and not ( + instance.startswith("http://") or instance.startswith("https://") + ): instance_alias = instance - token = _resolve_token(token, url, config_dir / "auth.json", config_dir / "config.json") + token = _resolve_token( + token, url, config_dir / "auth.json", config_dir / "config.json" + ) db = _resolve_database(database, instance_alias, config_dir / "config.json") query_url = url.rstrip("/") + "/" + db + ".json" headers = {} @@ -610,7 +763,9 @@ def default_query(sql, instance, database, token, verbose): params = {"sql": sql, "_shape": "objects"} if verbose: click.echo(query_url + "?" + urllib.parse.urlencode(params), err=True) - response = httpx.get(query_url, params=params, headers=headers, follow_redirects=True) + response = httpx.get( + query_url, params=params, headers=headers, follow_redirects=True + ) if response.status_code != 200: try: @@ -668,7 +823,11 @@ def instances(_json): else: for name, inst in inst_map.items(): marker = "* " if name == default else " " - db_info = f" (db: {inst['default_database']})" if inst.get("default_database") else "" + db_info = ( + f" (db: {inst['default_database']})" + if inst.get("default_database") + else "" + ) click.echo(f"{marker}{name} = {inst['url']}{db_info}") @@ -693,7 +852,11 @@ def alias_list(_json): else: for name, inst in instances.items(): marker = "* " if name == default else " " - db_info = f" (db: {inst['default_database']})" if inst.get("default_database") else "" + db_info = ( + f" (db: {inst['default_database']})" + if inst.get("default_database") + else "" + ) click.echo(f"{marker}{name} = {inst['url']}{db_info}") @@ -891,12 +1054,116 @@ def auth_status(instance, token): """ config_dir = get_config_dir() url = _resolve_instance(instance, config_dir / "config.json") - token = _resolve_token(token, url, config_dir / "auth.json", config_dir / "config.json") + token = _resolve_token( + token, url, config_dir / "auth.json", config_dir / "config.json" + ) response = _make_request(url, token, "/-/actor.json") response.raise_for_status() click.echo(json.dumps(response.json(), indent=4)) +# -- login command (OAuth device flow) -- + + +@cli.command() +@click.argument("alias_or_url", required=False, default=None) +@click.option("--scope", default=None, help="JSON scope array") +def login(alias_or_url, scope): + """ + Authenticate with a Datasette instance using OAuth + + Uses the OAuth device flow: opens a URL in your browser where you + approve access, then saves the resulting API token. + + Example usage: + + \b + dclient login https://simon.datasette.cloud/ + dclient login myalias + dclient login + """ + config_dir = get_config_dir() + config_dir.mkdir(parents=True, exist_ok=True) + config_file = config_dir / "config.json" + + if alias_or_url is None: + click.echo("Enter the URL of your Datasette instance, or an alias you have") + click.echo("already configured with 'dclient alias add'.\n") + alias_or_url = click.prompt("Instance URL or alias") + + # Resolve alias to URL if needed + if alias_or_url.startswith("http://") or alias_or_url.startswith("https://"): + url = alias_or_url + auth_key = alias_or_url + else: + url = _resolve_instance(alias_or_url, config_file) + auth_key = alias_or_url + + # Ensure trailing slash + if not url.endswith("/"): + url += "/" + + # Step 1: Request device code + device_url = url + "-/oauth/device" + data = {} + if scope: + data["scope"] = scope + response = httpx.post(device_url, data=data, timeout=30.0) + if response.status_code != 200: + raise click.ClickException( + f"Failed to start login flow: {response.status_code} from {device_url}" + ) + device_data = response.json() + device_code = device_data["device_code"] + user_code = device_data["user_code"] + verification_uri = device_data["verification_uri"] + interval = device_data.get("interval", 5) + + # Step 2: Show instructions + click.echo(f"\nOpen this URL in your browser:\n") + click.echo(f" {verification_uri}\n") + click.echo(f"Enter this code: {user_code}\n") + click.echo("Waiting for authorization...", nl=False) + + # Step 3: Poll for token + token_url = url + "-/oauth/token" + while True: + time.sleep(interval) + click.echo(".", nl=False) + token_response = httpx.post( + token_url, + data={ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": device_code, + }, + timeout=30.0, + ) + token_data = token_response.json() + if "access_token" in token_data: + break + error = token_data.get("error") + if error == "authorization_pending": + continue + elif error == "access_denied": + click.echo() + raise click.ClickException("Authorization denied.") + elif error == "expired_token": + click.echo() + raise click.ClickException("Device code expired. Run login again.") + else: + click.echo() + raise click.ClickException(f"Unexpected error: {error}") + + # Step 4: Save token + click.echo() + access_token = token_data["access_token"] + auth_file = config_dir / "auth.json" + auths = _load_auths(auth_file) + auths[auth_key] = access_token + auth_file.write_text(json.dumps(auths, indent=4)) + click.echo(f"Login successful. Token saved for {auth_key}") + + # -- v1 → v2 migration -- @@ -947,7 +1214,6 @@ def _migrate_v1_to_v2(config_dir): aliases_file.rename(config_dir / "aliases.json.bak") - def _batches(iterable, size, interval=None): iterable = iter(iterable) last_yield_time = time.time() @@ -967,8 +1233,18 @@ def _batches(iterable, size, interval=None): def _insert_batch( - *, url, table, batch, token, create, alter, pks, replace, ignore, verbose, - endpoint="insert" + *, + url, + table, + batch, + token, + create, + alter, + pks, + replace, + ignore, + verbose, + endpoint="insert", ): if create: data = { diff --git a/tests/test_cli_auth.py b/tests/test_cli_auth.py index 60b8bb3..ea17dd1 100644 --- a/tests/test_cli_auth.py +++ b/tests/test_cli_auth.py @@ -2,6 +2,7 @@ from click.testing import CliRunner from dclient.cli import cli import pathlib import json +import pytest def test_auth(mocker, tmpdir): @@ -39,3 +40,139 @@ def test_auth(mocker, tmpdir): # Check the tokens file auth_file = pathlib.Path(tmpdir) / "auth.json" assert json.loads(auth_file.read_text()) == {} + + +# -- login command (OAuth device flow) -- + +DEVICE_RESPONSE = { + "device_code": "devcode123", + "user_code": "ABCD-EFGH", + "verification_uri": "https://example.com/-/oauth/device/verify", + "expires_in": 900, + "interval": 0, +} + +TOKEN_SUCCESS = { + "access_token": "dstok_abc123", + "token_type": "bearer", + "expires_in": 3600, +} + + +def test_login_with_url(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + mocker.patch("dclient.cli.time.sleep") + httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200) + httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200) + runner = CliRunner() + result = runner.invoke(cli, ["login", "https://example.com/"]) + assert result.exit_code == 0 + assert "ABCD-EFGH" in result.output + assert "Login successful" in result.output + # Token should be saved + auth_file = pathlib.Path(tmpdir) / "auth.json" + auths = json.loads(auth_file.read_text()) + assert auths["https://example.com/"] == "dstok_abc123" + + +def test_login_adds_trailing_slash(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + mocker.patch("dclient.cli.time.sleep") + httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200) + httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200) + runner = CliRunner() + result = runner.invoke(cli, ["login", "https://example.com"]) + assert result.exit_code == 0 + # Check that the device request went to the right URL + requests = httpx_mock.get_requests() + assert str(requests[0].url) == "https://example.com/-/oauth/device" + + +def test_login_with_alias(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + mocker.patch("dclient.cli.time.sleep") + # Set up an alias first + config_file = pathlib.Path(tmpdir) / "config.json" + config_file.write_text( + json.dumps( + { + "default_instance": None, + "instances": { + "prod": { + "url": "https://prod.example.com", + "default_database": None, + } + }, + } + ) + ) + httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200) + httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200) + runner = CliRunner() + result = runner.invoke(cli, ["login", "prod"]) + assert result.exit_code == 0 + assert "Login successful" in result.output + # Token should be saved by alias name + auth_file = pathlib.Path(tmpdir) / "auth.json" + auths = json.loads(auth_file.read_text()) + assert auths["prod"] == "dstok_abc123" + + +def test_login_interactive_prompt(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + mocker.patch("dclient.cli.time.sleep") + httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200) + httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200) + runner = CliRunner() + result = runner.invoke(cli, ["login"], input="https://example.com/\n") + assert result.exit_code == 0 + assert "Instance URL or alias" in result.output + assert "Login successful" in result.output + + +def test_login_access_denied(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + mocker.patch("dclient.cli.time.sleep") + httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200) + httpx_mock.add_response(json={"error": "access_denied"}, status_code=400) + runner = CliRunner() + result = runner.invoke(cli, ["login", "https://example.com/"]) + assert result.exit_code == 1 + assert "Authorization denied" in result.output + + +def test_login_expired_token(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + mocker.patch("dclient.cli.time.sleep") + httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200) + httpx_mock.add_response(json={"error": "expired_token"}, status_code=400) + runner = CliRunner() + result = runner.invoke(cli, ["login", "https://example.com/"]) + assert result.exit_code == 1 + assert "expired" in result.output + + +def test_login_pending_then_success(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + mocker.patch("dclient.cli.time.sleep") + httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200) + # First poll: pending + httpx_mock.add_response(json={"error": "authorization_pending"}, status_code=400) + # Second poll: success + httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200) + runner = CliRunner() + result = runner.invoke(cli, ["login", "https://example.com/"]) + assert result.exit_code == 0 + assert "Login successful" in result.output + auth_file = pathlib.Path(tmpdir) / "auth.json" + auths = json.loads(auth_file.read_text()) + assert auths["https://example.com/"] == "dstok_abc123" + + +def test_login_device_endpoint_error(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response(status_code=403) + runner = CliRunner() + result = runner.invoke(cli, ["login", "https://example.com/"]) + assert result.exit_code == 1 + assert "Failed to start login flow" in result.output diff --git a/tests/test_commands_v2.py b/tests/test_commands_v2.py index afc4e7c..a4326b5 100644 --- a/tests/test_commands_v2.py +++ b/tests/test_commands_v2.py @@ -6,7 +6,6 @@ import json import pathlib import pytest - # -- databases command -- @@ -22,9 +21,7 @@ def test_databases_json(httpx_mock, mocker, tmpdir): status_code=200, ) runner = CliRunner() - result = runner.invoke( - cli, ["databases", "-i", "https://example.com", "--json"] - ) + result = runner.invoke(cli, ["databases", "-i", "https://example.com", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert len(data) == 2 @@ -175,9 +172,7 @@ def test_tables_hidden(httpx_mock, mocker, tmpdir): ) runner = CliRunner() # Without --hidden - result = runner.invoke( - cli, ["tables", "-i", "https://example.com", "-d", "db"] - ) + result = runner.invoke(cli, ["tables", "-i", "https://example.com", "-d", "db"]) assert "visible" in result.output assert "hidden_t" not in result.output @@ -213,9 +208,7 @@ def test_plugins_json(httpx_mock, mocker, tmpdir): status_code=200, ) runner = CliRunner() - result = runner.invoke( - cli, ["plugins", "-i", "https://example.com", "--json"] - ) + result = runner.invoke(cli, ["plugins", "-i", "https://example.com", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert len(data) == 2 @@ -262,9 +255,7 @@ def test_schema_all_tables(httpx_mock, mocker, tmpdir): status_code=200, ) runner = CliRunner() - result = runner.invoke( - cli, ["schema", "-i", "https://example.com", "-d", "main"] - ) + result = runner.invoke(cli, ["schema", "-i", "https://example.com", "-d", "main"]) assert result.exit_code == 0 assert "CREATE TABLE users" in result.output assert "CREATE VIEW user_count" in result.output @@ -372,9 +363,7 @@ def test_default_query_with_database_override(httpx_mock, mocker, tmpdir): status_code=200, ) runner = CliRunner() - result = runner.invoke( - cli, ["select count(*) from events", "-d", "analytics"] - ) + result = runner.invoke(cli, ["select count(*) from events", "-d", "analytics"]) assert result.exit_code == 0 request = httpx_mock.get_request() assert request.url.path == "/analytics.json" @@ -410,9 +399,7 @@ def test_default_query_with_instance_override(httpx_mock, mocker, tmpdir): status_code=200, ) runner = CliRunner() - result = runner.invoke( - cli, ["select count(*) from users", "-i", "staging"] - ) + result = runner.invoke(cli, ["select count(*) from users", "-i", "staging"]) assert result.exit_code == 0 request = httpx_mock.get_request() assert request.url.host == "staging.example.com" @@ -528,9 +515,7 @@ def test_get_command(httpx_mock, mocker, tmpdir): status_code=200, ) runner = CliRunner() - result = runner.invoke( - cli, ["get", "/-/plugins.json", "-i", "https://example.com"] - ) + result = runner.invoke(cli, ["get", "/-/plugins.json", "-i", "https://example.com"]) assert result.exit_code == 0 data = json.loads(result.output) assert data == {"hello": "world"} @@ -549,8 +534,14 @@ def test_instances_plain(mocker, tmpdir): { "default_instance": "prod", "instances": { - "prod": {"url": "https://prod.example.com", "default_database": "main"}, - "staging": {"url": "https://staging.example.com", "default_database": None}, + "prod": { + "url": "https://prod.example.com", + "default_database": "main", + }, + "staging": { + "url": "https://staging.example.com", + "default_database": None, + }, }, } ) @@ -570,7 +561,10 @@ def test_instances_json(mocker, tmpdir): { "default_instance": "prod", "instances": { - "prod": {"url": "https://prod.example.com", "default_database": "main"}, + "prod": { + "url": "https://prod.example.com", + "default_database": "main", + }, }, } ) diff --git a/tests/test_config.py b/tests/test_config.py index f12bf85..d39a2ee 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -12,7 +12,6 @@ import json import pathlib import pytest - # -- Config loading/saving -- @@ -196,7 +195,9 @@ def test_resolve_token_from_flag(tmpdir): """An explicit --token flag is used directly.""" auth_file = pathlib.Path(tmpdir) / "auth.json" config_file = pathlib.Path(tmpdir) / "config.json" - token = _resolve_token("explicit-token", "https://example.com", auth_file, config_file) + token = _resolve_token( + "explicit-token", "https://example.com", auth_file, config_file + ) assert token == "explicit-token" @@ -218,7 +219,9 @@ def test_resolve_token_from_auth_by_alias(tmpdir): } ) ) - token = _resolve_token(None, "https://myapp.datasette.cloud", auth_file, config_file) + token = _resolve_token( + None, "https://myapp.datasette.cloud", auth_file, config_file + ) assert token == "tok123" diff --git a/tests/test_env.py b/tests/test_env.py index 185611c..67f2c3f 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -3,7 +3,6 @@ from dclient.cli import cli import json import pathlib - QUERY_RESPONSE = { "ok": True, "database": "data", @@ -42,9 +41,13 @@ def test_token_flag_overrides_datasette_token(httpx_mock, mocker, tmpdir): result = runner.invoke( cli, [ - "query", "data", "select 1", - "-i", "https://example.com", - "--token", "flag-token", + "query", + "data", + "select 1", + "-i", + "https://example.com", + "--token", + "flag-token", ], ) assert result.exit_code == 0 diff --git a/tests/test_migration.py b/tests/test_migration.py index 2e81f03..c720023 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -9,9 +9,7 @@ def test_migration_simple(tmpdir): """Migrate a simple aliases.json with a database-in-URL alias.""" config_dir = pathlib.Path(tmpdir) aliases_file = config_dir / "aliases.json" - aliases_file.write_text( - json.dumps({"content": "https://datasette.io/content"}) - ) + aliases_file.write_text(json.dumps({"content": "https://datasette.io/content"})) _migrate_v1_to_v2(config_dir) @@ -29,9 +27,7 @@ def test_migration_no_path_segment(tmpdir): """Migrate an alias that has no database in the URL.""" config_dir = pathlib.Path(tmpdir) aliases_file = config_dir / "aliases.json" - aliases_file.write_text( - json.dumps({"local": "http://localhost:8001"}) - ) + aliases_file.write_text(json.dumps({"local": "http://localhost:8001"})) _migrate_v1_to_v2(config_dir) @@ -44,13 +40,9 @@ def test_migration_with_auth(tmpdir): """Auth keys are migrated from URLs to alias names.""" config_dir = pathlib.Path(tmpdir) aliases_file = config_dir / "aliases.json" - aliases_file.write_text( - json.dumps({"content": "https://datasette.io/content"}) - ) + aliases_file.write_text(json.dumps({"content": "https://datasette.io/content"})) auth_file = config_dir / "auth.json" - auth_file.write_text( - json.dumps({"https://datasette.io/content": "tok123"}) - ) + auth_file.write_text(json.dumps({"https://datasette.io/content": "tok123"})) _migrate_v1_to_v2(config_dir) @@ -68,9 +60,7 @@ def test_migration_auth_url_fallback(tmpdir): aliases_file = config_dir / "aliases.json" aliases_file.write_text(json.dumps({})) auth_file = config_dir / "auth.json" - auth_file.write_text( - json.dumps({"https://other.example.com": "tok456"}) - ) + auth_file.write_text(json.dumps({"https://other.example.com": "tok456"})) _migrate_v1_to_v2(config_dir) @@ -105,9 +95,7 @@ def test_migration_multi_path_segments(tmpdir): """URL with multiple path segments stores URL as-is.""" config_dir = pathlib.Path(tmpdir) aliases_file = config_dir / "aliases.json" - aliases_file.write_text( - json.dumps({"deep": "https://example.com/a/b/c"}) - ) + aliases_file.write_text(json.dumps({"deep": "https://example.com/a/b/c"})) _migrate_v1_to_v2(config_dir) diff --git a/tests/test_query.py b/tests/test_query.py index e273beb..4531bb2 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -53,7 +53,9 @@ def test_query(httpx_mock, with_token): # Check the request request = httpx_mock.get_request() - assert str(request.url) == "https://example.com/content.json?sql=hello&_shape=objects" + assert ( + str(request.url) == "https://example.com/content.json?sql=hello&_shape=objects" + ) if with_token: assert request.headers["authorization"] == "Bearer xyz" else: @@ -67,9 +69,7 @@ def test_aliases(mocker, tmpdir, httpx_mock): assert result.exit_code == 0 assert result.output == "" - result = runner.invoke( - cli, ["alias", "add", "foo", "https://example.com"] - ) + result = runner.invoke(cli, ["alias", "add", "foo", "https://example.com"]) assert result.exit_code == 0 assert result.output == "" From ebb8268d51001ebca11b796f033243b5c4031104 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 26 Feb 2026 15:00:49 -0800 Subject: [PATCH 03/17] Release 0.5a0 Refs #29 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5bc7cfa..c037918 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dclient" -version = "0.4" +version = "0.5a0" description = "A client CLI utility for Datasette instances" readme = "README.md" authors = [{name = "Simon Willison"}] From 9b03289e3ac68295aa28966eafb1341c1b9d956b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 26 Feb 2026 15:05:43 -0800 Subject: [PATCH 04/17] Tests now use Datasette 1.0a25+ --- pyproject.toml | 2 +- tests/test_insert.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c037918..676ff9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dev = [ "pytest-httpx", "cogapp", "pytest-mock", - "datasette>=1.0a2", + "datasette>=1.0a25", ] [tool.uv.build-backend] diff --git a/tests/test_insert.py b/tests/test_insert.py index 64f38e7..aa28583 100644 --- a/tests/test_insert.py +++ b/tests/test_insert.py @@ -271,7 +271,7 @@ async def test_insert_against_datasette( "insert into table1 (a, b, c) values (1, 2, 3), (4, 5, 6)" ) - token = ds.create_token("actor") + token = await ds.create_token("actor") # These are useful with pytest --pdb to see what happened datasette_requests = [] From 9df1828d1da682f242b29b2146d71fafe5dbafc1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 26 Feb 2026 15:25:06 -0800 Subject: [PATCH 05/17] Set defaults on login, if not already set --- dclient/cli.py | 49 ++++++++++++++++ tests/test_cli_auth.py | 125 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) diff --git a/dclient/cli.py b/dclient/cli.py index 91ccec5..cf081e0 100644 --- a/dclient/cli.py +++ b/dclient/cli.py @@ -1163,6 +1163,55 @@ def login(alias_or_url, scope): auth_file.write_text(json.dumps(auths, indent=4)) click.echo(f"Login successful. Token saved for {auth_key}") + # Step 5: Set defaults if not already configured + config = _load_config(config_file) + default_alias = config.get("default_instance") + has_default_instance = default_alias is not None + has_default_db = bool( + config.get("instances", {}).get(default_alias or "", {}).get("default_database") + ) + if has_default_instance and has_default_db: + return + # Find alias for this instance, or use the auth_key (URL) as the instance key + instance_key = _instance_alias_for_url(url, config_file) or auth_key + # Ensure instance entry exists in config + if instance_key not in config.get("instances", {}): + config.setdefault("instances", {})[instance_key] = { + "url": url.rstrip("/"), + "default_database": None, + } + # Set as default instance if none configured + if not has_default_instance: + config["default_instance"] = instance_key + click.echo(f"Set default instance to {instance_key}") + # Query databases and set default database if none configured + if not has_default_db: + try: + db_response = _make_request(url, access_token, "/.json") + if db_response.status_code == 200: + db_data = db_response.json() + if isinstance(db_data, list): + databases_list = db_data + else: + databases_list = db_data.get("databases", []) + if isinstance(databases_list, dict): + databases_list = list(databases_list.values()) + db_names = [ + db["name"] if isinstance(db, dict) else db for db in databases_list + ] + if db_names: + if len(db_names) == 1: + default_db = db_names[0] + elif "data" in db_names: + default_db = "data" + else: + default_db = db_names[0] + config["instances"][instance_key]["default_database"] = default_db + click.echo(f"Set default database to {default_db}") + except Exception: + pass # Don't fail login if databases check fails + _save_config(config_file, config) + # -- v1 → v2 migration -- diff --git a/tests/test_cli_auth.py b/tests/test_cli_auth.py index ea17dd1..39ba383 100644 --- a/tests/test_cli_auth.py +++ b/tests/test_cli_auth.py @@ -64,6 +64,7 @@ def test_login_with_url(httpx_mock, mocker, tmpdir): mocker.patch("dclient.cli.time.sleep") httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200) httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200) + httpx_mock.add_response(json=[{"name": "data"}], status_code=200) runner = CliRunner() result = runner.invoke(cli, ["login", "https://example.com/"]) assert result.exit_code == 0 @@ -80,6 +81,7 @@ def test_login_adds_trailing_slash(httpx_mock, mocker, tmpdir): mocker.patch("dclient.cli.time.sleep") httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200) httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200) + httpx_mock.add_response(json=[{"name": "data"}], status_code=200) runner = CliRunner() result = runner.invoke(cli, ["login", "https://example.com"]) assert result.exit_code == 0 @@ -108,6 +110,7 @@ def test_login_with_alias(httpx_mock, mocker, tmpdir): ) httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200) httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200) + httpx_mock.add_response(json=[{"name": "data"}], status_code=200) runner = CliRunner() result = runner.invoke(cli, ["login", "prod"]) assert result.exit_code == 0 @@ -123,6 +126,7 @@ def test_login_interactive_prompt(httpx_mock, mocker, tmpdir): mocker.patch("dclient.cli.time.sleep") httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200) httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200) + httpx_mock.add_response(json=[{"name": "data"}], status_code=200) runner = CliRunner() result = runner.invoke(cli, ["login"], input="https://example.com/\n") assert result.exit_code == 0 @@ -160,6 +164,7 @@ def test_login_pending_then_success(httpx_mock, mocker, tmpdir): httpx_mock.add_response(json={"error": "authorization_pending"}, status_code=400) # Second poll: success httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200) + httpx_mock.add_response(json=[{"name": "data"}], status_code=200) runner = CliRunner() result = runner.invoke(cli, ["login", "https://example.com/"]) assert result.exit_code == 0 @@ -176,3 +181,123 @@ def test_login_device_endpoint_error(httpx_mock, mocker, tmpdir): result = runner.invoke(cli, ["login", "https://example.com/"]) assert result.exit_code == 1 assert "Failed to start login flow" in result.output + + +# -- login sets defaults -- + + +@pytest.mark.parametrize( + "databases_response,expected_db", + [ + ([{"name": "mydata", "is_mutable": True}], "mydata"), + ( + [ + {"name": "fixtures", "is_mutable": False}, + {"name": "data", "is_mutable": True}, + {"name": "extra", "is_mutable": True}, + ], + "data", + ), + ( + [ + {"name": "alpha", "is_mutable": True}, + {"name": "beta", "is_mutable": True}, + {"name": "gamma", "is_mutable": False}, + ], + "alpha", + ), + ], + ids=["single_db", "prefers_data", "first_when_no_data"], +) +def test_login_sets_defaults( + httpx_mock, mocker, tmpdir, databases_response, expected_db +): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + mocker.patch("dclient.cli.time.sleep") + httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200) + httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200) + httpx_mock.add_response(json=databases_response, status_code=200) + runner = CliRunner() + result = runner.invoke(cli, ["login", "https://example.com/"]) + assert result.exit_code == 0, result.output + config = json.loads((pathlib.Path(tmpdir) / "config.json").read_text()) + assert config["default_instance"] == "https://example.com/" + assert ( + config["instances"]["https://example.com/"]["default_database"] == expected_db + ) + + +def test_login_does_not_override_existing_defaults(httpx_mock, mocker, tmpdir): + """When defaults are already configured, login should not change them.""" + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + mocker.patch("dclient.cli.time.sleep") + config_file = pathlib.Path(tmpdir) / "config.json" + config_file.write_text( + json.dumps( + { + "default_instance": "prod", + "instances": { + "prod": { + "url": "https://prod.example.com", + "default_database": "main", + } + }, + } + ) + ) + httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200) + httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200) + runner = CliRunner() + result = runner.invoke(cli, ["login", "https://other.example.com/"]) + assert result.exit_code == 0, result.output + config = json.loads(config_file.read_text()) + assert config["default_instance"] == "prod" + + +def test_login_with_alias_sets_defaults(httpx_mock, mocker, tmpdir): + """When logging in with an alias that has no default_database, set it.""" + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + mocker.patch("dclient.cli.time.sleep") + config_file = pathlib.Path(tmpdir) / "config.json" + config_file.write_text( + json.dumps( + { + "default_instance": None, + "instances": { + "prod": { + "url": "https://prod.example.com", + "default_database": None, + } + }, + } + ) + ) + httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200) + httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200) + httpx_mock.add_response( + json=[ + {"name": "fixtures", "is_mutable": False}, + {"name": "data", "is_mutable": True}, + {"name": "extra", "is_mutable": True}, + ], + status_code=200, + ) + runner = CliRunner() + result = runner.invoke(cli, ["login", "prod"]) + assert result.exit_code == 0, result.output + config = json.loads(config_file.read_text()) + assert config["default_instance"] == "prod" + assert config["instances"]["prod"]["default_database"] == "data" + + +def test_login_databases_error_still_succeeds(httpx_mock, mocker, tmpdir): + """If the databases check fails, login should still succeed.""" + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + mocker.patch("dclient.cli.time.sleep") + httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200) + httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200) + httpx_mock.add_response(status_code=500) + runner = CliRunner() + result = runner.invoke(cli, ["login", "https://example.com/"]) + assert result.exit_code == 0, result.output + assert "Login successful" in result.output From 04e396b020d6835494f14b3528c42c113f4e9057 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 26 Feb 2026 15:25:22 -0800 Subject: [PATCH 06/17] Release 0.5a1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 676ff9d..946fdd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dclient" -version = "0.5a0" +version = "0.5a1" description = "A client CLI utility for Datasette instances" readme = "README.md" authors = [{name = "Simon Willison"}] From 7a8643f72f297dbb0585fcc51186d715f169ab2f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 26 Feb 2026 20:43:18 -0800 Subject: [PATCH 07/17] Justfile now uses uv, docs gets a dependency group --- Justfile | 30 +++++++++++------------------- pyproject.toml | 13 ++++++++++++- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/Justfile b/Justfile index b3cc38a..8904d06 100644 --- a/Justfile +++ b/Justfile @@ -1,37 +1,29 @@ # Run tests and linters @default: test lint -# Install dependencies and test dependencies -@init: - pipenv run pip install -e '.[test]' - # Run pytest with supplied options @test *options: - pipenv run pytest {{options}} + uv run pytest {{options}} # Run linters @lint: echo "Linters..." - echo " Black" - pipenv run black . --check echo " cog" - pipenv run cog --check README.md docs/*.md - echo " ruff" - pipenv run ruff . + uv run cog --check README.md docs/*.md + echo " ruff check" + uv run ruff check . + echo " ruff format" + uv run ruff format . --check # Rebuild docs with cog @cog: - pipenv run cog -r docs/*.md + uv run cog -r docs/*.md # Serve live docs on localhost:8000 @docs: cog - cd docs && pipenv run make livehtml + cd docs && uv run make livehtml -# Apply Black -@black: - pipenv run black . - -# Run automatic fixes +# Apply ruff fixes and formatting @fix: cog - pipenv run ruff . --fix - pipenv run black . + uv run ruff check . --fix + uv run ruff format . diff --git a/pyproject.toml b/pyproject.toml index 946fdd1..3028b99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dclient = "dclient.cli:cli" client = "dclient.plugin" [dependency-groups] -dev = [ +test = [ "pytest", "pytest-asyncio", "pytest-httpx", @@ -34,6 +34,17 @@ dev = [ "pytest-mock", "datasette>=1.0a25", ] +docs = [ + "furo", + "sphinx-autobuild", + "sphinx-copybutton", + "myst-parser", + "cogapp", +] +dev = [ + {include-group = "test"}, + {include-group = "docs"}, +] [tool.uv.build-backend] module-root = "" From 2f7f2a737899799bcd3ea5a3074a11f8b72cf1a4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 26 Feb 2026 20:44:39 -0800 Subject: [PATCH 08/17] Moved default commands to dclient default ..., closes #30 --- README.md | 4 +- dclient/cli.py | 95 ++++++++++++++++++++++++------------ docs/aliases.md | 87 ++------------------------------- docs/defaults.md | 106 +++++++++++++++++++++++++++++++++++++++++ docs/index.md | 1 + tests/test_alias_v2.py | 56 ++++++++++++++++------ 6 files changed, 220 insertions(+), 129 deletions(-) create mode 100644 docs/defaults.md diff --git a/README.md b/README.md index 2012f77..96b7764 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,8 @@ datasette install dclient Add an alias for a Datasette instance: ```bash dclient alias add latest https://latest.datasette.io -dclient alias default latest -dclient alias default-db latest fixtures +dclient default instance latest +dclient default database latest fixtures ``` Now run queries directly: ```bash diff --git a/dclient/cli.py b/dclient/cli.py index cf081e0..fcc8047 100644 --- a/dclient/cli.py +++ b/dclient/cli.py @@ -53,8 +53,11 @@ def _resolve_instance(instance, config_file): ) # Try config default default = config.get("default_instance") - if default and default in config.get("instances", {}): - return config["instances"][default]["url"] + if default: + if default in config.get("instances", {}): + return config["instances"][default]["url"] + if default.startswith("http://") or default.startswith("https://"): + return default.rstrip("/") # Try env var env_url = os.environ.get("DATASETTE_URL") if env_url: @@ -62,7 +65,7 @@ def _resolve_instance(instance, config_file): raise click.ClickException( "No instance specified. Use -i , or configure a default:\n\n" " dclient alias add \n" - " dclient alias default \n\n" + " dclient default instance \n\n" "Or set the DATASETTE_URL environment variable." ) @@ -75,8 +78,13 @@ def _resolve_database(database, instance_alias, config_file): if instance_alias: config = _load_config(config_file) instances = config.get("instances", {}) - if instance_alias in instances: - default_db = instances[instance_alias].get("default_database") + key = instance_alias + if key not in instances and ( + key.startswith("http://") or key.startswith("https://") + ): + key = _instance_alias_for_url(key, config_file) + if key in instances: + default_db = instances[key].get("default_database") if default_db: return default_db # Try env var @@ -85,7 +93,7 @@ def _resolve_database(database, instance_alias, config_file): return env_db raise click.ClickException( "No database specified. Use -d , or configure a default:\n\n" - " dclient alias default-db \n\n" + " dclient default database \n\n" "Or set the DATASETTE_DATABASE environment variable." ) @@ -902,29 +910,56 @@ def alias_remove(name): raise click.ClickException("No such alias") -@alias.command(name="default") -@click.argument("name", required=False, default=None) +def _resolve_instance_key(alias_or_url, config): + instances = config.get("instances", {}) + if alias_or_url in instances: + return alias_or_url + if alias_or_url.startswith("http://") or alias_or_url.startswith("https://"): + normalized = alias_or_url.rstrip("/") + for name, inst in instances.items(): + if inst.get("url", "").rstrip("/") == normalized: + return name + raise click.ClickException(f"No such instance URL: {alias_or_url}") + raise click.ClickException(f"No such alias: {alias_or_url}") + + +# -- default command group -- + + +@cli.group() +def default(): + "Manage default instance and database" + + +@default.command(name="instance") +@click.argument("alias_or_url", required=False, default=None) @click.option("--clear", is_flag=True, help="Clear default instance") -def alias_default(name, clear): +def default_instance(alias_or_url, clear): """ Set or show the default instance Example usage: \b - dclient alias default prod - dclient alias default - dclient alias default --clear + dclient default instance prod + dclient default instance https://myapp.datasette.cloud + dclient default instance + dclient default instance --clear """ config_file = get_config_dir() / "config.json" config = _load_config(config_file) if clear: config["default_instance"] = None _save_config(config_file, config) - elif name: - if name not in config.get("instances", {}): - raise click.ClickException(f"No such alias: {name}") - config["default_instance"] = name + elif alias_or_url: + if alias_or_url.startswith("http://") or alias_or_url.startswith("https://"): + try: + key = _resolve_instance_key(alias_or_url, config) + except click.ClickException: + key = alias_or_url.rstrip("/") + else: + key = _resolve_instance_key(alias_or_url, config) + config["default_instance"] = key _save_config(config_file, config) else: default = config.get("default_instance") @@ -934,37 +969,37 @@ def alias_default(name, clear): click.echo("No default instance set") -@alias.command(name="default-db") -@click.argument("alias_name") +@default.command(name="database") +@click.argument("alias_or_url") @click.argument("db", required=False, default=None) -@click.option("--clear", is_flag=True, help="Clear default database for this alias") -def alias_default_db(alias_name, db, clear): +@click.option("--clear", is_flag=True, help="Clear default database for this instance") +def default_database(alias_or_url, db, clear): """ - Set or show the default database for an alias + Set or show the default database for an instance Example usage: \b - dclient alias default-db prod main - dclient alias default-db prod - dclient alias default-db prod --clear + dclient default database prod main + dclient default database https://myapp.datasette.cloud main + dclient default database prod + dclient default database prod --clear """ config_file = get_config_dir() / "config.json" config = _load_config(config_file) - if alias_name not in config.get("instances", {}): - raise click.ClickException(f"No such alias: {alias_name}") + instance_key = _resolve_instance_key(alias_or_url, config) if clear: - config["instances"][alias_name]["default_database"] = None + config["instances"][instance_key]["default_database"] = None _save_config(config_file, config) elif db: - config["instances"][alias_name]["default_database"] = db + config["instances"][instance_key]["default_database"] = db _save_config(config_file, config) else: - default_db = config["instances"][alias_name].get("default_database") + default_db = config["instances"][instance_key].get("default_database") if default_db: click.echo(default_db) else: - click.echo(f"No default database set for {alias_name}") + click.echo(f"No default database set for {instance_key}") # -- auth command group -- diff --git a/docs/aliases.md b/docs/aliases.md index 8399085..4c4255d 100644 --- a/docs/aliases.md +++ b/docs/aliases.md @@ -13,28 +13,7 @@ Once registered, you can pass an alias to commands using the `-i` flag: dclient query fixtures "select * from news limit 1" -i latest -## Default instance - -Set a default instance so you don't need `-i` every time: - - dclient alias default latest - -Now commands will use `latest` automatically: - - dclient databases - dclient tables -d fixtures - -## Default database - -Set a default database for an alias: - - dclient alias default-db latest fixtures - -Now you can run bare SQL queries directly: - - dclient "select * from facetable limit 5" - -This uses the default instance and default database. +See [Defaults](defaults.md) for default instance and default database settings. ## dclient alias --help @@ -137,59 +114,3 @@ Options: ``` - -## dclient alias default --help - - -``` -Usage: dclient alias default [OPTIONS] [NAME] - - Set or show the default instance - - Example usage: - - dclient alias default prod - dclient alias default - dclient alias default --clear - -Options: - --clear Clear default instance - --help Show this message and exit. - -``` - - -## dclient alias default-db --help - - -``` -Usage: dclient alias default-db [OPTIONS] ALIAS_NAME [DB] - - Set or show the default database for an alias - - Example usage: - - dclient alias default-db prod main - dclient alias default-db prod - dclient alias default-db prod --clear - -Options: - --clear Clear default database for this alias - --help Show this message and exit. - -``` - diff --git a/docs/defaults.md b/docs/defaults.md new file mode 100644 index 0000000..b53c334 --- /dev/null +++ b/docs/defaults.md @@ -0,0 +1,106 @@ +# Defaults + +Set a default instance so you don't need `-i` every time: + + dclient default instance latest + +Now commands will use `latest` automatically: + + dclient databases + dclient tables -d fixtures + +Set a default database for an alias or instance URL: + + dclient default database latest fixtures + dclient default database https://latest.datasette.io fixtures + +Now you can run bare SQL queries directly: + + dclient "select * from facetable limit 5" + +This uses the default instance and default database. + +## dclient default --help + +``` +Usage: dclient default [OPTIONS] COMMAND [ARGS]... + + Manage default instance and database + +Options: + --help Show this message and exit. + +Commands: + database Set or show the default database for an instance + instance Set or show the default instance + +``` + + +## dclient default instance --help + + +``` +Usage: dclient default instance [OPTIONS] [ALIAS_OR_URL] + + Set or show the default instance + + Example usage: + + dclient default instance prod + dclient default instance https://myapp.datasette.cloud + dclient default instance + dclient default instance --clear + +Options: + --clear Clear default instance + --help Show this message and exit. + +``` + + +## dclient default database --help + + +``` +Usage: dclient default database [OPTIONS] ALIAS_OR_URL [DB] + + Set or show the default database for an instance + + Example usage: + + dclient default database prod main + dclient default database https://myapp.datasette.cloud main + dclient default database prod + dclient default database prod --clear + +Options: + --clear Clear default database for this instance + --help Show this message and exit. + +``` + diff --git a/docs/index.md b/docs/index.md index dda58da..0b43234 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,6 +35,7 @@ maxdepth: 3 --- queries aliases +defaults authentication inserting environment diff --git a/tests/test_alias_v2.py b/tests/test_alias_v2.py index e93902d..173382e 100644 --- a/tests/test_alias_v2.py +++ b/tests/test_alias_v2.py @@ -1,4 +1,4 @@ -"""Tests for v2 alias command: default, default-db subcommands.""" +"""Tests for v2 defaults command: instance and database subcommands.""" from click.testing import CliRunner from dclient.cli import cli @@ -15,16 +15,16 @@ def test_alias_default_workflow(mocker, tmpdir): assert result.exit_code == 0 # No default yet - result = runner.invoke(cli, ["alias", "default"]) + result = runner.invoke(cli, ["default", "instance"]) assert result.exit_code == 0 assert "No default instance set" in result.output # Set default - result = runner.invoke(cli, ["alias", "default", "prod"]) + result = runner.invoke(cli, ["default", "instance", "prod"]) assert result.exit_code == 0 # Show default - result = runner.invoke(cli, ["alias", "default"]) + result = runner.invoke(cli, ["default", "instance"]) assert result.exit_code == 0 assert result.output.strip() == "prod" @@ -34,17 +34,17 @@ def test_alias_default_workflow(mocker, tmpdir): assert "* prod" in result.output # Clear default - result = runner.invoke(cli, ["alias", "default", "--clear"]) + result = runner.invoke(cli, ["default", "instance", "--clear"]) assert result.exit_code == 0 - result = runner.invoke(cli, ["alias", "default"]) + result = runner.invoke(cli, ["default", "instance"]) assert "No default instance set" in result.output def test_alias_default_unknown_alias(mocker, tmpdir): mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) runner = CliRunner() - result = runner.invoke(cli, ["alias", "default", "nonexistent"]) + result = runner.invoke(cli, ["default", "instance", "nonexistent"]) assert result.exit_code == 1 assert "No such alias" in result.output @@ -58,16 +58,16 @@ def test_alias_default_db_workflow(mocker, tmpdir): assert result.exit_code == 0 # No default database yet - result = runner.invoke(cli, ["alias", "default-db", "prod"]) + result = runner.invoke(cli, ["default", "database", "prod"]) assert result.exit_code == 0 assert "No default database set" in result.output # Set default database - result = runner.invoke(cli, ["alias", "default-db", "prod", "main"]) + result = runner.invoke(cli, ["default", "database", "prod", "main"]) assert result.exit_code == 0 # Show default database - result = runner.invoke(cli, ["alias", "default-db", "prod"]) + result = runner.invoke(cli, ["default", "database", "prod"]) assert result.exit_code == 0 assert result.output.strip() == "main" @@ -76,17 +76,17 @@ def test_alias_default_db_workflow(mocker, tmpdir): assert "(db: main)" in result.output # Clear default database - result = runner.invoke(cli, ["alias", "default-db", "prod", "--clear"]) + result = runner.invoke(cli, ["default", "database", "prod", "--clear"]) assert result.exit_code == 0 - result = runner.invoke(cli, ["alias", "default-db", "prod"]) + result = runner.invoke(cli, ["default", "database", "prod"]) assert "No default database set" in result.output def test_alias_default_db_unknown_alias(mocker, tmpdir): mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) runner = CliRunner() - result = runner.invoke(cli, ["alias", "default-db", "nonexistent", "main"]) + result = runner.invoke(cli, ["default", "database", "nonexistent", "main"]) assert result.exit_code == 1 assert "No such alias" in result.output @@ -97,7 +97,7 @@ def test_alias_remove_clears_default(mocker, tmpdir): runner = CliRunner() runner.invoke(cli, ["alias", "add", "prod", "https://prod.example.com"]) - runner.invoke(cli, ["alias", "default", "prod"]) + runner.invoke(cli, ["default", "instance", "prod"]) # Verify it's set config = json.loads((pathlib.Path(tmpdir) / "config.json").read_text()) @@ -109,3 +109,31 @@ def test_alias_remove_clears_default(mocker, tmpdir): config = json.loads((pathlib.Path(tmpdir) / "config.json").read_text()) assert config["default_instance"] is None assert "prod" not in config["instances"] + + +def test_default_instance_accepts_url(mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + runner = CliRunner() + + runner.invoke(cli, ["alias", "add", "prod", "https://prod.example.com"]) + result = runner.invoke(cli, ["default", "instance", "https://prod.example.com"]) + assert result.exit_code == 0 + + result = runner.invoke(cli, ["default", "instance"]) + assert result.exit_code == 0 + assert result.output.strip() == "prod" + + +def test_default_database_accepts_url(mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + runner = CliRunner() + + runner.invoke(cli, ["alias", "add", "prod", "https://prod.example.com"]) + result = runner.invoke( + cli, ["default", "database", "https://prod.example.com", "main"] + ) + assert result.exit_code == 0 + + result = runner.invoke(cli, ["default", "database", "prod"]) + assert result.exit_code == 0 + assert result.output.strip() == "main" From f2aea1b108f46b812740647aa6624d759e2af015 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 26 Feb 2026 20:56:22 -0800 Subject: [PATCH 09/17] --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 + +## dclient create-table --help + +``` +Usage: dclient create-table [OPTIONS] DATABASE TABLE_NAME + + Create a new empty table with an explicit schema + + Example usage: + + dclient create-table mydb dogs \ + --column id integer --column name text --pk id + +Options: + --column TEXT... Column definition: name type (e.g. --column id integer + --column name text) + --pk TEXT Column(s) to use as primary key + -i, --instance TEXT Datasette instance URL or alias + --token TEXT API token + -v, --verbose Verbose output: show HTTP request and response + --help Show this message and exit. + +``` + diff --git a/tests/test_create_table.py b/tests/test_create_table.py new file mode 100644 index 0000000..d3787de --- /dev/null +++ b/tests/test_create_table.py @@ -0,0 +1,203 @@ +"""Tests for the create-table command.""" + +from click.testing import CliRunner +from dclient.cli import cli +import json +import pathlib + + +def test_create_table_basic(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={ + "ok": True, + "database": "mydb", + "table": "dogs", + "table_url": "http://example.com/mydb/dogs", + "table_api_url": "http://example.com/mydb/dogs.json", + "schema": "CREATE TABLE [dogs] (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT\n)", + }, + status_code=201, + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "create-table", + "mydb", + "dogs", + "--column", + "id", + "integer", + "--column", + "name", + "text", + "--pk", + "id", + "-i", + "https://example.com", + "--token", + "tok", + ], + ) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["ok"] is True + assert data["table"] == "dogs" + + # Verify request + request = httpx_mock.get_request() + assert request.url.path == "/mydb/-/create" + assert request.headers["authorization"] == "Bearer tok" + body = json.loads(request.read()) + assert body["table"] == "dogs" + assert body["columns"] == [ + {"name": "id", "type": "integer"}, + {"name": "name", "type": "text"}, + ] + assert body["pk"] == "id" + + +def test_create_table_compound_pk(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response(json={"ok": True}, status_code=201) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "create-table", + "mydb", + "events", + "--column", + "user_id", + "integer", + "-c", + "event_id", + "integer", + "--column", + "data", + "text", + "--pk", + "user_id", + "--pk", + "event_id", + "-i", + "https://example.com", + "--token", + "tok", + ], + ) + assert result.exit_code == 0, result.output + body = json.loads(httpx_mock.get_request().read()) + assert body["pks"] == ["user_id", "event_id"] + assert "pk" not in body + + +def test_create_table_no_pk(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response(json={"ok": True}, status_code=201) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "create-table", + "mydb", + "logs", + "--column", + "message", + "text", + "--column", + "level", + "integer", + "-i", + "https://example.com", + "--token", + "tok", + ], + ) + assert result.exit_code == 0, result.output + body = json.loads(httpx_mock.get_request().read()) + assert "pk" not in body + assert "pks" not in body + + +def test_create_table_no_columns_error(mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "create-table", + "mydb", + "empty", + "-i", + "https://example.com", + "--token", + "tok", + ], + ) + assert result.exit_code == 1 + assert "at least one --column" in result.output + + +def test_create_table_api_error(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={"ok": False, "errors": ["Table already exists: dogs"]}, + status_code=400, + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "create-table", + "mydb", + "dogs", + "--column", + "id", + "integer", + "-i", + "https://example.com", + "--token", + "tok", + ], + ) + assert result.exit_code == 1 + assert "Table already exists" in result.output + + +def test_create_table_uses_default_instance(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + config_file = pathlib.Path(tmpdir) / "config.json" + config_file.write_text( + json.dumps( + { + "default_instance": "prod", + "instances": { + "prod": { + "url": "https://prod.example.com", + "default_database": "main", + } + }, + } + ) + ) + httpx_mock.add_response(json={"ok": True}, status_code=201) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "create-table", + "mydb", + "t1", + "--column", + "id", + "integer", + "--token", + "tok", + ], + ) + assert result.exit_code == 0, result.output + request = httpx_mock.get_request() + assert request.url.host == "prod.example.com" + assert request.url.path == "/mydb/-/create" diff --git a/tests/test_output_formats.py b/tests/test_output_formats.py new file mode 100644 index 0000000..b34a1e6 --- /dev/null +++ b/tests/test_output_formats.py @@ -0,0 +1,267 @@ +"""Tests for multiple output formats on the query and default_query commands.""" + +from click.testing import CliRunner +from dclient.cli import cli +import json +import pathlib + +QUERY_RESPONSE = { + "ok": True, + "database": "fixtures", + "query_name": None, + "rows": [ + {"id": 1, "name": "Cleo", "age": 5}, + {"id": 2, "name": "Pancakes", "age": 3}, + ], + "truncated": False, + "columns": ["id", "name", "age"], + "query": {"sql": "select * from dogs", "params": {}}, + "error": None, + "private": False, + "allow_execute_sql": True, +} + + +def _mock_and_invoke(httpx_mock, extra_args=None): + httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200) + runner = CliRunner() + args = ["query", "fixtures", "select * from dogs", "-i", "https://example.com"] + if extra_args: + args.extend(extra_args) + return runner.invoke(cli, args) + + +# -- query --csv -- + + +def test_query_csv(httpx_mock): + result = _mock_and_invoke(httpx_mock, ["--csv"]) + assert result.exit_code == 0 + lines = result.output.strip().split("\n") + assert lines[0] == "id,name,age" + assert lines[1] == "1,Cleo,5" + assert lines[2] == "2,Pancakes,3" + + +# -- query --tsv -- + + +def test_query_tsv(httpx_mock): + result = _mock_and_invoke(httpx_mock, ["--tsv"]) + assert result.exit_code == 0 + lines = result.output.strip().split("\n") + assert lines[0] == "id\tname\tage" + assert lines[1] == "1\tCleo\t5" + assert lines[2] == "2\tPancakes\t3" + + +# -- query --nl -- + + +def test_query_nl(httpx_mock): + result = _mock_and_invoke(httpx_mock, ["--nl"]) + assert result.exit_code == 0 + lines = result.output.strip().split("\n") + assert len(lines) == 2 + assert json.loads(lines[0]) == {"id": 1, "name": "Cleo", "age": 5} + assert json.loads(lines[1]) == {"id": 2, "name": "Pancakes", "age": 3} + + +# -- query --table -- + + +def test_query_table(httpx_mock): + result = _mock_and_invoke(httpx_mock, ["--table"]) + assert result.exit_code == 0 + lines = result.output.strip().split("\n") + # Should have a header row, a separator row, and 2 data rows + assert len(lines) == 4 + # Header should contain column names + assert "id" in lines[0] + assert "name" in lines[0] + assert "age" in lines[0] + # Data rows should contain values + assert "Cleo" in lines[2] + assert "Pancakes" in lines[3] + + +# -- query -t shortcut for --table -- + + +def test_query_table_shortcut(httpx_mock): + result = _mock_and_invoke(httpx_mock, ["-t"]) + assert result.exit_code == 0 + lines = result.output.strip().split("\n") + assert len(lines) == 4 + assert "Cleo" in lines[2] + + +# -- default JSON (no flag) stays the same -- + + +def test_query_default_json(httpx_mock): + result = _mock_and_invoke(httpx_mock) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data == [ + {"id": 1, "name": "Cleo", "age": 5}, + {"id": 2, "name": "Pancakes", "age": 3}, + ] + + +# -- default_query also supports output formats -- + + +def _mock_default_query(httpx_mock, mocker, tmpdir, extra_args=None): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + config_file = pathlib.Path(tmpdir) / "config.json" + config_file.write_text( + json.dumps( + { + "default_instance": "prod", + "instances": { + "prod": { + "url": "https://prod.example.com", + "default_database": "main", + } + }, + } + ) + ) + httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200) + runner = CliRunner() + args = ["select * from dogs"] + if extra_args: + args.extend(extra_args) + return runner.invoke(cli, args) + + +def test_default_query_csv(httpx_mock, mocker, tmpdir): + result = _mock_default_query(httpx_mock, mocker, tmpdir, ["--csv"]) + assert result.exit_code == 0 + lines = result.output.strip().split("\n") + assert lines[0] == "id,name,age" + assert lines[1] == "1,Cleo,5" + + +def test_default_query_table(httpx_mock, mocker, tmpdir): + result = _mock_default_query(httpx_mock, mocker, tmpdir, ["--table"]) + assert result.exit_code == 0 + assert "Cleo" in result.output + assert "Pancakes" in result.output + lines = result.output.strip().split("\n") + assert len(lines) == 4 + + +def test_default_query_nl(httpx_mock, mocker, tmpdir): + result = _mock_default_query(httpx_mock, mocker, tmpdir, ["--nl"]) + assert result.exit_code == 0 + lines = result.output.strip().split("\n") + assert json.loads(lines[0]) == {"id": 1, "name": "Cleo", "age": 5} + + +# -- edge cases -- + + +def test_query_csv_with_commas_in_values(httpx_mock): + httpx_mock.add_response( + json={ + "ok": True, + "rows": [{"name": "Smith, John", "note": 'He said "hi"'}], + "columns": ["name", "note"], + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "query", + "db", + "select * from t", + "-i", + "https://example.com", + "--csv", + ], + ) + assert result.exit_code == 0 + lines = result.output.strip().split("\n") + assert lines[0] == "name,note" + # CSV should properly quote fields with commas/quotes + assert '"Smith, John"' in lines[1] + + +def test_query_table_empty_results(httpx_mock): + httpx_mock.add_response( + json={ + "ok": True, + "rows": [], + "columns": ["id", "name"], + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "query", + "db", + "select * from t", + "-i", + "https://example.com", + "--table", + ], + ) + assert result.exit_code == 0 + + +def test_query_csv_empty_results(httpx_mock): + httpx_mock.add_response( + json={ + "ok": True, + "rows": [], + "columns": ["id", "name"], + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "query", + "db", + "select * from t", + "-i", + "https://example.com", + "--csv", + ], + ) + assert result.exit_code == 0 + lines = result.output.strip().split("\n") + assert lines[0] == "id,name" + assert len(lines) == 1 # header only, no data rows + + +def test_query_nl_empty_results(httpx_mock): + httpx_mock.add_response( + json={ + "ok": True, + "rows": [], + "columns": ["id", "name"], + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "query", + "db", + "select * from t", + "-i", + "https://example.com", + "--nl", + ], + ) + assert result.exit_code == 0 + assert result.output.strip() == "" From 1340f68255b2ff7024eb7eabca63b2730bb63dc8 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 26 Feb 2026 21:04:25 -0800 Subject: [PATCH 11/17] Ran cog --- docs/inserting.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/inserting.md b/docs/inserting.md index ede3d90..e3a3039 100644 --- a/docs/inserting.md +++ b/docs/inserting.md @@ -240,13 +240,13 @@ Usage: dclient create-table [OPTIONS] DATABASE TABLE_NAME --column id integer --column name text --pk id Options: - --column TEXT... Column definition: name type (e.g. --column id integer - --column name text) - --pk TEXT Column(s) to use as primary key - -i, --instance TEXT Datasette instance URL or alias - --token TEXT API token - -v, --verbose Verbose output: show HTTP request and response - --help Show this message and exit. + -c, --column TEXT... Column definition: name type (e.g. --column id integer + --column name text) + --pk TEXT Column(s) to use as primary key + -i, --instance TEXT Datasette instance URL or alias + --token TEXT API token + -v, --verbose Verbose output: show HTTP request and response + --help Show this message and exit. ``` From 0b590d1a6fc30a052d4df4327a06a2bc3dc27c1b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 26 Feb 2026 21:19:32 -0800 Subject: [PATCH 12/17] dclient rows command, closes #33 --- README.md | 5 + dclient/cli.py | 187 ++++++++++++++++- docs/queries.md | 117 ++++++++++- tests/test_commands_v2.py | 1 - tests/test_config.py | 1 - tests/test_rows.py | 410 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 717 insertions(+), 4 deletions(-) create mode 100644 tests/test_rows.py diff --git a/README.md b/README.md index 75225c6..a70a430 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Much of the functionality requires Datasette 1.0a2 or higher. ## Things you can do with dclient +- Browse table data with filtering, sorting, and pagination — no SQL required - 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 @@ -47,6 +48,10 @@ Output as a table with `-t`, or use `--csv`, `--tsv`, `--nl`: ```bash dclient "select pk, state from facetable limit 3" -t ``` +Browse table rows without SQL: +```bash +dclient rows facetable -f state eq CA --sort _city_id -t +``` ## Introspection diff --git a/dclient/cli.py b/dclient/cli.py index b99e577..8b7dc72 100644 --- a/dclient/cli.py +++ b/dclient/cli.py @@ -359,6 +359,191 @@ def tables(instance, database, views, views_only, hidden, _json, token): click.echo(item) +# Convenience aliases for common filter operations. +# Any operation not listed here is passed through directly to Datasette, +# so plugins that add custom filter operations will work too. +FILTER_ALIASES = { + "eq": "exact", +} + + +@cli.command() +@click.argument("db_or_table") +@click.argument("table", required=False, default=None) +@click.option("-i", "--instance", default=None, help="Datasette instance URL or alias") +@click.option("-d", "--database", default=None, help="Database name") +@click.option("--token", help="API token") +@click.option( + "-f", + "--filter", + "filters", + multiple=True, + nargs=3, + help="Filter: column operation value (e.g. -f age gte 3)", +) +@click.option("--search", default=None, help="Full-text search query") +@click.option("--sort", default=None, help="Sort by column (ascending)") +@click.option("--sort-desc", default=None, help="Sort by column (descending)") +@click.option("--col", "columns", multiple=True, help="Include only these columns") +@click.option("--nocol", "nocolumns", multiple=True, help="Exclude these columns") +@click.option("--size", type=int, default=None, help="Number of rows per page") +@click.option("--limit", type=int, default=None, help="Maximum total rows to return") +@click.option("--all", "fetch_all", is_flag=True, help="Fetch all pages") +@click.option("-v", "--verbose", is_flag=True, help="Verbose output: show HTTP request") +@output_format_options +def rows( + db_or_table, + table, + instance, + database, + token, + filters, + search, + sort, + sort_desc, + columns, + nocolumns, + size, + limit, + fetch_all, + verbose, + fmt_csv, + fmt_tsv, + fmt_nl, + fmt_table, +): + """ + Browse rows in a table with filtering and sorting + + If only one positional argument is given, it is treated as the table name + and the default database is used. Pass two arguments for database and table. + + Example usage: + + \b + dclient rows facet_cities + dclient rows fixtures facet_cities -i https://latest.datasette.io + dclient rows facet_cities -f id gte 3 --sort name -t + """ + config_dir = get_config_dir() + url = _resolve_instance(instance, config_dir / "config.json") + + # Figure out database and table from positional args + if table is not None: + db = db_or_table + else: + # Only one positional arg — it's the table, resolve database from defaults + table = db_or_table + instance_alias = ( + _instance_alias_for_url(url, config_dir / "config.json") + if not ( + instance + and (instance.startswith("http://") or instance.startswith("https://")) + ) + else None + ) + if instance and not ( + instance.startswith("http://") or instance.startswith("https://") + ): + instance_alias = instance + db = _resolve_database(database, instance_alias, config_dir / "config.json") + + token = _resolve_token( + token, url, config_dir / "auth.json", config_dir / "config.json" + ) + + # Build query params + params = {"_shape": "objects"} + for col, op, val in filters: + datasette_op = FILTER_ALIASES.get(op, op) + params[f"{col}__{datasette_op}"] = val + if search: + params["_search"] = search + if sort: + params["_sort"] = sort + if sort_desc: + params["_sort_desc"] = sort_desc + for col in columns: + # httpx handles repeated keys if we use a list of tuples + pass + for col in nocolumns: + pass + if size: + params["_size"] = str(size) + + # Convert to list of tuples to support repeated keys (_col, _nocol) + param_items = list(params.items()) + for col in columns: + param_items.append(("_col", col)) + for col in nocolumns: + param_items.append(("_nocol", col)) + + # First request + table_url = url.rstrip("/") + "/" + db + "/" + table + ".json" + if verbose: + click.echo(table_url, err=True) + + all_rows = [] + col_names = None + total = 0 + next_page_url = None + first = True + + while True: + if first: + response = _make_request( + url, token, f"/{db}/{table}.json", params=param_items + ) + first = False + else: + # Follow next_url directly + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + response = httpx.get( + next_page_url, + headers=headers, + follow_redirects=True, + timeout=30.0, + ) + + if response.status_code != 200: + try: + data = response.json() + except json.JSONDecodeError: + raise click.ClickException(f"{response.status_code} status code") + bits = [] + if data.get("title"): + bits.append(data["title"]) + if data.get("error"): + bits.append(data["error"]) + raise click.ClickException( + "{} status code. {}".format(response.status_code, ": ".join(bits)) + ) + + data = response.json() + page_rows = data.get("rows", []) + if col_names is None: + col_names = data.get("columns") + + if limit: + remaining = limit - total + page_rows = page_rows[:remaining] + + all_rows.extend(page_rows) + total += len(page_rows) + + if limit and total >= limit: + break + + next_page_url = data.get("next_url") + if not fetch_all or not next_page_url: + break + + fmt = _determine_output_format(fmt_csv, fmt_tsv, fmt_nl, fmt_table) + _output_rows(all_rows, fmt, col_names) + + @cli.command() @click.argument("database") @click.argument("sql") @@ -1320,7 +1505,7 @@ def login(alias_or_url, scope): interval = device_data.get("interval", 5) # Step 2: Show instructions - click.echo(f"\nOpen this URL in your browser:\n") + click.echo("\nOpen this URL in your browser:\n") click.echo(f" {verification_uri}\n") click.echo(f"Enter this code: {user_code}\n") click.echo("Waiting for authorization...", nl=False) diff --git a/docs/queries.md b/docs/queries.md index 2db5cdb..e9de25b 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -36,6 +36,121 @@ You can override just the database with `-d`: dclient "select * from counters" -d counters ``` +## Browsing rows + +The `dclient rows` command lets you browse table data without writing SQL: + +```bash +dclient rows fixtures facet_cities -i https://latest.datasette.io -t +``` +``` +id name +-- ------------- +3 Detroit +2 Los Angeles +4 Memnonia +1 San Francisco +``` + +If you have a default instance and database configured, you can just pass the table name: + +```bash +dclient rows facet_cities -t +``` + +### Filtering + +Use `-f` / `--filter` with three arguments: column, operation, value: + +```bash +dclient rows facet_cities -f id gte 3 -t +dclient rows facet_cities -f name eq Detroit +dclient rows facet_cities -f name contains M -f id gt 2 +``` + +The operation is passed directly to Datasette as a column filter suffix. Built-in Datasette operations include `exact`, `not`, `gt`, `gte`, `lt`, `lte`, `contains`, `like`, `startswith`, `endswith`, `glob`, `isnull`, `notnull`, and more. `eq` is a convenience alias for `exact`. Operations added by Datasette plugins will work too. + +### Sorting + +```bash +dclient rows dogs --sort age +dclient rows dogs --sort-desc age +``` + +### Column selection + +```bash +dclient rows dogs --col name --col age +dclient rows dogs --nocol id +``` + +### Search + +Full-text search (requires an FTS index on the table): + +```bash +dclient rows dogs --search "retriever" +``` + +### Pagination + +By default only one page of results is returned. Use `--all` to auto-paginate through all rows, and `--limit` to cap the total: + +```bash +dclient rows dogs --all +dclient rows dogs --all --limit 500 +dclient rows dogs --size 50 +``` + +### dclient rows --help + +``` +Usage: dclient rows [OPTIONS] DB_OR_TABLE [TABLE] + + Browse rows in a table with filtering and sorting + + If only one positional argument is given, it is treated as the table name and + the default database is used. Pass two arguments for database and table. + + Example usage: + + dclient rows facet_cities + dclient rows fixtures facet_cities -i https://latest.datasette.io + dclient rows facet_cities -f id gte 3 --sort name -t + +Options: + -i, --instance TEXT Datasette instance URL or alias + -d, --database TEXT Database name + --token TEXT API token + -f, --filter TEXT... Filter: column operation value (e.g. -f age gte 3) + --search TEXT Full-text search query + --sort TEXT Sort by column (ascending) + --sort-desc TEXT Sort by column (descending) + --col TEXT Include only these columns + --nocol TEXT Exclude these columns + --size INTEGER Number of rows per page + --limit INTEGER Maximum total rows to return + --all Fetch all pages + -v, --verbose Verbose output: show HTTP request + --csv Output as CSV + --tsv Output as TSV + --nl Output as newline-delimited JSON + -t, --table Output as ASCII table + --help Show this message and exit. + +``` + + ## Output formats By default, results are returned as JSON. Use these flags to change the output format: @@ -45,7 +160,7 @@ By default, results are returned as JSON. Use these flags to change the output f - `-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. +These flags work with `dclient query`, `dclient rows`, and the bare SQL shortcut. CSV output: diff --git a/tests/test_commands_v2.py b/tests/test_commands_v2.py index a4326b5..8d21ce4 100644 --- a/tests/test_commands_v2.py +++ b/tests/test_commands_v2.py @@ -4,7 +4,6 @@ from click.testing import CliRunner from dclient.cli import cli import json import pathlib -import pytest # -- databases command -- diff --git a/tests/test_config.py b/tests/test_config.py index d39a2ee..65be345 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,7 +6,6 @@ from dclient.cli import ( _resolve_instance, _resolve_database, _resolve_token, - get_config_dir, ) import json import pathlib diff --git a/tests/test_rows.py b/tests/test_rows.py new file mode 100644 index 0000000..33f8e0c --- /dev/null +++ b/tests/test_rows.py @@ -0,0 +1,410 @@ +"""Tests for the rows command.""" + +from click.testing import CliRunner +from dclient.cli import cli +import json +import pathlib + +TABLE_RESPONSE = { + "ok": True, + "rows": [ + {"id": 1, "name": "Cleo", "age": 5}, + {"id": 2, "name": "Pancakes", "age": 3}, + {"id": 3, "name": "Fido", "age": 7}, + ], + "columns": ["id", "name", "age"], + "next": None, + "next_url": None, +} + + +def _invoke(httpx_mock, extra_args=None, response=None): + httpx_mock.add_response(json=response or TABLE_RESPONSE, status_code=200) + runner = CliRunner() + args = ["rows", "fixtures", "dogs", "-i", "https://example.com"] + if extra_args: + args.extend(extra_args) + return runner.invoke(cli, args) + + +# -- basic usage -- + + +def test_rows_default_json(httpx_mock): + result = _invoke(httpx_mock) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert len(data) == 3 + assert data[0]["name"] == "Cleo" + # Verify request URL + request = httpx_mock.get_request() + assert request.url.path == "/fixtures/dogs.json" + assert "_shape" in dict(request.url.params) + assert dict(request.url.params)["_shape"] == "objects" + + +def test_rows_table_format(httpx_mock): + result = _invoke(httpx_mock, ["-t"]) + assert result.exit_code == 0 + lines = result.output.strip().split("\n") + assert len(lines) == 5 # header + separator + 3 data rows + assert "Cleo" in lines[2] + assert "Pancakes" in lines[3] + + +def test_rows_csv(httpx_mock): + result = _invoke(httpx_mock, ["--csv"]) + assert result.exit_code == 0 + lines = result.output.strip().split("\n") + assert lines[0] == "id,name,age" + assert lines[1] == "1,Cleo,5" + + +def test_rows_nl(httpx_mock): + result = _invoke(httpx_mock, ["--nl"]) + assert result.exit_code == 0 + lines = result.output.strip().split("\n") + assert json.loads(lines[0]) == {"id": 1, "name": "Cleo", "age": 5} + + +# -- single argument uses default database -- + + +def test_rows_single_arg_uses_default_database(httpx_mock, mocker, tmpdir): + """dclient rows tablename uses default database.""" + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + config_file = pathlib.Path(tmpdir) / "config.json" + config_file.write_text( + json.dumps( + { + "default_instance": "prod", + "instances": { + "prod": { + "url": "https://prod.example.com", + "default_database": "data", + } + }, + } + ) + ) + httpx_mock.add_response(json=TABLE_RESPONSE, status_code=200) + runner = CliRunner() + result = runner.invoke(cli, ["rows", "dogs"]) + assert result.exit_code == 0, result.output + request = httpx_mock.get_request() + assert request.url.host == "prod.example.com" + assert request.url.path == "/data/dogs.json" + + +def test_rows_single_arg_no_default_database_errors(mocker, tmpdir): + """dclient rows tablename without default database gives error.""" + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + config_file = pathlib.Path(tmpdir) / "config.json" + config_file.write_text( + json.dumps( + { + "default_instance": "prod", + "instances": { + "prod": { + "url": "https://prod.example.com", + "default_database": None, + } + }, + } + ) + ) + runner = CliRunner() + result = runner.invoke(cli, ["rows", "dogs"]) + assert result.exit_code == 1 + assert "No database specified" in result.output + + +# -- filters -- + + +def test_rows_filter_eq(httpx_mock): + result = _invoke(httpx_mock, ["-f", "name", "eq", "Cleo"]) + assert result.exit_code == 0 + request = httpx_mock.get_request() + params = dict(request.url.params) + assert params["name__exact"] == "Cleo" + + +def test_rows_filter_gt(httpx_mock): + result = _invoke(httpx_mock, ["--filter", "age", "gt", "3"]) + assert result.exit_code == 0 + assert dict(httpx_mock.get_request().url.params)["age__gt"] == "3" + + +def test_rows_filter_gte(httpx_mock): + result = _invoke(httpx_mock, ["-f", "age", "gte", "5"]) + assert result.exit_code == 0 + assert dict(httpx_mock.get_request().url.params)["age__gte"] == "5" + + +def test_rows_filter_lt(httpx_mock): + result = _invoke(httpx_mock, ["-f", "age", "lt", "5"]) + assert result.exit_code == 0 + assert dict(httpx_mock.get_request().url.params)["age__lt"] == "5" + + +def test_rows_filter_lte(httpx_mock): + result = _invoke(httpx_mock, ["-f", "age", "lte", "5"]) + assert result.exit_code == 0 + assert dict(httpx_mock.get_request().url.params)["age__lte"] == "5" + + +def test_rows_filter_not(httpx_mock): + result = _invoke(httpx_mock, ["-f", "name", "not", "Cleo"]) + assert result.exit_code == 0 + assert dict(httpx_mock.get_request().url.params)["name__not"] == "Cleo" + + +def test_rows_filter_contains(httpx_mock): + result = _invoke(httpx_mock, ["-f", "name", "contains", "leo"]) + assert result.exit_code == 0 + assert dict(httpx_mock.get_request().url.params)["name__contains"] == "leo" + + +def test_rows_filter_like(httpx_mock): + result = _invoke(httpx_mock, ["-f", "name", "like", "%leo%"]) + assert result.exit_code == 0 + assert dict(httpx_mock.get_request().url.params)["name__like"] == "%leo%" + + +def test_rows_filter_startswith(httpx_mock): + result = _invoke(httpx_mock, ["-f", "name", "startswith", "Cl"]) + assert result.exit_code == 0 + assert dict(httpx_mock.get_request().url.params)["name__startswith"] == "Cl" + + +def test_rows_filter_endswith(httpx_mock): + result = _invoke(httpx_mock, ["-f", "name", "endswith", "eo"]) + assert result.exit_code == 0 + assert dict(httpx_mock.get_request().url.params)["name__endswith"] == "eo" + + +def test_rows_filter_glob(httpx_mock): + result = _invoke(httpx_mock, ["-f", "name", "glob", "C*"]) + assert result.exit_code == 0 + assert dict(httpx_mock.get_request().url.params)["name__glob"] == "C*" + + +def test_rows_filter_isnull(httpx_mock): + result = _invoke(httpx_mock, ["-f", "name", "isnull", "1"]) + assert result.exit_code == 0 + assert dict(httpx_mock.get_request().url.params)["name__isnull"] == "1" + + +def test_rows_filter_notnull(httpx_mock): + result = _invoke(httpx_mock, ["-f", "name", "notnull", "1"]) + assert result.exit_code == 0 + assert dict(httpx_mock.get_request().url.params)["name__notnull"] == "1" + + +def test_rows_multiple_filters(httpx_mock): + result = _invoke(httpx_mock, ["-f", "name", "eq", "Cleo", "-f", "age", "gte", "3"]) + assert result.exit_code == 0 + params = list(httpx_mock.get_request().url.params.multi_items()) + param_dict = {k: v for k, v in params} + assert param_dict["name__exact"] == "Cleo" + assert param_dict["age__gte"] == "3" + + +def test_rows_custom_filter_op_passthrough(httpx_mock): + """Unknown ops are passed through to Datasette, supporting plugin-added filters.""" + result = _invoke(httpx_mock, ["-f", "name", "custom_plugin_op", "x"]) + assert result.exit_code == 0 + assert dict(httpx_mock.get_request().url.params)["name__custom_plugin_op"] == "x" + + +# -- sorting -- + + +def test_rows_sort(httpx_mock): + result = _invoke(httpx_mock, ["--sort", "age"]) + assert result.exit_code == 0 + assert dict(httpx_mock.get_request().url.params)["_sort"] == "age" + + +def test_rows_sort_desc(httpx_mock): + result = _invoke(httpx_mock, ["--sort-desc", "age"]) + assert result.exit_code == 0 + assert dict(httpx_mock.get_request().url.params)["_sort_desc"] == "age" + + +# -- column selection -- + + +def test_rows_col(httpx_mock): + result = _invoke(httpx_mock, ["--col", "name", "--col", "age"]) + assert result.exit_code == 0 + params = list(httpx_mock.get_request().url.params.multi_items()) + col_params = [v for k, v in params if k == "_col"] + assert col_params == ["name", "age"] + + +def test_rows_nocol(httpx_mock): + result = _invoke(httpx_mock, ["--nocol", "id"]) + assert result.exit_code == 0 + assert dict(httpx_mock.get_request().url.params)["_nocol"] == "id" + + +# -- search -- + + +def test_rows_search(httpx_mock): + result = _invoke(httpx_mock, ["--search", "pancakes"]) + assert result.exit_code == 0 + assert dict(httpx_mock.get_request().url.params)["_search"] == "pancakes" + + +# -- size -- + + +def test_rows_size(httpx_mock): + result = _invoke(httpx_mock, ["--size", "10"]) + assert result.exit_code == 0 + assert dict(httpx_mock.get_request().url.params)["_size"] == "10" + + +# -- limit -- + + +def test_rows_limit(httpx_mock): + response = { + "ok": True, + "rows": [{"id": 1}, {"id": 2}, {"id": 3}], + "columns": ["id"], + "next": None, + } + result = _invoke(httpx_mock, ["--limit", "2"], response=response) + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data) == 2 + + +# -- pagination with --all -- + + +def test_rows_all_pagination(httpx_mock): + page1 = { + "ok": True, + "rows": [{"id": 1}, {"id": 2}], + "columns": ["id"], + "next": "2", + "next_url": "https://example.com/fixtures/dogs.json?_next=2&_shape=objects", + } + page2 = { + "ok": True, + "rows": [{"id": 3}], + "columns": ["id"], + "next": None, + "next_url": None, + } + httpx_mock.add_response(json=page1, status_code=200) + httpx_mock.add_response(json=page2, status_code=200) + runner = CliRunner() + result = runner.invoke( + cli, + ["rows", "fixtures", "dogs", "-i", "https://example.com", "--all"], + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data) == 3 + assert [r["id"] for r in data] == [1, 2, 3] + + +def test_rows_all_with_limit(httpx_mock): + page1 = { + "ok": True, + "rows": [{"id": 1}, {"id": 2}], + "columns": ["id"], + "next": "2", + "next_url": "https://example.com/fixtures/dogs.json?_next=2&_shape=objects", + } + page2 = { + "ok": True, + "rows": [{"id": 3}, {"id": 4}], + "columns": ["id"], + "next": None, + } + httpx_mock.add_response(json=page1, status_code=200) + httpx_mock.add_response(json=page2, status_code=200) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "rows", + "fixtures", + "dogs", + "-i", + "https://example.com", + "--all", + "--limit", + "3", + ], + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data) == 3 + + +def test_rows_no_all_ignores_next(httpx_mock): + """Without --all, pagination is not followed even if next is present.""" + response = { + "ok": True, + "rows": [{"id": 1}, {"id": 2}], + "columns": ["id"], + "next": "2", + "next_url": "https://example.com/fixtures/dogs.json?_next=2&_shape=objects", + } + result = _invoke(httpx_mock, response=response) + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data) == 2 # only first page + + +# -- error handling -- + + +def test_rows_api_error(httpx_mock): + httpx_mock.add_response( + json={"ok": False, "error": "Table not found: dogs"}, + status_code=404, + ) + runner = CliRunner() + result = runner.invoke( + cli, + ["rows", "fixtures", "dogs", "-i", "https://example.com"], + ) + assert result.exit_code == 1 + assert "Table not found" in result.output + + +# -- uses default instance -- + + +def test_rows_default_instance(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + config_file = pathlib.Path(tmpdir) / "config.json" + config_file.write_text( + json.dumps( + { + "default_instance": "prod", + "instances": { + "prod": { + "url": "https://prod.example.com", + "default_database": "main", + } + }, + } + ) + ) + httpx_mock.add_response(json=TABLE_RESPONSE, status_code=200) + runner = CliRunner() + result = runner.invoke(cli, ["rows", "data", "dogs"]) + assert result.exit_code == 0 + request = httpx_mock.get_request() + assert request.url.host == "prod.example.com" + assert request.url.path == "/data/dogs.json" From e8d80a6495ecfebcd274eecc0a2d8fb7a3c98947 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 26 Feb 2026 21:25:56 -0800 Subject: [PATCH 13/17] Logging in with OAuth docs --- docs/authentication.md | 54 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/docs/authentication.md b/docs/authentication.md index cd3961c..d83042a 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -12,6 +12,60 @@ dclient query fixtures "select * from facetable" --token dstok_mytoken -i latest A more convenient way to handle this is to store tokens for your aliases. +## Logging in with OAuth + +The easiest way to authenticate is using the `dclient login` command, which uses the OAuth device flow to obtain and store a token. + +This requires the Datasette instance to be running the [datasette-oauth](https://github.com/datasette/datasette-oauth) plugin with [device flow enabled](https://github.com/datasette/datasette-oauth?tab=readme-ov-file#plugin-configuration). + +```bash +dclient login https://my-datasette.example.com/ +dclient login myalias +dclient login +``` + +This will display a URL and a code. Open the URL in your browser, enter the code to approve access, and the resulting API token will be saved automatically. If you run `dclient login` without an argument, you will be prompted for the instance URL or alias. + +You can also pass a `--scope` option to request specific permissions: + +```bash +dclient login myalias --scope '[["view-instance"]]' +``` + +## dclient login --help + + +``` +Usage: dclient login [OPTIONS] [ALIAS_OR_URL] + + Authenticate with a Datasette instance using OAuth + + Uses the OAuth device flow: opens a URL in your browser where you approve + access, then saves the resulting API token. + + Example usage: + + dclient login https://simon.datasette.cloud/ + dclient login myalias + dclient login + +Options: + --scope TEXT JSON scope array + --help Show this message and exit. + +``` + + ## Using stored tokens To store a token for an alias: From 54df24db5e167206fcf8528370ece375df7b9675 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 26 Feb 2026 21:45:58 -0800 Subject: [PATCH 14/17] --read/--write options plus --token-only, closes #34 --- dclient/cli.py | 75 +++++++++++++- docs/authentication.md | 50 +++++++++- tests/test_cli_auth.py | 220 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 342 insertions(+), 3 deletions(-) diff --git a/dclient/cli.py b/dclient/cli.py index 8b7dc72..05dc98d 100644 --- a/dclient/cli.py +++ b/dclient/cli.py @@ -1450,10 +1450,72 @@ def auth_status(instance, token): # -- login command (OAuth device flow) -- +READ_SCOPES = [ + "view-instance", + "view-table", + "view-database", + "view-query", + "execute-sql", +] +WRITE_SCOPES = [ + "insert-row", + "delete-row", + "update-row", + "create-table", + "alter-table", + "drop-table", +] + + +def _merge_scopes(scope, read_all, write_all, read, write): + """Merge --scope JSON with --read-all/--write-all/--read/--write options.""" + scopes = json.loads(scope) if scope else [] + if read_all: + for action in READ_SCOPES: + scopes.append([action]) + if write_all: + for action in READ_SCOPES + WRITE_SCOPES: + scopes.append([action]) + for target in read or []: + parts = target.split("/", 1) + if len(parts) == 1: + for action in READ_SCOPES: + scopes.append([action, parts[0]]) + else: + for action in READ_SCOPES: + scopes.append([action, parts[0], parts[1]]) + for target in write or []: + parts = target.split("/", 1) + if len(parts) == 1: + for action in READ_SCOPES + WRITE_SCOPES: + scopes.append([action, parts[0]]) + else: + for action in READ_SCOPES + WRITE_SCOPES: + scopes.append([action, parts[0], parts[1]]) + if scopes: + return json.dumps(scopes) + return None + + @cli.command() @click.argument("alias_or_url", required=False, default=None) @click.option("--scope", default=None, help="JSON scope array") -def login(alias_or_url, scope): +@click.option("--read-all", is_flag=True, help="Request instance-wide read access") +@click.option("--write-all", is_flag=True, help="Request instance-wide write access") +@click.option( + "--read", multiple=True, help="Request read access for a database or database/table" +) +@click.option( + "--write", + multiple=True, + help="Request write access for a database or database/table", +) +@click.option( + "--token-only", + is_flag=True, + help="Output the token to stdout instead of saving it", +) +def login(alias_or_url, scope, read_all, write_all, read, write, token_only): """ Authenticate with a Datasette instance using OAuth @@ -1466,7 +1528,13 @@ def login(alias_or_url, scope): dclient login https://simon.datasette.cloud/ dclient login myalias dclient login + dclient login --read-all + dclient login --write-all + dclient login --read db1 + dclient login --write db3/submissions + dclient login --read db1 --write db3/dogs """ + scope = _merge_scopes(scope, read_all, write_all, read, write) config_dir = get_config_dir() config_dir.mkdir(parents=True, exist_ok=True) config_file = config_dir / "config.json" @@ -1539,9 +1607,12 @@ def login(alias_or_url, scope): click.echo() raise click.ClickException(f"Unexpected error: {error}") - # Step 4: Save token + # Step 4: Save token (or print it) click.echo() access_token = token_data["access_token"] + if token_only: + click.echo(access_token) + return auth_file = config_dir / "auth.json" auths = _load_auths(auth_file) auths[auth_key] = access_token diff --git a/docs/authentication.md b/docs/authentication.md index d83042a..40a7700 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -26,12 +26,50 @@ dclient login This will display a URL and a code. Open the URL in your browser, enter the code to approve access, and the resulting API token will be saved automatically. If you run `dclient login` without an argument, you will be prompted for the instance URL or alias. -You can also pass a `--scope` option to request specific permissions: +### Requesting scoped tokens + +By default, `dclient login` requests an unrestricted token. You can request a token with limited permissions using the shorthand options: + +```bash +# Instance-wide read or write access +dclient login --read-all +dclient login --write-all + +# Database-level access +dclient login --read db1 +dclient login --write db3 + +# Table-level access +dclient login --read db1/dogs +dclient login --write db3/submissions + +# Mixed +dclient login --read db1 --write db3/dogs +``` + +`--read-all` grants: `view-instance`, `view-table`, `view-database`, `view-query`, `execute-sql`. + +`--write-all` grants all read scopes plus: `insert-row`, `delete-row`, `update-row`, `create-table`, `alter-table`, `drop-table`. + +`--read` and `--write` accept a database name or `database/table` and can be specified multiple times. `--write` implies read access for the same target. + +For advanced use, you can also pass raw scope JSON with `--scope`: ```bash dclient login myalias --scope '[["view-instance"]]' ``` +All scope options can be combined — the shorthand options append to whatever `--scope` provides. + +### Outputting the token directly + +Use `--token-only` to print the token to stdout instead of saving it. This is useful for creating debug tokens or piping them into other tools: + +```bash +dclient login --token-only --read mydb +dclient login --token-only --write-all +``` + ## dclient login --help