diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 741bc9e..967ae82 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,7 +14,7 @@ jobs: matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: @@ -34,7 +34,7 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6092103..ea123b5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: diff --git a/Justfile b/Justfile index 8904d06..b3cc38a 100644 --- a/Justfile +++ b/Justfile @@ -1,29 +1,37 @@ # Run tests and linters @default: test lint +# Install dependencies and test dependencies +@init: + pipenv run pip install -e '.[test]' + # Run pytest with supplied options @test *options: - uv run pytest {{options}} + pipenv run pytest {{options}} # Run linters @lint: echo "Linters..." + echo " Black" + pipenv run black . --check echo " cog" - uv run cog --check README.md docs/*.md - echo " ruff check" - uv run ruff check . - echo " ruff format" - uv run ruff format . --check + pipenv run cog --check README.md docs/*.md + echo " ruff" + pipenv run ruff . # Rebuild docs with cog @cog: - uv run cog -r docs/*.md + pipenv run cog -r docs/*.md # Serve live docs on localhost:8000 @docs: cog - cd docs && uv run make livehtml + cd docs && pipenv run make livehtml -# Apply ruff fixes and formatting +# Apply Black +@black: + pipenv run black . + +# Run automatic fixes @fix: cog - uv run ruff check . --fix - uv run ruff format . + pipenv run ruff . --fix + pipenv run black . diff --git a/README.md b/README.md index a70a430..2012f77 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,7 @@ Much of the functionality requires Datasette 1.0a2 or higher. ## Things you can do with dclient -- Browse table data with filtering, sorting, and pagination — no SQL required -- Run SQL queries against Datasette and return the results as JSON, CSV, TSV, or an ASCII table +- Run SQL queries against Datasette and return the results as JSON - Introspect databases, tables, plugins, and schema - Run queries against authenticated Datasette instances - Create aliases and set default instances/databases for convenient access @@ -33,8 +32,8 @@ datasette install dclient Add an alias for a Datasette instance: ```bash dclient alias add latest https://latest.datasette.io -dclient default instance latest -dclient default database latest fixtures +dclient alias default latest +dclient alias default-db latest fixtures ``` Now run queries directly: ```bash @@ -44,14 +43,6 @@ Or be explicit: ```bash dclient query fixtures "select * from facetable limit 1" -i latest ``` -Output as a table with `-t`, or use `--csv`, `--tsv`, `--nl`: -```bash -dclient "select pk, state from facetable limit 3" -t -``` -Browse table rows without SQL: -```bash -dclient rows facetable -f state eq CA --sort _city_id -t -``` ## Introspection diff --git a/dclient/cli.py b/dclient/cli.py index 05dc98d..91ccec5 100644 --- a/dclient/cli.py +++ b/dclient/cli.py @@ -1,8 +1,6 @@ import click from click_default_group import DefaultGroup -import csv import httpx -import io import json import os import pathlib @@ -55,11 +53,8 @@ def _resolve_instance(instance, config_file): ) # 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("/") + 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: @@ -67,7 +62,7 @@ def _resolve_instance(instance, config_file): raise click.ClickException( "No instance specified. Use -i , or configure a default:\n\n" " dclient alias add \n" - " dclient default instance \n\n" + " dclient alias default \n\n" "Or set the DATASETTE_URL environment variable." ) @@ -80,13 +75,8 @@ def _resolve_database(database, instance_alias, config_file): 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 instance_alias in instances: + default_db = instances[instance_alias].get("default_database") if default_db: return default_db # Try env var @@ -95,7 +85,7 @@ def _resolve_database(database, instance_alias, config_file): return env_db raise click.ClickException( "No database specified. Use -d , or configure a default:\n\n" - " dclient default database \n\n" + " dclient alias default-db \n\n" "Or set the DATASETTE_DATABASE environment variable." ) @@ -125,83 +115,6 @@ def _resolve_token(token, url, auth_file, config_file): 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(): @@ -359,199 +272,13 @@ def tables(instance, database, views, views_only, hidden, _json, token): 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): +def query(database, sql, instance, token, verbose): """ Run a SQL query against a Datasette database @@ -611,10 +338,7 @@ def query(database, sql, instance, token, verbose, fmt_csv, fmt_tsv, fmt_nl, fmt 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) + click.echo(json.dumps(response.json()["rows"], indent=2)) def _do_insert( @@ -767,7 +491,7 @@ _insert_options = [ click.option( "--interval", type=float, default=10, help="Send batch at least every X seconds" ), - click.option("--token", help="API token"), + click.option("--token", "-t", help="API token"), click.option("--silent", is_flag=True, help="Don't output progress"), click.option( "-v", @@ -904,82 +628,6 @@ def 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") @@ -1088,10 +736,7 @@ def actor(instance, token): @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 -): +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") @@ -1154,10 +799,7 @@ def default_query( 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) + click.echo(json.dumps(response.json()["rows"], indent=2)) @cli.command() @@ -1260,56 +902,29 @@ def alias_remove(name): 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) +@alias.command(name="default") +@click.argument("name", required=False, default=None) @click.option("--clear", is_flag=True, help="Clear default instance") -def default_instance(alias_or_url, clear): +def alias_default(name, 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 + 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 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 + 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") @@ -1319,37 +934,37 @@ def default_instance(alias_or_url, clear): click.echo("No default instance set") -@default.command(name="database") -@click.argument("alias_or_url") +@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 instance") -def default_database(alias_or_url, db, clear): +@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 instance + Set or show the default database for an alias 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 + 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) - instance_key = _resolve_instance_key(alias_or_url, config) + if alias_name not in config.get("instances", {}): + raise click.ClickException(f"No such alias: {alias_name}") if clear: - config["instances"][instance_key]["default_database"] = None + config["instances"][alias_name]["default_database"] = None _save_config(config_file, config) elif db: - config["instances"][instance_key]["default_database"] = db + config["instances"][alias_name]["default_database"] = db _save_config(config_file, config) else: - default_db = config["instances"][instance_key].get("default_database") + 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 {instance_key}") + click.echo(f"No default database set for {alias_name}") # -- auth command group -- @@ -1450,72 +1065,10 @@ def auth_status(instance, token): # -- 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): +def login(alias_or_url, scope): """ Authenticate with a Datasette instance using OAuth @@ -1528,13 +1081,7 @@ def login(alias_or_url, scope, read_all, write_all, read, write, token_only): 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" @@ -1573,7 +1120,7 @@ def login(alias_or_url, scope, read_all, write_all, read, write, token_only): interval = device_data.get("interval", 5) # Step 2: Show instructions - click.echo("\nOpen this URL in your browser:\n") + 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) @@ -1607,67 +1154,15 @@ def login(alias_or_url, scope, read_all, write_all, read, write, token_only): click.echo() raise click.ClickException(f"Unexpected error: {error}") - # Step 4: Save token (or print it) + # Step 4: Save token 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 -- diff --git a/docs/aliases.md b/docs/aliases.md index 4c4255d..8399085 100644 --- a/docs/aliases.md +++ b/docs/aliases.md @@ -13,7 +13,28 @@ Once registered, you can pass an alias to commands using the `-i` flag: dclient query fixtures "select * from news limit 1" -i latest -See [Defaults](defaults.md) for default instance and default database settings. +## Default instance + +Set a default instance so you don't need `-i` every time: + + dclient alias default latest + +Now commands will use `latest` automatically: + + dclient databases + dclient tables -d fixtures + +## Default database + +Set a default database for an alias: + + dclient alias default-db latest fixtures + +Now you can run bare SQL queries directly: + + dclient "select * from facetable limit 5" + +This uses the default instance and default database. ## dclient alias --help @@ -114,3 +137,59 @@ Options: ``` + +## dclient alias default --help + + +``` +Usage: dclient alias default [OPTIONS] [NAME] + + Set or show the default instance + + Example usage: + + dclient alias default prod + dclient alias default + dclient alias default --clear + +Options: + --clear Clear default instance + --help Show this message and exit. + +``` + + +## dclient alias default-db --help + + +``` +Usage: dclient alias default-db [OPTIONS] ALIAS_NAME [DB] + + Set or show the default database for an alias + + Example usage: + + dclient alias default-db prod main + dclient alias default-db prod + dclient alias default-db prod --clear + +Options: + --clear Clear default database for this alias + --help Show this message and exit. + +``` + diff --git a/docs/authentication.md b/docs/authentication.md index 40a7700..cd3961c 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -12,108 +12,6 @@ dclient query fixtures "select * from facetable" --token dstok_mytoken -i latest A more convenient way to handle this is to store tokens for your aliases. -## Logging in with OAuth - -The easiest way to authenticate is using the `dclient login` command, which uses the OAuth device flow to obtain and store a token. - -This requires the Datasette instance to be running the [datasette-oauth](https://github.com/datasette/datasette-oauth) plugin with [device flow enabled](https://github.com/datasette/datasette-oauth?tab=readme-ov-file#plugin-configuration). - -```bash -dclient login https://my-datasette.example.com/ -dclient login myalias -dclient login -``` - -This will display a URL and a code. Open the URL in your browser, enter the code to approve access, and the resulting API token will be saved automatically. If you run `dclient login` without an argument, you will be prompted for the instance URL or alias. - -### Requesting scoped tokens - -By default, `dclient login` requests an unrestricted token. You can request a token with limited permissions using the shorthand options: - -```bash -# Instance-wide read or write access -dclient login --read-all -dclient login --write-all - -# Database-level access -dclient login --read db1 -dclient login --write db3 - -# Table-level access -dclient login --read db1/dogs -dclient login --write db3/submissions - -# Mixed -dclient login --read db1 --write db3/dogs -``` - -`--read-all` grants: `view-instance`, `view-table`, `view-database`, `view-query`, `execute-sql`. - -`--write-all` grants all read scopes plus: `insert-row`, `delete-row`, `update-row`, `create-table`, `alter-table`, `drop-table`. - -`--read` and `--write` accept a database name or `database/table` and can be specified multiple times. `--write` implies read access for the same target. - -For advanced use, you can also pass raw scope JSON with `--scope`: - -```bash -dclient login myalias --scope '[["view-instance"]]' -``` - -All scope options can be combined — the shorthand options append to whatever `--scope` provides. - -### Outputting the token directly - -Use `--token-only` to print the token to stdout instead of saving it. This is useful for creating debug tokens or piping them into other tools: - -```bash -dclient login --token-only --read mydb -dclient login --token-only --write-all -``` - -## dclient login --help - - -``` -Usage: dclient login [OPTIONS] [ALIAS_OR_URL] - - 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: - - 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 - -Options: - --scope TEXT JSON scope array - --read-all Request instance-wide read access - --write-all Request instance-wide write access - --read TEXT Request read access for a database or database/table - --write TEXT Request write access for a database or database/table - --token-only Output the token to stdout instead of saving it - --help Show this message and exit. - -``` - - ## Using stored tokens To store a token for an alias: diff --git a/docs/defaults.md b/docs/defaults.md deleted file mode 100644 index b53c334..0000000 --- a/docs/defaults.md +++ /dev/null @@ -1,106 +0,0 @@ -# Defaults - -Set a default instance so you don't need `-i` every time: - - dclient default instance latest - -Now commands will use `latest` automatically: - - dclient databases - dclient tables -d fixtures - -Set a default database for an alias or instance URL: - - dclient default database latest fixtures - dclient default database https://latest.datasette.io fixtures - -Now you can run bare SQL queries directly: - - dclient "select * from facetable limit 5" - -This uses the default instance and default database. - -## dclient default --help - -``` -Usage: dclient default [OPTIONS] COMMAND [ARGS]... - - Manage default instance and database - -Options: - --help Show this message and exit. - -Commands: - database Set or show the default database for an instance - instance Set or show the default instance - -``` - - -## dclient default instance --help - - -``` -Usage: dclient default instance [OPTIONS] [ALIAS_OR_URL] - - Set or show the default instance - - Example usage: - - dclient default instance prod - dclient default instance https://myapp.datasette.cloud - dclient default instance - dclient default instance --clear - -Options: - --clear Clear default instance - --help Show this message and exit. - -``` - - -## dclient default database --help - - -``` -Usage: dclient default database [OPTIONS] ALIAS_OR_URL [DB] - - Set or show the default database for an instance - - Example usage: - - dclient default database prod main - dclient default database https://myapp.datasette.cloud main - dclient default database prod - dclient default database prod --clear - -Options: - --clear Clear default database for this instance - --help Show this message and exit. - -``` - diff --git a/docs/index.md b/docs/index.md index 0b43234..dda58da 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,7 +35,6 @@ maxdepth: 3 --- queries aliases -defaults authentication inserting environment diff --git a/docs/inserting.md b/docs/inserting.md index e3a3039..17d81a7 100644 --- a/docs/inserting.md +++ b/docs/inserting.md @@ -1,43 +1,5 @@ # Inserting data -## Creating tables - -The `dclient create-table` command creates a new empty table with an explicit schema. Define columns with `--column name type` and optionally set primary keys with `--pk`: - -```bash -dclient create-table mydb dogs \ - --column id integer \ - --column name text \ - --column age integer \ - --pk id \ - -i myapp -``` - -This hits the Datasette [create API](https://docs.datasette.io/en/latest/json_api.html#the-json-write-api) with a `columns` array. The response includes the generated schema: - -```json -{ - "ok": true, - "database": "mydb", - "table": "dogs", - "schema": "CREATE TABLE [dogs] (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT,\n [age] INTEGER\n)" -} -``` - -Compound primary keys are supported by passing `--pk` multiple times: - -```bash -dclient create-table mydb events \ - --column user_id integer \ - --column event_id integer \ - --column data text \ - --pk user_id --pk event_id -``` - -If you want to create a table and populate it with data in one step, use `dclient insert --create` instead. - -## Inserting rows - The `dclient insert` command can be used to insert data from a local file directly into a Datasette instance, via the [Write API](https://docs.datasette.io/en/latest/json_api.html#the-json-write-api) introduced in the Datasette 1.0 alphas. First you'll need to {ref}`authenticate ` with the instance. @@ -170,7 +132,7 @@ Options: table --batch-size INTEGER Send rows in batches of this size --interval FLOAT Send batch at least every X seconds - --token TEXT API token + -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 @@ -212,41 +174,10 @@ Options: table --batch-size INTEGER Send rows in batches of this size --interval FLOAT Send batch at least every X seconds - --token TEXT API token + -t, --token TEXT API token --silent Don't output progress -v, --verbose Verbose output: show HTTP request and response --help Show this message and exit. ``` - -## dclient create-table --help - -``` -Usage: dclient create-table [OPTIONS] DATABASE TABLE_NAME - - Create a new empty table with an explicit schema - - Example usage: - - dclient create-table mydb dogs \ - --column id integer --column name text --pk id - -Options: - -c, --column TEXT... Column definition: name type (e.g. --column id integer - --column name text) - --pk TEXT Column(s) to use as primary key - -i, --instance TEXT Datasette instance URL or alias - --token TEXT API token - -v, --verbose Verbose output: show HTTP request and response - --help Show this message and exit. - -``` - diff --git a/docs/queries.md b/docs/queries.md index e9de25b..900f0ca 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -36,166 +36,6 @@ You can override just the database with `-d`: dclient "select * from counters" -d counters ``` -## Browsing rows - -The `dclient rows` command lets you browse table data without writing SQL: - -```bash -dclient rows fixtures facet_cities -i https://latest.datasette.io -t -``` -``` -id name --- ------------- -3 Detroit -2 Los Angeles -4 Memnonia -1 San Francisco -``` - -If you have a default instance and database configured, you can just pass the table name: - -```bash -dclient rows facet_cities -t -``` - -### Filtering - -Use `-f` / `--filter` with three arguments: column, operation, value: - -```bash -dclient rows facet_cities -f id gte 3 -t -dclient rows facet_cities -f name eq Detroit -dclient rows facet_cities -f name contains M -f id gt 2 -``` - -The operation is passed directly to Datasette as a column filter suffix. Built-in Datasette operations include `exact`, `not`, `gt`, `gte`, `lt`, `lte`, `contains`, `like`, `startswith`, `endswith`, `glob`, `isnull`, `notnull`, and more. `eq` is a convenience alias for `exact`. Operations added by Datasette plugins will work too. - -### Sorting - -```bash -dclient rows dogs --sort age -dclient rows dogs --sort-desc age -``` - -### Column selection - -```bash -dclient rows dogs --col name --col age -dclient rows dogs --nocol id -``` - -### Search - -Full-text search (requires an FTS index on the table): - -```bash -dclient rows dogs --search "retriever" -``` - -### Pagination - -By default only one page of results is returned. Use `--all` to auto-paginate through all rows, and `--limit` to cap the total: - -```bash -dclient rows dogs --all -dclient rows dogs --all --limit 500 -dclient rows dogs --size 50 -``` - -### dclient rows --help - -``` -Usage: dclient rows [OPTIONS] DB_OR_TABLE [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: - - 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 - -Options: - -i, --instance TEXT Datasette instance URL or alias - -d, --database TEXT Database name - --token TEXT API token - -f, --filter TEXT... Filter: column operation value (e.g. -f age gte 3) - --search TEXT Full-text search query - --sort TEXT Sort by column (ascending) - --sort-desc TEXT Sort by column (descending) - --col TEXT Include only these columns - --nocol TEXT Exclude these columns - --size INTEGER Number of rows per page - --limit INTEGER Maximum total rows to return - --all Fetch all pages - -v, --verbose Verbose output: show HTTP request - --csv Output as CSV - --tsv Output as TSV - --nl Output as newline-delimited JSON - -t, --table Output as ASCII table - --help Show this message and exit. - -``` - - -## Output formats - -By default, results are returned as JSON. Use these flags to change the output format: - -- `--csv` — CSV -- `--tsv` — TSV -- `-t` / `--table` — ASCII table -- `--nl` — newline-delimited JSON (one JSON object per line) - -These flags work with `dclient query`, `dclient rows`, and the bare SQL shortcut. - -CSV output: - -```bash -dclient query fixtures "select * from facetable limit 2" -i latest --csv -``` -``` -pk,created,planet_int,on_earth,state,_city_id,_neighborhood -1,2019-01-14 08:00:00,1,1,CA,1,Mission -2,2019-01-15 08:00:00,1,1,CA,1,Dogpatch -``` - -ASCII table output: - -```bash -dclient query fixtures "select pk, state, _neighborhood from facetable limit 3" -i latest -t -``` -``` -pk state _neighborhood --- ----- ------------- -1 CA Mission -2 CA Dogpatch -3 CA SOMA -``` - -Newline-delimited JSON, useful for piping into `jq` or other line-oriented tools: - -```bash -dclient query fixtures "select pk, state from facetable limit 2" -i latest --nl -``` -``` -{"pk": 1, "state": "CA"} -{"pk": 2, "state": "CA"} -``` - ## dclient query --help