From ff95969d10924eab7fa7b3c1a2c068fa37565ca1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 24 Feb 2026 14:59:23 -0800 Subject: [PATCH 1/6] 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 3621f132632cd34ef8c7c0b11ec567d7164f55c7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 24 Feb 2026 15:21:52 -0800 Subject: [PATCH 2/6] v2 config system, CLI rewrite, and updated core commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace aliases.json with config.json storing instances with default_database. Instance resolution: -i flag → config default → DATASETTE_URL. Database resolution: -d flag → instance default_database → DATASETTE_DATABASE. Token resolution: --token → auth.json by alias → auth.json by URL → DATASETTE_TOKEN. Add click-default-group dependency for bare SQL shortcut support. Add DCLIENT_CONFIG_DIR env var to override config directory. Rewrite query, insert, get, and actor commands for v2 API where instance is always -i flag. Add upsert command sharing insert implementation. Add databases, tables, schema, plugins commands. Add default_query hidden command for bare SQL shortcut. Add alias default, alias default-db subcommands. Add auth status subcommand. refs #29 Co-Authored-By: Claude Opus 4.6 --- dclient/cli.py | 834 +++++++++++++++++++++++++++++------------ docs/environment.md | 45 ++- docs/inserting.md | 69 +++- docs/queries.md | 32 +- pyproject.toml | 1 + tests/test_cli_auth.py | 16 +- tests/test_config.py | 249 ++++++++++++ tests/test_env.py | 69 +++- tests/test_insert.py | 8 +- tests/test_query.py | 38 +- 10 files changed, 1048 insertions(+), 313 deletions(-) create mode 100644 tests/test_config.py diff --git a/dclient/cli.py b/dclient/cli.py index d19a57e..3adfebc 100644 --- a/dclient/cli.py +++ b/dclient/cli.py @@ -1,4 +1,5 @@ import click +from click_default_group import DefaultGroup import httpx import json import os @@ -12,18 +13,128 @@ import urllib def get_config_dir(): + env = os.environ.get("DCLIENT_CONFIG_DIR") + if env: + return pathlib.Path(env) return pathlib.Path(click.get_app_dir("io.datasette.dclient")) -@click.group() +def _load_config(config_file): + if config_file.exists(): + return json.loads(config_file.read_text()) + return {"default_instance": None, "instances": {}} + + +def _save_config(config_file, config): + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text(json.dumps(config, indent=4)) + + +def _load_auths(auth_file): + if auth_file.exists(): + auths = json.loads(auth_file.read_text()) + else: + auths = {} + return auths + + +def _resolve_instance(instance, config_file): + """Resolve instance: -i flag (alias or URL) → config default → DATASETTE_URL → error.""" + config = _load_config(config_file) + if instance: + # If it looks like a URL, use directly + if instance.startswith("http://") or instance.startswith("https://"): + return instance + # Otherwise look up as alias + if instance in config.get("instances", {}): + return config["instances"][instance]["url"] + raise click.ClickException( + f"Unknown instance: {instance}. Use a URL or configure an alias." + ) + # Try config default + default = config.get("default_instance") + if default and default in config.get("instances", {}): + return config["instances"][default]["url"] + # Try env var + env_url = os.environ.get("DATASETTE_URL") + if env_url: + return env_url.rstrip("/") + raise click.ClickException( + "No instance specified. Use -i, set a default instance, or set DATASETTE_URL." + ) + + +def _resolve_database(database, instance_alias, config_file): + """Resolve database: -d flag → instance default_database → DATASETTE_DATABASE → error.""" + if database: + return database + # Try instance's default_database from config + 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") + if default_db: + return default_db + # Try env var + env_db = os.environ.get("DATASETTE_DATABASE") + if env_db: + return env_db + raise click.ClickException( + "No database specified. Use -d, set a default database, or set DATASETTE_DATABASE." + ) + + +def _instance_alias_for_url(url, config_file): + """Find the alias name for a given instance URL, if any.""" + config = _load_config(config_file) + for name, inst in config.get("instances", {}).items(): + if inst.get("url", "").rstrip("/") == url.rstrip("/"): + return name + return None + + +def _resolve_token(token, url, auth_file, config_file): + """Resolve token: --token flag → auth.json by alias → auth.json by URL → DATASETTE_TOKEN → None.""" + if token is not None: + return token + auths = _load_auths(auth_file) + # Try alias-based lookup + alias = _instance_alias_for_url(url, config_file) + if alias and alias in auths: + return auths[alias] + # Try URL-based prefix matching (fallback) + stored = token_for_url(url, auths) + if stored is not None: + return stored + return os.environ.get("DATASETTE_TOKEN") + + +@click.group(cls=DefaultGroup, default="default_query", default_if_no_args=False) @click.version_option() def cli(): "A client CLI utility for Datasette instances" +def _make_request(url, token, extra_path="", params=None): + """Make an authenticated GET request to a Datasette instance.""" + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + full_url = url.rstrip("/") + extra_path + response = httpx.get( + full_url, + headers=headers, + params=params, + follow_redirects=True, + timeout=30.0, + ) + return response + + @cli.command() @click.argument("path") -@click.option("--instance", default=None, help="Datasette URL or alias") +@click.option("-i", "--instance", default=None, help="Datasette instance URL or alias") @click.option("--token", help="API token") def get(path, instance, token): """ @@ -33,23 +144,15 @@ def get(path, instance, token): \b dclient get /-/plugins.json - dclient get /data/creatures.json --instance https://my.datasette.io + dclient get /data/creatures.json -i https://my.datasette.io """ - url = _resolve_url(instance) - token = _resolve_token(token, url) - headers = {} - if token: - headers["Authorization"] = f"Bearer {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") full_url = url.rstrip("/") + "/" + path.lstrip("/") - response = httpx.get( - full_url, - headers=headers, - follow_redirects=True, - timeout=30.0, - ) + response = _make_request(url, token, "/" + path.lstrip("/")) if response.status_code != 200: raise click.ClickException(f"{response.status_code} error for {full_url}") - # Pretty-print if JSON, otherwise raw if "json" in response.headers.get("content-type", ""): click.echo(json.dumps(response.json(), indent=2)) else: @@ -57,93 +160,129 @@ def get(path, instance, token): @cli.command() -@click.argument("url_or_alias", default=None, required=False) +@click.option("-i", "--instance", default=None, help="Datasette instance URL or alias") +@click.option("--json", "_json", is_flag=True, help="Output raw JSON") @click.option("--token", help="API token") -def databases(url_or_alias, token): +def databases(instance, _json, token): """ - List databases available on a Datasette instance + List databases on an instance Example usage: \b - dclient databases https://latest.datasette.io + dclient databases + dclient databases -i https://latest.datasette.io """ - url = _resolve_url(url_or_alias) - token = _resolve_token(token, url) - headers = {} - if token: - headers["Authorization"] = f"Bearer {token}" - response = httpx.get( - url.rstrip("/") + "/-/databases.json", - headers=headers, - follow_redirects=True, - timeout=30.0, - ) + 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") + response = _make_request(url, token, "/.json") if response.status_code != 200: raise click.ClickException(f"{response.status_code} error") - click.echo(json.dumps(response.json(), indent=2)) + data = response.json() + databases_data = data.get("databases", data if isinstance(data, list) else {}) + # Normalize: could be a dict {name: info} or a list [{name: ...}, ...] + if isinstance(databases_data, dict): + db_list = list(databases_data.values()) + else: + db_list = databases_data + if _json: + click.echo(json.dumps(db_list, indent=2)) + else: + for db in db_list: + name = db["name"] if isinstance(db, dict) else db + click.echo(name) @cli.command() -@click.argument("url_or_alias", default=None, required=False) +@click.option("-i", "--instance", default=None, help="Datasette instance URL or alias") +@click.option("-d", "--database", default=None, help="Database name") +@click.option("--views", is_flag=True, help="Include views") +@click.option("--views-only", is_flag=True, help="Only show views") +@click.option("--hidden", is_flag=True, help="Include hidden tables") +@click.option("--json", "_json", is_flag=True, help="Output raw JSON") @click.option("--token", help="API token") -def tables(url_or_alias, token): +def tables(instance, database, views, views_only, hidden, _json, token): """ - List tables in a Datasette database + List tables in a database Example usage: \b - dclient tables https://latest.datasette.io/fixtures + dclient tables + dclient tables -d fixtures -i https://latest.datasette.io """ - url = _resolve_url(url_or_alias) - token = _resolve_token(token, url) - headers = {} - if token: - headers["Authorization"] = f"Bearer {token}" - response = httpx.get( - url.rstrip("/") + "/-/tables.json", - headers=headers, - follow_redirects=True, - timeout=30.0, - ) + 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 + 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: raise click.ClickException(f"{response.status_code} error") - click.echo(json.dumps(response.json(), indent=2)) + data = response.json() + table_list = data.get("tables", []) + view_list = data.get("views", []) + if _json: + if views_only: + click.echo(json.dumps(view_list, indent=2)) + elif views: + click.echo(json.dumps(table_list + view_list, indent=2)) + else: + click.echo(json.dumps(table_list, indent=2)) + else: + items = [] + if not views_only: + for t in table_list: + if not hidden and t.get("hidden"): + continue + name = t["name"] if isinstance(t, dict) else t + count = t.get("count") if isinstance(t, dict) else None + if count is not None: + items.append(f"{name}\t{count} rows") + else: + items.append(name) + if views or views_only: + for v in view_list: + name = v["name"] if isinstance(v, dict) else v + items.append(name) + for item in items: + click.echo(item) @cli.command() -@click.argument("url_or_alias") +@click.argument("database") @click.argument("sql") +@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(url_or_alias, sql, token, verbose): +def query(database, sql, instance, token, verbose): """ - Run a SQL query against a Datasette database URL + Run a SQL query against a Datasette database - Returns a JSON array of objects + Requires both a database name and a SQL string. Example usage: \b - dclient query \\ - https://datasette.io/content \\ - 'select * from news limit 10' + dclient query fixtures "select * from facetable limit 5" + dclient query analytics "select count(*) from events" -i staging """ - url = _resolve_url(url_or_alias) - token = _resolve_token(token, url) - if not url.endswith(".json"): - url += ".json" + 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") + query_url = url.rstrip("/") + "/" + database + ".json" headers = {} if token: headers["Authorization"] = f"Bearer {token}" params = {"sql": sql, "_shape": "objects"} if verbose: - click.echo(url + "?" + urllib.parse.urlencode(params), err=True) - response = httpx.get(url, params=params, headers=headers, follow_redirects=True) + click.echo(query_url + "?" + urllib.parse.urlencode(params), err=True) + response = httpx.get(query_url, params=params, headers=headers, follow_redirects=True) if response.status_code != 200: - # Is it valid JSON? try: data = response.json() except json.JSONDecodeError: @@ -161,12 +300,10 @@ def query(url_or_alias, sql, token, verbose): "{} status code. {}".format(response.status_code, ": ".join(bits)) ) - # We should have JSON now try: data = response.json() except json.JSONDecodeError: raise click.ClickException("Response was not valid JSON") - # ... but it may have a {"ok": false} error if not data.get("ok"): bits = [] if data.get("title"): @@ -177,83 +314,17 @@ def query(url_or_alias, sql, token, verbose): bits = [json.dumps(data)] raise click.ClickException(": ".join(bits)) - # Output results click.echo(json.dumps(response.json()["rows"], indent=2)) -@cli.command() -@click.argument("url_or_alias") -@click.argument("table") -@click.argument( - "filepath", type=click.Path("rb", readable=True, allow_dash=True, dir_okay=False) -) -@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("--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( - "--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") -@click.option("--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( - "--batch-size", type=int, default=100, help="Send rows in batches of this size" -) -@click.option( - "--interval", type=float, default=10, help="Send batch at least every X seconds" -) -@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, - help="Verbose output: show HTTP request and response", -) -def insert( - url_or_alias, - 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, -): - """ - Insert data into a remote Datasette instance - - Example usage: - - \b - dclient insert \\ - https://private.datasette.cloud/data \\ - mytable data.csv --pk id --create - """ - url = _resolve_url(url_or_alias) - token = _resolve_token(token, url) +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") format = None if format_csv: @@ -283,11 +354,11 @@ def insert( raise click.ClickException(str(ex)) if format in (Format.JSON, Format.NL): - # Disable progress bar - it can't handle these formats file_size = None no_detect_types = True first = True + base_url = url.rstrip("/") + "/" + database with progressbar( length=file_size, @@ -304,15 +375,12 @@ def insert( bar.update(new_bytes) bytes_so_far += new_bytes except ValueError: - # File has likely been closed, so fp.tell() fails pass types = None if first and not no_detect_types: - # Detect types on first batch tracker = TypeTracker() list(tracker.wrap(batch)) types = tracker.types - # Convert types for row in batch: for key, value in row.items(): if value is None: @@ -329,7 +397,7 @@ def insert( row[key] = float(value) first = False _insert_batch( - url=url, + url=base_url, table=table, batch=batch, token=token, @@ -339,36 +407,244 @@ def insert( replace=replace, ignore=ignore, verbose=verbose, + endpoint=endpoint, ) +_insert_options = [ + click.argument("database"), + click.argument("table"), + click.argument( + "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("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("--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, + help="Columns to use as the primary key when creating the table", + ), + click.option( + "--batch-size", type=int, default=100, help="Send rows in batches of this size" + ), + click.option( + "--interval", type=float, default=10, help="Send batch at least every X seconds" + ), + 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, + help="Verbose output: show HTTP request and response", + ), +] + + +def _apply_options(options): + def decorator(func): + for option in reversed(options): + func = option(func) + return func + return decorator + + @cli.command() -@click.argument("url_or_alias", default=None, required=False) +@_apply_options(_insert_options) +@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): + """ + Insert data into a remote Datasette instance + + Example usage: + + \b + 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") + + +@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): + """ + Upsert data into a remote Datasette instance + + Example usage: + + \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") + + +@cli.command() +@click.argument("table_name", 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("--json", "_json", is_flag=True, help="Output raw JSON") @click.option("--token", help="API token") -def actor(url_or_alias, token): +def schema(table_name, instance, database, _json, token): + """ + Show SQL schema for a database or specific table + + Example usage: + + \b + dclient schema + dclient schema facetable + dclient schema -d analytics + """ + 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 + 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") + else: + response = _make_request(url, token, f"/{db}/-/schema.json") + if response.status_code != 200: + raise click.ClickException(f"{response.status_code} error") + data = response.json() + if _json: + click.echo(json.dumps(data, indent=2)) + else: + click.echo(data.get("schema", "")) + + +@cli.command() +@click.option("-i", "--instance", default=None, help="Datasette instance URL or alias") +@click.option("--json", "_json", is_flag=True, help="Output raw JSON") +@click.option("--token", help="API token") +def plugins(instance, _json, token): + """ + List installed plugins on an instance + + Example usage: + + \b + dclient plugins + dclient plugins -i https://latest.datasette.io + """ + 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") + response = _make_request(url, token, "/-/plugins.json") + if response.status_code != 200: + raise click.ClickException(f"{response.status_code} error") + data = response.json() + if _json: + click.echo(json.dumps(data, indent=2)) + else: + for plugin in data: + name = plugin["name"] if isinstance(plugin, dict) else plugin + click.echo(name) + + +@cli.command() +@click.option("-i", "--instance", default=None, help="Datasette instance URL or alias") +@click.option("--token", help="API token") +def actor(instance, token): """ Show the actor represented by an API token Example usage: \b - dclient actor https://latest.datasette.io/fixtures + dclient actor + dclient actor -i prod """ - url = _resolve_url(url_or_alias) - - if not (url.startswith("http://") or url.startswith("https://")): - raise click.ClickException("Invalid URL: " + url) - - token = _resolve_token(token, url) - - actor_url = url.rstrip("/") + "/-/actor.json" - response = httpx.get( - actor_url, headers={"Authorization": "Bearer {}".format(token)}, timeout=40.0 - ) + 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") + response = _make_request(url, token, "/-/actor.json") response.raise_for_status() click.echo(json.dumps(response.json(), indent=4)) +@cli.command(name="default_query", hidden=True) +@click.argument("sql") +@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("-v", "--verbose", is_flag=True, help="Verbose output: show HTTP request") +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 + 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 = {} + if token: + headers["Authorization"] = f"Bearer {token}" + 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) + + if response.status_code != 200: + try: + data = response.json() + except json.JSONDecodeError: + raise click.ClickException( + "{} status code. Response was not valid JSON".format( + response.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)) + ) + + try: + data = response.json() + except json.JSONDecodeError: + raise click.ClickException("Response was not valid JSON") + if not data.get("ok"): + bits = [] + if data.get("title"): + bits.append(data["title"]) + if data.get("error"): + bits.append(data["error"]) + if not bits: + bits = [json.dumps(data)] + raise click.ClickException(": ".join(bits)) + + click.echo(json.dumps(response.json()["rows"], indent=2)) + + +# -- alias command group -- + + @cli.group() def alias(): "Manage aliases for different instances" @@ -376,22 +652,19 @@ def alias(): @alias.command(name="list") @click.option("_json", "--json", is_flag=True, help="Output raw JSON") -def list_(_json): - """ - List aliases - - Example usage: - - \b - dclient aliases list - """ - aliases_file = get_config_dir() / "aliases.json" - aliases = _load_aliases(aliases_file) +def alias_list(_json): + """List aliases""" + config_file = get_config_dir() / "config.json" + config = _load_config(config_file) + instances = config.get("instances", {}) + default = config.get("default_instance") if _json: - click.echo(json.dumps(aliases, indent=2)) + click.echo(json.dumps(config, indent=2)) else: - for alias, url in aliases.items(): - click.echo(f"{alias} = {url}") + for name, inst in instances.items(): + marker = "* " if name == default else " " + db_info = f" (db: {inst['default_database']})" if inst.get("default_database") else "" + click.echo(f"{marker}{name} = {inst['url']}{db_info}") @alias.command(name="add") @@ -399,23 +672,19 @@ def list_(_json): @click.argument("url") def alias_add(name, url): """ - Add an alias + Add an alias for a Datasette instance Example usage: \b - dclient alias add content https://datasette.io/content - - Then: - - dclient query content 'select * from news limit 3' + dclient alias add prod https://myapp.datasette.cloud """ config_dir = get_config_dir() config_dir.mkdir(parents=True, exist_ok=True) - aliases_file = config_dir / "aliases.json" - aliases = _load_aliases(aliases_file) - aliases[name] = url - aliases_file.write_text(json.dumps(aliases, indent=4)) + config_file = config_dir / "config.json" + config = _load_config(config_file) + config["instances"][name] = {"url": url, "default_database": None} + _save_config(config_file, config) @alias.command(name="remove") @@ -427,18 +696,87 @@ def alias_remove(name): Example usage: \b - dclient alias remove content + dclient alias remove prod """ - config_dir = get_config_dir() - aliases_file = config_dir / "aliases.json" - aliases = _load_aliases(aliases_file) - if name in aliases: - del aliases[name] - aliases_file.write_text(json.dumps(aliases, indent=4)) + config_file = get_config_dir() / "config.json" + config = _load_config(config_file) + if name in config.get("instances", {}): + del config["instances"][name] + if config.get("default_instance") == name: + config["default_instance"] = None + _save_config(config_file, config) else: raise click.ClickException("No such alias") +@alias.command(name="default") +@click.argument("name", required=False, default=None) +@click.option("--clear", is_flag=True, help="Clear default instance") +def alias_default(name, clear): + """ + Set or show the default instance + + Example usage: + + \b + dclient alias default prod + dclient alias default + dclient alias default --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 + _save_config(config_file, config) + else: + default = config.get("default_instance") + if default: + click.echo(default) + else: + click.echo("No default instance set") + + +@alias.command(name="default-db") +@click.argument("alias_name") +@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): + """ + Set or show the default database for an alias + + Example usage: + + \b + dclient alias default-db prod main + dclient alias default-db prod + dclient alias default-db 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}") + if clear: + config["instances"][alias_name]["default_database"] = None + _save_config(config_file, config) + elif db: + config["instances"][alias_name]["default_database"] = db + _save_config(config_file, config) + else: + default_db = config["instances"][alias_name].get("default_database") + if default_db: + click.echo(default_db) + else: + click.echo(f"No default database set for {alias_name}") + + +# -- auth command group -- + + @cli.group() def auth(): "Manage authentication for different instances" @@ -454,20 +792,17 @@ def auth_add(alias_or_url, token): Example usage: \b - dclient auth add https://datasette.io/content + dclient auth add prod + dclient auth add https://datasette.io Paste in the token when prompted. """ - aliases_file = get_config_dir() / "aliases.json" - aliases = _load_aliases(aliases_file) - url = alias_or_url - if alias_or_url in aliases: - url = aliases[alias_or_url] config_dir = get_config_dir() config_dir.mkdir(parents=True, exist_ok=True) auth_file = config_dir / "auth.json" auths = _load_auths(auth_file) - auths[url] = token + # Store by alias name or URL as-is + auths[alias_or_url] = token auth_file.write_text(json.dumps(auths, indent=4)) @@ -486,8 +821,8 @@ def auth_list(): auths = _load_auths(auths_file) if auths: click.echo() - for url, token in auths.items(): - click.echo("{}:\t{}..".format(url, token[:1])) + for key, token in auths.items(): + click.echo("{}:\t{}..".format(key, token[:1])) @auth.command(name="remove") @@ -499,7 +834,7 @@ def auth_remove(alias_or_url): Example usage: \b - dclient auth remove https://datasette.io/content + dclient auth remove prod """ config_dir = get_config_dir() auth_file = config_dir / "auth.json" @@ -511,48 +846,76 @@ def auth_remove(alias_or_url): raise click.ClickException("No such URL or alias") -def _load_aliases(aliases_file): - if aliases_file.exists(): - aliases = json.loads(aliases_file.read_text()) - else: - aliases = {} - return aliases +@auth.command(name="status") +@click.option("-i", "--instance", default=None, help="Datasette instance URL or alias") +@click.option("--token", help="API token") +def auth_status(instance, token): + """ + Verify authentication by calling /-/actor.json + + Example usage: + + \b + dclient auth status + dclient auth status -i prod + """ + 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") + response = _make_request(url, token, "/-/actor.json") + response.raise_for_status() + click.echo(json.dumps(response.json(), indent=4)) -def _load_auths(auth_file): - if auth_file.exists(): - auths = json.loads(auth_file.read_text()) - else: - auths = {} - return auths +# -- v1 → v2 migration -- -def _resolve_url(url_or_alias): - if not url_or_alias: - base_url = os.environ.get("DATASETTE_URL") - if base_url: - return base_url.rstrip("/") - raise click.ClickException( - "No URL provided. Set DATASETTE_URL or pass a URL/alias." - ) - aliases = _load_aliases(get_config_dir() / "aliases.json") - if url_or_alias in aliases: - return aliases[url_or_alias] - if url_or_alias.startswith("http://") or url_or_alias.startswith("https://"): - return url_or_alias - base_url = os.environ.get("DATASETTE_URL") - if base_url: - return base_url.rstrip("/") + "/" + url_or_alias - return url_or_alias +def _migrate_v1_to_v2(config_dir): + """Migrate v1 aliases.json + auth.json to v2 config.json + auth.json.""" + config_file = config_dir / "config.json" + aliases_file = config_dir / "aliases.json" + auth_file = config_dir / "auth.json" + if config_file.exists() or not aliases_file.exists(): + return + + aliases = json.loads(aliases_file.read_text()) if aliases_file.exists() else {} + old_auths = json.loads(auth_file.read_text()) if auth_file.exists() else {} + + config = {"default_instance": None, "instances": {}} + new_auths = {} + url_to_alias = {} + + for alias_name, alias_url in aliases.items(): + parsed = urllib.parse.urlparse(alias_url) + path_parts = [p for p in parsed.path.split("/") if p] + if len(path_parts) == 1: + # URL has a single path segment → instance URL + default database + instance_url = f"{parsed.scheme}://{parsed.netloc}" + default_db = path_parts[0] + else: + instance_url = alias_url + default_db = None + config["instances"][alias_name] = { + "url": instance_url, + "default_database": default_db, + } + url_to_alias[alias_url] = alias_name + + # Migrate auth keys from URLs to alias names + for url, token in old_auths.items(): + if url in url_to_alias: + new_auths[url_to_alias[url]] = token + else: + # Keep URL-keyed entries as fallbacks + new_auths[url] = token + + _save_config(config_file, config) + if new_auths or old_auths: + auth_file.rename(config_dir / "auth.json.bak") + auth_file.write_text(json.dumps(new_auths, indent=4)) + aliases_file.rename(config_dir / "aliases.json.bak") -def _resolve_token(token, url): - if token is not None: - return token - stored = token_for_url(url, _load_auths(get_config_dir() / "auth.json")) - if stored is not None: - return stored - return os.environ.get("DATASETTE_TOKEN") def _batches(iterable, size, interval=None): @@ -574,7 +937,8 @@ def _batches(iterable, size, interval=None): def _insert_batch( - *, url, table, batch, token, create, alter, pks, replace, ignore, verbose + *, url, table, batch, token, create, alter, pks, replace, ignore, verbose, + endpoint="insert" ): if create: data = { @@ -603,7 +967,7 @@ def _insert_batch( data["ignore"] = True if alter: data["alter"] = True - url = "{}/{}/-/insert".format(url, table) + url = "{}/{}/-/{}".format(url, table, endpoint) if verbose: click.echo("POST {}".format(url), err=True) click.echo(textwrap.indent(json.dumps(data, indent=2), " "), err=True) diff --git a/docs/environment.md b/docs/environment.md index f597617..1ae7f81 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -2,37 +2,39 @@ # Environment variables -`dclient` supports two environment variables for convenient access to a Datasette instance without needing aliases or repeated URLs. +`dclient` supports several environment variables for convenient access to Datasette instances. ## DATASETTE_URL -Set this to the base URL of your Datasette instance: +Set this to the base URL of your Datasette instance. It is used as a fallback when no instance is specified via `-i` and no default instance is configured: ```bash export DATASETTE_URL=https://my-instance.datasette.cloud ``` -Then pass just the database name as the first argument to any command: +Then you can omit the `-i` flag: ```bash +dclient databases dclient query data "select * from my_table limit 10" ``` -This is equivalent to: +Aliases and the `-i` flag always take priority over `DATASETTE_URL`. + +## DATASETTE_DATABASE + +Set this to a default database name. It is used as a fallback when no database is specified via `-d` and the current instance has no `default_database` configured: ```bash -dclient query https://my-instance.datasette.cloud/data "select * from my_table limit 10" +export DATASETTE_DATABASE=data ``` -It works with all commands: +Then you can use the bare SQL shortcut: ```bash -dclient insert data my_table data.csv --csv -dclient actor data +dclient "select * from my_table limit 10" ``` -Full URLs and aliases always take priority over `DATASETTE_URL`. If the argument starts with `http://` or `https://`, it is used as-is. If it matches an alias in `aliases.json`, the alias is used. - ## DATASETTE_TOKEN Set this to an API token: @@ -46,23 +48,34 @@ The token will be used automatically for any request that doesn't have a more sp The precedence order for tokens is: 1. `--token` CLI flag (highest priority) -2. Stored token from `auth.json` (matched by URL prefix) +2. Stored token from `auth.json` (matched by alias name, then URL prefix) 3. `DATASETTE_TOKEN` environment variable (lowest priority) -## Using both together +## DCLIENT_CONFIG_DIR + +Override the config directory (default `~/.config/io.datasette.dclient` or platform equivalent): + +```bash +export DCLIENT_CONFIG_DIR=/path/to/config +``` + +This is useful for testing or running multiple configurations side by side. + +## Using them together These variables work well together for quick access to a single instance: ```bash export DATASETTE_URL=https://my-instance.datasette.cloud +export DATASETTE_DATABASE=data export DATASETTE_TOKEN=dstok_abc123 -# Query the "data" database -dclient query data "select * from my_table" +# Query +dclient "select * from my_table" -# Insert into the "data" database +# Insert cat records.json | dclient insert data my_table - --json # Check your actor identity -dclient actor data +dclient actor ``` diff --git a/docs/inserting.md b/docs/inserting.md index 316a3c0..17d81a7 100644 --- a/docs/inserting.md +++ b/docs/inserting.md @@ -4,19 +4,23 @@ The `dclient insert` command can be used to insert data from a local file direct First you'll need to {ref}`authenticate ` with the instance. -To insert data from a `data.csv` file into a table called `my_table`, creating that table if it does not exist: +To insert data from a `data.csv` file into a table called `my_table` in the `data` database: ```bash -dclient insert \ - https://my-private-space.datasette.cloud/data \ - my_table data.csv --create +dclient insert data my_table data.csv --create -i myapp ``` You can also pipe data into standard input: ```bash curl -s 'https://api.github.com/repos/simonw/dclient/issues' | \ - dclient insert \ - https://my-private-space.datasette.cloud/data \ - issues - --create + dclient insert data issues - --create -i myapp +``` + +## Upserting data + +The `dclient upsert` command works exactly like `insert` but uses the upsert endpoint, which will update existing rows with matching primary keys rather than raising an error. + +```bash +dclient upsert data my_table data.csv --csv -i myapp ``` ## Streaming data @@ -26,7 +30,7 @@ curl -s 'https://api.github.com/repos/simonw/dclient/issues' | \ If you have a log file containing newline-delimited JSON you can tail it and send it to a Datasette instance like this: ```bash tail -f log.jsonl | \ - dclient insert https://my-private-space.datasette.cloud/data logs - --nl + dclient insert data logs - --nl -i myapp ``` When reading from standard input (filename `-`) you are required to specify the format. In this example that's `--nl` for newline-delimited JSON. `--csv` and `--tsv` are supported for streaming as well, but `--json` is not. @@ -34,7 +38,7 @@ In streaming mode records default to being sent to the server every 100 records ```bash tail -f log.jsonl | dclient insert \ - https://my-private-space.datasette.cloud/data logs - --nl --create \ + data logs - --nl --create -i myapp \ --batch-size 10 \ --interval 5 ``` @@ -106,26 +110,65 @@ cog.out( ) ]]] --> ``` -Usage: dclient insert [OPTIONS] URL_OR_ALIAS TABLE FILEPATH +Usage: dclient insert [OPTIONS] DATABASE TABLE FILEPATH Insert data into a remote Datasette instance Example usage: - dclient insert \ - https://private.datasette.cloud/data \ - mytable data.csv --pk id --create + dclient insert main mytable data.csv --csv -i myapp + dclient insert main mytable data.csv --csv --create --pk id Options: + -i, --instance TEXT Datasette instance URL or alias --csv Input is CSV --tsv Input is TSV --json Input is JSON --nl Input is newline-delimited JSON --encoding TEXT Character encoding for CSV/TSV --no-detect-types Don't detect column types for CSV/TSV + --alter Alter table to add any missing columns + --pk TEXT Columns to use as the primary key when creating the + 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 + --silent Don't output progress + -v, --verbose Verbose output: show HTTP request and response --replace Replace rows with a matching primary key --ignore Ignore rows with a matching primary key --create Create table if it does not exist + --help Show this message and exit. + +``` + + +## dclient upsert --help + +``` +Usage: dclient upsert [OPTIONS] DATABASE TABLE FILEPATH + + Upsert data into a remote Datasette instance + + Example usage: + + dclient upsert main mytable data.csv --csv -i myapp + +Options: + -i, --instance TEXT Datasette instance URL or alias + --csv Input is CSV + --tsv Input is TSV + --json Input is JSON + --nl Input is newline-delimited JSON + --encoding TEXT Character encoding for CSV/TSV + --no-detect-types Don't detect column types for CSV/TSV --alter Alter table to add any missing columns --pk TEXT Columns to use as the primary key when creating the table diff --git a/docs/queries.md b/docs/queries.md index 3bd671a..900f0ca 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -3,7 +3,7 @@ You can run SQL queries against a Datasette instance like this: ```bash -dclient query https://latest.datasette.io/fixtures "select * from facetable limit 1" +dclient query fixtures "select * from facetable limit 1" -i https://latest.datasette.io ``` Output: ```json @@ -24,6 +24,18 @@ Output: ] ``` +The `query` command takes a database name and SQL string as positional arguments. Use `-i` to specify the instance (alias or URL). If you have a default instance and default database configured, you can use the bare SQL shortcut instead: + +```bash +dclient "select * from facetable limit 1" +``` + +You can override just the database with `-d`: + +```bash +dclient "select * from counters" -d counters +``` + ## dclient query --help ``` -Usage: dclient query [OPTIONS] URL_OR_ALIAS SQL +Usage: dclient query [OPTIONS] DATABASE SQL - Run a SQL query against a Datasette database URL + Run a SQL query against a Datasette database - Returns a JSON array of objects + Requires both a database name and a SQL string. Example usage: - dclient query \ - https://datasette.io/content \ - 'select * from news limit 10' + dclient query fixtures "select * from facetable limit 5" + dclient query analytics "select count(*) from events" -i staging Options: - --token TEXT API token - -v, --verbose Verbose output: show HTTP request - --help Show this message and exit. + -i, --instance TEXT Datasette instance URL or alias + --token TEXT API token + -v, --verbose Verbose output: show HTTP request + --help Show this message and exit. ``` diff --git a/pyproject.toml b/pyproject.toml index 15d9c56..5bc7cfa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ license = "Apache-2.0" requires-python = ">=3.10" dependencies = [ "click", + "click-default-group", "httpx", "sqlite-utils", ] diff --git a/tests/test_cli_auth.py b/tests/test_cli_auth.py index ba30e22..60b8bb3 100644 --- a/tests/test_cli_auth.py +++ b/tests/test_cli_auth.py @@ -13,26 +13,26 @@ def test_auth(mocker, tmpdir): # Should only have one line assert len([line for line in result.output.split("\n") if line.strip()]) == 1 - # Now add a token - result2 = runner.invoke(cli, ["auth", "add", "https://example.com"], input="xyz\n") + # Now add a token (keys are now alias names or URLs) + result2 = runner.invoke(cli, ["auth", "add", "prod"], input="xyz\n") assert result2.exit_code == 0 # Check the tokens file auth_file = pathlib.Path(tmpdir) / "auth.json" - assert json.loads(auth_file.read_text()) == {"https://example.com": "xyz"} + assert json.loads(auth_file.read_text()) == {"prod": "xyz"} # auth list should show that now result3 = runner.invoke(cli, ["auth", "list"]) assert result3.output.startswith("Tokens file:") - assert "https://example.com" in result3.output + assert "prod" in result3.output - # Remove should fail with an incorrect URL - result4 = runner.invoke(cli, ["auth", "remove", "https://example.com/foo"]) + # Remove should fail with an incorrect key + result4 = runner.invoke(cli, ["auth", "remove", "nonexistent"]) assert result4.exit_code == 1 assert result4.output == "Error: No such URL or alias\n" - # Remove should work with the correct URL - result5 = runner.invoke(cli, ["auth", "remove", "https://example.com"]) + # Remove should work with the correct key + result5 = runner.invoke(cli, ["auth", "remove", "prod"]) assert result5.exit_code == 0 assert result5.output == "" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..f12bf85 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,249 @@ +"""Tests for the v2 config system: config.json format, instance resolution, database resolution.""" + +from dclient.cli import ( + _load_config, + _save_config, + _resolve_instance, + _resolve_database, + _resolve_token, + get_config_dir, +) +import json +import pathlib +import pytest + + +# -- Config loading/saving -- + + +def test_load_config_empty(tmpdir): + """Loading config when no file exists returns empty defaults.""" + config_file = pathlib.Path(tmpdir) / "config.json" + config = _load_config(config_file) + assert config == {"default_instance": None, "instances": {}} + + +def test_load_config_existing(tmpdir): + """Loading config reads the JSON file.""" + config_file = pathlib.Path(tmpdir) / "config.json" + config_file.write_text( + json.dumps( + { + "default_instance": "prod", + "instances": { + "prod": { + "url": "https://myapp.datasette.cloud", + "default_database": "main", + } + }, + } + ) + ) + config = _load_config(config_file) + assert config["default_instance"] == "prod" + assert config["instances"]["prod"]["url"] == "https://myapp.datasette.cloud" + assert config["instances"]["prod"]["default_database"] == "main" + + +def test_save_config(tmpdir): + """Saving config writes JSON to disk.""" + config_file = pathlib.Path(tmpdir) / "config.json" + config = { + "default_instance": "local", + "instances": { + "local": { + "url": "http://localhost:8001", + "default_database": None, + } + }, + } + _save_config(config_file, config) + assert json.loads(config_file.read_text()) == config + + +# -- Instance resolution -- + + +def test_resolve_instance_from_flag(tmpdir): + """An explicit -i flag with a URL is used directly.""" + config_file = pathlib.Path(tmpdir) / "config.json" + url = _resolve_instance("https://example.com", config_file) + assert url == "https://example.com" + + +def test_resolve_instance_from_flag_alias(tmpdir): + """An explicit -i flag with an alias name resolves via config.""" + config_file = pathlib.Path(tmpdir) / "config.json" + config_file.write_text( + json.dumps( + { + "default_instance": None, + "instances": { + "prod": { + "url": "https://myapp.datasette.cloud", + "default_database": None, + } + }, + } + ) + ) + url = _resolve_instance("prod", config_file) + assert url == "https://myapp.datasette.cloud" + + +def test_resolve_instance_from_config_default(tmpdir): + """When no -i flag, uses config.default_instance.""" + config_file = pathlib.Path(tmpdir) / "config.json" + config_file.write_text( + json.dumps( + { + "default_instance": "prod", + "instances": { + "prod": { + "url": "https://myapp.datasette.cloud", + "default_database": None, + } + }, + } + ) + ) + url = _resolve_instance(None, config_file) + assert url == "https://myapp.datasette.cloud" + + +def test_resolve_instance_from_env(tmpdir, monkeypatch): + """When no -i flag and no config default, falls back to DATASETTE_URL.""" + config_file = pathlib.Path(tmpdir) / "config.json" + monkeypatch.setenv("DATASETTE_URL", "https://env.example.com") + url = _resolve_instance(None, config_file) + assert url == "https://env.example.com" + + +def test_resolve_instance_from_env_strips_trailing_slash(tmpdir, monkeypatch): + """DATASETTE_URL trailing slash is stripped.""" + config_file = pathlib.Path(tmpdir) / "config.json" + monkeypatch.setenv("DATASETTE_URL", "https://env.example.com/") + url = _resolve_instance(None, config_file) + assert url == "https://env.example.com" + + +def test_resolve_instance_error(tmpdir, monkeypatch): + """When nothing is configured, raises an error.""" + config_file = pathlib.Path(tmpdir) / "config.json" + monkeypatch.delenv("DATASETTE_URL", raising=False) + with pytest.raises(Exception, match="No instance specified"): + _resolve_instance(None, config_file) + + +def test_resolve_instance_unknown_alias(tmpdir): + """An -i flag with an unknown alias name raises an error.""" + config_file = pathlib.Path(tmpdir) / "config.json" + config_file.write_text(json.dumps({"default_instance": None, "instances": {}})) + with pytest.raises(Exception, match="Unknown instance"): + _resolve_instance("nonexistent", config_file) + + +# -- Database resolution (optional -d flag mode) -- + + +def test_resolve_database_from_flag(tmpdir): + """An explicit -d flag is used directly.""" + config_file = pathlib.Path(tmpdir) / "config.json" + db = _resolve_database("mydb", None, config_file) + assert db == "mydb" + + +def test_resolve_database_from_instance_default(tmpdir): + """When no -d flag, uses the instance's default_database from config.""" + config_file = pathlib.Path(tmpdir) / "config.json" + config_file.write_text( + json.dumps( + { + "default_instance": "prod", + "instances": { + "prod": { + "url": "https://myapp.datasette.cloud", + "default_database": "main", + } + }, + } + ) + ) + db = _resolve_database(None, "prod", config_file) + assert db == "main" + + +def test_resolve_database_from_env(tmpdir, monkeypatch): + """When no -d flag and no instance default, falls back to DATASETTE_DATABASE.""" + config_file = pathlib.Path(tmpdir) / "config.json" + monkeypatch.setenv("DATASETTE_DATABASE", "envdb") + db = _resolve_database(None, None, config_file) + assert db == "envdb" + + +def test_resolve_database_error(tmpdir, monkeypatch): + """When nothing is configured, raises an error.""" + config_file = pathlib.Path(tmpdir) / "config.json" + monkeypatch.delenv("DATASETTE_DATABASE", raising=False) + with pytest.raises(Exception, match="No database specified"): + _resolve_database(None, None, config_file) + + +# -- Token resolution -- + + +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) + assert token == "explicit-token" + + +def test_resolve_token_from_auth_by_alias(tmpdir): + """Auth token looked up by alias name.""" + auth_file = pathlib.Path(tmpdir) / "auth.json" + auth_file.write_text(json.dumps({"prod": "tok123"})) + config_file = pathlib.Path(tmpdir) / "config.json" + config_file.write_text( + json.dumps( + { + "default_instance": None, + "instances": { + "prod": { + "url": "https://myapp.datasette.cloud", + "default_database": None, + } + }, + } + ) + ) + token = _resolve_token(None, "https://myapp.datasette.cloud", auth_file, config_file) + assert token == "tok123" + + +def test_resolve_token_from_auth_by_url_fallback(tmpdir): + """Auth token falls back to URL prefix matching when no alias match.""" + auth_file = pathlib.Path(tmpdir) / "auth.json" + auth_file.write_text(json.dumps({"https://example.com": "url-tok"})) + config_file = pathlib.Path(tmpdir) / "config.json" + token = _resolve_token(None, "https://example.com/db", auth_file, config_file) + assert token == "url-tok" + + +def test_resolve_token_from_env(tmpdir, monkeypatch): + """Falls back to DATASETTE_TOKEN env var.""" + auth_file = pathlib.Path(tmpdir) / "auth.json" + config_file = pathlib.Path(tmpdir) / "config.json" + monkeypatch.setenv("DATASETTE_TOKEN", "env-tok") + token = _resolve_token(None, "https://example.com", auth_file, config_file) + assert token == "env-tok" + + +def test_resolve_token_none(tmpdir, monkeypatch): + """Returns None when nothing is configured.""" + auth_file = pathlib.Path(tmpdir) / "auth.json" + config_file = pathlib.Path(tmpdir) / "config.json" + monkeypatch.delenv("DATASETTE_TOKEN", raising=False) + token = _resolve_token(None, "https://example.com", auth_file, config_file) + assert token is None diff --git a/tests/test_env.py b/tests/test_env.py index 5afd593..185611c 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -26,7 +26,9 @@ def test_datasette_token_used_as_fallback(httpx_mock, mocker, tmpdir): mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200) runner = CliRunner(env={"DATASETTE_TOKEN": "env-token-123"}) - result = runner.invoke(cli, ["query", "https://example.com", "select 1"]) + result = runner.invoke( + cli, ["query", "data", "select 1", "-i", "https://example.com"] + ) assert result.exit_code == 0 request = httpx_mock.get_request() assert request.headers["authorization"] == "Bearer env-token-123" @@ -38,7 +40,12 @@ def test_token_flag_overrides_datasette_token(httpx_mock, mocker, tmpdir): httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200) runner = CliRunner(env={"DATASETTE_TOKEN": "env-token"}) result = runner.invoke( - cli, ["query", "https://example.com", "select 1", "--token", "flag-token"] + cli, + [ + "query", "data", "select 1", + "-i", "https://example.com", + "--token", "flag-token", + ], ) assert result.exit_code == 0 request = httpx_mock.get_request() @@ -52,7 +59,9 @@ def test_auth_json_overrides_datasette_token(httpx_mock, mocker, tmpdir): auth_file.write_text(json.dumps({"https://example.com": "stored-token"})) httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200) runner = CliRunner(env={"DATASETTE_TOKEN": "env-token"}) - result = runner.invoke(cli, ["query", "https://example.com", "select 1"]) + result = runner.invoke( + cli, ["query", "data", "select 1", "-i", "https://example.com"] + ) assert result.exit_code == 0 request = httpx_mock.get_request() assert request.headers["authorization"] == "Bearer stored-token" @@ -61,8 +70,8 @@ def test_auth_json_overrides_datasette_token(httpx_mock, mocker, tmpdir): # -- DATASETTE_URL tests -- -def test_datasette_url_combines_with_database_name(httpx_mock, mocker, tmpdir): - """DATASETTE_URL + database name arg → combined URL.""" +def test_datasette_url_used_as_instance(httpx_mock, mocker, tmpdir): + """DATASETTE_URL provides the instance when no -i flag.""" mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200) runner = CliRunner(env={"DATASETTE_URL": "https://my-instance.datasette.cloud"}) @@ -84,12 +93,14 @@ def test_datasette_url_with_trailing_slash(httpx_mock, mocker, tmpdir): assert request.url.path == "/data.json" -def test_full_url_ignores_datasette_url(httpx_mock, mocker, tmpdir): - """A full URL argument is used as-is, ignoring DATASETTE_URL.""" +def test_explicit_instance_ignores_datasette_url(httpx_mock, mocker, tmpdir): + """An explicit -i flag is used, ignoring DATASETTE_URL.""" mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200) runner = CliRunner(env={"DATASETTE_URL": "https://should-be-ignored.com"}) - result = runner.invoke(cli, ["query", "https://other.example.com/db", "select 1"]) + result = runner.invoke( + cli, ["query", "db", "select 1", "-i", "https://other.example.com"] + ) assert result.exit_code == 0 request = httpx_mock.get_request() assert request.url.host == "other.example.com" @@ -97,13 +108,25 @@ def test_full_url_ignores_datasette_url(httpx_mock, mocker, tmpdir): def test_alias_takes_priority_over_datasette_url(httpx_mock, mocker, tmpdir): - """Alias match takes priority over DATASETTE_URL.""" + """Alias match via -i takes priority over DATASETTE_URL.""" mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) - aliases_file = pathlib.Path(tmpdir) / "aliases.json" - aliases_file.write_text(json.dumps({"myalias": "https://aliased.example.com/db"})) + config_file = pathlib.Path(tmpdir) / "config.json" + config_file.write_text( + json.dumps( + { + "default_instance": None, + "instances": { + "myalias": { + "url": "https://aliased.example.com", + "default_database": None, + } + }, + } + ) + ) httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200) runner = CliRunner(env={"DATASETTE_URL": "https://should-be-ignored.com"}) - result = runner.invoke(cli, ["query", "myalias", "select 1"]) + result = runner.invoke(cli, ["query", "db", "select 1", "-i", "myalias"]) assert result.exit_code == 0 request = httpx_mock.get_request() assert request.url.host == "aliased.example.com" @@ -150,7 +173,7 @@ def test_datasette_url_with_actor(httpx_mock, mocker, tmpdir): "DATASETTE_TOKEN": "env-token", } ) - result = runner.invoke(cli, ["actor", "data"]) + result = runner.invoke(cli, ["actor"]) assert result.exit_code == 0 request = httpx_mock.get_request() assert request.url.host == "my-instance.datasette.cloud" @@ -176,3 +199,23 @@ def test_datasette_url_and_token_together(httpx_mock, mocker, tmpdir): assert request.url.host == "my-instance.datasette.cloud" assert request.url.path == "/mydb.json" assert request.headers["authorization"] == "Bearer env-token-456" + + +# -- DATASETTE_DATABASE tests -- + + +def test_datasette_database_with_default_query(httpx_mock, mocker, tmpdir): + """DATASETTE_DATABASE is used by default_query shortcut.""" + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200) + runner = CliRunner( + env={ + "DATASETTE_URL": "https://my-instance.datasette.cloud", + "DATASETTE_DATABASE": "mydb", + } + ) + result = runner.invoke(cli, ["select 1"]) + assert result.exit_code == 0 + request = httpx_mock.get_request() + assert request.url.host == "my-instance.datasette.cloud" + assert request.url.path == "/mydb.json" diff --git a/tests/test_insert.py b/tests/test_insert.py index 32fbceb..64f38e7 100644 --- a/tests/test_insert.py +++ b/tests/test_insert.py @@ -44,12 +44,14 @@ def test_insert_mocked(httpx_mock, tmpdir): cli, [ "insert", - "https://datasette.example.com/data", + "data", "table1", str(path), "--csv", "--token", "x", + "-i", + "https://datasette.example.com", ], catch_exceptions=False, ) @@ -311,11 +313,13 @@ async def test_insert_against_datasette( cli, [ "insert", - "http://datasette.example.com/data", + "data", "table1", str(path), "--token", token, + "-i", + "http://datasette.example.com", ] + cmd_args, ) diff --git a/tests/test_query.py b/tests/test_query.py index 552acb0..e273beb 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -16,7 +16,9 @@ def test_query_error(httpx_mock): status_code=400, ) runner = CliRunner() - result = runner.invoke(cli, ["query", "https://example.com", "hello"]) + result = runner.invoke( + cli, ["query", "content", "hello", "-i", "https://example.com"] + ) assert result.exit_code == 1 assert ( result.output @@ -42,17 +44,16 @@ def test_query(httpx_mock, with_token): status_code=200, ) runner = CliRunner() - args = ["query", "https://example.com", "hello"] + args = ["query", "content", "hello", "-i", "https://example.com"] if with_token: - args.append("--token") - args.append("xyz") + args.extend(["--token", "xyz"]) result = runner.invoke(cli, args) assert result.exit_code == 0 assert json.loads(result.output) == [{"5 * 2": 10}] # Check the request request = httpx_mock.get_request() - assert str(request.url) == "https://example.com.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: @@ -66,28 +67,32 @@ 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/foo"]) + result = runner.invoke( + cli, ["alias", "add", "foo", "https://example.com"] + ) assert result.exit_code == 0 assert result.output == "" result = runner.invoke(cli, ["alias", "list"]) assert result.exit_code == 0 - assert result.output == "foo = https://example.com/foo\n" + assert "foo = https://example.com" in result.output # --json mode: result = runner.invoke(cli, ["alias", "list", "--json"]) assert result.exit_code == 0 - assert json.loads(result.output) == {"foo": "https://example.com/foo"} + data = json.loads(result.output) + assert data["instances"]["foo"]["url"] == "https://example.com" - # Check the aliases file - aliases_file = pathlib.Path(tmpdir) / "aliases.json" - assert json.loads(aliases_file.read_text()) == {"foo": "https://example.com/foo"} + # Check the config file + config_file = pathlib.Path(tmpdir) / "config.json" + config = json.loads(config_file.read_text()) + assert config["instances"]["foo"]["url"] == "https://example.com" # Try a query against that alias httpx_mock.add_response( json={ "ok": True, - "database": "foo", + "database": "mydb", "query_name": None, "rows": [{"11 * 3": 33}], "truncated": False, @@ -99,14 +104,14 @@ def test_aliases(mocker, tmpdir, httpx_mock): }, status_code=200, ) - result = runner.invoke(cli, ["query", "foo", "select 11 * 3"]) + result = runner.invoke(cli, ["query", "mydb", "select 11 * 3", "-i", "foo"]) assert result.exit_code == 0 assert json.loads(result.output) == [{"11 * 3": 33}] - # Should have hit https://example.com/foo.json + # Should have hit https://example.com/mydb.json url = httpx_mock.get_request().url assert url.host == "example.com" - assert url.path == "/foo.json" + assert url.path == "/mydb.json" assert dict(url.params) == {"sql": "select 11 * 3", "_shape": "objects"} # Remove alias @@ -117,4 +122,5 @@ def test_aliases(mocker, tmpdir, httpx_mock): result = runner.invoke(cli, ["alias", "remove", "foo"]) assert result.exit_code == 0 assert result.output == "" - assert json.loads(aliases_file.read_text()) == {} + config = json.loads(config_file.read_text()) + assert config["instances"] == {} From 92c0d2a5ba29f12d79dc181a3c56bf248c8e6cbb Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 24 Feb 2026 15:21:58 -0800 Subject: [PATCH 3/6] Tests and docs for v2 introspection commands and alias features Add test_commands_v2.py with 28 tests covering databases, tables, plugins, schema, default_query, upsert, auth status, actor, and get commands. Add test_alias_v2.py with 5 tests for alias default, alias default-db, and removing the default alias. Update aliases.md and authentication.md docs for v2 API. refs #29 Co-Authored-By: Claude Opus 4.6 --- docs/aliases.md | 111 ++++++-- docs/authentication.md | 88 +++++-- tests/test_alias_v2.py | 111 ++++++++ tests/test_commands_v2.py | 538 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 812 insertions(+), 36 deletions(-) create mode 100644 tests/test_alias_v2.py create mode 100644 tests/test_commands_v2.py diff --git a/docs/aliases.md b/docs/aliases.md index 3bb6f7c..8399085 100644 --- a/docs/aliases.md +++ b/docs/aliases.md @@ -1,17 +1,40 @@ # Aliases -You can assign an alias to a Datasette database using the `dclient alias` command: +You can assign an alias to a Datasette instance using the `dclient alias add` command: - dclient alias add content https://datasette.io/content + dclient alias add latest https://latest.datasette.io You can list aliases with `dclient alias list`: $ dclient alias list - content = https://datasette.io/content + latest = https://latest.datasette.io -Once registered, you can pass an alias to commands such as `dclient query`: +Once registered, you can pass an alias to commands using the `-i` flag: - dclient query content "select * from news limit 1" + 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. ## dclient alias --help @@ -56,10 +81,6 @@ Usage: dclient alias list [OPTIONS] List aliases - Example usage: - - dclient aliases list - Options: --json Output raw JSON --help Show this message and exit. @@ -80,15 +101,11 @@ cog.out( ``` Usage: dclient alias add [OPTIONS] NAME URL - Add an alias + Add an alias for a Datasette instance Example usage: - dclient alias add content https://datasette.io/content - - Then: - - dclient query content 'select * from news limit 3' + dclient alias add prod https://myapp.datasette.cloud Options: --help Show this message and exit. @@ -113,10 +130,66 @@ Usage: dclient alias remove [OPTIONS] NAME Example usage: - dclient alias remove content + dclient alias remove prod Options: --help Show this message and exit. ``` + +## 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/authentication.md b/docs/authentication.md index caadba7..cd3961c 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -4,35 +4,57 @@ `dclient` can handle API tokens for Datasette instances that require authentication. -You can pass an API token to `query` using `-t/--token` like this: +You can pass an API token to any command using `--token` like this: ```bash -dclient query https://latest.datasette.io/fixtures "select * from facetable" -t dstok_mytoken +dclient query fixtures "select * from facetable" --token dstok_mytoken -i latest ``` -A more convenient way to handle this is to store tokens to be used with different URL prefixes. +A more convenient way to handle this is to store tokens for your aliases. ## Using stored tokens -To always use `dstok_mytoken` for any URL on the `https://latest.datasette.io/` instance you can run this: +To store a token for an alias: ```bash -dclient auth add https://latest.datasette.io/ +dclient auth add latest ``` Then paste in the token and hit enter when prompted to do so. -To list which URLs you have set tokens for, run the `auth list` command: +Tokens can also be stored for direct URLs: +```bash +dclient auth add https://latest.datasette.io +``` + +To list which aliases/URLs you have set tokens for, run the `auth list` command: ```bash dclient auth list ``` -To delete the token for a specific URL, run `auth remove`: +To delete the token for a specific alias or URL, run `auth remove`: ```bash -dclient auth remove https://latest.datasette.io/ +dclient auth remove latest ``` + +## Token resolution order + +When making a request, dclient resolves the token in this order: + +1. `--token` CLI flag (highest priority) +2. Token stored by alias name in `auth.json` +3. Token stored by URL prefix in `auth.json` +4. `DATASETTE_TOKEN` environment variable (lowest priority) + ## Testing a token -The `dclient actor` command can be used to test a token, retrieving the actor that the token represents. +The `dclient auth status` command can be used to verify authentication by calling `/-/actor.json`: ```bash -dclient actor https://latest.datasette.io/content +dclient auth status +dclient auth status -i prod +``` + +The `dclient actor` command also shows the actor: +```bash +dclient actor +dclient actor -i prod ``` The output looks like this: ```json @@ -68,6 +90,7 @@ Commands: add Add an authentication token for an alias or URL list List stored API tokens remove Remove the API token for an alias or URL + status Verify authentication by calling /-/actor.json ``` @@ -89,7 +112,8 @@ Usage: dclient auth add [OPTIONS] ALIAS_OR_URL Example usage: - dclient auth add https://datasette.io/content + dclient auth add prod + dclient auth add https://datasette.io Paste in the token when prompted. @@ -142,7 +166,7 @@ Usage: dclient auth remove [OPTIONS] ALIAS_OR_URL Example usage: - dclient auth remove https://datasette.io/content + dclient auth remove prod Options: --help Show this message and exit. @@ -150,6 +174,34 @@ Options: ``` +## dclient auth status --help + + +``` +Usage: dclient auth status [OPTIONS] + + Verify authentication by calling /-/actor.json + + Example usage: + + dclient auth status + dclient auth status -i prod + +Options: + -i, --instance TEXT Datasette instance URL or alias + --token TEXT API token + --help Show this message and exit. + +``` + + ## dclient actor --help ``` -Usage: dclient actor [OPTIONS] [URL_OR_ALIAS] +Usage: dclient actor [OPTIONS] Show the actor represented by an API token Example usage: - dclient actor https://latest.datasette.io/fixtures + dclient actor + dclient actor -i prod Options: - --token TEXT API token - --help Show this message and exit. + -i, --instance TEXT Datasette instance URL or alias + --token TEXT API token + --help Show this message and exit. ``` - \ No newline at end of file + diff --git a/tests/test_alias_v2.py b/tests/test_alias_v2.py new file mode 100644 index 0000000..e93902d --- /dev/null +++ b/tests/test_alias_v2.py @@ -0,0 +1,111 @@ +"""Tests for v2 alias command: default, default-db subcommands.""" + +from click.testing import CliRunner +from dclient.cli import cli +import json +import pathlib + + +def test_alias_default_workflow(mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + runner = CliRunner() + + # Add an alias + result = runner.invoke(cli, ["alias", "add", "prod", "https://prod.example.com"]) + assert result.exit_code == 0 + + # No default yet + result = runner.invoke(cli, ["alias", "default"]) + assert result.exit_code == 0 + assert "No default instance set" in result.output + + # Set default + result = runner.invoke(cli, ["alias", "default", "prod"]) + assert result.exit_code == 0 + + # Show default + result = runner.invoke(cli, ["alias", "default"]) + assert result.exit_code == 0 + assert result.output.strip() == "prod" + + # List should show * marker + result = runner.invoke(cli, ["alias", "list"]) + assert result.exit_code == 0 + assert "* prod" in result.output + + # Clear default + result = runner.invoke(cli, ["alias", "default", "--clear"]) + assert result.exit_code == 0 + + result = runner.invoke(cli, ["alias", "default"]) + 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"]) + assert result.exit_code == 1 + assert "No such alias" in result.output + + +def test_alias_default_db_workflow(mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + runner = CliRunner() + + # Add an alias + result = runner.invoke(cli, ["alias", "add", "prod", "https://prod.example.com"]) + assert result.exit_code == 0 + + # No default database yet + result = runner.invoke(cli, ["alias", "default-db", "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"]) + assert result.exit_code == 0 + + # Show default database + result = runner.invoke(cli, ["alias", "default-db", "prod"]) + assert result.exit_code == 0 + assert result.output.strip() == "main" + + # List should show db info + result = runner.invoke(cli, ["alias", "list"]) + assert "(db: main)" in result.output + + # Clear default database + result = runner.invoke(cli, ["alias", "default-db", "prod", "--clear"]) + assert result.exit_code == 0 + + result = runner.invoke(cli, ["alias", "default-db", "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"]) + assert result.exit_code == 1 + assert "No such alias" in result.output + + +def test_alias_remove_clears_default(mocker, tmpdir): + """Removing the default alias also clears default_instance.""" + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + runner = CliRunner() + + runner.invoke(cli, ["alias", "add", "prod", "https://prod.example.com"]) + runner.invoke(cli, ["alias", "default", "prod"]) + + # Verify it's set + config = json.loads((pathlib.Path(tmpdir) / "config.json").read_text()) + assert config["default_instance"] == "prod" + + # Remove alias + runner.invoke(cli, ["alias", "remove", "prod"]) + + config = json.loads((pathlib.Path(tmpdir) / "config.json").read_text()) + assert config["default_instance"] is None + assert "prod" not in config["instances"] diff --git a/tests/test_commands_v2.py b/tests/test_commands_v2.py new file mode 100644 index 0000000..2077a45 --- /dev/null +++ b/tests/test_commands_v2.py @@ -0,0 +1,538 @@ +"""Tests for v2 commands: databases, tables, plugins, schema, default_query, upsert.""" + +from click.testing import CliRunner +from dclient.cli import cli +import json +import pathlib +import pytest + + +# -- databases command -- + + +def test_databases_json(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={ + "databases": [ + {"name": "main", "tables_count": 12}, + {"name": "extra", "tables_count": 3}, + ] + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, ["databases", "-i", "https://example.com", "--json"] + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data) == 2 + assert data[0]["name"] == "main" + + +def test_databases_plain(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={ + "databases": [ + {"name": "main", "tables_count": 12}, + {"name": "extra", "tables_count": 3}, + ] + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke(cli, ["databases", "-i", "https://example.com"]) + assert result.exit_code == 0 + assert "main\n" in result.output + assert "extra\n" in result.output + + +def test_databases_url(httpx_mock, mocker, tmpdir): + """databases command hits /.json on the instance.""" + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={"databases": [{"name": "db1"}]}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke(cli, ["databases", "-i", "https://example.com"]) + assert result.exit_code == 0 + request = httpx_mock.get_request() + assert request.url.path == "/.json" + + +# -- tables command -- + + +def test_tables_json(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={ + "tables": [ + {"name": "facetable", "count": 15, "hidden": False}, + {"name": "facet_cities", "count": 4, "hidden": False}, + ], + "views": [], + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + ["tables", "-i", "https://example.com", "-d", "fixtures", "--json"], + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data) == 2 + + +def test_tables_plain(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={ + "tables": [ + {"name": "facetable", "count": 15, "hidden": False}, + ], + "views": [], + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, ["tables", "-i", "https://example.com", "-d", "fixtures"] + ) + assert result.exit_code == 0 + assert "facetable" in result.output + assert "15 rows" in result.output + + +def test_tables_url(httpx_mock, mocker, tmpdir): + """tables command hits /.json.""" + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={"tables": [], "views": []}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, ["tables", "-i", "https://example.com", "-d", "fixtures"] + ) + assert result.exit_code == 0 + request = httpx_mock.get_request() + assert request.url.path == "/fixtures.json" + + +def test_tables_with_views(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={ + "tables": [{"name": "t1", "count": 5, "hidden": False}], + "views": [{"name": "v1"}], + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, ["tables", "-i", "https://example.com", "-d", "db", "--views"] + ) + assert result.exit_code == 0 + assert "t1" in result.output + assert "v1" in result.output + + +def test_tables_views_only(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={ + "tables": [{"name": "t1", "count": 5, "hidden": False}], + "views": [{"name": "v1"}], + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + ["tables", "-i", "https://example.com", "-d", "db", "--views-only"], + ) + assert result.exit_code == 0 + assert "t1" not in result.output + assert "v1" in result.output + + +def test_tables_hidden(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={ + "tables": [ + {"name": "visible", "count": 5, "hidden": False}, + {"name": "hidden_t", "count": 2, "hidden": True}, + ], + "views": [], + }, + status_code=200, + ) + runner = CliRunner() + # Without --hidden + result = runner.invoke( + cli, ["tables", "-i", "https://example.com", "-d", "db"] + ) + assert "visible" in result.output + assert "hidden_t" not in result.output + + # With --hidden + httpx_mock.add_response( + json={ + "tables": [ + {"name": "visible", "count": 5, "hidden": False}, + {"name": "hidden_t", "count": 2, "hidden": True}, + ], + "views": [], + }, + status_code=200, + ) + result = runner.invoke( + cli, + ["tables", "-i", "https://example.com", "-d", "db", "--hidden"], + ) + assert "visible" in result.output + assert "hidden_t" in result.output + + +# -- plugins command -- + + +def test_plugins_json(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json=[ + {"name": "datasette-files", "version": "0.3.1"}, + {"name": "datasette-auth-tokens", "version": "0.4"}, + ], + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, ["plugins", "-i", "https://example.com", "--json"] + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data) == 2 + + +def test_plugins_plain(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json=[ + {"name": "datasette-files", "version": "0.3.1"}, + {"name": "datasette-auth-tokens", "version": "0.4"}, + ], + status_code=200, + ) + runner = CliRunner() + result = runner.invoke(cli, ["plugins", "-i", "https://example.com"]) + assert result.exit_code == 0 + assert "datasette-files\n" in result.output + assert "datasette-auth-tokens\n" in result.output + + +def test_plugins_url(httpx_mock, mocker, tmpdir): + """plugins command hits /-/plugins.json.""" + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response(json=[], status_code=200) + runner = CliRunner() + result = runner.invoke(cli, ["plugins", "-i", "https://example.com"]) + assert result.exit_code == 0 + request = httpx_mock.get_request() + assert request.url.path == "/-/plugins.json" + + +# -- schema command -- + + +def test_schema_all_tables(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + full_schema = ( + "CREATE TABLE users (id integer primary key, name text);\n" + "CREATE VIEW user_count AS SELECT count(*) FROM users;" + ) + httpx_mock.add_response( + json={"database": "main", "schema": full_schema}, + status_code=200, + ) + runner = CliRunner() + 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 + request = httpx_mock.get_request() + assert request.url.path == "/main/-/schema.json" + + +def test_schema_specific_table(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={ + "database": "main", + "table": "users", + "schema": "CREATE TABLE users (id integer primary key, name text)", + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, ["schema", "users", "-i", "https://example.com", "-d", "main"] + ) + assert result.exit_code == 0 + assert "CREATE TABLE users" in result.output + request = httpx_mock.get_request() + assert request.url.path == "/main/users/-/schema.json" + + +def test_schema_json(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + full_schema = "CREATE TABLE users (id integer primary key);" + httpx_mock.add_response( + json={"database": "main", "schema": full_schema}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, + ["schema", "-i", "https://example.com", "-d", "main", "--json"], + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert "schema" in data + + +# -- default_query (bare SQL shortcut) -- + + +def test_default_query_with_defaults(httpx_mock, mocker, tmpdir): + """Bare SQL uses default instance + 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": "main", + } + }, + } + ) + ) + httpx_mock.add_response( + json={ + "ok": True, + "rows": [{"count(*)": 42}], + "columns": ["count(*)"], + }, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke(cli, ["select count(*) from users"]) + assert result.exit_code == 0 + assert json.loads(result.output) == [{"count(*)": 42}] + request = httpx_mock.get_request() + assert request.url.host == "prod.example.com" + assert request.url.path == "/main.json" + + +def test_default_query_with_database_override(httpx_mock, mocker, tmpdir): + """Bare SQL with -d flag overrides the 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": "main", + } + }, + } + ) + ) + httpx_mock.add_response( + json={ + "ok": True, + "rows": [{"count(*)": 100}], + "columns": ["count(*)"], + }, + status_code=200, + ) + runner = CliRunner() + 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" + + +def test_default_query_with_instance_override(httpx_mock, mocker, tmpdir): + """Bare SQL with -i flag overrides the instance.""" + 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", + }, + "staging": { + "url": "https://staging.example.com", + "default_database": "main", + }, + }, + } + ) + ) + httpx_mock.add_response( + json={ + "ok": True, + "rows": [{"count(*)": 5}], + "columns": ["count(*)"], + }, + status_code=200, + ) + runner = CliRunner() + 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" + + +def test_default_query_no_database_error(mocker, tmpdir): + """Bare SQL without a default database gives an 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, ["select 1"]) + assert result.exit_code == 1 + assert "No database specified" in result.output + + +# -- upsert command -- + + +def test_upsert_mocked(httpx_mock, tmpdir, mocker): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={ + "ok": True, + } + ) + path = pathlib.Path(tmpdir) / "data.csv" + path.write_text("a,b,c\n1,2,3\n") + runner = CliRunner() + result = runner.invoke( + cli, + [ + "upsert", + "data", + "table1", + str(path), + "--csv", + "--token", + "x", + "-i", + "https://datasette.example.com", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0 + request = httpx_mock.get_request() + assert request.headers["authorization"] == "Bearer x" + # Should hit /-/upsert endpoint + assert "/table1/-/upsert" in str(request.url) + assert json.loads(request.read()) == {"rows": [{"a": 1, "b": 2, "c": 3}]} + + +# -- auth status command -- + + +def test_auth_status(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={"actor": {"id": "root"}}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, ["auth", "status", "-i", "https://example.com", "--token", "tok"] + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["actor"]["id"] == "root" + request = httpx_mock.get_request() + assert request.url.path == "/-/actor.json" + + +# -- actor command -- + + +def test_actor_with_instance_flag(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={"actor": {"id": "root"}}, + status_code=200, + ) + runner = CliRunner() + result = runner.invoke( + cli, ["actor", "-i", "https://example.com", "--token", "tok"] + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["actor"]["id"] == "root" + request = httpx_mock.get_request() + assert request.url.path == "/-/actor.json" + assert request.headers["authorization"] == "Bearer tok" + + +# -- get command -- + + +def test_get_command(httpx_mock, mocker, tmpdir): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + httpx_mock.add_response( + json={"hello": "world"}, + status_code=200, + ) + runner = CliRunner() + 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"} + request = httpx_mock.get_request() + assert request.url.path == "/-/plugins.json" From 742d46de0595027938d8bfea781f9bdf0f24f395 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 24 Feb 2026 15:22:05 -0800 Subject: [PATCH 4/6] v1 to v2 config migration with tests Add _migrate_v1_to_v2() to convert aliases.json to config.json format. Single-path-segment URLs are split into instance URL + default_database. Auth keys are migrated from URLs to alias names. Original files are backed up as .bak. 7 tests covering simple migration, no path segment, auth migration, URL fallback for unmatched auth, skip if config exists, skip if no aliases, and multi-path-segment URLs. refs #29 Co-Authored-By: Claude Opus 4.6 --- tests/test_migration.py | 116 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/test_migration.py diff --git a/tests/test_migration.py b/tests/test_migration.py new file mode 100644 index 0000000..2e81f03 --- /dev/null +++ b/tests/test_migration.py @@ -0,0 +1,116 @@ +"""Tests for v1 → v2 config migration.""" + +from dclient.cli import _migrate_v1_to_v2 +import json +import pathlib + + +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"}) + ) + + _migrate_v1_to_v2(config_dir) + + config = json.loads((config_dir / "config.json").read_text()) + assert config["instances"]["content"]["url"] == "https://datasette.io" + assert config["instances"]["content"]["default_database"] == "content" + assert config["default_instance"] is None + + # Original should be renamed + assert (config_dir / "aliases.json.bak").exists() + assert not (config_dir / "aliases.json").exists() + + +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"}) + ) + + _migrate_v1_to_v2(config_dir) + + config = json.loads((config_dir / "config.json").read_text()) + assert config["instances"]["local"]["url"] == "http://localhost:8001" + assert config["instances"]["local"]["default_database"] is None + + +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"}) + ) + auth_file = config_dir / "auth.json" + auth_file.write_text( + json.dumps({"https://datasette.io/content": "tok123"}) + ) + + _migrate_v1_to_v2(config_dir) + + new_auths = json.loads((config_dir / "auth.json").read_text()) + assert "content" in new_auths + assert new_auths["content"] == "tok123" + + # Old auth should be backed up + assert (config_dir / "auth.json.bak").exists() + + +def test_migration_auth_url_fallback(tmpdir): + """Auth entries without matching aliases are kept as URL keys.""" + config_dir = pathlib.Path(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"}) + ) + + _migrate_v1_to_v2(config_dir) + + new_auths = json.loads((config_dir / "auth.json").read_text()) + assert new_auths["https://other.example.com"] == "tok456" + + +def test_migration_skips_if_config_exists(tmpdir): + """If config.json already exists, migration is skipped.""" + config_dir = pathlib.Path(tmpdir) + (config_dir / "config.json").write_text(json.dumps({"existing": True})) + (config_dir / "aliases.json").write_text(json.dumps({"foo": "https://bar.com"})) + + _migrate_v1_to_v2(config_dir) + + # config.json should be untouched + assert json.loads((config_dir / "config.json").read_text()) == {"existing": True} + # aliases.json should NOT be renamed + assert (config_dir / "aliases.json").exists() + + +def test_migration_skips_if_no_aliases(tmpdir): + """If aliases.json doesn't exist, migration is skipped.""" + config_dir = pathlib.Path(tmpdir) + + _migrate_v1_to_v2(config_dir) + + assert not (config_dir / "config.json").exists() + + +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"}) + ) + + _migrate_v1_to_v2(config_dir) + + config = json.loads((config_dir / "config.json").read_text()) + assert config["instances"]["deep"]["url"] == "https://example.com/a/b/c" + assert config["instances"]["deep"]["default_database"] is None From 3f3221fdd3f23dacc243b3018919fae3ababde70 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 24 Feb 2026 15:22:12 -0800 Subject: [PATCH 5/6] Update README and docs index for v2 Update examples to use v2 command syntax with -i flag and alias defaults. Add quick start section showing alias add/default/default-db workflow. Add introspection commands section. Add environment.md to docs toctree. Co-Authored-By: Claude Opus 4.6 --- README.md | 40 +++++++++++++++++++++++++++------------- docs/index.md | 10 +++------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index ec1925a..2012f77 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,11 @@ Much of the functionality requires Datasette 1.0a2 or higher. ## Things you can do with dclient -- Run SQL queries against Datasette and returning the results as JSON +- Run SQL queries against Datasette and return the results as JSON +- Introspect databases, tables, plugins, and schema - Run queries against authenticated Datasette instances -- Create aliases and store authentication tokens for convenient access to Datasette -- Insert data into Datasette using the [insert API](https://docs.datasette.io/en/latest/json_api.html#the-json-write-api) (Datasette 1.0 alpha or higher) +- Create aliases and set default instances/databases for convenient access +- Insert and upsert data using the [write API](https://docs.datasette.io/en/latest/json_api.html#the-json-write-api) (Datasette 1.0 alpha or higher) ## Installation @@ -26,19 +27,32 @@ If you want to install it in the same virtual environment as Datasette (to use i ```bash datasette install dclient ``` -## Running a query +## Quick start + +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 +``` +Now run queries directly: +```bash +dclient "select * from facetable limit 1" +``` +Or be explicit: +```bash +dclient query fixtures "select * from facetable limit 1" -i latest +``` + +## Introspection ```bash -dclient query https://latest.datasette.io/fixtures "select * from facetable limit 1" -``` -To shorten that, create an alias: -```bash -dclient alias add fixtures https://latest.datasette.io/fixtures -``` -Then run it like this instead: -```bash -dclient query fixtures "select * from facetable limit 1" +dclient databases +dclient tables +dclient plugins +dclient schema facetable ``` + ## Documentation Visit **[dclient.datasette.io](https://dclient.datasette.io)** for full documentation on using this tool. diff --git a/docs/index.md b/docs/index.md index 00897bd..dda58da 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,15 +21,10 @@ If you also have Datasette installed in the same environment it will register it ```bash datasette install dclient ``` -This means you can run any of these commands using `datasette dt` instead, like this: +This means you can run any of these commands using `datasette dc` instead, like this: ```bash datasette dc --help -datasette dc query https://latest.datasette.io/fixtures "select * from facetable limit 1" -``` -You can install it into Datasette this way using: - -```bash -datasette install dclient +datasette dc query fixtures "select * from facetable limit 1" -i latest ``` ## Contents @@ -42,4 +37,5 @@ queries aliases authentication inserting +environment ``` From 7b3a15027e710717f4e1976f90e5b72ceba15e76 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 24 Feb 2026 15:25:57 -0800 Subject: [PATCH 6/6] Add instances command, improve error messages with actionable hints Add "dclient instances" command to list known instances from config, with --json support. Improve "No instance specified" and "No database specified" error messages to show the exact commands needed to fix the problem. Co-Authored-By: Claude Opus 4.6 --- dclient/cli.py | 34 ++++++++++++++++++++++-- tests/test_commands_v2.py | 54 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/dclient/cli.py b/dclient/cli.py index 3adfebc..48f249b 100644 --- a/dclient/cli.py +++ b/dclient/cli.py @@ -60,7 +60,10 @@ def _resolve_instance(instance, config_file): if env_url: return env_url.rstrip("/") raise click.ClickException( - "No instance specified. Use -i, set a default instance, or set DATASETTE_URL." + "No instance specified. Use -i , or configure a default:\n\n" + " dclient alias add \n" + " dclient alias default \n\n" + "Or set the DATASETTE_URL environment variable." ) @@ -81,7 +84,9 @@ def _resolve_database(database, instance_alias, config_file): if env_db: return env_db raise click.ClickException( - "No database specified. Use -d, set a default database, or set DATASETTE_DATABASE." + "No database specified. Use -d , or configure a default:\n\n" + " dclient alias default-db \n\n" + "Or set the DATASETTE_DATABASE environment variable." ) @@ -642,6 +647,31 @@ def default_query(sql, instance, database, token, verbose): click.echo(json.dumps(response.json()["rows"], indent=2)) +@cli.command() +@click.option("--json", "_json", is_flag=True, help="Output raw JSON") +def instances(_json): + """ + List known instances from the config + + Example usage: + + \b + dclient instances + dclient instances --json + """ + config_file = get_config_dir() / "config.json" + config = _load_config(config_file) + inst_map = config.get("instances", {}) + default = config.get("default_instance") + if _json: + click.echo(json.dumps(config, indent=2)) + 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 "" + click.echo(f"{marker}{name} = {inst['url']}{db_info}") + + # -- alias command group -- diff --git a/tests/test_commands_v2.py b/tests/test_commands_v2.py index 2077a45..afc4e7c 100644 --- a/tests/test_commands_v2.py +++ b/tests/test_commands_v2.py @@ -536,3 +536,57 @@ def test_get_command(httpx_mock, mocker, tmpdir): assert data == {"hello": "world"} request = httpx_mock.get_request() assert request.url.path == "/-/plugins.json" + + +# -- instances command -- + + +def test_instances_plain(mocker, tmpdir): + config_dir = pathlib.Path(tmpdir) + mocker.patch("dclient.cli.get_config_dir", return_value=config_dir) + (config_dir / "config.json").write_text( + json.dumps( + { + "default_instance": "prod", + "instances": { + "prod": {"url": "https://prod.example.com", "default_database": "main"}, + "staging": {"url": "https://staging.example.com", "default_database": None}, + }, + } + ) + ) + runner = CliRunner() + result = runner.invoke(cli, ["instances"]) + assert result.exit_code == 0 + assert "* prod = https://prod.example.com (db: main)" in result.output + assert " staging = https://staging.example.com" in result.output + + +def test_instances_json(mocker, tmpdir): + config_dir = pathlib.Path(tmpdir) + mocker.patch("dclient.cli.get_config_dir", return_value=config_dir) + (config_dir / "config.json").write_text( + json.dumps( + { + "default_instance": "prod", + "instances": { + "prod": {"url": "https://prod.example.com", "default_database": "main"}, + }, + } + ) + ) + runner = CliRunner() + result = runner.invoke(cli, ["instances", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert "prod" in data["instances"] + assert data["default_instance"] == "prod" + + +def test_instances_empty(mocker, tmpdir): + config_dir = pathlib.Path(tmpdir) + mocker.patch("dclient.cli.get_config_dir", return_value=config_dir) + runner = CliRunner() + result = runner.invoke(cli, ["instances"]) + assert result.exit_code == 0 + assert result.output.strip() == ""