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) @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() @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) -- @cli.command() @click.argument("alias_or_url", required=False, default=None) @click.option("--scope", default=None, help="JSON scope array") def login(alias_or_url, scope): """ Authenticate with a Datasette instance using OAuth Uses the OAuth device flow: opens a URL in your browser where you approve access, then saves the resulting API token. Example usage: \b dclient login https://simon.datasette.cloud/ dclient login myalias dclient login """ config_dir = get_config_dir() config_dir.mkdir(parents=True, exist_ok=True) config_file = config_dir / "config.json" if alias_or_url is None: click.echo("Enter the URL of your Datasette instance, or an alias you have") click.echo("already configured with 'dclient alias add'.\n") alias_or_url = click.prompt("Instance URL or alias") # Resolve alias to URL if needed if alias_or_url.startswith("http://") or alias_or_url.startswith("https://"): url = alias_or_url auth_key = alias_or_url else: url = _resolve_instance(alias_or_url, config_file) auth_key = alias_or_url # Ensure trailing slash if not url.endswith("/"): url += "/" # Step 1: Request device code device_url = url + "-/oauth/device" data = {} if scope: data["scope"] = scope response = httpx.post(device_url, data=data, timeout=30.0) if response.status_code != 200: raise click.ClickException( f"Failed to start login flow: {response.status_code} from {device_url}" ) device_data = response.json() device_code = device_data["device_code"] user_code = device_data["user_code"] verification_uri = device_data["verification_uri"] interval = device_data.get("interval", 5) # Step 2: Show instructions click.echo(f"\nOpen this URL in your browser:\n") click.echo(f" {verification_uri}\n") click.echo(f"Enter this code: {user_code}\n") click.echo("Waiting for authorization...", nl=False) # Step 3: Poll for token token_url = url + "-/oauth/token" while True: time.sleep(interval) click.echo(".", nl=False) token_response = httpx.post( token_url, data={ "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": device_code, }, timeout=30.0, ) token_data = token_response.json() if "access_token" in token_data: break error = token_data.get("error") if error == "authorization_pending": continue elif error == "access_denied": click.echo() raise click.ClickException("Authorization denied.") elif error == "expired_token": click.echo() raise click.ClickException("Device code expired. Run login again.") else: click.echo() raise click.ClickException(f"Unexpected error: {error}") # Step 4: Save token click.echo() access_token = token_data["access_token"] auth_file = config_dir / "auth.json" auths = _load_auths(auth_file) auths[auth_key] = access_token auth_file.write_text(json.dumps(auths, indent=4)) click.echo(f"Login successful. Token saved for {auth_key}") # 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