From 3621f132632cd34ef8c7c0b11ec567d7164f55c7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 24 Feb 2026 15:21:52 -0800 Subject: [PATCH] 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"] == {}