From 4df076d1f0962ff6e0ecffe47fa9bd5324e4ea54 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 26 Feb 2026 15:00:15 -0800 Subject: [PATCH] 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 == ""