import click from click_default_group import DefaultGroup import csv import httpx import io import json import os import pathlib from sqlite_utils.utils import rows_from_file, Format, TypeTracker, progressbar import sys import textwrap import time from .utils import token_for_url 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")) 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: if default in config.get("instances", {}): return config["instances"][default]["url"] if default.startswith("http://") or default.startswith("https://"): return default.rstrip("/") # Try env var env_url = os.environ.get("DATASETTE_URL") if env_url: return env_url.rstrip("/") raise click.ClickException( "No instance specified. Use -i , or configure a default:\n\n" " dclient alias add \n" " dclient default instance \n\n" "Or set the DATASETTE_URL environment variable." ) 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", {}) key = instance_alias if key not in instances and ( key.startswith("http://") or key.startswith("https://") ): key = _instance_alias_for_url(key, config_file) if key in instances: default_db = instances[key].get("default_database") if default_db: return default_db # Try env var env_db = os.environ.get("DATASETTE_DATABASE") if env_db: return env_db raise click.ClickException( "No database specified. Use -d , or configure a default:\n\n" " dclient default database \n\n" "Or set the DATASETTE_DATABASE environment variable." ) 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") def _output_rows(rows, fmt, columns=None): """Output rows in the specified format. fmt is one of 'json', 'csv', 'tsv', 'nl', 'table'.""" if fmt == "csv": _output_csv(rows, columns) elif fmt == "tsv": _output_csv(rows, columns, delimiter="\t") elif fmt == "nl": for row in rows: click.echo(json.dumps(row, default=str)) elif fmt == "table": _output_table(rows, columns) else: click.echo(json.dumps(rows, indent=2, default=str)) def _output_csv(rows, columns=None, delimiter=","): if not rows and not columns: return if columns is None: columns = list(rows[0].keys()) if rows else [] buf = io.StringIO() writer = csv.writer(buf, delimiter=delimiter) writer.writerow(columns) for row in rows: writer.writerow(str(row.get(col, "")) for col in columns) click.echo(buf.getvalue(), nl=False) def _output_table(rows, columns=None): if not rows and not columns: return if columns is None: columns = list(rows[0].keys()) if rows else [] if not columns: return # Calculate column widths widths = {col: len(str(col)) for col in columns} for row in rows: for col in columns: widths[col] = max(widths[col], len(str(row.get(col, "")))) # Header header = " ".join(str(col).ljust(widths[col]) for col in columns) click.echo(header) # Separator sep = " ".join("-" * widths[col] for col in columns) click.echo(sep) # Rows for row in rows: line = " ".join(str(row.get(col, "")).ljust(widths[col]) for col in columns) click.echo(line) def _determine_output_format(fmt_csv, fmt_tsv, fmt_nl, fmt_table): if fmt_csv: return "csv" if fmt_tsv: return "tsv" if fmt_nl: return "nl" if fmt_table: return "table" return "json" def output_format_options(f): """Decorator that adds --csv, --tsv, --nl, --table options to a command.""" f = click.option( "fmt_table", "--table", "-t", is_flag=True, help="Output as ASCII table" )(f) f = click.option( "fmt_nl", "--nl", is_flag=True, help="Output as newline-delimited JSON" )(f) f = click.option("fmt_tsv", "--tsv", is_flag=True, help="Output as TSV")(f) f = click.option("fmt_csv", "--csv", is_flag=True, help="Output as CSV")(f) return f @click.group(cls=DefaultGroup, default="default_query", default_if_no_args=False) @click.version_option() def cli(): "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("-i", "--instance", default=None, help="Datasette instance URL or alias") @click.option("--token", help="API token") def get(path, instance, token): """ Make an authenticated GET request to a Datasette instance Example usage: \b dclient get /-/plugins.json dclient get /data/creatures.json -i https://my.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" ) full_url = url.rstrip("/") + "/" + path.lstrip("/") response = _make_request(url, token, "/" + path.lstrip("/")) if response.status_code != 200: raise click.ClickException(f"{response.status_code} error for {full_url}") if "json" in response.headers.get("content-type", ""): click.echo(json.dumps(response.json(), indent=2)) else: click.echo(response.text) @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 databases(instance, _json, token): """ List databases on an instance Example usage: \b dclient databases dclient databases -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, "/.json") if response.status_code != 200: raise click.ClickException(f"{response.status_code} error") 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.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(instance, database, views, views_only, hidden, _json, token): """ List tables in a database Example usage: \b dclient tables dclient tables -d fixtures -i https://latest.datasette.io """ 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") 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) # Convenience aliases for common filter operations. # Any operation not listed here is passed through directly to Datasette, # so plugins that add custom filter operations will work too. FILTER_ALIASES = { "eq": "exact", } @cli.command() @click.argument("db_or_table") @click.argument("table", required=False, default=None) @click.option("-i", "--instance", default=None, help="Datasette instance URL or alias") @click.option("-d", "--database", default=None, help="Database name") @click.option("--token", help="API token") @click.option( "-f", "--filter", "filters", multiple=True, nargs=3, help="Filter: column operation value (e.g. -f age gte 3)", ) @click.option("--search", default=None, help="Full-text search query") @click.option("--sort", default=None, help="Sort by column (ascending)") @click.option("--sort-desc", default=None, help="Sort by column (descending)") @click.option("--col", "columns", multiple=True, help="Include only these columns") @click.option("--nocol", "nocolumns", multiple=True, help="Exclude these columns") @click.option("--size", type=int, default=None, help="Number of rows per page") @click.option("--limit", type=int, default=None, help="Maximum total rows to return") @click.option("--all", "fetch_all", is_flag=True, help="Fetch all pages") @click.option("-v", "--verbose", is_flag=True, help="Verbose output: show HTTP request") @output_format_options def rows( db_or_table, table, instance, database, token, filters, search, sort, sort_desc, columns, nocolumns, size, limit, fetch_all, verbose, fmt_csv, fmt_tsv, fmt_nl, fmt_table, ): """ Browse rows in a table with filtering and sorting If only one positional argument is given, it is treated as the table name and the default database is used. Pass two arguments for database and table. Example usage: \b dclient rows facet_cities dclient rows fixtures facet_cities -i https://latest.datasette.io dclient rows facet_cities -f id gte 3 --sort name -t """ config_dir = get_config_dir() url = _resolve_instance(instance, config_dir / "config.json") # Figure out database and table from positional args if table is not None: db = db_or_table else: # Only one positional arg — it's the table, resolve database from defaults table = db_or_table instance_alias = ( _instance_alias_for_url(url, config_dir / "config.json") if not ( instance and (instance.startswith("http://") or instance.startswith("https://")) ) else None ) if instance and not ( instance.startswith("http://") or instance.startswith("https://") ): instance_alias = instance db = _resolve_database(database, instance_alias, config_dir / "config.json") token = _resolve_token( token, url, config_dir / "auth.json", config_dir / "config.json" ) # Build query params params = {"_shape": "objects"} for col, op, val in filters: datasette_op = FILTER_ALIASES.get(op, op) params[f"{col}__{datasette_op}"] = val if search: params["_search"] = search if sort: params["_sort"] = sort if sort_desc: params["_sort_desc"] = sort_desc for col in columns: # httpx handles repeated keys if we use a list of tuples pass for col in nocolumns: pass if size: params["_size"] = str(size) # Convert to list of tuples to support repeated keys (_col, _nocol) param_items = list(params.items()) for col in columns: param_items.append(("_col", col)) for col in nocolumns: param_items.append(("_nocol", col)) # First request table_url = url.rstrip("/") + "/" + db + "/" + table + ".json" if verbose: click.echo(table_url, err=True) all_rows = [] col_names = None total = 0 next_page_url = None first = True while True: if first: response = _make_request( url, token, f"/{db}/{table}.json", params=param_items ) first = False else: # Follow next_url directly headers = {} if token: headers["Authorization"] = f"Bearer {token}" response = httpx.get( next_page_url, headers=headers, follow_redirects=True, timeout=30.0, ) if response.status_code != 200: try: data = response.json() except json.JSONDecodeError: raise click.ClickException(f"{response.status_code} status code") bits = [] if data.get("title"): bits.append(data["title"]) if data.get("error"): bits.append(data["error"]) raise click.ClickException( "{} status code. {}".format(response.status_code, ": ".join(bits)) ) data = response.json() page_rows = data.get("rows", []) if col_names is None: col_names = data.get("columns") if limit: remaining = limit - total page_rows = page_rows[:remaining] all_rows.extend(page_rows) total += len(page_rows) if limit and total >= limit: break next_page_url = data.get("next_url") if not fetch_all or not next_page_url: break fmt = _determine_output_format(fmt_csv, fmt_tsv, fmt_nl, fmt_table) _output_rows(all_rows, fmt, col_names) @cli.command() @click.argument("database") @click.argument("sql") @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") @output_format_options def query(database, sql, instance, token, verbose, fmt_csv, fmt_tsv, fmt_nl, fmt_table): """ Run a SQL query against a Datasette database Requires both a database name and a SQL string. Example usage: \b dclient query fixtures "select * from facetable limit 5" dclient query analytics "select count(*) from events" -i staging """ 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(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)) rows = response.json()["rows"] columns = response.json().get("columns") fmt = _determine_output_format(fmt_csv, fmt_tsv, fmt_nl, fmt_table) _output_rows(rows, fmt, columns) def _do_insert( 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: format = Format.CSV elif format_tsv: format = Format.TSV elif format_json: format = Format.JSON elif format_nl: format = Format.NL if format is None and filepath == "-": raise click.ClickException( "An explicit format is required - e.g. --csv " "- when reading from standard input" ) if filepath != "-": file_size = pathlib.Path(filepath).stat().st_size fp = open(filepath, "rb") else: fp = sys.stdin.buffer file_size = None try: rows, format = rows_from_file(fp, format=format, encoding=encoding) except Exception as ex: raise click.ClickException(str(ex)) if format in (Format.JSON, Format.NL): file_size = None no_detect_types = True first = True base_url = url.rstrip("/") + "/" + database with progressbar( length=file_size, label="Inserting rows", silent=silent or (file_size is None), show_percent=True, ) as bar: bytes_so_far = 0 for batch in _batches(rows, batch_size, interval=interval): if file_size is not None: try: bytes_consumed_so_far = fp.tell() new_bytes = bytes_consumed_so_far - bytes_so_far bar.update(new_bytes) bytes_so_far += new_bytes except ValueError: pass types = None if first and not no_detect_types: tracker = TypeTracker() list(tracker.wrap(batch)) types = tracker.types for row in batch: for key, value in row.items(): if value is None: continue if types[key] == "integer": if not value: row[key] = None else: row[key] = int(value) elif types[key] == "float": if not value: row[key] = None else: row[key] = float(value) first = False _insert_batch( url=base_url, table=table, batch=batch, token=token, create=create, alter=alter, pks=pks, 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", 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() @_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(name="create-table") @click.argument("database") @click.argument("table_name") @click.option( "--column", "-c", "column_defs", multiple=True, nargs=2, help="Column definition: name type (e.g. --column id integer --column name text)", ) @click.option( "pks", "--pk", multiple=True, help="Column(s) to use as primary key", ) @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 and response", ) def create_table(database, table_name, column_defs, pks, instance, token, verbose): """ Create a new empty table with an explicit schema Example usage: \b dclient create-table mydb dogs \\ --column id integer --column name text --pk id """ 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" ) if not column_defs: raise click.ClickException("Provide at least one --column definition") columns = [{"name": name, "type": typ} for name, typ in column_defs] data = {"table": table_name, "columns": columns} if pks: if len(pks) == 1: data["pk"] = pks[0] else: data["pks"] = list(pks) api_url = url.rstrip("/") + "/" + database + "/-/create" if verbose: click.echo("POST {}".format(api_url), err=True) click.echo(textwrap.indent(json.dumps(data, indent=2), " "), err=True) response = httpx.post( api_url, headers={ "Authorization": "Bearer {}".format(token), "Content-Type": "application/json", }, json=data, timeout=30.0, ) if verbose: click.echo(str(response), err=True) if str(response.status_code)[0] != "2": if "/json" in response.headers.get("content-type", ""): resp_data = response.json() if "errors" in resp_data: raise click.ClickException("\n".join(resp_data["errors"])) response.raise_for_status() click.echo(json.dumps(response.json(), indent=2)) @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 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 dclient actor -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)) @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") @output_format_options def default_query( sql, instance, database, token, verbose, fmt_csv, fmt_tsv, fmt_nl, fmt_table ): """Run a SQL query using default instance and database.""" config_dir = get_config_dir() url = _resolve_instance(instance, config_dir / "config.json") 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)) rows = response.json()["rows"] columns = response.json().get("columns") fmt = _determine_output_format(fmt_csv, fmt_tsv, fmt_nl, fmt_table) _output_rows(rows, fmt, columns) @cli.command() @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 -- @cli.group() def alias(): "Manage aliases for different instances" @alias.command(name="list") @click.option("_json", "--json", is_flag=True, help="Output raw JSON") 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(config, indent=2)) else: for name, inst in instances.items(): marker = "* " if name == default else " " db_info = ( f" (db: {inst['default_database']})" if inst.get("default_database") else "" ) click.echo(f"{marker}{name} = {inst['url']}{db_info}") @alias.command(name="add") @click.argument("name") @click.argument("url") def alias_add(name, url): """ Add an alias for a Datasette instance Example usage: \b dclient alias add prod https://myapp.datasette.cloud """ config_dir = get_config_dir() config_dir.mkdir(parents=True, exist_ok=True) 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") @click.argument("name") def alias_remove(name): """ Remove an alias Example usage: \b dclient alias remove prod """ 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") def _resolve_instance_key(alias_or_url, config): instances = config.get("instances", {}) if alias_or_url in instances: return alias_or_url if alias_or_url.startswith("http://") or alias_or_url.startswith("https://"): normalized = alias_or_url.rstrip("/") for name, inst in instances.items(): if inst.get("url", "").rstrip("/") == normalized: return name raise click.ClickException(f"No such instance URL: {alias_or_url}") raise click.ClickException(f"No such alias: {alias_or_url}") # -- default command group -- @cli.group() def default(): "Manage default instance and database" @default.command(name="instance") @click.argument("alias_or_url", required=False, default=None) @click.option("--clear", is_flag=True, help="Clear default instance") def default_instance(alias_or_url, clear): """ Set or show the default instance Example usage: \b dclient default instance prod dclient default instance https://myapp.datasette.cloud dclient default instance dclient default instance --clear """ config_file = get_config_dir() / "config.json" config = _load_config(config_file) if clear: config["default_instance"] = None _save_config(config_file, config) elif alias_or_url: if alias_or_url.startswith("http://") or alias_or_url.startswith("https://"): try: key = _resolve_instance_key(alias_or_url, config) except click.ClickException: key = alias_or_url.rstrip("/") else: key = _resolve_instance_key(alias_or_url, config) config["default_instance"] = key _save_config(config_file, config) else: default = config.get("default_instance") if default: click.echo(default) else: click.echo("No default instance set") @default.command(name="database") @click.argument("alias_or_url") @click.argument("db", required=False, default=None) @click.option("--clear", is_flag=True, help="Clear default database for this instance") def default_database(alias_or_url, db, clear): """ Set or show the default database for an instance Example usage: \b dclient default database prod main dclient default database https://myapp.datasette.cloud main dclient default database prod dclient default database prod --clear """ config_file = get_config_dir() / "config.json" config = _load_config(config_file) instance_key = _resolve_instance_key(alias_or_url, config) if clear: config["instances"][instance_key]["default_database"] = None _save_config(config_file, config) elif db: config["instances"][instance_key]["default_database"] = db _save_config(config_file, config) else: default_db = config["instances"][instance_key].get("default_database") if default_db: click.echo(default_db) else: click.echo(f"No default database set for {instance_key}") # -- auth command group -- @cli.group() def auth(): "Manage authentication for different instances" @auth.command(name="add") @click.argument("alias_or_url") @click.option("--token", prompt=True, hide_input=True) def auth_add(alias_or_url, token): """ Add an authentication token for an alias or URL Example usage: \b dclient auth add prod dclient auth add https://datasette.io Paste in the token when prompted. """ config_dir = get_config_dir() config_dir.mkdir(parents=True, exist_ok=True) auth_file = config_dir / "auth.json" auths = _load_auths(auth_file) # Store by alias name or URL as-is auths[alias_or_url] = token auth_file.write_text(json.dumps(auths, indent=4)) @auth.command(name="list") def auth_list(): """ List stored API tokens Example usage: \b dclient auth list """ auths_file = get_config_dir() / "auth.json" click.echo("Tokens file: {}".format(auths_file)) auths = _load_auths(auths_file) if auths: click.echo() for key, token in auths.items(): click.echo("{}:\t{}..".format(key, token[:1])) @auth.command(name="remove") @click.argument("alias_or_url") def auth_remove(alias_or_url): """ Remove the API token for an alias or URL Example usage: \b dclient auth remove prod """ config_dir = get_config_dir() auth_file = config_dir / "auth.json" auths = _load_auths(auth_file) try: del auths[alias_or_url] auth_file.write_text(json.dumps(auths, indent=4)) except KeyError: raise click.ClickException("No such URL or alias") @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)) # -- login command (OAuth device flow) -- READ_SCOPES = [ "view-instance", "view-table", "view-database", "view-query", "execute-sql", ] WRITE_SCOPES = [ "insert-row", "delete-row", "update-row", "create-table", "alter-table", "drop-table", ] def _merge_scopes(scope, read_all, write_all, read, write): """Merge --scope JSON with --read-all/--write-all/--read/--write options.""" scopes = json.loads(scope) if scope else [] if read_all: for action in READ_SCOPES: scopes.append([action]) if write_all: for action in READ_SCOPES + WRITE_SCOPES: scopes.append([action]) for target in read or []: parts = target.split("/", 1) if len(parts) == 1: for action in READ_SCOPES: scopes.append([action, parts[0]]) else: for action in READ_SCOPES: scopes.append([action, parts[0], parts[1]]) for target in write or []: parts = target.split("/", 1) if len(parts) == 1: for action in READ_SCOPES + WRITE_SCOPES: scopes.append([action, parts[0]]) else: for action in READ_SCOPES + WRITE_SCOPES: scopes.append([action, parts[0], parts[1]]) if scopes: return json.dumps(scopes) return None @cli.command() @click.argument("alias_or_url", required=False, default=None) @click.option("--scope", default=None, help="JSON scope array") @click.option("--read-all", is_flag=True, help="Request instance-wide read access") @click.option("--write-all", is_flag=True, help="Request instance-wide write access") @click.option( "--read", multiple=True, help="Request read access for a database or database/table" ) @click.option( "--write", multiple=True, help="Request write access for a database or database/table", ) @click.option( "--token-only", is_flag=True, help="Output the token to stdout instead of saving it", ) def login(alias_or_url, scope, read_all, write_all, read, write, token_only): """ Authenticate with a Datasette instance using OAuth Uses the OAuth device flow: opens a URL in your browser where you approve access, then saves the resulting API token. Example usage: \b dclient login https://simon.datasette.cloud/ dclient login myalias dclient login dclient login --read-all dclient login --write-all dclient login --read db1 dclient login --write db3/submissions dclient login --read db1 --write db3/dogs """ scope = _merge_scopes(scope, read_all, write_all, read, write) config_dir = get_config_dir() config_dir.mkdir(parents=True, exist_ok=True) config_file = config_dir / "config.json" if alias_or_url is None: click.echo("Enter the URL of your Datasette instance, or an alias you have") click.echo("already configured with 'dclient alias add'.\n") alias_or_url = click.prompt("Instance URL or alias") # Resolve alias to URL if needed if alias_or_url.startswith("http://") or alias_or_url.startswith("https://"): url = alias_or_url auth_key = alias_or_url else: url = _resolve_instance(alias_or_url, config_file) auth_key = alias_or_url # Ensure trailing slash if not url.endswith("/"): url += "/" # Step 1: Request device code device_url = url + "-/oauth/device" data = {} if scope: data["scope"] = scope response = httpx.post(device_url, data=data, timeout=30.0) if response.status_code != 200: raise click.ClickException( f"Failed to start login flow: {response.status_code} from {device_url}" ) device_data = response.json() device_code = device_data["device_code"] user_code = device_data["user_code"] verification_uri = device_data["verification_uri"] interval = device_data.get("interval", 5) # Step 2: Show instructions click.echo("\nOpen this URL in your browser:\n") click.echo(f" {verification_uri}\n") click.echo(f"Enter this code: {user_code}\n") click.echo("Waiting for authorization...", nl=False) # Step 3: Poll for token token_url = url + "-/oauth/token" while True: time.sleep(interval) click.echo(".", nl=False) token_response = httpx.post( token_url, data={ "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": device_code, }, timeout=30.0, ) token_data = token_response.json() if "access_token" in token_data: break error = token_data.get("error") if error == "authorization_pending": continue elif error == "access_denied": click.echo() raise click.ClickException("Authorization denied.") elif error == "expired_token": click.echo() raise click.ClickException("Device code expired. Run login again.") else: click.echo() raise click.ClickException(f"Unexpected error: {error}") # Step 4: Save token (or print it) click.echo() access_token = token_data["access_token"] if token_only: click.echo(access_token) return auth_file = config_dir / "auth.json" auths = _load_auths(auth_file) auths[auth_key] = access_token auth_file.write_text(json.dumps(auths, indent=4)) click.echo(f"Login successful. Token saved for {auth_key}") # Step 5: Set defaults if not already configured config = _load_config(config_file) default_alias = config.get("default_instance") has_default_instance = default_alias is not None has_default_db = bool( config.get("instances", {}).get(default_alias or "", {}).get("default_database") ) if has_default_instance and has_default_db: return # Find alias for this instance, or use the auth_key (URL) as the instance key instance_key = _instance_alias_for_url(url, config_file) or auth_key # Ensure instance entry exists in config if instance_key not in config.get("instances", {}): config.setdefault("instances", {})[instance_key] = { "url": url.rstrip("/"), "default_database": None, } # Set as default instance if none configured if not has_default_instance: config["default_instance"] = instance_key click.echo(f"Set default instance to {instance_key}") # Query databases and set default database if none configured if not has_default_db: try: db_response = _make_request(url, access_token, "/.json") if db_response.status_code == 200: db_data = db_response.json() if isinstance(db_data, list): databases_list = db_data else: databases_list = db_data.get("databases", []) if isinstance(databases_list, dict): databases_list = list(databases_list.values()) db_names = [ db["name"] if isinstance(db, dict) else db for db in databases_list ] if db_names: if len(db_names) == 1: default_db = db_names[0] elif "data" in db_names: default_db = "data" else: default_db = db_names[0] config["instances"][instance_key]["default_database"] = default_db click.echo(f"Set default database to {default_db}") except Exception: pass # Don't fail login if databases check fails _save_config(config_file, config) # -- v1 → v2 migration -- 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 _batches(iterable, size, interval=None): iterable = iter(iterable) last_yield_time = time.time() while True: batch = [] for _ in range(size): try: batch.append(next(iterable)) except StopIteration: break if interval is not None and time.time() - last_yield_time >= interval: break if not batch: return yield batch last_yield_time = time.time() def _insert_batch( *, url, table, batch, token, create, alter, pks, replace, ignore, verbose, endpoint="insert", ): if create: data = { "table": table, "rows": batch, } if replace: data["replace"] = True if ignore: data["ignore"] = True if alter: data["alter"] = True if pks: if len(pks) == 1: data["pk"] = pks[0] else: data["pks"] = pks url = "{}/-/create".format(url) else: data = { "rows": batch, } if replace: data["replace"] = True if ignore: data["ignore"] = True if alter: data["alter"] = True 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) response = httpx.post( url, headers={ "Authorization": "Bearer {}".format(token), "Content-Type": "application/json", }, json=data, timeout=40.0, ) if verbose: click.echo(str(response), err=True) if str(response.status_code)[0] != "2": # Is there an error we can show? if "/json" in response.headers["content-type"]: data = response.json() if "errors" in data: raise click.ClickException("\n".join(data["errors"])) response.raise_for_status() response_data = response.json() if verbose: click.echo(textwrap.indent(json.dumps(response_data, indent=2), " "), err=True) return response_data