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