mirror of
https://github.com/simonw/dclient.git
synced 2026-07-26 10:54:33 +02:00
1474 lines
45 KiB
Python
1474 lines
45 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)
|
|
|
|
|
|
@cli.command()
|
|
@click.argument("database")
|
|
@click.argument("sql")
|
|
@click.option("-i", "--instance", default=None, help="Datasette instance URL or alias")
|
|
@click.option("--token", help="API token")
|
|
@click.option("-v", "--verbose", is_flag=True, help="Verbose output: show HTTP request")
|
|
@output_format_options
|
|
def query(database, sql, instance, token, verbose, fmt_csv, fmt_tsv, fmt_nl, fmt_table):
|
|
"""
|
|
Run a SQL query against a Datasette database
|
|
|
|
Requires both a database name and a SQL string.
|
|
|
|
Example usage:
|
|
|
|
\b
|
|
dclient query fixtures "select * from facetable limit 5"
|
|
dclient query analytics "select count(*) from events" -i staging
|
|
"""
|
|
config_dir = get_config_dir()
|
|
url = _resolve_instance(instance, config_dir / "config.json")
|
|
token = _resolve_token(
|
|
token, url, config_dir / "auth.json", config_dir / "config.json"
|
|
)
|
|
query_url = url.rstrip("/") + "/" + database + ".json"
|
|
headers = {}
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
params = {"sql": sql, "_shape": "objects"}
|
|
if verbose:
|
|
click.echo(query_url + "?" + urllib.parse.urlencode(params), err=True)
|
|
response = httpx.get(
|
|
query_url, params=params, headers=headers, follow_redirects=True
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
try:
|
|
data = response.json()
|
|
except json.JSONDecodeError:
|
|
raise click.ClickException(
|
|
"{} status code. Response was not valid JSON".format(
|
|
response.status_code
|
|
)
|
|
)
|
|
bits = []
|
|
if data.get("title"):
|
|
bits.append(data["title"])
|
|
if data.get("error"):
|
|
bits.append(data["error"])
|
|
raise click.ClickException(
|
|
"{} status code. {}".format(response.status_code, ": ".join(bits))
|
|
)
|
|
|
|
try:
|
|
data = response.json()
|
|
except json.JSONDecodeError:
|
|
raise click.ClickException("Response was not valid JSON")
|
|
if not data.get("ok"):
|
|
bits = []
|
|
if data.get("title"):
|
|
bits.append(data["title"])
|
|
if data.get("error"):
|
|
bits.append(data["error"])
|
|
if not bits:
|
|
bits = [json.dumps(data)]
|
|
raise click.ClickException(": ".join(bits))
|
|
|
|
rows = response.json()["rows"]
|
|
columns = response.json().get("columns")
|
|
fmt = _determine_output_format(fmt_csv, fmt_tsv, fmt_nl, fmt_table)
|
|
_output_rows(rows, fmt, columns)
|
|
|
|
|
|
def _do_insert(
|
|
database,
|
|
table,
|
|
filepath,
|
|
format_csv,
|
|
format_tsv,
|
|
format_json,
|
|
format_nl,
|
|
encoding,
|
|
no_detect_types,
|
|
replace,
|
|
ignore,
|
|
create,
|
|
alter,
|
|
pks,
|
|
batch_size,
|
|
interval,
|
|
token,
|
|
silent,
|
|
verbose,
|
|
instance,
|
|
endpoint="insert",
|
|
):
|
|
"""Shared implementation for insert and upsert commands."""
|
|
config_dir = get_config_dir()
|
|
url = _resolve_instance(instance, config_dir / "config.json")
|
|
token = _resolve_token(
|
|
token, url, config_dir / "auth.json", config_dir / "config.json"
|
|
)
|
|
|
|
format = None
|
|
if format_csv:
|
|
format = Format.CSV
|
|
elif format_tsv:
|
|
format = Format.TSV
|
|
elif format_json:
|
|
format = Format.JSON
|
|
elif format_nl:
|
|
format = Format.NL
|
|
if format is None and filepath == "-":
|
|
raise click.ClickException(
|
|
"An explicit format is required - e.g. --csv "
|
|
"- when reading from standard input"
|
|
)
|
|
|
|
if filepath != "-":
|
|
file_size = pathlib.Path(filepath).stat().st_size
|
|
fp = open(filepath, "rb")
|
|
else:
|
|
fp = sys.stdin.buffer
|
|
file_size = None
|
|
|
|
try:
|
|
rows, format = rows_from_file(fp, format=format, encoding=encoding)
|
|
except Exception as ex:
|
|
raise click.ClickException(str(ex))
|
|
|
|
if format in (Format.JSON, Format.NL):
|
|
file_size = None
|
|
no_detect_types = True
|
|
|
|
first = True
|
|
base_url = url.rstrip("/") + "/" + database
|
|
|
|
with progressbar(
|
|
length=file_size,
|
|
label="Inserting rows",
|
|
silent=silent or (file_size is None),
|
|
show_percent=True,
|
|
) as bar:
|
|
bytes_so_far = 0
|
|
for batch in _batches(rows, batch_size, interval=interval):
|
|
if file_size is not None:
|
|
try:
|
|
bytes_consumed_so_far = fp.tell()
|
|
new_bytes = bytes_consumed_so_far - bytes_so_far
|
|
bar.update(new_bytes)
|
|
bytes_so_far += new_bytes
|
|
except ValueError:
|
|
pass
|
|
types = None
|
|
if first and not no_detect_types:
|
|
tracker = TypeTracker()
|
|
list(tracker.wrap(batch))
|
|
types = tracker.types
|
|
for row in batch:
|
|
for key, value in row.items():
|
|
if value is None:
|
|
continue
|
|
if types[key] == "integer":
|
|
if not value:
|
|
row[key] = None
|
|
else:
|
|
row[key] = int(value)
|
|
elif types[key] == "float":
|
|
if not value:
|
|
row[key] = None
|
|
else:
|
|
row[key] = float(value)
|
|
first = False
|
|
_insert_batch(
|
|
url=base_url,
|
|
table=table,
|
|
batch=batch,
|
|
token=token,
|
|
create=create,
|
|
alter=alter,
|
|
pks=pks,
|
|
replace=replace,
|
|
ignore=ignore,
|
|
verbose=verbose,
|
|
endpoint=endpoint,
|
|
)
|
|
|
|
|
|
_insert_options = [
|
|
click.argument("database"),
|
|
click.argument("table"),
|
|
click.argument(
|
|
"filepath",
|
|
type=click.Path("rb", readable=True, allow_dash=True, dir_okay=False),
|
|
),
|
|
click.option(
|
|
"-i", "--instance", default=None, help="Datasette instance URL or alias"
|
|
),
|
|
click.option("format_csv", "--csv", is_flag=True, help="Input is CSV"),
|
|
click.option("format_tsv", "--tsv", is_flag=True, help="Input is TSV"),
|
|
click.option("format_json", "--json", is_flag=True, help="Input is JSON"),
|
|
click.option(
|
|
"format_nl", "--nl", is_flag=True, help="Input is newline-delimited JSON"
|
|
),
|
|
click.option("--encoding", help="Character encoding for CSV/TSV"),
|
|
click.option(
|
|
"--no-detect-types", is_flag=True, help="Don't detect column types for CSV/TSV"
|
|
),
|
|
click.option(
|
|
"--alter", is_flag=True, help="Alter table to add any missing columns"
|
|
),
|
|
click.option(
|
|
"pks",
|
|
"--pk",
|
|
multiple=True,
|
|
help="Columns to use as the primary key when creating the table",
|
|
),
|
|
click.option(
|
|
"--batch-size", type=int, default=100, help="Send rows in batches of this size"
|
|
),
|
|
click.option(
|
|
"--interval", type=float, default=10, help="Send batch at least every X seconds"
|
|
),
|
|
click.option("--token", help="API token"),
|
|
click.option("--silent", is_flag=True, help="Don't output progress"),
|
|
click.option(
|
|
"-v",
|
|
"--verbose",
|
|
is_flag=True,
|
|
help="Verbose output: show HTTP request and response",
|
|
),
|
|
]
|
|
|
|
|
|
def _apply_options(options):
|
|
def decorator(func):
|
|
for option in reversed(options):
|
|
func = option(func)
|
|
return func
|
|
|
|
return decorator
|
|
|
|
|
|
@cli.command()
|
|
@_apply_options(_insert_options)
|
|
@click.option(
|
|
"--replace", is_flag=True, help="Replace rows with a matching primary key"
|
|
)
|
|
@click.option("--ignore", is_flag=True, help="Ignore rows with a matching primary key")
|
|
@click.option("--create", is_flag=True, help="Create table if it does not exist")
|
|
def insert(
|
|
database,
|
|
table,
|
|
filepath,
|
|
instance,
|
|
format_csv,
|
|
format_tsv,
|
|
format_json,
|
|
format_nl,
|
|
encoding,
|
|
no_detect_types,
|
|
alter,
|
|
pks,
|
|
batch_size,
|
|
interval,
|
|
token,
|
|
silent,
|
|
verbose,
|
|
replace,
|
|
ignore,
|
|
create,
|
|
):
|
|
"""
|
|
Insert data into a remote Datasette instance
|
|
|
|
Example usage:
|
|
|
|
\b
|
|
dclient insert main mytable data.csv --csv -i myapp
|
|
dclient insert main mytable data.csv --csv --create --pk id
|
|
"""
|
|
_do_insert(
|
|
database,
|
|
table,
|
|
filepath,
|
|
format_csv,
|
|
format_tsv,
|
|
format_json,
|
|
format_nl,
|
|
encoding,
|
|
no_detect_types,
|
|
replace,
|
|
ignore,
|
|
create,
|
|
alter,
|
|
pks,
|
|
batch_size,
|
|
interval,
|
|
token,
|
|
silent,
|
|
verbose,
|
|
instance,
|
|
endpoint="insert",
|
|
)
|
|
|
|
|
|
@cli.command()
|
|
@_apply_options(_insert_options)
|
|
def upsert(
|
|
database,
|
|
table,
|
|
filepath,
|
|
instance,
|
|
format_csv,
|
|
format_tsv,
|
|
format_json,
|
|
format_nl,
|
|
encoding,
|
|
no_detect_types,
|
|
alter,
|
|
pks,
|
|
batch_size,
|
|
interval,
|
|
token,
|
|
silent,
|
|
verbose,
|
|
):
|
|
"""
|
|
Upsert data into a remote Datasette instance
|
|
|
|
Example usage:
|
|
|
|
\b
|
|
dclient upsert main mytable data.csv --csv -i myapp
|
|
"""
|
|
_do_insert(
|
|
database,
|
|
table,
|
|
filepath,
|
|
format_csv,
|
|
format_tsv,
|
|
format_json,
|
|
format_nl,
|
|
encoding,
|
|
no_detect_types,
|
|
False,
|
|
False,
|
|
False,
|
|
alter,
|
|
pks,
|
|
batch_size,
|
|
interval,
|
|
token,
|
|
silent,
|
|
verbose,
|
|
instance,
|
|
endpoint="upsert",
|
|
)
|
|
|
|
|
|
@cli.command()
|
|
@click.argument("table_name", required=False, default=None)
|
|
@click.option("-i", "--instance", default=None, help="Datasette instance URL or alias")
|
|
@click.option("-d", "--database", default=None, help="Database name")
|
|
@click.option("--json", "_json", is_flag=True, help="Output raw JSON")
|
|
@click.option("--token", help="API token")
|
|
def schema(table_name, instance, database, _json, token):
|
|
"""
|
|
Show SQL schema for a database or specific table
|
|
|
|
Example usage:
|
|
|
|
\b
|
|
dclient schema
|
|
dclient schema facetable
|
|
dclient schema -d analytics
|
|
"""
|
|
config_dir = get_config_dir()
|
|
url = _resolve_instance(instance, config_dir / "config.json")
|
|
instance_alias = (
|
|
_instance_alias_for_url(url, config_dir / "config.json")
|
|
if not (
|
|
instance
|
|
and (instance.startswith("http://") or instance.startswith("https://"))
|
|
)
|
|
else None
|
|
)
|
|
if instance and not (
|
|
instance.startswith("http://") or instance.startswith("https://")
|
|
):
|
|
instance_alias = instance
|
|
token = _resolve_token(
|
|
token, url, config_dir / "auth.json", config_dir / "config.json"
|
|
)
|
|
db = _resolve_database(database, instance_alias, config_dir / "config.json")
|
|
if table_name:
|
|
response = _make_request(url, token, f"/{db}/{table_name}/-/schema.json")
|
|
else:
|
|
response = _make_request(url, token, f"/{db}/-/schema.json")
|
|
if response.status_code != 200:
|
|
raise click.ClickException(f"{response.status_code} error")
|
|
data = response.json()
|
|
if _json:
|
|
click.echo(json.dumps(data, indent=2))
|
|
else:
|
|
click.echo(data.get("schema", ""))
|
|
|
|
|
|
@cli.command()
|
|
@click.option("-i", "--instance", default=None, help="Datasette instance URL or alias")
|
|
@click.option("--json", "_json", is_flag=True, help="Output raw JSON")
|
|
@click.option("--token", help="API token")
|
|
def plugins(instance, _json, token):
|
|
"""
|
|
List installed plugins on an instance
|
|
|
|
Example usage:
|
|
|
|
\b
|
|
dclient plugins
|
|
dclient plugins -i https://latest.datasette.io
|
|
"""
|
|
config_dir = get_config_dir()
|
|
url = _resolve_instance(instance, config_dir / "config.json")
|
|
token = _resolve_token(
|
|
token, url, config_dir / "auth.json", config_dir / "config.json"
|
|
)
|
|
response = _make_request(url, token, "/-/plugins.json")
|
|
if response.status_code != 200:
|
|
raise click.ClickException(f"{response.status_code} error")
|
|
data = response.json()
|
|
if _json:
|
|
click.echo(json.dumps(data, indent=2))
|
|
else:
|
|
for plugin in data:
|
|
name = plugin["name"] if isinstance(plugin, dict) else plugin
|
|
click.echo(name)
|
|
|
|
|
|
@cli.command()
|
|
@click.option("-i", "--instance", default=None, help="Datasette instance URL or alias")
|
|
@click.option("--token", help="API token")
|
|
def actor(instance, token):
|
|
"""
|
|
Show the actor represented by an API token
|
|
|
|
Example usage:
|
|
|
|
\b
|
|
dclient actor
|
|
dclient actor -i prod
|
|
"""
|
|
config_dir = get_config_dir()
|
|
url = _resolve_instance(instance, config_dir / "config.json")
|
|
token = _resolve_token(
|
|
token, url, config_dir / "auth.json", config_dir / "config.json"
|
|
)
|
|
response = _make_request(url, token, "/-/actor.json")
|
|
response.raise_for_status()
|
|
click.echo(json.dumps(response.json(), indent=4))
|
|
|
|
|
|
@cli.command(name="default_query", hidden=True)
|
|
@click.argument("sql")
|
|
@click.option("-i", "--instance", default=None, help="Datasette instance URL or alias")
|
|
@click.option("-d", "--database", default=None, help="Database name")
|
|
@click.option("--token", help="API token")
|
|
@click.option("-v", "--verbose", is_flag=True, help="Verbose output: show HTTP request")
|
|
@output_format_options
|
|
def default_query(
|
|
sql, instance, database, token, verbose, fmt_csv, fmt_tsv, fmt_nl, fmt_table
|
|
):
|
|
"""Run a SQL query using default instance and database."""
|
|
config_dir = get_config_dir()
|
|
url = _resolve_instance(instance, config_dir / "config.json")
|
|
instance_alias = (
|
|
_instance_alias_for_url(url, config_dir / "config.json")
|
|
if not (
|
|
instance
|
|
and (instance.startswith("http://") or instance.startswith("https://"))
|
|
)
|
|
else None
|
|
)
|
|
if instance and not (
|
|
instance.startswith("http://") or instance.startswith("https://")
|
|
):
|
|
instance_alias = instance
|
|
token = _resolve_token(
|
|
token, url, config_dir / "auth.json", config_dir / "config.json"
|
|
)
|
|
db = _resolve_database(database, instance_alias, config_dir / "config.json")
|
|
query_url = url.rstrip("/") + "/" + db + ".json"
|
|
headers = {}
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
params = {"sql": sql, "_shape": "objects"}
|
|
if verbose:
|
|
click.echo(query_url + "?" + urllib.parse.urlencode(params), err=True)
|
|
response = httpx.get(
|
|
query_url, params=params, headers=headers, follow_redirects=True
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
try:
|
|
data = response.json()
|
|
except json.JSONDecodeError:
|
|
raise click.ClickException(
|
|
"{} status code. Response was not valid JSON".format(
|
|
response.status_code
|
|
)
|
|
)
|
|
bits = []
|
|
if data.get("title"):
|
|
bits.append(data["title"])
|
|
if data.get("error"):
|
|
bits.append(data["error"])
|
|
raise click.ClickException(
|
|
"{} status code. {}".format(response.status_code, ": ".join(bits))
|
|
)
|
|
|
|
try:
|
|
data = response.json()
|
|
except json.JSONDecodeError:
|
|
raise click.ClickException("Response was not valid JSON")
|
|
if not data.get("ok"):
|
|
bits = []
|
|
if data.get("title"):
|
|
bits.append(data["title"])
|
|
if data.get("error"):
|
|
bits.append(data["error"])
|
|
if not bits:
|
|
bits = [json.dumps(data)]
|
|
raise click.ClickException(": ".join(bits))
|
|
|
|
rows = response.json()["rows"]
|
|
columns = response.json().get("columns")
|
|
fmt = _determine_output_format(fmt_csv, fmt_tsv, fmt_nl, fmt_table)
|
|
_output_rows(rows, fmt, columns)
|
|
|
|
|
|
@cli.command()
|
|
@click.option("--json", "_json", is_flag=True, help="Output raw JSON")
|
|
def instances(_json):
|
|
"""
|
|
List known instances from the config
|
|
|
|
Example usage:
|
|
|
|
\b
|
|
dclient instances
|
|
dclient instances --json
|
|
"""
|
|
config_file = get_config_dir() / "config.json"
|
|
config = _load_config(config_file)
|
|
inst_map = config.get("instances", {})
|
|
default = config.get("default_instance")
|
|
if _json:
|
|
click.echo(json.dumps(config, indent=2))
|
|
else:
|
|
for name, inst in inst_map.items():
|
|
marker = "* " if name == default else " "
|
|
db_info = (
|
|
f" (db: {inst['default_database']})"
|
|
if inst.get("default_database")
|
|
else ""
|
|
)
|
|
click.echo(f"{marker}{name} = {inst['url']}{db_info}")
|
|
|
|
|
|
# -- alias command group --
|
|
|
|
|
|
@cli.group()
|
|
def alias():
|
|
"Manage aliases for different instances"
|
|
|
|
|
|
@alias.command(name="list")
|
|
@click.option("_json", "--json", is_flag=True, help="Output raw JSON")
|
|
def alias_list(_json):
|
|
"""List aliases"""
|
|
config_file = get_config_dir() / "config.json"
|
|
config = _load_config(config_file)
|
|
instances = config.get("instances", {})
|
|
default = config.get("default_instance")
|
|
if _json:
|
|
click.echo(json.dumps(config, indent=2))
|
|
else:
|
|
for name, inst in instances.items():
|
|
marker = "* " if name == default else " "
|
|
db_info = (
|
|
f" (db: {inst['default_database']})"
|
|
if inst.get("default_database")
|
|
else ""
|
|
)
|
|
click.echo(f"{marker}{name} = {inst['url']}{db_info}")
|
|
|
|
|
|
@alias.command(name="add")
|
|
@click.argument("name")
|
|
@click.argument("url")
|
|
def alias_add(name, url):
|
|
"""
|
|
Add an alias for a Datasette instance
|
|
|
|
Example usage:
|
|
|
|
\b
|
|
dclient alias add prod https://myapp.datasette.cloud
|
|
"""
|
|
config_dir = get_config_dir()
|
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
config_file = config_dir / "config.json"
|
|
config = _load_config(config_file)
|
|
config["instances"][name] = {"url": url, "default_database": None}
|
|
_save_config(config_file, config)
|
|
|
|
|
|
@alias.command(name="remove")
|
|
@click.argument("name")
|
|
def alias_remove(name):
|
|
"""
|
|
Remove an alias
|
|
|
|
Example usage:
|
|
|
|
\b
|
|
dclient alias remove prod
|
|
"""
|
|
config_file = get_config_dir() / "config.json"
|
|
config = _load_config(config_file)
|
|
if name in config.get("instances", {}):
|
|
del config["instances"][name]
|
|
if config.get("default_instance") == name:
|
|
config["default_instance"] = None
|
|
_save_config(config_file, config)
|
|
else:
|
|
raise click.ClickException("No such alias")
|
|
|
|
|
|
def _resolve_instance_key(alias_or_url, config):
|
|
instances = config.get("instances", {})
|
|
if alias_or_url in instances:
|
|
return alias_or_url
|
|
if alias_or_url.startswith("http://") or alias_or_url.startswith("https://"):
|
|
normalized = alias_or_url.rstrip("/")
|
|
for name, inst in instances.items():
|
|
if inst.get("url", "").rstrip("/") == normalized:
|
|
return name
|
|
raise click.ClickException(f"No such instance URL: {alias_or_url}")
|
|
raise click.ClickException(f"No such alias: {alias_or_url}")
|
|
|
|
|
|
# -- default command group --
|
|
|
|
|
|
@cli.group()
|
|
def default():
|
|
"Manage default instance and database"
|
|
|
|
|
|
@default.command(name="instance")
|
|
@click.argument("alias_or_url", required=False, default=None)
|
|
@click.option("--clear", is_flag=True, help="Clear default instance")
|
|
def default_instance(alias_or_url, clear):
|
|
"""
|
|
Set or show the default instance
|
|
|
|
Example usage:
|
|
|
|
\b
|
|
dclient default instance prod
|
|
dclient default instance https://myapp.datasette.cloud
|
|
dclient default instance
|
|
dclient default instance --clear
|
|
"""
|
|
config_file = get_config_dir() / "config.json"
|
|
config = _load_config(config_file)
|
|
if clear:
|
|
config["default_instance"] = None
|
|
_save_config(config_file, config)
|
|
elif alias_or_url:
|
|
if alias_or_url.startswith("http://") or alias_or_url.startswith("https://"):
|
|
try:
|
|
key = _resolve_instance_key(alias_or_url, config)
|
|
except click.ClickException:
|
|
key = alias_or_url.rstrip("/")
|
|
else:
|
|
key = _resolve_instance_key(alias_or_url, config)
|
|
config["default_instance"] = key
|
|
_save_config(config_file, config)
|
|
else:
|
|
default = config.get("default_instance")
|
|
if default:
|
|
click.echo(default)
|
|
else:
|
|
click.echo("No default instance set")
|
|
|
|
|
|
@default.command(name="database")
|
|
@click.argument("alias_or_url")
|
|
@click.argument("db", required=False, default=None)
|
|
@click.option("--clear", is_flag=True, help="Clear default database for this instance")
|
|
def default_database(alias_or_url, db, clear):
|
|
"""
|
|
Set or show the default database for an instance
|
|
|
|
Example usage:
|
|
|
|
\b
|
|
dclient default database prod main
|
|
dclient default database https://myapp.datasette.cloud main
|
|
dclient default database prod
|
|
dclient default database prod --clear
|
|
"""
|
|
config_file = get_config_dir() / "config.json"
|
|
config = _load_config(config_file)
|
|
instance_key = _resolve_instance_key(alias_or_url, config)
|
|
if clear:
|
|
config["instances"][instance_key]["default_database"] = None
|
|
_save_config(config_file, config)
|
|
elif db:
|
|
config["instances"][instance_key]["default_database"] = db
|
|
_save_config(config_file, config)
|
|
else:
|
|
default_db = config["instances"][instance_key].get("default_database")
|
|
if default_db:
|
|
click.echo(default_db)
|
|
else:
|
|
click.echo(f"No default database set for {instance_key}")
|
|
|
|
|
|
# -- auth command group --
|
|
|
|
|
|
@cli.group()
|
|
def auth():
|
|
"Manage authentication for different instances"
|
|
|
|
|
|
@auth.command(name="add")
|
|
@click.argument("alias_or_url")
|
|
@click.option("--token", prompt=True, hide_input=True)
|
|
def auth_add(alias_or_url, token):
|
|
"""
|
|
Add an authentication token for an alias or URL
|
|
|
|
Example usage:
|
|
|
|
\b
|
|
dclient auth add prod
|
|
dclient auth add https://datasette.io
|
|
|
|
Paste in the token when prompted.
|
|
"""
|
|
config_dir = get_config_dir()
|
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
auth_file = config_dir / "auth.json"
|
|
auths = _load_auths(auth_file)
|
|
# Store by alias name or URL as-is
|
|
auths[alias_or_url] = token
|
|
auth_file.write_text(json.dumps(auths, indent=4))
|
|
|
|
|
|
@auth.command(name="list")
|
|
def auth_list():
|
|
"""
|
|
List stored API tokens
|
|
|
|
Example usage:
|
|
|
|
\b
|
|
dclient auth list
|
|
"""
|
|
auths_file = get_config_dir() / "auth.json"
|
|
click.echo("Tokens file: {}".format(auths_file))
|
|
auths = _load_auths(auths_file)
|
|
if auths:
|
|
click.echo()
|
|
for key, token in auths.items():
|
|
click.echo("{}:\t{}..".format(key, token[:1]))
|
|
|
|
|
|
@auth.command(name="remove")
|
|
@click.argument("alias_or_url")
|
|
def auth_remove(alias_or_url):
|
|
"""
|
|
Remove the API token for an alias or URL
|
|
|
|
Example usage:
|
|
|
|
\b
|
|
dclient auth remove prod
|
|
"""
|
|
config_dir = get_config_dir()
|
|
auth_file = config_dir / "auth.json"
|
|
auths = _load_auths(auth_file)
|
|
try:
|
|
del auths[alias_or_url]
|
|
auth_file.write_text(json.dumps(auths, indent=4))
|
|
except KeyError:
|
|
raise click.ClickException("No such URL or alias")
|
|
|
|
|
|
@auth.command(name="status")
|
|
@click.option("-i", "--instance", default=None, help="Datasette instance URL or alias")
|
|
@click.option("--token", help="API token")
|
|
def auth_status(instance, token):
|
|
"""
|
|
Verify authentication by calling /-/actor.json
|
|
|
|
Example usage:
|
|
|
|
\b
|
|
dclient auth status
|
|
dclient auth status -i prod
|
|
"""
|
|
config_dir = get_config_dir()
|
|
url = _resolve_instance(instance, config_dir / "config.json")
|
|
token = _resolve_token(
|
|
token, url, config_dir / "auth.json", config_dir / "config.json"
|
|
)
|
|
response = _make_request(url, token, "/-/actor.json")
|
|
response.raise_for_status()
|
|
click.echo(json.dumps(response.json(), indent=4))
|
|
|
|
|
|
# -- login command (OAuth device flow) --
|
|
|
|
|
|
@cli.command()
|
|
@click.argument("alias_or_url", required=False, default=None)
|
|
@click.option("--scope", default=None, help="JSON scope array")
|
|
def login(alias_or_url, scope):
|
|
"""
|
|
Authenticate with a Datasette instance using OAuth
|
|
|
|
Uses the OAuth device flow: opens a URL in your browser where you
|
|
approve access, then saves the resulting API token.
|
|
|
|
Example usage:
|
|
|
|
\b
|
|
dclient login https://simon.datasette.cloud/
|
|
dclient login myalias
|
|
dclient login
|
|
"""
|
|
config_dir = get_config_dir()
|
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
config_file = config_dir / "config.json"
|
|
|
|
if alias_or_url is None:
|
|
click.echo("Enter the URL of your Datasette instance, or an alias you have")
|
|
click.echo("already configured with 'dclient alias add'.\n")
|
|
alias_or_url = click.prompt("Instance URL or alias")
|
|
|
|
# Resolve alias to URL if needed
|
|
if alias_or_url.startswith("http://") or alias_or_url.startswith("https://"):
|
|
url = alias_or_url
|
|
auth_key = alias_or_url
|
|
else:
|
|
url = _resolve_instance(alias_or_url, config_file)
|
|
auth_key = alias_or_url
|
|
|
|
# Ensure trailing slash
|
|
if not url.endswith("/"):
|
|
url += "/"
|
|
|
|
# Step 1: Request device code
|
|
device_url = url + "-/oauth/device"
|
|
data = {}
|
|
if scope:
|
|
data["scope"] = scope
|
|
response = httpx.post(device_url, data=data, timeout=30.0)
|
|
if response.status_code != 200:
|
|
raise click.ClickException(
|
|
f"Failed to start login flow: {response.status_code} from {device_url}"
|
|
)
|
|
device_data = response.json()
|
|
device_code = device_data["device_code"]
|
|
user_code = device_data["user_code"]
|
|
verification_uri = device_data["verification_uri"]
|
|
interval = device_data.get("interval", 5)
|
|
|
|
# Step 2: Show instructions
|
|
click.echo(f"\nOpen this URL in your browser:\n")
|
|
click.echo(f" {verification_uri}\n")
|
|
click.echo(f"Enter this code: {user_code}\n")
|
|
click.echo("Waiting for authorization...", nl=False)
|
|
|
|
# Step 3: Poll for token
|
|
token_url = url + "-/oauth/token"
|
|
while True:
|
|
time.sleep(interval)
|
|
click.echo(".", nl=False)
|
|
token_response = httpx.post(
|
|
token_url,
|
|
data={
|
|
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
"device_code": device_code,
|
|
},
|
|
timeout=30.0,
|
|
)
|
|
token_data = token_response.json()
|
|
if "access_token" in token_data:
|
|
break
|
|
error = token_data.get("error")
|
|
if error == "authorization_pending":
|
|
continue
|
|
elif error == "access_denied":
|
|
click.echo()
|
|
raise click.ClickException("Authorization denied.")
|
|
elif error == "expired_token":
|
|
click.echo()
|
|
raise click.ClickException("Device code expired. Run login again.")
|
|
else:
|
|
click.echo()
|
|
raise click.ClickException(f"Unexpected error: {error}")
|
|
|
|
# Step 4: Save token
|
|
click.echo()
|
|
access_token = token_data["access_token"]
|
|
auth_file = config_dir / "auth.json"
|
|
auths = _load_auths(auth_file)
|
|
auths[auth_key] = access_token
|
|
auth_file.write_text(json.dumps(auths, indent=4))
|
|
click.echo(f"Login successful. Token saved for {auth_key}")
|
|
|
|
# Step 5: Set defaults if not already configured
|
|
config = _load_config(config_file)
|
|
default_alias = config.get("default_instance")
|
|
has_default_instance = default_alias is not None
|
|
has_default_db = bool(
|
|
config.get("instances", {}).get(default_alias or "", {}).get("default_database")
|
|
)
|
|
if has_default_instance and has_default_db:
|
|
return
|
|
# Find alias for this instance, or use the auth_key (URL) as the instance key
|
|
instance_key = _instance_alias_for_url(url, config_file) or auth_key
|
|
# Ensure instance entry exists in config
|
|
if instance_key not in config.get("instances", {}):
|
|
config.setdefault("instances", {})[instance_key] = {
|
|
"url": url.rstrip("/"),
|
|
"default_database": None,
|
|
}
|
|
# Set as default instance if none configured
|
|
if not has_default_instance:
|
|
config["default_instance"] = instance_key
|
|
click.echo(f"Set default instance to {instance_key}")
|
|
# Query databases and set default database if none configured
|
|
if not has_default_db:
|
|
try:
|
|
db_response = _make_request(url, access_token, "/.json")
|
|
if db_response.status_code == 200:
|
|
db_data = db_response.json()
|
|
if isinstance(db_data, list):
|
|
databases_list = db_data
|
|
else:
|
|
databases_list = db_data.get("databases", [])
|
|
if isinstance(databases_list, dict):
|
|
databases_list = list(databases_list.values())
|
|
db_names = [
|
|
db["name"] if isinstance(db, dict) else db for db in databases_list
|
|
]
|
|
if db_names:
|
|
if len(db_names) == 1:
|
|
default_db = db_names[0]
|
|
elif "data" in db_names:
|
|
default_db = "data"
|
|
else:
|
|
default_db = db_names[0]
|
|
config["instances"][instance_key]["default_database"] = default_db
|
|
click.echo(f"Set default database to {default_db}")
|
|
except Exception:
|
|
pass # Don't fail login if databases check fails
|
|
_save_config(config_file, config)
|
|
|
|
|
|
# -- v1 → v2 migration --
|
|
|
|
|
|
def _migrate_v1_to_v2(config_dir):
|
|
"""Migrate v1 aliases.json + auth.json to v2 config.json + auth.json."""
|
|
config_file = config_dir / "config.json"
|
|
aliases_file = config_dir / "aliases.json"
|
|
auth_file = config_dir / "auth.json"
|
|
|
|
if config_file.exists() or not aliases_file.exists():
|
|
return
|
|
|
|
aliases = json.loads(aliases_file.read_text()) if aliases_file.exists() else {}
|
|
old_auths = json.loads(auth_file.read_text()) if auth_file.exists() else {}
|
|
|
|
config = {"default_instance": None, "instances": {}}
|
|
new_auths = {}
|
|
url_to_alias = {}
|
|
|
|
for alias_name, alias_url in aliases.items():
|
|
parsed = urllib.parse.urlparse(alias_url)
|
|
path_parts = [p for p in parsed.path.split("/") if p]
|
|
if len(path_parts) == 1:
|
|
# URL has a single path segment → instance URL + default database
|
|
instance_url = f"{parsed.scheme}://{parsed.netloc}"
|
|
default_db = path_parts[0]
|
|
else:
|
|
instance_url = alias_url
|
|
default_db = None
|
|
config["instances"][alias_name] = {
|
|
"url": instance_url,
|
|
"default_database": default_db,
|
|
}
|
|
url_to_alias[alias_url] = alias_name
|
|
|
|
# Migrate auth keys from URLs to alias names
|
|
for url, token in old_auths.items():
|
|
if url in url_to_alias:
|
|
new_auths[url_to_alias[url]] = token
|
|
else:
|
|
# Keep URL-keyed entries as fallbacks
|
|
new_auths[url] = token
|
|
|
|
_save_config(config_file, config)
|
|
if new_auths or old_auths:
|
|
auth_file.rename(config_dir / "auth.json.bak")
|
|
auth_file.write_text(json.dumps(new_auths, indent=4))
|
|
aliases_file.rename(config_dir / "aliases.json.bak")
|
|
|
|
|
|
def _batches(iterable, size, interval=None):
|
|
iterable = iter(iterable)
|
|
last_yield_time = time.time()
|
|
while True:
|
|
batch = []
|
|
for _ in range(size):
|
|
try:
|
|
batch.append(next(iterable))
|
|
except StopIteration:
|
|
break
|
|
if interval is not None and time.time() - last_yield_time >= interval:
|
|
break
|
|
if not batch:
|
|
return
|
|
yield batch
|
|
last_yield_time = time.time()
|
|
|
|
|
|
def _insert_batch(
|
|
*,
|
|
url,
|
|
table,
|
|
batch,
|
|
token,
|
|
create,
|
|
alter,
|
|
pks,
|
|
replace,
|
|
ignore,
|
|
verbose,
|
|
endpoint="insert",
|
|
):
|
|
if create:
|
|
data = {
|
|
"table": table,
|
|
"rows": batch,
|
|
}
|
|
if replace:
|
|
data["replace"] = True
|
|
if ignore:
|
|
data["ignore"] = True
|
|
if alter:
|
|
data["alter"] = True
|
|
if pks:
|
|
if len(pks) == 1:
|
|
data["pk"] = pks[0]
|
|
else:
|
|
data["pks"] = pks
|
|
url = "{}/-/create".format(url)
|
|
else:
|
|
data = {
|
|
"rows": batch,
|
|
}
|
|
if replace:
|
|
data["replace"] = True
|
|
if ignore:
|
|
data["ignore"] = True
|
|
if alter:
|
|
data["alter"] = True
|
|
url = "{}/{}/-/{}".format(url, table, endpoint)
|
|
if verbose:
|
|
click.echo("POST {}".format(url), err=True)
|
|
click.echo(textwrap.indent(json.dumps(data, indent=2), " "), err=True)
|
|
response = httpx.post(
|
|
url,
|
|
headers={
|
|
"Authorization": "Bearer {}".format(token),
|
|
"Content-Type": "application/json",
|
|
},
|
|
json=data,
|
|
timeout=40.0,
|
|
)
|
|
if verbose:
|
|
click.echo(str(response), err=True)
|
|
if str(response.status_code)[0] != "2":
|
|
# Is there an error we can show?
|
|
if "/json" in response.headers["content-type"]:
|
|
data = response.json()
|
|
if "errors" in data:
|
|
raise click.ClickException("\n".join(data["errors"]))
|
|
response.raise_for_status()
|
|
response_data = response.json()
|
|
if verbose:
|
|
click.echo(textwrap.indent(json.dumps(response_data, indent=2), " "), err=True)
|
|
return response_data
|