Implement T1 and T2: exploration, CRUD commands, and output formats

Refactored shared infrastructure:
- _resolve_url(), _resolve_token(), _api_get(), _api_post() helpers
- Shared output formatters: JSON, CSV, TSV, NL-JSON, text table
- DCLIENT_TOKEN and DCLIENT_URL environment variable support

T1 - Core table browsing:
- `databases` - list databases on a Datasette instance
- `tables` - list tables with optional --schema flag
- `schema` - show CREATE TABLE SQL for database/table
- `rows` - browse rows with filtering (-w), sorting, pagination,
  column selection, faceting, full-text search, --limit, --all
- `get` - fetch a single row by primary key

T2 - Write operations:
- `upsert` - insert-or-update rows from file (CSV/TSV/JSON/JSONL)
- `update` - update a single row by PK with key=value pairs
- `delete` - delete a row by PK (with --yes to skip confirmation)
- `drop` - drop a table (shows row count, requires confirmation)
- `create-table` - create empty table with --column name type --pk

Output formats added to query and rows:
- --csv, --tsv, --nl, --json, --table flags

23 new tests covering all commands (62 total, all passing).

https://claude.ai/code/session_01DoiJf9Gbvbw2asN9yvP6dx
This commit is contained in:
Claude 2026-02-12 00:22:12 +00:00
commit 5ee8b2d93c
No known key found for this signature in database
2 changed files with 1316 additions and 91 deletions

View file

@ -1,5 +1,7 @@
import click
import csv
import httpx
import io
import json
import pathlib
from sqlite_utils.utils import rows_from_file, Format, TypeTracker, progressbar
@ -14,10 +16,234 @@ def get_config_dir():
return pathlib.Path(click.get_app_dir("io.datasette.dclient"))
# --- Shared infrastructure ---
def _load_aliases(aliases_file):
if aliases_file.exists():
aliases = json.loads(aliases_file.read_text())
else:
aliases = {}
return aliases
def _load_auths(auth_file):
if auth_file.exists():
auths = json.loads(auth_file.read_text())
else:
auths = {}
return auths
def _resolve_url(url_or_alias):
"""Resolve an alias to a URL, or return the input unchanged."""
aliases_file = get_config_dir() / "aliases.json"
aliases = _load_aliases(aliases_file)
if url_or_alias in aliases:
return aliases[url_or_alias]
return url_or_alias
def _resolve_token(url, token=None):
"""Resolve a token from CLI arg, env var, or auth.json."""
if token:
return token
env_token = click.get_current_context().obj.get("env_token") if click.get_current_context().obj else None
if env_token:
return env_token
return token_for_url(url, _load_auths(get_config_dir() / "auth.json"))
def _make_headers(token):
"""Build request headers with optional auth."""
headers = {}
if token:
headers["Authorization"] = "Bearer {}".format(token)
return headers
def _api_get(url, token=None, params=None, verbose=False):
"""Make an authenticated GET request and return parsed JSON."""
headers = _make_headers(token)
if verbose:
full_url = url
if params:
full_url += "?" + urllib.parse.urlencode(params, doseq=True)
click.echo(full_url, err=True)
response = httpx.get(url, params=params, headers=headers, timeout=40.0)
if response.status_code != 200:
_raise_api_error(response)
try:
data = response.json()
except json.JSONDecodeError:
raise click.ClickException("Response was not valid JSON")
if isinstance(data, dict) and data.get("ok") is False:
_raise_from_data(data)
return data
def _api_post(url, data, token=None, verbose=False):
"""Make an authenticated POST request and return parsed JSON."""
headers = _make_headers(token)
headers["Content-Type"] = "application/json"
if verbose:
click.echo("POST {}".format(url), err=True)
click.echo(textwrap.indent(json.dumps(data, indent=2), " "), err=True)
response = httpx.post(url, headers=headers, json=data, timeout=40.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"]))
_raise_from_data(resp_data)
response.raise_for_status()
response_data = response.json()
if verbose:
click.echo(textwrap.indent(json.dumps(response_data, indent=2), " "), err=True)
return response_data
def _raise_api_error(response):
"""Raise a ClickException from a non-200 response."""
try:
data = response.json()
except json.JSONDecodeError:
raise click.ClickException(
"{} status code. Response was not valid JSON".format(response.status_code)
)
bits = []
if data.get("title"):
bits.append(data["title"])
if data.get("error"):
bits.append(data["error"])
raise click.ClickException(
"{} status code. {}".format(response.status_code, ": ".join(bits))
)
def _raise_from_data(data):
"""Raise a ClickException from a JSON error response."""
bits = []
if data.get("title"):
bits.append(data["title"])
if data.get("error"):
bits.append(data["error"])
if data.get("errors"):
bits.extend(data["errors"])
if not bits:
bits = [json.dumps(data)]
raise click.ClickException(": ".join(bits))
def _output_rows(rows, output_format, nl=False, cols=None):
"""Output rows in the specified format."""
if output_format == "json":
click.echo(json.dumps(rows, indent=2))
elif output_format == "nl":
for row in rows:
click.echo(json.dumps(row))
elif output_format == "csv":
_output_csv(rows, cols)
elif output_format == "tsv":
_output_csv(rows, cols, delimiter="\t")
elif output_format == "table":
_output_table(rows, cols)
else:
click.echo(json.dumps(rows, indent=2))
def _output_csv(rows, cols=None, delimiter=","):
"""Write rows as CSV/TSV to stdout."""
if not rows:
return
if cols is None:
cols = list(rows[0].keys())
writer = csv.DictWriter(sys.stdout, fieldnames=cols, delimiter=delimiter, extrasaction="ignore")
writer.writeheader()
writer.writerows(rows)
def _output_table(rows, cols=None):
"""Write rows as a simple aligned text table."""
if not rows:
return
if cols is None:
cols = list(rows[0].keys())
# Calculate column widths
widths = {col: len(col) for col in cols}
str_rows = []
for row in rows:
str_row = {}
for col in cols:
val = str(row.get(col, "")) if row.get(col) is not None else ""
str_row[col] = val
widths[col] = max(widths[col], len(val))
str_rows.append(str_row)
# Header
header = " ".join(col.ljust(widths[col]) for col in cols)
click.echo(header)
click.echo(" ".join("-" * widths[col] for col in cols))
for str_row in str_rows:
click.echo(" ".join(str_row[col].ljust(widths[col]) for col in cols))
def _determine_format(fmt_csv, fmt_tsv, fmt_json, fmt_nl, fmt_table, default="json"):
"""Determine output format from flags."""
if fmt_csv:
return "csv"
if fmt_tsv:
return "tsv"
if fmt_json:
return "json"
if fmt_nl:
return "nl"
if fmt_table:
return "table"
return default
# --- CLI ---
@click.group()
@click.version_option()
def cli():
@click.pass_context
def cli(ctx):
"A client CLI utility for Datasette instances"
ctx.ensure_object(dict)
import os
ctx.obj["env_token"] = os.environ.get("DCLIENT_TOKEN")
ctx.obj["env_url"] = os.environ.get("DCLIENT_URL")
def _resolve_url_with_env(url_or_alias):
"""Resolve URL, falling back to DCLIENT_URL env var."""
if url_or_alias:
return _resolve_url(url_or_alias)
ctx = click.get_current_context()
env_url = ctx.obj.get("env_url") if ctx.obj else None
if env_url:
return env_url
raise click.ClickException("No URL provided and DCLIENT_URL not set")
# --- Output format options (shared decorator) ---
def output_format_options(f):
"""Add --csv, --tsv, --json, --nl, --table output options."""
f = click.option("fmt_table", "--table", is_flag=True, help="Output as text table")(f)
f = click.option("fmt_nl", "--nl", is_flag=True, help="Output as newline-delimited JSON")(f)
f = click.option("fmt_json", "--json", is_flag=True, help="Output as JSON (default)")(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
# --- query command ---
@cli.command()
@ -25,7 +251,9 @@ def cli():
@click.argument("sql")
@click.option("--token", help="API token")
@click.option("-v", "--verbose", is_flag=True, help="Verbose output: show HTTP request")
def query(url_or_alias, sql, token, verbose):
@output_format_options
@click.pass_context
def query(ctx, url_or_alias, sql, token, verbose, fmt_csv, fmt_tsv, fmt_json, fmt_nl, fmt_table):
"""
Run a SQL query against a Datasette database URL
@ -38,62 +266,277 @@ def query(url_or_alias, sql, token, verbose):
https://datasette.io/content \\
'select * from news limit 10'
"""
aliases_file = get_config_dir() / "aliases.json"
aliases = _load_aliases(aliases_file)
if url_or_alias in aliases:
url = aliases[url_or_alias]
else:
url = url_or_alias
if not url_or_alias.endswith(".json"):
url = _resolve_url_with_env(url_or_alias)
if not url.endswith(".json"):
url += ".json"
if token is None:
# Maybe there's a token in auth.json?
token = token_for_url(url, _load_auths(get_config_dir() / "auth.json"))
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
params = {"sql": sql, "_shape": "objects"}
if verbose:
click.echo(url + "?" + urllib.parse.urlencode(params), err=True)
response = httpx.get(url, params=params, headers=headers)
token = _resolve_token(url, token)
data = _api_get(url, token=token, params={"sql": sql, "_shape": "objects"}, verbose=verbose)
output_format = _determine_format(fmt_csv, fmt_tsv, fmt_json, fmt_nl, fmt_table)
_output_rows(data["rows"], output_format)
if response.status_code != 200:
# Is it valid JSON?
try:
data = response.json()
except json.JSONDecodeError:
raise click.ClickException(
"{} status code. Response was not valid JSON".format(
response.status_code
)
)
bits = []
if data.get("title"):
bits.append(data["title"])
if data.get("error"):
bits.append(data["error"])
raise click.ClickException(
"{} status code. {}".format(response.status_code, ": ".join(bits))
)
# We should have JSON now
try:
data = response.json()
except json.JSONDecodeError:
raise click.ClickException("Response was not valid JSON")
# ... but it may have a {"ok": false} error
if not data.get("ok"):
bits = []
if data.get("title"):
bits.append(data["title"])
if data.get("error"):
bits.append(data["error"])
if not bits:
bits = [json.dumps(data)]
raise click.ClickException(": ".join(bits))
# --- databases command ---
# Output results
click.echo(json.dumps(response.json()["rows"], indent=2))
@cli.command()
@click.argument("url_or_alias", required=False)
@click.option("--token", help="API token")
@click.option("-v", "--verbose", is_flag=True, help="Verbose output")
@output_format_options
@click.pass_context
def databases(ctx, url_or_alias, token, verbose, fmt_csv, fmt_tsv, fmt_json, fmt_nl, fmt_table):
"""
List databases on a Datasette instance
Example usage:
\b
dclient databases https://latest.datasette.io
"""
url = _resolve_url_with_env(url_or_alias)
# Ensure we hit the instance root
api_url = url.rstrip("/") + "/-/databases.json"
token = _resolve_token(url, token)
data = _api_get(api_url, token=token, verbose=verbose)
output_format = _determine_format(fmt_csv, fmt_tsv, fmt_json, fmt_nl, fmt_table)
if output_format == "table":
rows = []
for db in data:
rows.append({
"name": db["name"],
"path": db.get("path", ""),
"is_mutable": db.get("is_mutable", ""),
})
_output_rows(rows, "table")
elif output_format == "json":
click.echo(json.dumps(data, indent=2))
else:
_output_rows(data, output_format)
# --- tables command ---
@cli.command()
@click.argument("url_or_alias", required=False)
@click.option("--token", help="API token")
@click.option("--views", is_flag=True, help="Include views")
@click.option("--schema", is_flag=True, help="Show CREATE TABLE SQL")
@click.option("-v", "--verbose", is_flag=True, help="Verbose output")
@output_format_options
@click.pass_context
def tables(ctx, url_or_alias, token, views, schema, verbose, fmt_csv, fmt_tsv, fmt_json, fmt_nl, fmt_table):
"""
List tables in a Datasette database
Example usage:
\b
dclient tables https://latest.datasette.io/fixtures
"""
url = _resolve_url_with_env(url_or_alias)
api_url = url.rstrip("/") + ".json"
token = _resolve_token(url, token)
data = _api_get(api_url, token=token, verbose=verbose)
output_format = _determine_format(fmt_csv, fmt_tsv, fmt_json, fmt_nl, fmt_table)
result_tables = data.get("tables", []) if isinstance(data, dict) else data
if not views:
result_tables = [t for t in result_tables if not t.get("hidden")]
if schema:
# Fetch schema for the database
schema_url = url.rstrip("/") + "/-/schema.json"
schema_data = _api_get(schema_url, token=token, verbose=verbose)
schema_map = {}
if isinstance(schema_data, dict):
for item in schema_data.get("tables", []):
schema_map[item["name"]] = item.get("schema", "")
for t in result_tables:
t["schema"] = schema_map.get(t["name"], "")
if output_format == "json":
click.echo(json.dumps(result_tables, indent=2))
elif output_format == "table":
rows = []
for t in result_tables:
row = {
"name": t["name"],
"rows": str(t.get("count", t.get("rows_count", ""))),
"columns": ", ".join(t.get("columns", [])),
}
if schema:
row["schema"] = t.get("schema", "")
rows.append(row)
_output_rows(rows, "table")
else:
_output_rows(result_tables, output_format)
# --- schema command ---
@cli.command()
@click.argument("url_or_alias", required=False)
@click.option("--token", help="API token")
@click.option("-v", "--verbose", is_flag=True, help="Verbose output")
@click.pass_context
def schema(ctx, url_or_alias, token, verbose):
"""
Show the SQL schema for a database or table
Example usage:
\b
dclient schema https://latest.datasette.io/fixtures
dclient schema https://latest.datasette.io/fixtures/facetable
"""
url = _resolve_url_with_env(url_or_alias)
token = _resolve_token(url, token)
schema_url = url.rstrip("/") + "/-/schema.json"
data = _api_get(schema_url, token=token, verbose=verbose)
if isinstance(data, dict) and "tables" in data:
for t in data["tables"]:
click.echo(t.get("schema", "") + ";")
click.echo()
elif isinstance(data, dict) and "schema" in data:
click.echo(data["schema"] + ";")
else:
click.echo(json.dumps(data, indent=2))
# --- rows command ---
@cli.command()
@click.argument("url_or_alias", required=False)
@click.option("--token", help="API token")
@click.option("-w", "--where", "where_clauses", multiple=True, help="Filter: column__op=value (e.g. age__gt=30)")
@click.option("--where-sql", "where_sql", multiple=True, help="Raw SQL WHERE clause")
@click.option("--search", help="Full-text search query")
@click.option("--sort", help="Sort by column (ascending)")
@click.option("--sort-desc", 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("--facet", "facets", multiple=True, help="Facet by column")
@click.option("--size", type=int, help="Number of rows per page")
@click.option("--all", "fetch_all", is_flag=True, help="Fetch all pages")
@click.option("--limit", type=int, help="Maximum total rows to return")
@click.option("-v", "--verbose", is_flag=True, help="Verbose output")
@output_format_options
@click.pass_context
def rows(ctx, url_or_alias, token, where_clauses, where_sql, search, sort,
sort_desc, columns, nocolumns, facets, size, fetch_all, limit, verbose,
fmt_csv, fmt_tsv, fmt_json, fmt_nl, fmt_table):
"""
Browse rows in a Datasette table with filtering and sorting
Example usage:
\b
dclient rows https://latest.datasette.io/fixtures/facetable
dclient rows https://latest.datasette.io/fixtures/facetable -w state=CA
dclient rows https://latest.datasette.io/fixtures/facetable --sort city
dclient rows https://latest.datasette.io/fixtures/facetable --search text
"""
url = _resolve_url_with_env(url_or_alias)
token = _resolve_token(url, token)
output_format = _determine_format(fmt_csv, fmt_tsv, fmt_json, fmt_nl, fmt_table)
# Build query params
params = [("_shape", "objects"), ("_extra", "next_url")]
for w in where_clauses:
if "=" in w:
key, _, value = w.partition("=")
params.append((key, value))
else:
params.append(("_where", w))
for w in where_sql:
params.append(("_where", w))
if search:
params.append(("_search", search))
if sort:
params.append(("_sort", sort))
if sort_desc:
params.append(("_sort_desc", sort_desc))
for col in columns:
params.append(("_col", col))
for col in nocolumns:
params.append(("_nocol", col))
for facet in facets:
params.append(("_facet", facet))
if size:
params.append(("_size", str(size)))
api_url = url.rstrip("/") + ".json"
all_rows = []
total = 0
page_url = api_url
while True:
data = _api_get(page_url, token=token, params=params if page_url == api_url else None, verbose=verbose)
page_rows = data.get("rows", [])
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_url = data.get("next_url")
if not fetch_all or not next_url:
break
page_url = next_url
params = None # next_url includes params
# Show facets if requested
facet_results = data.get("facet_results") if facets else None
if facet_results:
click.echo("Facets:", err=True)
for facet_name, facet_info in facet_results.items():
click.echo(" {}:".format(facet_name), err=True)
results_list = facet_info.get("results", facet_info) if isinstance(facet_info, dict) else facet_info
if isinstance(results_list, list):
for item in results_list:
click.echo(" {} ({})".format(item.get("value", ""), item.get("count", "")), err=True)
click.echo(err=True)
_output_rows(all_rows, output_format)
# --- get command ---
@cli.command()
@click.argument("url_or_alias", required=False)
@click.argument("pk_values")
@click.option("--token", help="API token")
@click.option("-v", "--verbose", is_flag=True, help="Verbose output")
@click.pass_context
def get(ctx, url_or_alias, pk_values, token, verbose):
"""
Fetch a single row by primary key
Example usage:
\b
dclient get https://latest.datasette.io/fixtures/facetable 1
dclient get https://latest.datasette.io/fixtures/compound_pk a,b
"""
url = _resolve_url_with_env(url_or_alias)
token = _resolve_token(url, token)
api_url = url.rstrip("/") + "/" + pk_values + ".json"
data = _api_get(api_url, token=token, params={"_shape": "objects"}, verbose=verbose)
rows = data.get("rows", [data] if isinstance(data, dict) else data)
if rows:
click.echo(json.dumps(rows[0], indent=2))
else:
raise click.ClickException("Row not found")
# --- insert command ---
@cli.command()
@ -167,15 +610,8 @@ def insert(
https://private.datasette.cloud/data \\
mytable data.csv --pk id --create
"""
aliases_file = get_config_dir() / "aliases.json"
aliases = _load_aliases(aliases_file)
if url_or_alias in aliases:
url = aliases[url_or_alias]
else:
url = url_or_alias
if token is None:
token = token_for_url(url, _load_auths(get_config_dir() / "auth.json"))
url = _resolve_url(url_or_alias)
token = _resolve_token(url, token)
format = None
if format_csv:
@ -205,7 +641,6 @@ def insert(
raise click.ClickException(str(ex))
if format in (Format.JSON, Format.NL):
# Disable progress bar - it can't handle these formats
file_size = None
no_detect_types = True
@ -226,15 +661,12 @@ def insert(
bar.update(new_bytes)
bytes_so_far += new_bytes
except ValueError:
# File has likely been closed, so fp.tell() fails
pass
types = None
if first and not no_detect_types:
# Detect types on first batch
tracker = TypeTracker()
list(tracker.wrap(batch))
types = tracker.types
# Convert types
for row in batch:
for key, value in row.items():
if value is None:
@ -264,10 +696,311 @@ def insert(
)
# --- upsert command ---
@cli.command()
@click.argument("url_or_alias")
@click.argument("table")
@click.argument(
"filepath", type=click.Path("rb", readable=True, allow_dash=True, dir_okay=False)
)
@click.option("format_csv", "--csv", is_flag=True, help="Input is CSV")
@click.option("format_tsv", "--tsv", is_flag=True, help="Input is TSV")
@click.option("format_json", "--json", is_flag=True, help="Input is JSON")
@click.option("format_nl", "--nl", is_flag=True, help="Input is newline-delimited JSON")
@click.option("--encoding", help="Character encoding for CSV/TSV")
@click.option(
"--no-detect-types", is_flag=True, help="Don't detect column types for CSV/TSV"
)
@click.option("--alter", is_flag=True, help="Alter table to add any missing columns")
@click.option(
"--batch-size", type=int, default=100, help="Send rows in batches of this size"
)
@click.option(
"--interval", type=float, default=10, help="Send batch at least every X seconds"
)
@click.option("--token", "-t", help="API token")
@click.option("--silent", is_flag=True, help="Don't output progress")
@click.option(
"-v",
"--verbose",
is_flag=True,
help="Verbose output: show HTTP request and response",
)
def upsert(
url_or_alias,
table,
filepath,
format_csv,
format_tsv,
format_json,
format_nl,
encoding,
no_detect_types,
alter,
batch_size,
interval,
token,
silent,
verbose,
):
"""
Upsert data into a remote Datasette table
Rows with matching primary keys will be updated; others will be inserted.
Each row must include the primary key column(s).
Example usage:
\b
dclient upsert \\
https://private.datasette.cloud/data \\
mytable data.csv
"""
url = _resolve_url(url_or_alias)
token = _resolve_token(url, token)
format = None
if format_csv:
format = Format.CSV
elif format_tsv:
format = Format.TSV
elif format_json:
format = Format.JSON
elif format_nl:
format = Format.NL
if format is None and filepath == "-":
raise click.ClickException(
"An explicit format is required - e.g. --csv "
"- when reading from standard input"
)
if filepath != "-":
file_size = pathlib.Path(filepath).stat().st_size
fp = open(filepath, "rb")
else:
fp = sys.stdin.buffer
file_size = None
try:
rows, format = rows_from_file(fp, format=format, encoding=encoding)
except Exception as ex:
raise click.ClickException(str(ex))
if format in (Format.JSON, Format.NL):
file_size = None
no_detect_types = True
first = True
with progressbar(
length=file_size,
label="Upserting rows",
silent=silent or (file_size is None),
show_percent=True,
) as bar:
bytes_so_far = 0
for batch in _batches(rows, batch_size, interval=interval):
if file_size is not None:
try:
bytes_consumed_so_far = fp.tell()
new_bytes = bytes_consumed_so_far - bytes_so_far
bar.update(new_bytes)
bytes_so_far += new_bytes
except ValueError:
pass
if first and not no_detect_types:
tracker = TypeTracker()
list(tracker.wrap(batch))
types = tracker.types
for row in batch:
for key, value in row.items():
if value is None:
continue
if types[key] == "integer":
if not value:
row[key] = None
else:
row[key] = int(value)
elif types[key] == "float":
if not value:
row[key] = None
else:
row[key] = float(value)
first = False
data = {"rows": batch}
if alter:
data["alter"] = True
upsert_url = "{}/{}/-/upsert".format(url, table)
_api_post(upsert_url, data, token=token, verbose=verbose)
# --- update command ---
@cli.command()
@click.argument("url_or_alias")
@click.argument("pk_values")
@click.argument("updates", nargs=-1)
@click.option("--token", help="API token")
@click.option("--alter", is_flag=True, help="Alter table to add any missing columns")
@click.option("-v", "--verbose", is_flag=True, help="Verbose output")
def update(url_or_alias, pk_values, updates, token, alter, verbose):
"""
Update a row by primary key
Pass key=value pairs to set column values.
Example usage:
\b
dclient update https://example.com/db/table 42 name=Alice age=30
"""
url = _resolve_url(url_or_alias)
token = _resolve_token(url, token)
if not updates:
raise click.ClickException("Provide at least one key=value pair")
update_dict = {}
for pair in updates:
if "=" not in pair:
raise click.ClickException("Invalid key=value pair: {}".format(pair))
key, _, value = pair.partition("=")
# Try to parse as JSON for numbers, booleans, null
try:
value = json.loads(value)
except (json.JSONDecodeError, ValueError):
pass
update_dict[key] = value
data = {"update": update_dict}
if alter:
data["alter"] = True
api_url = "{}/{}/-/update".format(url.rstrip("/"), pk_values)
result = _api_post(api_url, data, token=token, verbose=verbose)
click.echo(json.dumps(result, indent=2))
# --- delete command ---
@cli.command()
@click.argument("url_or_alias")
@click.argument("pk_values")
@click.option("--token", help="API token")
@click.option("--yes", is_flag=True, help="Skip confirmation")
@click.option("-v", "--verbose", is_flag=True, help="Verbose output")
def delete(url_or_alias, pk_values, token, yes, verbose):
"""
Delete a row by primary key
Example usage:
\b
dclient delete https://example.com/db/table 42
dclient delete https://example.com/db/table 42 --yes
"""
url = _resolve_url(url_or_alias)
token = _resolve_token(url, token)
if not yes:
click.confirm("Delete row {}?".format(pk_values), abort=True)
api_url = "{}/{}/-/delete".format(url.rstrip("/"), pk_values)
result = _api_post(api_url, {}, token=token, verbose=verbose)
click.echo(json.dumps(result, indent=2))
# --- drop command ---
@cli.command()
@click.argument("url_or_alias")
@click.option("--token", help="API token")
def actor(url_or_alias, token):
@click.option("--yes", is_flag=True, help="Skip confirmation")
@click.option("-v", "--verbose", is_flag=True, help="Verbose output")
def drop(url_or_alias, token, yes, verbose):
"""
Drop a table
Example usage:
\b
dclient drop https://example.com/db/table
dclient drop https://example.com/db/table --yes
"""
url = _resolve_url(url_or_alias)
token = _resolve_token(url, token)
api_url = url.rstrip("/") + "/-/drop"
if not yes:
# First request to get row count
info = _api_post(api_url, {}, token=token, verbose=verbose)
row_count = info.get("row_count", "unknown")
table_name = info.get("table", url.rstrip("/").split("/")[-1])
click.confirm("Drop table {} ({} rows)?".format(table_name, row_count), abort=True)
result = _api_post(api_url, {"confirm": True}, token=token, verbose=verbose)
click.echo(json.dumps(result, indent=2))
# --- create-table command ---
@cli.command(name="create-table")
@click.argument("url_or_alias")
@click.argument("table_name")
@click.option(
"--column", "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("--token", help="API token")
@click.option("-v", "--verbose", is_flag=True, help="Verbose output")
def create_table(url_or_alias, table_name, column_defs, pks, token, verbose):
"""
Create a new empty table with explicit schema
Example usage:
\b
dclient create-table https://example.com/db mytable \\
--column id integer --column name text --pk id
"""
url = _resolve_url(url_or_alias)
token = _resolve_token(url, token)
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("/") + "/-/create"
result = _api_post(api_url, data, token=token, verbose=verbose)
click.echo(json.dumps(result, indent=2))
# --- actor command ---
@cli.command()
@click.argument("url_or_alias", required=False)
@click.option("--token", help="API token")
@click.pass_context
def actor(ctx, url_or_alias, token):
"""
Show the actor represented by an API token
@ -276,18 +1009,12 @@ def actor(url_or_alias, token):
\b
dclient actor https://latest.datasette.io/fixtures
"""
aliases_file = get_config_dir() / "aliases.json"
aliases = _load_aliases(aliases_file)
if url_or_alias in aliases:
url = aliases[url_or_alias]
else:
url = url_or_alias
url = _resolve_url_with_env(url_or_alias)
if not (url.startswith("http://") or url.startswith("https://")):
raise click.ClickException("Invalid URL: " + url)
if token is None:
token = token_for_url(url, _load_auths(get_config_dir() / "auth.json"))
token = _resolve_token(url, token)
url_bits = url.split("/")
url_bits[-1] = "-/actor.json"
@ -299,6 +1026,9 @@ def actor(url_or_alias, token):
click.echo(json.dumps(response.json(), indent=4))
# --- alias commands ---
@cli.group()
def alias():
"Manage aliases for different instances"
@ -369,6 +1099,9 @@ def alias_remove(name):
raise click.ClickException("No such alias")
# --- auth commands ---
@cli.group()
def auth():
"Manage authentication for different instances"
@ -441,20 +1174,7 @@ def auth_remove(alias_or_url):
raise click.ClickException("No such URL or alias")
def _load_aliases(aliases_file):
if aliases_file.exists():
aliases = json.loads(aliases_file.read_text())
else:
aliases = {}
return aliases
def _load_auths(auth_file):
if auth_file.exists():
auths = json.loads(auth_file.read_text())
else:
auths = {}
return auths
# --- Internal helpers ---
def _batches(iterable, size, interval=None):
@ -521,7 +1241,6 @@ def _insert_batch(
if verbose:
click.echo(str(response), err=True)
if str(response.status_code)[0] != "2":
# Is there an error we can show?
if "/json" in response.headers["content-type"]:
data = response.json()
if "errors" in data:

506
tests/test_new_commands.py Normal file
View file

@ -0,0 +1,506 @@
"""Tests for new T1 and T2 commands: databases, tables, schema, rows, get,
upsert, update, delete, drop, create-table."""
import asyncio
import json
import pathlib
import pytest
from click.testing import CliRunner
from datasette.app import Datasette
from dclient.cli import cli
@pytest.fixture
def ds():
"""Create a Datasette instance with a memory database and sample data."""
datasette = Datasette(
config={
"permissions": {
"create-table": {"id": "*"},
"insert-row": {"id": "*"},
"update-row": {"id": "*"},
"delete-row": {"id": "*"},
"drop-table": {"id": "*"},
"alter-table": {"id": "*"},
}
}
)
db = datasette.add_memory_database("data")
loop = asyncio.get_event_loop()
async def setup():
# Drop all tables first
for table in await db.table_names():
await db.execute_write("drop table [{}]".format(table))
await db.execute_write(
"create table dogs (id integer primary key, name text, age integer)"
)
await db.execute_write(
"insert into dogs (id, name, age) values (1, 'Cleo', 5), (2, 'Pancakes', 3), (3, 'Fido', 7)"
)
loop.run_until_complete(setup())
return datasette, db, loop
@pytest.fixture
def token(ds):
datasette, _, _ = ds
return datasette.create_token("actor")
def _mock_datasette(httpx_mock, ds):
"""Set up httpx_mock to route requests through the Datasette instance."""
datasette, _, loop = ds
def custom_response(request):
async def run():
method = request.method
url_path = request.url.raw_path.decode()
if method == "GET":
response = await datasette.client.get(
url_path, headers=request.headers
)
else:
content = request.read()
try:
body = json.loads(content) if content else {}
except json.JSONDecodeError:
body = {}
response = await datasette.client.request(
method, url_path, json=body, headers=request.headers
)
import httpx as httpx_lib
return httpx_lib.Response(
status_code=response.status_code,
headers=response.headers,
content=response.content,
)
return loop.run_until_complete(run())
httpx_mock.add_callback(custom_response, is_optional=True)
# --- databases command ---
def test_databases(httpx_mock, ds, token):
_mock_datasette(httpx_mock, ds)
runner = CliRunner()
result = runner.invoke(cli, ["databases", "http://localhost/", "--token", token])
assert result.exit_code == 0
data = json.loads(result.output)
names = [d["name"] for d in data]
assert "data" in names
def test_databases_table_format(httpx_mock, ds, token):
_mock_datasette(httpx_mock, ds)
runner = CliRunner()
result = runner.invoke(
cli, ["databases", "http://localhost/", "--token", token, "--table"]
)
assert result.exit_code == 0
assert "name" in result.output
assert "data" in result.output
# --- tables command ---
def test_tables(httpx_mock, ds, token):
_mock_datasette(httpx_mock, ds)
runner = CliRunner()
result = runner.invoke(cli, ["tables", "http://localhost/data", "--token", token])
assert result.exit_code == 0
data = json.loads(result.output)
table_names = [t["name"] for t in data]
assert "dogs" in table_names
def test_tables_table_format(httpx_mock, ds, token):
_mock_datasette(httpx_mock, ds)
runner = CliRunner()
result = runner.invoke(
cli, ["tables", "http://localhost/data", "--token", token, "--table"]
)
assert result.exit_code == 0
assert "dogs" in result.output
# --- schema command ---
def test_schema(httpx_mock, ds, token):
_mock_datasette(httpx_mock, ds)
runner = CliRunner()
result = runner.invoke(cli, ["schema", "http://localhost/data", "--token", token])
assert result.exit_code == 0
assert "CREATE TABLE" in result.output
assert "dogs" in result.output
# --- rows command ---
def test_rows_basic(httpx_mock, ds, token):
_mock_datasette(httpx_mock, ds)
runner = CliRunner()
result = runner.invoke(
cli, ["rows", "http://localhost/data/dogs", "--token", token]
)
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data) == 3
names = [r["name"] for r in data]
assert "Cleo" in names
def test_rows_with_filter(httpx_mock, ds, token):
_mock_datasette(httpx_mock, ds)
runner = CliRunner()
result = runner.invoke(
cli,
["rows", "http://localhost/data/dogs", "--token", token, "-w", "age__gt=4"],
)
assert result.exit_code == 0
data = json.loads(result.output)
for row in data:
assert row["age"] > 4
def test_rows_with_sort(httpx_mock, ds, token):
_mock_datasette(httpx_mock, ds)
runner = CliRunner()
result = runner.invoke(
cli,
["rows", "http://localhost/data/dogs", "--token", token, "--sort", "name"],
)
assert result.exit_code == 0
data = json.loads(result.output)
names = [r["name"] for r in data]
assert names == sorted(names)
def test_rows_with_sort_desc(httpx_mock, ds, token):
_mock_datasette(httpx_mock, ds)
runner = CliRunner()
result = runner.invoke(
cli,
[
"rows",
"http://localhost/data/dogs",
"--token",
token,
"--sort-desc",
"age",
],
)
assert result.exit_code == 0
data = json.loads(result.output)
ages = [r["age"] for r in data]
assert ages == sorted(ages, reverse=True)
def test_rows_csv(httpx_mock, ds, token):
_mock_datasette(httpx_mock, ds)
runner = CliRunner()
result = runner.invoke(
cli, ["rows", "http://localhost/data/dogs", "--token", token, "--csv"]
)
assert result.exit_code == 0
assert "id,name,age" in result.output
assert "Cleo" in result.output
def test_rows_nl(httpx_mock, ds, token):
_mock_datasette(httpx_mock, ds)
runner = CliRunner()
result = runner.invoke(
cli, ["rows", "http://localhost/data/dogs", "--token", token, "--nl"]
)
assert result.exit_code == 0
lines = [l for l in result.output.strip().split("\n") if l]
for line in lines:
parsed = json.loads(line)
assert "name" in parsed
def test_rows_table_format(httpx_mock, ds, token):
_mock_datasette(httpx_mock, ds)
runner = CliRunner()
result = runner.invoke(
cli, ["rows", "http://localhost/data/dogs", "--token", token, "--table"]
)
assert result.exit_code == 0
assert "id" in result.output
assert "name" in result.output
assert "Cleo" in result.output
# Should have header separator
assert "---" in result.output
def test_rows_with_limit(httpx_mock, ds, token):
_mock_datasette(httpx_mock, ds)
runner = CliRunner()
result = runner.invoke(
cli,
["rows", "http://localhost/data/dogs", "--token", token, "--limit", "2"],
)
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data) == 2
def test_rows_with_columns(httpx_mock, ds, token):
_mock_datasette(httpx_mock, ds)
runner = CliRunner()
result = runner.invoke(
cli,
["rows", "http://localhost/data/dogs", "--token", token, "--col", "name"],
)
assert result.exit_code == 0
data = json.loads(result.output)
for row in data:
assert "name" in row
# --- get command ---
def test_get(httpx_mock, ds, token):
_mock_datasette(httpx_mock, ds)
runner = CliRunner()
result = runner.invoke(
cli, ["get", "http://localhost/data/dogs", "1", "--token", token]
)
assert result.exit_code == 0
data = json.loads(result.output)
assert data["name"] == "Cleo"
assert data["id"] == 1
# --- upsert command ---
def test_upsert(httpx_mock, ds, token, tmpdir):
_mock_datasette(httpx_mock, ds)
# Upsert: update Cleo's age and add a new dog
data = json.dumps([
{"id": 1, "name": "Cleo", "age": 6},
{"id": 4, "name": "Muffin", "age": 2},
])
path = pathlib.Path(tmpdir) / "upsert.json"
path.write_text(data)
runner = CliRunner()
result = runner.invoke(
cli,
[
"upsert",
"http://localhost/data",
"dogs",
str(path),
"--json",
"--token",
token,
],
)
assert result.exit_code == 0, result.output
# Verify
datasette, _, loop = ds
async def check():
r = await datasette.client.get("/data/dogs.json?_shape=array")
return r.json()
rows = loop.run_until_complete(check())
names = {r["name"]: r["age"] for r in rows}
assert names["Cleo"] == 6 # updated
assert names["Muffin"] == 2 # inserted
# --- update command ---
def test_update(httpx_mock, ds, token):
_mock_datasette(httpx_mock, ds)
runner = CliRunner()
result = runner.invoke(
cli,
[
"update",
"http://localhost/data/dogs",
"1",
"name=Rex",
"age=10",
"--token",
token,
],
)
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data.get("ok") is True
# Verify the update
datasette, _, loop = ds
async def check():
r = await datasette.client.get("/data/dogs/1.json?_shape=objects")
return r.json()
row_data = loop.run_until_complete(check())
row = row_data["rows"][0]
assert row["name"] == "Rex"
assert row["age"] == 10
# --- delete command ---
def test_delete(httpx_mock, ds, token):
_mock_datasette(httpx_mock, ds)
runner = CliRunner()
result = runner.invoke(
cli,
[
"delete",
"http://localhost/data/dogs",
"3",
"--yes",
"--token",
token,
],
)
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data.get("ok") is True
# Verify deletion
datasette, _, loop = ds
async def check():
r = await datasette.client.get("/data/dogs.json?_shape=array")
return r.json()
rows = loop.run_until_complete(check())
ids = [r["id"] for r in rows]
assert 3 not in ids
assert len(rows) == 2
# --- drop command ---
def test_drop(httpx_mock, ds, token):
_mock_datasette(httpx_mock, ds)
runner = CliRunner()
result = runner.invoke(
cli,
["drop", "http://localhost/data/dogs", "--yes", "--token", token],
)
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data.get("ok") is True
# Verify table is gone
datasette, db, loop = ds
async def check():
return await db.table_names()
tables = loop.run_until_complete(check())
assert "dogs" not in tables
# --- create-table command ---
def test_create_table(httpx_mock, ds, token):
_mock_datasette(httpx_mock, ds)
runner = CliRunner()
result = runner.invoke(
cli,
[
"create-table",
"http://localhost/data",
"cats",
"--column",
"id",
"integer",
"--column",
"name",
"text",
"--pk",
"id",
"--token",
token,
],
)
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data.get("ok") is True
# Verify table exists
datasette, db, loop = ds
async def check():
return await db.table_names()
tables = loop.run_until_complete(check())
assert "cats" in tables
# --- output format tests for query ---
SAMPLE_QUERY_RESPONSE = {
"ok": True,
"database": "data",
"rows": [
{"id": 1, "name": "Cleo", "age": 5},
{"id": 2, "name": "Pancakes", "age": 3},
{"id": 3, "name": "Fido", "age": 7},
],
"truncated": False,
"columns": ["id", "name", "age"],
}
def test_query_csv(httpx_mock):
httpx_mock.add_response(json=SAMPLE_QUERY_RESPONSE, status_code=200)
runner = CliRunner()
result = runner.invoke(
cli, ["query", "https://example.com/data", "select * from dogs", "--csv"]
)
assert result.exit_code == 0
assert "id,name,age" in result.output
assert "Cleo" in result.output
def test_query_nl(httpx_mock):
httpx_mock.add_response(json=SAMPLE_QUERY_RESPONSE, status_code=200)
runner = CliRunner()
result = runner.invoke(
cli, ["query", "https://example.com/data", "select * from dogs", "--nl"]
)
assert result.exit_code == 0
lines = [l for l in result.output.strip().split("\n") if l]
assert len(lines) == 3
first = json.loads(lines[0])
assert first["name"] == "Cleo"
def test_query_table_format(httpx_mock):
httpx_mock.add_response(json=SAMPLE_QUERY_RESPONSE, status_code=200)
runner = CliRunner()
result = runner.invoke(
cli, ["query", "https://example.com/data", "select * from dogs", "--table"]
)
assert result.exit_code == 0
assert "Cleo" in result.output
assert "---" in result.output