Compare commits

...

13 commits

Author SHA1 Message Date
Simon Willison
f6cbf95b01 Upgrade to actions/checkout@v7 2026-06-23 16:51:17 -07:00
Simon Willison
80786dadaf
Release 0.5a3
Pin to httpx<1.0
2026-03-07 07:15:10 -08:00
Simon Willison
ed589d34cf
Release 0.5a2
Refs #30, #31, #32, #33, #34
2026-02-26 21:55:56 -08:00
Simon Willison
54df24db5e --read/--write options plus --token-only, closes #34 2026-02-26 21:45:58 -08:00
Simon Willison
e8d80a6495 Logging in with OAuth docs 2026-02-26 21:25:56 -08:00
Simon Willison
0b590d1a6f dclient rows command, closes #33 2026-02-26 21:19:32 -08:00
Simon Willison
1340f68255 Ran cog 2026-02-26 21:04:25 -08:00
Simon Willison
021c595de4 dclient create-table, closes #32 2026-02-26 21:03:17 -08:00
Simon Willison
f2aea1b108 --csv, --tsv, --nl, -t/--table output formats, closes #31 2026-02-26 20:56:22 -08:00
Simon Willison
2f7f2a7378 Moved default commands to dclient default ..., closes #30 2026-02-26 20:44:39 -08:00
Simon Willison
7a8643f72f Justfile now uses uv, docs gets a dependency group 2026-02-26 20:43:18 -08:00
Simon Willison
04e396b020 Release 0.5a1 2026-02-26 15:25:22 -08:00
Simon Willison
9df1828d1d Set defaults on login, if not already set 2026-02-26 15:25:06 -08:00
19 changed files with 2298 additions and 167 deletions

View file

@ -14,7 +14,7 @@ jobs:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- 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@v6
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
with:

View file

@ -12,7 +12,7 @@ jobs:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:

View file

@ -1,37 +1,29 @@
# 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:
pipenv run pytest {{options}}
uv run pytest {{options}}
# Run linters
@lint:
echo "Linters..."
echo " Black"
pipenv run black . --check
echo " cog"
pipenv run cog --check README.md docs/*.md
echo " ruff"
pipenv run ruff .
uv run cog --check README.md docs/*.md
echo " ruff check"
uv run ruff check .
echo " ruff format"
uv run ruff format . --check
# Rebuild docs with cog
@cog:
pipenv run cog -r docs/*.md
uv run cog -r docs/*.md
# Serve live docs on localhost:8000
@docs: cog
cd docs && pipenv run make livehtml
cd docs && uv run make livehtml
# Apply Black
@black:
pipenv run black .
# Run automatic fixes
# Apply ruff fixes and formatting
@fix: cog
pipenv run ruff . --fix
pipenv run black .
uv run ruff check . --fix
uv run ruff format .

View file

@ -11,7 +11,8 @@ Much of the functionality requires Datasette 1.0a2 or higher.
## Things you can do with dclient
- Run SQL queries against Datasette and return the results as JSON
- 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
- Introspect databases, tables, plugins, and schema
- Run queries against authenticated Datasette instances
- Create aliases and set default instances/databases for convenient access
@ -32,8 +33,8 @@ datasette install dclient
Add an alias for a Datasette instance:
```bash
dclient alias add latest https://latest.datasette.io
dclient alias default latest
dclient alias default-db latest fixtures
dclient default instance latest
dclient default database latest fixtures
```
Now run queries directly:
```bash
@ -43,6 +44,14 @@ 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

View file

@ -1,6 +1,8 @@
import click
from click_default_group import DefaultGroup
import csv
import httpx
import io
import json
import os
import pathlib
@ -53,8 +55,11 @@ def _resolve_instance(instance, config_file):
)
# Try config default
default = config.get("default_instance")
if default and default in config.get("instances", {}):
return config["instances"][default]["url"]
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:
@ -62,7 +67,7 @@ def _resolve_instance(instance, config_file):
raise click.ClickException(
"No instance specified. Use -i <url-or-alias>, or configure a default:\n\n"
" dclient alias add <name> <url>\n"
" dclient alias default <name>\n\n"
" dclient default instance <name-or-url>\n\n"
"Or set the DATASETTE_URL environment variable."
)
@ -75,8 +80,13 @@ def _resolve_database(database, instance_alias, config_file):
if instance_alias:
config = _load_config(config_file)
instances = config.get("instances", {})
if instance_alias in instances:
default_db = instances[instance_alias].get("default_database")
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
@ -85,7 +95,7 @@ def _resolve_database(database, instance_alias, config_file):
return env_db
raise click.ClickException(
"No database specified. Use -d <name>, or configure a default:\n\n"
" dclient alias default-db <alias> <database>\n\n"
" dclient default database <alias-or-url> <database>\n\n"
"Or set the DATASETTE_DATABASE environment variable."
)
@ -115,6 +125,83 @@ 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():
@ -272,13 +359,199 @@ 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")
def query(database, sql, instance, token, verbose):
@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
@ -338,7 +611,10 @@ def query(database, sql, instance, token, verbose):
bits = [json.dumps(data)]
raise click.ClickException(": ".join(bits))
click.echo(json.dumps(response.json()["rows"], indent=2))
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(
@ -491,7 +767,7 @@ _insert_options = [
click.option(
"--interval", type=float, default=10, help="Send batch at least every X seconds"
),
click.option("--token", "-t", help="API token"),
click.option("--token", help="API token"),
click.option("--silent", is_flag=True, help="Don't output progress"),
click.option(
"-v",
@ -628,6 +904,82 @@ 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")
@ -736,7 +1088,10 @@ 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")
def default_query(sql, instance, database, token, verbose):
@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")
@ -799,7 +1154,10 @@ def default_query(sql, instance, database, token, verbose):
bits = [json.dumps(data)]
raise click.ClickException(": ".join(bits))
click.echo(json.dumps(response.json()["rows"], indent=2))
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()
@ -902,29 +1260,56 @@ def alias_remove(name):
raise click.ClickException("No such alias")
@alias.command(name="default")
@click.argument("name", required=False, default=None)
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 alias_default(name, clear):
def default_instance(alias_or_url, clear):
"""
Set or show the default instance
Example usage:
\b
dclient alias default prod
dclient alias default
dclient alias default --clear
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 name:
if name not in config.get("instances", {}):
raise click.ClickException(f"No such alias: {name}")
config["default_instance"] = name
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")
@ -934,37 +1319,37 @@ def alias_default(name, clear):
click.echo("No default instance set")
@alias.command(name="default-db")
@click.argument("alias_name")
@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 alias")
def alias_default_db(alias_name, db, clear):
@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 alias
Set or show the default database for an instance
Example usage:
\b
dclient alias default-db prod main
dclient alias default-db prod
dclient alias default-db prod --clear
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)
if alias_name not in config.get("instances", {}):
raise click.ClickException(f"No such alias: {alias_name}")
instance_key = _resolve_instance_key(alias_or_url, config)
if clear:
config["instances"][alias_name]["default_database"] = None
config["instances"][instance_key]["default_database"] = None
_save_config(config_file, config)
elif db:
config["instances"][alias_name]["default_database"] = db
config["instances"][instance_key]["default_database"] = db
_save_config(config_file, config)
else:
default_db = config["instances"][alias_name].get("default_database")
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 {alias_name}")
click.echo(f"No default database set for {instance_key}")
# -- auth command group --
@ -1065,10 +1450,72 @@ 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")
def login(alias_or_url, scope):
@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
@ -1081,7 +1528,13 @@ def login(alias_or_url, scope):
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"
@ -1120,7 +1573,7 @@ def login(alias_or_url, scope):
interval = device_data.get("interval", 5)
# Step 2: Show instructions
click.echo(f"\nOpen this URL in your browser:\n")
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)
@ -1154,15 +1607,67 @@ def login(alias_or_url, scope):
click.echo()
raise click.ClickException(f"Unexpected error: {error}")
# Step 4: Save token
# 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 --

View file

@ -13,28 +13,7 @@ Once registered, you can pass an alias to commands using the `-i` flag:
dclient query fixtures "select * from news limit 1" -i latest
## Default instance
Set a default instance so you don't need `-i` every time:
dclient alias default latest
Now commands will use `latest` automatically:
dclient databases
dclient tables -d fixtures
## Default database
Set a default database for an alias:
dclient alias default-db latest fixtures
Now you can run bare SQL queries directly:
dclient "select * from facetable limit 5"
This uses the default instance and default database.
See [Defaults](defaults.md) for default instance and default database settings.
## dclient alias --help
<!-- [[[cog
@ -57,11 +36,9 @@ Options:
--help Show this message and exit.
Commands:
add Add an alias for a Datasette instance
default Set or show the default instance
default-db Set or show the default database for an alias
list List aliases
remove Remove an alias
add Add an alias for a Datasette instance
list List aliases
remove Remove an alias
```
<!-- [[[end]]] -->
@ -137,59 +114,3 @@ Options:
```
<!-- [[[end]]] -->
## dclient alias default --help
<!-- [[[cog
import cog
result = runner.invoke(cli.cli, ["alias", "default", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(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.
```
<!-- [[[end]]] -->
## dclient alias default-db --help
<!-- [[[cog
import cog
result = runner.invoke(cli.cli, ["alias", "default-db", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(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.
```
<!-- [[[end]]] -->

View file

@ -12,6 +12,108 @@ 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
<!-- [[[cog
import cog
from dclient import cli
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(cli.cli, ["login", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(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.
```
<!-- [[[end]]] -->
## Using stored tokens
To store a token for an alias:

106
docs/defaults.md Normal file
View file

@ -0,0 +1,106 @@
# 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
<!-- [[[cog
import cog
from dclient import cli
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(cli.cli, ["default", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(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
```
<!-- [[[end]]] -->
## dclient default instance --help
<!-- [[[cog
import cog
result = runner.invoke(cli.cli, ["default", "instance", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(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.
```
<!-- [[[end]]] -->
## dclient default database --help
<!-- [[[cog
import cog
result = runner.invoke(cli.cli, ["default", "database", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(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.
```
<!-- [[[end]]] -->

View file

@ -35,6 +35,7 @@ maxdepth: 3
---
queries
aliases
defaults
authentication
inserting
environment

View file

@ -1,5 +1,43 @@
# 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 <authentication>` with the instance.
@ -132,7 +170,7 @@ Options:
table
--batch-size INTEGER Send rows in batches of this size
--interval FLOAT Send batch at least every X seconds
-t, --token TEXT API token
--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
@ -174,10 +212,41 @@ Options:
table
--batch-size INTEGER Send rows in batches of this size
--interval FLOAT Send batch at least every X seconds
-t, --token TEXT API token
--token TEXT API token
--silent Don't output progress
-v, --verbose Verbose output: show HTTP request and response
--help Show this message and exit.
```
<!-- [[[end]]] -->
## dclient create-table --help
<!-- [[[cog
import cog
result = runner.invoke(cli.cli, ["create-table", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(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.
```
<!-- [[[end]]] -->

View file

@ -36,6 +36,166 @@ 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
<!-- [[[cog
import cog
from dclient import cli
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(cli.cli, ["rows", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(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.
```
<!-- [[[end]]] -->
## 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
<!-- [[[cog
import cog
@ -64,6 +224,10 @@ Options:
-i, --instance TEXT Datasette instance URL or alias
--token TEXT API token
-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.
```

View file

@ -1,6 +1,6 @@
[project]
name = "dclient"
version = "0.5a0"
version = "0.5a3"
description = "A client CLI utility for Datasette instances"
readme = "README.md"
authors = [{name = "Simon Willison"}]
@ -9,7 +9,7 @@ requires-python = ">=3.10"
dependencies = [
"click",
"click-default-group",
"httpx",
"httpx<1.0",
"sqlite-utils",
]
@ -26,7 +26,7 @@ dclient = "dclient.cli:cli"
client = "dclient.plugin"
[dependency-groups]
dev = [
test = [
"pytest",
"pytest-asyncio",
"pytest-httpx",
@ -34,6 +34,17 @@ dev = [
"pytest-mock",
"datasette>=1.0a25",
]
docs = [
"furo",
"sphinx-autobuild",
"sphinx-copybutton",
"myst-parser",
"cogapp",
]
dev = [
{include-group = "test"},
{include-group = "docs"},
]
[tool.uv.build-backend]
module-root = ""

View file

@ -1,4 +1,4 @@
"""Tests for v2 alias command: default, default-db subcommands."""
"""Tests for v2 defaults command: instance and database subcommands."""
from click.testing import CliRunner
from dclient.cli import cli
@ -15,16 +15,16 @@ def test_alias_default_workflow(mocker, tmpdir):
assert result.exit_code == 0
# No default yet
result = runner.invoke(cli, ["alias", "default"])
result = runner.invoke(cli, ["default", "instance"])
assert result.exit_code == 0
assert "No default instance set" in result.output
# Set default
result = runner.invoke(cli, ["alias", "default", "prod"])
result = runner.invoke(cli, ["default", "instance", "prod"])
assert result.exit_code == 0
# Show default
result = runner.invoke(cli, ["alias", "default"])
result = runner.invoke(cli, ["default", "instance"])
assert result.exit_code == 0
assert result.output.strip() == "prod"
@ -34,17 +34,17 @@ def test_alias_default_workflow(mocker, tmpdir):
assert "* prod" in result.output
# Clear default
result = runner.invoke(cli, ["alias", "default", "--clear"])
result = runner.invoke(cli, ["default", "instance", "--clear"])
assert result.exit_code == 0
result = runner.invoke(cli, ["alias", "default"])
result = runner.invoke(cli, ["default", "instance"])
assert "No default instance set" in result.output
def test_alias_default_unknown_alias(mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
runner = CliRunner()
result = runner.invoke(cli, ["alias", "default", "nonexistent"])
result = runner.invoke(cli, ["default", "instance", "nonexistent"])
assert result.exit_code == 1
assert "No such alias" in result.output
@ -58,16 +58,16 @@ def test_alias_default_db_workflow(mocker, tmpdir):
assert result.exit_code == 0
# No default database yet
result = runner.invoke(cli, ["alias", "default-db", "prod"])
result = runner.invoke(cli, ["default", "database", "prod"])
assert result.exit_code == 0
assert "No default database set" in result.output
# Set default database
result = runner.invoke(cli, ["alias", "default-db", "prod", "main"])
result = runner.invoke(cli, ["default", "database", "prod", "main"])
assert result.exit_code == 0
# Show default database
result = runner.invoke(cli, ["alias", "default-db", "prod"])
result = runner.invoke(cli, ["default", "database", "prod"])
assert result.exit_code == 0
assert result.output.strip() == "main"
@ -76,17 +76,17 @@ def test_alias_default_db_workflow(mocker, tmpdir):
assert "(db: main)" in result.output
# Clear default database
result = runner.invoke(cli, ["alias", "default-db", "prod", "--clear"])
result = runner.invoke(cli, ["default", "database", "prod", "--clear"])
assert result.exit_code == 0
result = runner.invoke(cli, ["alias", "default-db", "prod"])
result = runner.invoke(cli, ["default", "database", "prod"])
assert "No default database set" in result.output
def test_alias_default_db_unknown_alias(mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
runner = CliRunner()
result = runner.invoke(cli, ["alias", "default-db", "nonexistent", "main"])
result = runner.invoke(cli, ["default", "database", "nonexistent", "main"])
assert result.exit_code == 1
assert "No such alias" in result.output
@ -97,7 +97,7 @@ def test_alias_remove_clears_default(mocker, tmpdir):
runner = CliRunner()
runner.invoke(cli, ["alias", "add", "prod", "https://prod.example.com"])
runner.invoke(cli, ["alias", "default", "prod"])
runner.invoke(cli, ["default", "instance", "prod"])
# Verify it's set
config = json.loads((pathlib.Path(tmpdir) / "config.json").read_text())
@ -109,3 +109,31 @@ def test_alias_remove_clears_default(mocker, tmpdir):
config = json.loads((pathlib.Path(tmpdir) / "config.json").read_text())
assert config["default_instance"] is None
assert "prod" not in config["instances"]
def test_default_instance_accepts_url(mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
runner = CliRunner()
runner.invoke(cli, ["alias", "add", "prod", "https://prod.example.com"])
result = runner.invoke(cli, ["default", "instance", "https://prod.example.com"])
assert result.exit_code == 0
result = runner.invoke(cli, ["default", "instance"])
assert result.exit_code == 0
assert result.output.strip() == "prod"
def test_default_database_accepts_url(mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
runner = CliRunner()
runner.invoke(cli, ["alias", "add", "prod", "https://prod.example.com"])
result = runner.invoke(
cli, ["default", "database", "https://prod.example.com", "main"]
)
assert result.exit_code == 0
result = runner.invoke(cli, ["default", "database", "prod"])
assert result.exit_code == 0
assert result.output.strip() == "main"

View file

@ -64,6 +64,7 @@ def test_login_with_url(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/"])
assert result.exit_code == 0
@ -80,6 +81,7 @@ def test_login_adds_trailing_slash(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com"])
assert result.exit_code == 0
@ -108,6 +110,7 @@ def test_login_with_alias(httpx_mock, mocker, tmpdir):
)
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "prod"])
assert result.exit_code == 0
@ -123,6 +126,7 @@ def test_login_interactive_prompt(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login"], input="https://example.com/\n")
assert result.exit_code == 0
@ -160,6 +164,7 @@ def test_login_pending_then_success(httpx_mock, mocker, tmpdir):
httpx_mock.add_response(json={"error": "authorization_pending"}, status_code=400)
# Second poll: success
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/"])
assert result.exit_code == 0
@ -176,3 +181,343 @@ def test_login_device_endpoint_error(httpx_mock, mocker, tmpdir):
result = runner.invoke(cli, ["login", "https://example.com/"])
assert result.exit_code == 1
assert "Failed to start login flow" in result.output
# -- login sets defaults --
@pytest.mark.parametrize(
"databases_response,expected_db",
[
([{"name": "mydata", "is_mutable": True}], "mydata"),
(
[
{"name": "fixtures", "is_mutable": False},
{"name": "data", "is_mutable": True},
{"name": "extra", "is_mutable": True},
],
"data",
),
(
[
{"name": "alpha", "is_mutable": True},
{"name": "beta", "is_mutable": True},
{"name": "gamma", "is_mutable": False},
],
"alpha",
),
],
ids=["single_db", "prefers_data", "first_when_no_data"],
)
def test_login_sets_defaults(
httpx_mock, mocker, tmpdir, databases_response, expected_db
):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=databases_response, status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/"])
assert result.exit_code == 0, result.output
config = json.loads((pathlib.Path(tmpdir) / "config.json").read_text())
assert config["default_instance"] == "https://example.com/"
assert (
config["instances"]["https://example.com/"]["default_database"] == expected_db
)
def test_login_does_not_override_existing_defaults(httpx_mock, mocker, tmpdir):
"""When defaults are already configured, login should not change them."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
config_file = pathlib.Path(tmpdir) / "config.json"
config_file.write_text(
json.dumps(
{
"default_instance": "prod",
"instances": {
"prod": {
"url": "https://prod.example.com",
"default_database": "main",
}
},
}
)
)
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://other.example.com/"])
assert result.exit_code == 0, result.output
config = json.loads(config_file.read_text())
assert config["default_instance"] == "prod"
def test_login_with_alias_sets_defaults(httpx_mock, mocker, tmpdir):
"""When logging in with an alias that has no default_database, set it."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
config_file = pathlib.Path(tmpdir) / "config.json"
config_file.write_text(
json.dumps(
{
"default_instance": None,
"instances": {
"prod": {
"url": "https://prod.example.com",
"default_database": None,
}
},
}
)
)
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(
json=[
{"name": "fixtures", "is_mutable": False},
{"name": "data", "is_mutable": True},
{"name": "extra", "is_mutable": True},
],
status_code=200,
)
runner = CliRunner()
result = runner.invoke(cli, ["login", "prod"])
assert result.exit_code == 0, result.output
config = json.loads(config_file.read_text())
assert config["default_instance"] == "prod"
assert config["instances"]["prod"]["default_database"] == "data"
def test_login_read_all(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/", "--read-all"])
assert result.exit_code == 0, result.output
from urllib.parse import parse_qs
request = httpx_mock.get_requests()[0]
body = parse_qs(request.content.decode())
scope = json.loads(body["scope"][0])
assert scope == [
["view-instance"],
["view-table"],
["view-database"],
["view-query"],
["execute-sql"],
]
def test_login_write_all(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/", "--write-all"])
assert result.exit_code == 0, result.output
from urllib.parse import parse_qs
request = httpx_mock.get_requests()[0]
body = parse_qs(request.content.decode())
scope = json.loads(body["scope"][0])
assert scope == [
["view-instance"],
["view-table"],
["view-database"],
["view-query"],
["execute-sql"],
["insert-row"],
["delete-row"],
["update-row"],
["create-table"],
["alter-table"],
["drop-table"],
]
def test_login_read_database(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/", "--read", "db1"])
assert result.exit_code == 0, result.output
from urllib.parse import parse_qs
request = httpx_mock.get_requests()[0]
body = parse_qs(request.content.decode())
scope = json.loads(body["scope"][0])
assert scope == [
["view-instance", "db1"],
["view-table", "db1"],
["view-database", "db1"],
["view-query", "db1"],
["execute-sql", "db1"],
]
def test_login_write_table(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(
cli, ["login", "https://example.com/", "--write", "db3/submissions"]
)
assert result.exit_code == 0, result.output
from urllib.parse import parse_qs
request = httpx_mock.get_requests()[0]
body = parse_qs(request.content.decode())
scope = json.loads(body["scope"][0])
assert scope == [
["view-instance", "db3", "submissions"],
["view-table", "db3", "submissions"],
["view-database", "db3", "submissions"],
["view-query", "db3", "submissions"],
["execute-sql", "db3", "submissions"],
["insert-row", "db3", "submissions"],
["delete-row", "db3", "submissions"],
["update-row", "db3", "submissions"],
["create-table", "db3", "submissions"],
["alter-table", "db3", "submissions"],
["drop-table", "db3", "submissions"],
]
def test_login_mixed_read_write(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(
cli,
["login", "https://example.com/", "--read", "db1", "--write", "db3/dogs"],
)
assert result.exit_code == 0, result.output
from urllib.parse import parse_qs
request = httpx_mock.get_requests()[0]
body = parse_qs(request.content.decode())
scope = json.loads(body["scope"][0])
assert scope == [
["view-instance", "db1"],
["view-table", "db1"],
["view-database", "db1"],
["view-query", "db1"],
["execute-sql", "db1"],
["view-instance", "db3", "dogs"],
["view-table", "db3", "dogs"],
["view-database", "db3", "dogs"],
["view-query", "db3", "dogs"],
["execute-sql", "db3", "dogs"],
["insert-row", "db3", "dogs"],
["delete-row", "db3", "dogs"],
["update-row", "db3", "dogs"],
["create-table", "db3", "dogs"],
["alter-table", "db3", "dogs"],
["drop-table", "db3", "dogs"],
]
def test_login_scope_combined_with_shortcuts(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(
cli,
[
"login",
"https://example.com/",
"--scope",
'[["view-instance"]]',
"--write",
"db1/dogs",
],
)
assert result.exit_code == 0, result.output
from urllib.parse import parse_qs
request = httpx_mock.get_requests()[0]
body = parse_qs(request.content.decode())
scope = json.loads(body["scope"][0])
assert scope == [
["view-instance"],
["view-instance", "db1", "dogs"],
["view-table", "db1", "dogs"],
["view-database", "db1", "dogs"],
["view-query", "db1", "dogs"],
["execute-sql", "db1", "dogs"],
["insert-row", "db1", "dogs"],
["delete-row", "db1", "dogs"],
["update-row", "db1", "dogs"],
["create-table", "db1", "dogs"],
["alter-table", "db1", "dogs"],
["drop-table", "db1", "dogs"],
]
def test_login_no_scope_sends_no_scope(httpx_mock, mocker, tmpdir):
"""Without any scope options, no scope field should be sent."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/"])
assert result.exit_code == 0, result.output
request = httpx_mock.get_requests()[0]
assert request.content == b""
def test_login_token_only(httpx_mock, mocker, tmpdir):
"""--token-only prints the token to stdout and does not save it."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
runner = CliRunner()
result = runner.invoke(
cli, ["login", "https://example.com/", "--token-only", "--read", "foo/bar"]
)
assert result.exit_code == 0, result.output
# Last line of output should be the raw token
assert result.output.strip().endswith("dstok_abc123")
# Should NOT have "Login successful" message
assert "Login successful" not in result.output
# auth.json should not exist
auth_file = pathlib.Path(tmpdir) / "auth.json"
assert not auth_file.exists()
# config.json should not exist (no defaults set)
config_file = pathlib.Path(tmpdir) / "config.json"
assert not config_file.exists()
def test_login_databases_error_still_succeeds(httpx_mock, mocker, tmpdir):
"""If the databases check fails, login should still succeed."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(status_code=500)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/"])
assert result.exit_code == 0, result.output
assert "Login successful" in result.output

View file

@ -4,7 +4,6 @@ from click.testing import CliRunner
from dclient.cli import cli
import json
import pathlib
import pytest
# -- databases command --

View file

@ -6,7 +6,6 @@ from dclient.cli import (
_resolve_instance,
_resolve_database,
_resolve_token,
get_config_dir,
)
import json
import pathlib

203
tests/test_create_table.py Normal file
View file

@ -0,0 +1,203 @@
"""Tests for the create-table command."""
from click.testing import CliRunner
from dclient.cli import cli
import json
import pathlib
def test_create_table_basic(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(
json={
"ok": True,
"database": "mydb",
"table": "dogs",
"table_url": "http://example.com/mydb/dogs",
"table_api_url": "http://example.com/mydb/dogs.json",
"schema": "CREATE TABLE [dogs] (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT\n)",
},
status_code=201,
)
runner = CliRunner()
result = runner.invoke(
cli,
[
"create-table",
"mydb",
"dogs",
"--column",
"id",
"integer",
"--column",
"name",
"text",
"--pk",
"id",
"-i",
"https://example.com",
"--token",
"tok",
],
)
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["ok"] is True
assert data["table"] == "dogs"
# Verify request
request = httpx_mock.get_request()
assert request.url.path == "/mydb/-/create"
assert request.headers["authorization"] == "Bearer tok"
body = json.loads(request.read())
assert body["table"] == "dogs"
assert body["columns"] == [
{"name": "id", "type": "integer"},
{"name": "name", "type": "text"},
]
assert body["pk"] == "id"
def test_create_table_compound_pk(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(json={"ok": True}, status_code=201)
runner = CliRunner()
result = runner.invoke(
cli,
[
"create-table",
"mydb",
"events",
"--column",
"user_id",
"integer",
"-c",
"event_id",
"integer",
"--column",
"data",
"text",
"--pk",
"user_id",
"--pk",
"event_id",
"-i",
"https://example.com",
"--token",
"tok",
],
)
assert result.exit_code == 0, result.output
body = json.loads(httpx_mock.get_request().read())
assert body["pks"] == ["user_id", "event_id"]
assert "pk" not in body
def test_create_table_no_pk(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(json={"ok": True}, status_code=201)
runner = CliRunner()
result = runner.invoke(
cli,
[
"create-table",
"mydb",
"logs",
"--column",
"message",
"text",
"--column",
"level",
"integer",
"-i",
"https://example.com",
"--token",
"tok",
],
)
assert result.exit_code == 0, result.output
body = json.loads(httpx_mock.get_request().read())
assert "pk" not in body
assert "pks" not in body
def test_create_table_no_columns_error(mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
runner = CliRunner()
result = runner.invoke(
cli,
[
"create-table",
"mydb",
"empty",
"-i",
"https://example.com",
"--token",
"tok",
],
)
assert result.exit_code == 1
assert "at least one --column" in result.output
def test_create_table_api_error(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(
json={"ok": False, "errors": ["Table already exists: dogs"]},
status_code=400,
)
runner = CliRunner()
result = runner.invoke(
cli,
[
"create-table",
"mydb",
"dogs",
"--column",
"id",
"integer",
"-i",
"https://example.com",
"--token",
"tok",
],
)
assert result.exit_code == 1
assert "Table already exists" in result.output
def test_create_table_uses_default_instance(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
config_file = pathlib.Path(tmpdir) / "config.json"
config_file.write_text(
json.dumps(
{
"default_instance": "prod",
"instances": {
"prod": {
"url": "https://prod.example.com",
"default_database": "main",
}
},
}
)
)
httpx_mock.add_response(json={"ok": True}, status_code=201)
runner = CliRunner()
result = runner.invoke(
cli,
[
"create-table",
"mydb",
"t1",
"--column",
"id",
"integer",
"--token",
"tok",
],
)
assert result.exit_code == 0, result.output
request = httpx_mock.get_request()
assert request.url.host == "prod.example.com"
assert request.url.path == "/mydb/-/create"

View file

@ -0,0 +1,267 @@
"""Tests for multiple output formats on the query and default_query commands."""
from click.testing import CliRunner
from dclient.cli import cli
import json
import pathlib
QUERY_RESPONSE = {
"ok": True,
"database": "fixtures",
"query_name": None,
"rows": [
{"id": 1, "name": "Cleo", "age": 5},
{"id": 2, "name": "Pancakes", "age": 3},
],
"truncated": False,
"columns": ["id", "name", "age"],
"query": {"sql": "select * from dogs", "params": {}},
"error": None,
"private": False,
"allow_execute_sql": True,
}
def _mock_and_invoke(httpx_mock, extra_args=None):
httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200)
runner = CliRunner()
args = ["query", "fixtures", "select * from dogs", "-i", "https://example.com"]
if extra_args:
args.extend(extra_args)
return runner.invoke(cli, args)
# -- query --csv --
def test_query_csv(httpx_mock):
result = _mock_and_invoke(httpx_mock, ["--csv"])
assert result.exit_code == 0
lines = result.output.strip().split("\n")
assert lines[0] == "id,name,age"
assert lines[1] == "1,Cleo,5"
assert lines[2] == "2,Pancakes,3"
# -- query --tsv --
def test_query_tsv(httpx_mock):
result = _mock_and_invoke(httpx_mock, ["--tsv"])
assert result.exit_code == 0
lines = result.output.strip().split("\n")
assert lines[0] == "id\tname\tage"
assert lines[1] == "1\tCleo\t5"
assert lines[2] == "2\tPancakes\t3"
# -- query --nl --
def test_query_nl(httpx_mock):
result = _mock_and_invoke(httpx_mock, ["--nl"])
assert result.exit_code == 0
lines = result.output.strip().split("\n")
assert len(lines) == 2
assert json.loads(lines[0]) == {"id": 1, "name": "Cleo", "age": 5}
assert json.loads(lines[1]) == {"id": 2, "name": "Pancakes", "age": 3}
# -- query --table --
def test_query_table(httpx_mock):
result = _mock_and_invoke(httpx_mock, ["--table"])
assert result.exit_code == 0
lines = result.output.strip().split("\n")
# Should have a header row, a separator row, and 2 data rows
assert len(lines) == 4
# Header should contain column names
assert "id" in lines[0]
assert "name" in lines[0]
assert "age" in lines[0]
# Data rows should contain values
assert "Cleo" in lines[2]
assert "Pancakes" in lines[3]
# -- query -t shortcut for --table --
def test_query_table_shortcut(httpx_mock):
result = _mock_and_invoke(httpx_mock, ["-t"])
assert result.exit_code == 0
lines = result.output.strip().split("\n")
assert len(lines) == 4
assert "Cleo" in lines[2]
# -- default JSON (no flag) stays the same --
def test_query_default_json(httpx_mock):
result = _mock_and_invoke(httpx_mock)
assert result.exit_code == 0
data = json.loads(result.output)
assert data == [
{"id": 1, "name": "Cleo", "age": 5},
{"id": 2, "name": "Pancakes", "age": 3},
]
# -- default_query also supports output formats --
def _mock_default_query(httpx_mock, mocker, tmpdir, extra_args=None):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
config_file = pathlib.Path(tmpdir) / "config.json"
config_file.write_text(
json.dumps(
{
"default_instance": "prod",
"instances": {
"prod": {
"url": "https://prod.example.com",
"default_database": "main",
}
},
}
)
)
httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200)
runner = CliRunner()
args = ["select * from dogs"]
if extra_args:
args.extend(extra_args)
return runner.invoke(cli, args)
def test_default_query_csv(httpx_mock, mocker, tmpdir):
result = _mock_default_query(httpx_mock, mocker, tmpdir, ["--csv"])
assert result.exit_code == 0
lines = result.output.strip().split("\n")
assert lines[0] == "id,name,age"
assert lines[1] == "1,Cleo,5"
def test_default_query_table(httpx_mock, mocker, tmpdir):
result = _mock_default_query(httpx_mock, mocker, tmpdir, ["--table"])
assert result.exit_code == 0
assert "Cleo" in result.output
assert "Pancakes" in result.output
lines = result.output.strip().split("\n")
assert len(lines) == 4
def test_default_query_nl(httpx_mock, mocker, tmpdir):
result = _mock_default_query(httpx_mock, mocker, tmpdir, ["--nl"])
assert result.exit_code == 0
lines = result.output.strip().split("\n")
assert json.loads(lines[0]) == {"id": 1, "name": "Cleo", "age": 5}
# -- edge cases --
def test_query_csv_with_commas_in_values(httpx_mock):
httpx_mock.add_response(
json={
"ok": True,
"rows": [{"name": "Smith, John", "note": 'He said "hi"'}],
"columns": ["name", "note"],
},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(
cli,
[
"query",
"db",
"select * from t",
"-i",
"https://example.com",
"--csv",
],
)
assert result.exit_code == 0
lines = result.output.strip().split("\n")
assert lines[0] == "name,note"
# CSV should properly quote fields with commas/quotes
assert '"Smith, John"' in lines[1]
def test_query_table_empty_results(httpx_mock):
httpx_mock.add_response(
json={
"ok": True,
"rows": [],
"columns": ["id", "name"],
},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(
cli,
[
"query",
"db",
"select * from t",
"-i",
"https://example.com",
"--table",
],
)
assert result.exit_code == 0
def test_query_csv_empty_results(httpx_mock):
httpx_mock.add_response(
json={
"ok": True,
"rows": [],
"columns": ["id", "name"],
},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(
cli,
[
"query",
"db",
"select * from t",
"-i",
"https://example.com",
"--csv",
],
)
assert result.exit_code == 0
lines = result.output.strip().split("\n")
assert lines[0] == "id,name"
assert len(lines) == 1 # header only, no data rows
def test_query_nl_empty_results(httpx_mock):
httpx_mock.add_response(
json={
"ok": True,
"rows": [],
"columns": ["id", "name"],
},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(
cli,
[
"query",
"db",
"select * from t",
"-i",
"https://example.com",
"--nl",
],
)
assert result.exit_code == 0
assert result.output.strip() == ""

410
tests/test_rows.py Normal file
View file

@ -0,0 +1,410 @@
"""Tests for the rows command."""
from click.testing import CliRunner
from dclient.cli import cli
import json
import pathlib
TABLE_RESPONSE = {
"ok": True,
"rows": [
{"id": 1, "name": "Cleo", "age": 5},
{"id": 2, "name": "Pancakes", "age": 3},
{"id": 3, "name": "Fido", "age": 7},
],
"columns": ["id", "name", "age"],
"next": None,
"next_url": None,
}
def _invoke(httpx_mock, extra_args=None, response=None):
httpx_mock.add_response(json=response or TABLE_RESPONSE, status_code=200)
runner = CliRunner()
args = ["rows", "fixtures", "dogs", "-i", "https://example.com"]
if extra_args:
args.extend(extra_args)
return runner.invoke(cli, args)
# -- basic usage --
def test_rows_default_json(httpx_mock):
result = _invoke(httpx_mock)
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert len(data) == 3
assert data[0]["name"] == "Cleo"
# Verify request URL
request = httpx_mock.get_request()
assert request.url.path == "/fixtures/dogs.json"
assert "_shape" in dict(request.url.params)
assert dict(request.url.params)["_shape"] == "objects"
def test_rows_table_format(httpx_mock):
result = _invoke(httpx_mock, ["-t"])
assert result.exit_code == 0
lines = result.output.strip().split("\n")
assert len(lines) == 5 # header + separator + 3 data rows
assert "Cleo" in lines[2]
assert "Pancakes" in lines[3]
def test_rows_csv(httpx_mock):
result = _invoke(httpx_mock, ["--csv"])
assert result.exit_code == 0
lines = result.output.strip().split("\n")
assert lines[0] == "id,name,age"
assert lines[1] == "1,Cleo,5"
def test_rows_nl(httpx_mock):
result = _invoke(httpx_mock, ["--nl"])
assert result.exit_code == 0
lines = result.output.strip().split("\n")
assert json.loads(lines[0]) == {"id": 1, "name": "Cleo", "age": 5}
# -- single argument uses default database --
def test_rows_single_arg_uses_default_database(httpx_mock, mocker, tmpdir):
"""dclient rows tablename uses default database."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
config_file = pathlib.Path(tmpdir) / "config.json"
config_file.write_text(
json.dumps(
{
"default_instance": "prod",
"instances": {
"prod": {
"url": "https://prod.example.com",
"default_database": "data",
}
},
}
)
)
httpx_mock.add_response(json=TABLE_RESPONSE, status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["rows", "dogs"])
assert result.exit_code == 0, result.output
request = httpx_mock.get_request()
assert request.url.host == "prod.example.com"
assert request.url.path == "/data/dogs.json"
def test_rows_single_arg_no_default_database_errors(mocker, tmpdir):
"""dclient rows tablename without default database gives error."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
config_file = pathlib.Path(tmpdir) / "config.json"
config_file.write_text(
json.dumps(
{
"default_instance": "prod",
"instances": {
"prod": {
"url": "https://prod.example.com",
"default_database": None,
}
},
}
)
)
runner = CliRunner()
result = runner.invoke(cli, ["rows", "dogs"])
assert result.exit_code == 1
assert "No database specified" in result.output
# -- filters --
def test_rows_filter_eq(httpx_mock):
result = _invoke(httpx_mock, ["-f", "name", "eq", "Cleo"])
assert result.exit_code == 0
request = httpx_mock.get_request()
params = dict(request.url.params)
assert params["name__exact"] == "Cleo"
def test_rows_filter_gt(httpx_mock):
result = _invoke(httpx_mock, ["--filter", "age", "gt", "3"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["age__gt"] == "3"
def test_rows_filter_gte(httpx_mock):
result = _invoke(httpx_mock, ["-f", "age", "gte", "5"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["age__gte"] == "5"
def test_rows_filter_lt(httpx_mock):
result = _invoke(httpx_mock, ["-f", "age", "lt", "5"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["age__lt"] == "5"
def test_rows_filter_lte(httpx_mock):
result = _invoke(httpx_mock, ["-f", "age", "lte", "5"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["age__lte"] == "5"
def test_rows_filter_not(httpx_mock):
result = _invoke(httpx_mock, ["-f", "name", "not", "Cleo"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["name__not"] == "Cleo"
def test_rows_filter_contains(httpx_mock):
result = _invoke(httpx_mock, ["-f", "name", "contains", "leo"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["name__contains"] == "leo"
def test_rows_filter_like(httpx_mock):
result = _invoke(httpx_mock, ["-f", "name", "like", "%leo%"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["name__like"] == "%leo%"
def test_rows_filter_startswith(httpx_mock):
result = _invoke(httpx_mock, ["-f", "name", "startswith", "Cl"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["name__startswith"] == "Cl"
def test_rows_filter_endswith(httpx_mock):
result = _invoke(httpx_mock, ["-f", "name", "endswith", "eo"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["name__endswith"] == "eo"
def test_rows_filter_glob(httpx_mock):
result = _invoke(httpx_mock, ["-f", "name", "glob", "C*"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["name__glob"] == "C*"
def test_rows_filter_isnull(httpx_mock):
result = _invoke(httpx_mock, ["-f", "name", "isnull", "1"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["name__isnull"] == "1"
def test_rows_filter_notnull(httpx_mock):
result = _invoke(httpx_mock, ["-f", "name", "notnull", "1"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["name__notnull"] == "1"
def test_rows_multiple_filters(httpx_mock):
result = _invoke(httpx_mock, ["-f", "name", "eq", "Cleo", "-f", "age", "gte", "3"])
assert result.exit_code == 0
params = list(httpx_mock.get_request().url.params.multi_items())
param_dict = {k: v for k, v in params}
assert param_dict["name__exact"] == "Cleo"
assert param_dict["age__gte"] == "3"
def test_rows_custom_filter_op_passthrough(httpx_mock):
"""Unknown ops are passed through to Datasette, supporting plugin-added filters."""
result = _invoke(httpx_mock, ["-f", "name", "custom_plugin_op", "x"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["name__custom_plugin_op"] == "x"
# -- sorting --
def test_rows_sort(httpx_mock):
result = _invoke(httpx_mock, ["--sort", "age"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["_sort"] == "age"
def test_rows_sort_desc(httpx_mock):
result = _invoke(httpx_mock, ["--sort-desc", "age"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["_sort_desc"] == "age"
# -- column selection --
def test_rows_col(httpx_mock):
result = _invoke(httpx_mock, ["--col", "name", "--col", "age"])
assert result.exit_code == 0
params = list(httpx_mock.get_request().url.params.multi_items())
col_params = [v for k, v in params if k == "_col"]
assert col_params == ["name", "age"]
def test_rows_nocol(httpx_mock):
result = _invoke(httpx_mock, ["--nocol", "id"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["_nocol"] == "id"
# -- search --
def test_rows_search(httpx_mock):
result = _invoke(httpx_mock, ["--search", "pancakes"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["_search"] == "pancakes"
# -- size --
def test_rows_size(httpx_mock):
result = _invoke(httpx_mock, ["--size", "10"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["_size"] == "10"
# -- limit --
def test_rows_limit(httpx_mock):
response = {
"ok": True,
"rows": [{"id": 1}, {"id": 2}, {"id": 3}],
"columns": ["id"],
"next": None,
}
result = _invoke(httpx_mock, ["--limit", "2"], response=response)
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data) == 2
# -- pagination with --all --
def test_rows_all_pagination(httpx_mock):
page1 = {
"ok": True,
"rows": [{"id": 1}, {"id": 2}],
"columns": ["id"],
"next": "2",
"next_url": "https://example.com/fixtures/dogs.json?_next=2&_shape=objects",
}
page2 = {
"ok": True,
"rows": [{"id": 3}],
"columns": ["id"],
"next": None,
"next_url": None,
}
httpx_mock.add_response(json=page1, status_code=200)
httpx_mock.add_response(json=page2, status_code=200)
runner = CliRunner()
result = runner.invoke(
cli,
["rows", "fixtures", "dogs", "-i", "https://example.com", "--all"],
)
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data) == 3
assert [r["id"] for r in data] == [1, 2, 3]
def test_rows_all_with_limit(httpx_mock):
page1 = {
"ok": True,
"rows": [{"id": 1}, {"id": 2}],
"columns": ["id"],
"next": "2",
"next_url": "https://example.com/fixtures/dogs.json?_next=2&_shape=objects",
}
page2 = {
"ok": True,
"rows": [{"id": 3}, {"id": 4}],
"columns": ["id"],
"next": None,
}
httpx_mock.add_response(json=page1, status_code=200)
httpx_mock.add_response(json=page2, status_code=200)
runner = CliRunner()
result = runner.invoke(
cli,
[
"rows",
"fixtures",
"dogs",
"-i",
"https://example.com",
"--all",
"--limit",
"3",
],
)
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data) == 3
def test_rows_no_all_ignores_next(httpx_mock):
"""Without --all, pagination is not followed even if next is present."""
response = {
"ok": True,
"rows": [{"id": 1}, {"id": 2}],
"columns": ["id"],
"next": "2",
"next_url": "https://example.com/fixtures/dogs.json?_next=2&_shape=objects",
}
result = _invoke(httpx_mock, response=response)
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data) == 2 # only first page
# -- error handling --
def test_rows_api_error(httpx_mock):
httpx_mock.add_response(
json={"ok": False, "error": "Table not found: dogs"},
status_code=404,
)
runner = CliRunner()
result = runner.invoke(
cli,
["rows", "fixtures", "dogs", "-i", "https://example.com"],
)
assert result.exit_code == 1
assert "Table not found" in result.output
# -- uses default instance --
def test_rows_default_instance(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
config_file = pathlib.Path(tmpdir) / "config.json"
config_file.write_text(
json.dumps(
{
"default_instance": "prod",
"instances": {
"prod": {
"url": "https://prod.example.com",
"default_database": "main",
}
},
}
)
)
httpx_mock.add_response(json=TABLE_RESPONSE, status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["rows", "data", "dogs"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.url.host == "prod.example.com"
assert request.url.path == "/data/dogs.json"