mirror of
https://github.com/simonw/dclient.git
synced 2026-07-26 19:04:33 +02:00
Compare commits
13 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6cbf95b01 | ||
|
|
80786dadaf |
||
|
|
ed589d34cf |
||
|
|
54df24db5e | ||
|
|
e8d80a6495 | ||
|
|
0b590d1a6f | ||
|
|
1340f68255 | ||
|
|
021c595de4 | ||
|
|
f2aea1b108 | ||
|
|
2f7f2a7378 | ||
|
|
7a8643f72f | ||
|
|
04e396b020 | ||
|
|
9df1828d1d |
19 changed files with 2298 additions and 167 deletions
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -34,7 +34,7 @@ jobs:
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
30
Justfile
30
Justfile
|
|
@ -1,37 +1,29 @@
|
||||||
# Run tests and linters
|
# Run tests and linters
|
||||||
@default: test lint
|
@default: test lint
|
||||||
|
|
||||||
# Install dependencies and test dependencies
|
|
||||||
@init:
|
|
||||||
pipenv run pip install -e '.[test]'
|
|
||||||
|
|
||||||
# Run pytest with supplied options
|
# Run pytest with supplied options
|
||||||
@test *options:
|
@test *options:
|
||||||
pipenv run pytest {{options}}
|
uv run pytest {{options}}
|
||||||
|
|
||||||
# Run linters
|
# Run linters
|
||||||
@lint:
|
@lint:
|
||||||
echo "Linters..."
|
echo "Linters..."
|
||||||
echo " Black"
|
|
||||||
pipenv run black . --check
|
|
||||||
echo " cog"
|
echo " cog"
|
||||||
pipenv run cog --check README.md docs/*.md
|
uv run cog --check README.md docs/*.md
|
||||||
echo " ruff"
|
echo " ruff check"
|
||||||
pipenv run ruff .
|
uv run ruff check .
|
||||||
|
echo " ruff format"
|
||||||
|
uv run ruff format . --check
|
||||||
|
|
||||||
# Rebuild docs with cog
|
# Rebuild docs with cog
|
||||||
@cog:
|
@cog:
|
||||||
pipenv run cog -r docs/*.md
|
uv run cog -r docs/*.md
|
||||||
|
|
||||||
# Serve live docs on localhost:8000
|
# Serve live docs on localhost:8000
|
||||||
@docs: cog
|
@docs: cog
|
||||||
cd docs && pipenv run make livehtml
|
cd docs && uv run make livehtml
|
||||||
|
|
||||||
# Apply Black
|
# Apply ruff fixes and formatting
|
||||||
@black:
|
|
||||||
pipenv run black .
|
|
||||||
|
|
||||||
# Run automatic fixes
|
|
||||||
@fix: cog
|
@fix: cog
|
||||||
pipenv run ruff . --fix
|
uv run ruff check . --fix
|
||||||
pipenv run black .
|
uv run ruff format .
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -11,7 +11,8 @@ Much of the functionality requires Datasette 1.0a2 or higher.
|
||||||
|
|
||||||
## Things you can do with dclient
|
## Things you can do with dclient
|
||||||
|
|
||||||
- Run SQL queries against Datasette and return the results as JSON
|
- Browse table data with filtering, sorting, and pagination — no SQL required
|
||||||
|
- Run SQL queries against Datasette and return the results as JSON, CSV, TSV, or an ASCII table
|
||||||
- Introspect databases, tables, plugins, and schema
|
- Introspect databases, tables, plugins, and schema
|
||||||
- Run queries against authenticated Datasette instances
|
- Run queries against authenticated Datasette instances
|
||||||
- Create aliases and set default instances/databases for convenient access
|
- Create aliases and set default instances/databases for convenient access
|
||||||
|
|
@ -32,8 +33,8 @@ datasette install dclient
|
||||||
Add an alias for a Datasette instance:
|
Add an alias for a Datasette instance:
|
||||||
```bash
|
```bash
|
||||||
dclient alias add latest https://latest.datasette.io
|
dclient alias add latest https://latest.datasette.io
|
||||||
dclient alias default latest
|
dclient default instance latest
|
||||||
dclient alias default-db latest fixtures
|
dclient default database latest fixtures
|
||||||
```
|
```
|
||||||
Now run queries directly:
|
Now run queries directly:
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -43,6 +44,14 @@ Or be explicit:
|
||||||
```bash
|
```bash
|
||||||
dclient query fixtures "select * from facetable limit 1" -i latest
|
dclient query fixtures "select * from facetable limit 1" -i latest
|
||||||
```
|
```
|
||||||
|
Output as a table with `-t`, or use `--csv`, `--tsv`, `--nl`:
|
||||||
|
```bash
|
||||||
|
dclient "select pk, state from facetable limit 3" -t
|
||||||
|
```
|
||||||
|
Browse table rows without SQL:
|
||||||
|
```bash
|
||||||
|
dclient rows facetable -f state eq CA --sort _city_id -t
|
||||||
|
```
|
||||||
|
|
||||||
## Introspection
|
## Introspection
|
||||||
|
|
||||||
|
|
|
||||||
581
dclient/cli.py
581
dclient/cli.py
|
|
@ -1,6 +1,8 @@
|
||||||
import click
|
import click
|
||||||
from click_default_group import DefaultGroup
|
from click_default_group import DefaultGroup
|
||||||
|
import csv
|
||||||
import httpx
|
import httpx
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
@ -53,8 +55,11 @@ def _resolve_instance(instance, config_file):
|
||||||
)
|
)
|
||||||
# Try config default
|
# Try config default
|
||||||
default = config.get("default_instance")
|
default = config.get("default_instance")
|
||||||
if default and default in config.get("instances", {}):
|
if default:
|
||||||
return config["instances"][default]["url"]
|
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
|
# Try env var
|
||||||
env_url = os.environ.get("DATASETTE_URL")
|
env_url = os.environ.get("DATASETTE_URL")
|
||||||
if env_url:
|
if env_url:
|
||||||
|
|
@ -62,7 +67,7 @@ def _resolve_instance(instance, config_file):
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
"No instance specified. Use -i <url-or-alias>, or configure a default:\n\n"
|
"No instance specified. Use -i <url-or-alias>, or configure a default:\n\n"
|
||||||
" dclient alias add <name> <url>\n"
|
" dclient alias add <name> <url>\n"
|
||||||
" dclient alias default <name>\n\n"
|
" dclient default instance <name-or-url>\n\n"
|
||||||
"Or set the DATASETTE_URL environment variable."
|
"Or set the DATASETTE_URL environment variable."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -75,8 +80,13 @@ def _resolve_database(database, instance_alias, config_file):
|
||||||
if instance_alias:
|
if instance_alias:
|
||||||
config = _load_config(config_file)
|
config = _load_config(config_file)
|
||||||
instances = config.get("instances", {})
|
instances = config.get("instances", {})
|
||||||
if instance_alias in instances:
|
key = instance_alias
|
||||||
default_db = instances[instance_alias].get("default_database")
|
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:
|
if default_db:
|
||||||
return default_db
|
return default_db
|
||||||
# Try env var
|
# Try env var
|
||||||
|
|
@ -85,7 +95,7 @@ def _resolve_database(database, instance_alias, config_file):
|
||||||
return env_db
|
return env_db
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
"No database specified. Use -d <name>, or configure a default:\n\n"
|
"No database specified. Use -d <name>, or configure a default:\n\n"
|
||||||
" dclient alias default-db <alias> <database>\n\n"
|
" dclient default database <alias-or-url> <database>\n\n"
|
||||||
"Or set the DATASETTE_DATABASE environment variable."
|
"Or set the DATASETTE_DATABASE environment variable."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -115,6 +125,83 @@ def _resolve_token(token, url, auth_file, config_file):
|
||||||
return os.environ.get("DATASETTE_TOKEN")
|
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.group(cls=DefaultGroup, default="default_query", default_if_no_args=False)
|
||||||
@click.version_option()
|
@click.version_option()
|
||||||
def cli():
|
def cli():
|
||||||
|
|
@ -272,13 +359,199 @@ def tables(instance, database, views, views_only, hidden, _json, token):
|
||||||
click.echo(item)
|
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()
|
@cli.command()
|
||||||
@click.argument("database")
|
@click.argument("database")
|
||||||
@click.argument("sql")
|
@click.argument("sql")
|
||||||
@click.option("-i", "--instance", default=None, help="Datasette instance URL or alias")
|
@click.option("-i", "--instance", default=None, help="Datasette instance URL or alias")
|
||||||
@click.option("--token", help="API token")
|
@click.option("--token", help="API token")
|
||||||
@click.option("-v", "--verbose", is_flag=True, help="Verbose output: show HTTP request")
|
@click.option("-v", "--verbose", is_flag=True, help="Verbose output: show HTTP request")
|
||||||
def query(database, sql, instance, token, verbose):
|
@output_format_options
|
||||||
|
def query(database, sql, instance, token, verbose, fmt_csv, fmt_tsv, fmt_nl, fmt_table):
|
||||||
"""
|
"""
|
||||||
Run a SQL query against a Datasette database
|
Run a SQL query against a Datasette database
|
||||||
|
|
||||||
|
|
@ -338,7 +611,10 @@ def query(database, sql, instance, token, verbose):
|
||||||
bits = [json.dumps(data)]
|
bits = [json.dumps(data)]
|
||||||
raise click.ClickException(": ".join(bits))
|
raise click.ClickException(": ".join(bits))
|
||||||
|
|
||||||
click.echo(json.dumps(response.json()["rows"], indent=2))
|
rows = response.json()["rows"]
|
||||||
|
columns = response.json().get("columns")
|
||||||
|
fmt = _determine_output_format(fmt_csv, fmt_tsv, fmt_nl, fmt_table)
|
||||||
|
_output_rows(rows, fmt, columns)
|
||||||
|
|
||||||
|
|
||||||
def _do_insert(
|
def _do_insert(
|
||||||
|
|
@ -491,7 +767,7 @@ _insert_options = [
|
||||||
click.option(
|
click.option(
|
||||||
"--interval", type=float, default=10, help="Send batch at least every X seconds"
|
"--interval", type=float, default=10, help="Send batch at least every X seconds"
|
||||||
),
|
),
|
||||||
click.option("--token", "-t", help="API token"),
|
click.option("--token", help="API token"),
|
||||||
click.option("--silent", is_flag=True, help="Don't output progress"),
|
click.option("--silent", is_flag=True, help="Don't output progress"),
|
||||||
click.option(
|
click.option(
|
||||||
"-v",
|
"-v",
|
||||||
|
|
@ -628,6 +904,82 @@ def upsert(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command(name="create-table")
|
||||||
|
@click.argument("database")
|
||||||
|
@click.argument("table_name")
|
||||||
|
@click.option(
|
||||||
|
"--column",
|
||||||
|
"-c",
|
||||||
|
"column_defs",
|
||||||
|
multiple=True,
|
||||||
|
nargs=2,
|
||||||
|
help="Column definition: name type (e.g. --column id integer --column name text)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"pks",
|
||||||
|
"--pk",
|
||||||
|
multiple=True,
|
||||||
|
help="Column(s) to use as primary key",
|
||||||
|
)
|
||||||
|
@click.option("-i", "--instance", default=None, help="Datasette instance URL or alias")
|
||||||
|
@click.option("--token", help="API token")
|
||||||
|
@click.option(
|
||||||
|
"-v",
|
||||||
|
"--verbose",
|
||||||
|
is_flag=True,
|
||||||
|
help="Verbose output: show HTTP request and response",
|
||||||
|
)
|
||||||
|
def create_table(database, table_name, column_defs, pks, instance, token, verbose):
|
||||||
|
"""
|
||||||
|
Create a new empty table with an explicit schema
|
||||||
|
|
||||||
|
Example usage:
|
||||||
|
|
||||||
|
\b
|
||||||
|
dclient create-table mydb dogs \\
|
||||||
|
--column id integer --column name text --pk id
|
||||||
|
"""
|
||||||
|
config_dir = get_config_dir()
|
||||||
|
url = _resolve_instance(instance, config_dir / "config.json")
|
||||||
|
token = _resolve_token(
|
||||||
|
token, url, config_dir / "auth.json", config_dir / "config.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not column_defs:
|
||||||
|
raise click.ClickException("Provide at least one --column definition")
|
||||||
|
|
||||||
|
columns = [{"name": name, "type": typ} for name, typ in column_defs]
|
||||||
|
data = {"table": table_name, "columns": columns}
|
||||||
|
if pks:
|
||||||
|
if len(pks) == 1:
|
||||||
|
data["pk"] = pks[0]
|
||||||
|
else:
|
||||||
|
data["pks"] = list(pks)
|
||||||
|
|
||||||
|
api_url = url.rstrip("/") + "/" + database + "/-/create"
|
||||||
|
if verbose:
|
||||||
|
click.echo("POST {}".format(api_url), err=True)
|
||||||
|
click.echo(textwrap.indent(json.dumps(data, indent=2), " "), err=True)
|
||||||
|
response = httpx.post(
|
||||||
|
api_url,
|
||||||
|
headers={
|
||||||
|
"Authorization": "Bearer {}".format(token),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json=data,
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
if verbose:
|
||||||
|
click.echo(str(response), err=True)
|
||||||
|
if str(response.status_code)[0] != "2":
|
||||||
|
if "/json" in response.headers.get("content-type", ""):
|
||||||
|
resp_data = response.json()
|
||||||
|
if "errors" in resp_data:
|
||||||
|
raise click.ClickException("\n".join(resp_data["errors"]))
|
||||||
|
response.raise_for_status()
|
||||||
|
click.echo(json.dumps(response.json(), indent=2))
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@click.argument("table_name", required=False, default=None)
|
@click.argument("table_name", required=False, default=None)
|
||||||
@click.option("-i", "--instance", default=None, help="Datasette instance URL or alias")
|
@click.option("-i", "--instance", default=None, help="Datasette instance URL or alias")
|
||||||
|
|
@ -736,7 +1088,10 @@ def actor(instance, token):
|
||||||
@click.option("-d", "--database", default=None, help="Database name")
|
@click.option("-d", "--database", default=None, help="Database name")
|
||||||
@click.option("--token", help="API token")
|
@click.option("--token", help="API token")
|
||||||
@click.option("-v", "--verbose", is_flag=True, help="Verbose output: show HTTP request")
|
@click.option("-v", "--verbose", is_flag=True, help="Verbose output: show HTTP request")
|
||||||
def default_query(sql, instance, database, token, verbose):
|
@output_format_options
|
||||||
|
def default_query(
|
||||||
|
sql, instance, database, token, verbose, fmt_csv, fmt_tsv, fmt_nl, fmt_table
|
||||||
|
):
|
||||||
"""Run a SQL query using default instance and database."""
|
"""Run a SQL query using default instance and database."""
|
||||||
config_dir = get_config_dir()
|
config_dir = get_config_dir()
|
||||||
url = _resolve_instance(instance, config_dir / "config.json")
|
url = _resolve_instance(instance, config_dir / "config.json")
|
||||||
|
|
@ -799,7 +1154,10 @@ def default_query(sql, instance, database, token, verbose):
|
||||||
bits = [json.dumps(data)]
|
bits = [json.dumps(data)]
|
||||||
raise click.ClickException(": ".join(bits))
|
raise click.ClickException(": ".join(bits))
|
||||||
|
|
||||||
click.echo(json.dumps(response.json()["rows"], indent=2))
|
rows = response.json()["rows"]
|
||||||
|
columns = response.json().get("columns")
|
||||||
|
fmt = _determine_output_format(fmt_csv, fmt_tsv, fmt_nl, fmt_table)
|
||||||
|
_output_rows(rows, fmt, columns)
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
|
|
@ -902,29 +1260,56 @@ def alias_remove(name):
|
||||||
raise click.ClickException("No such alias")
|
raise click.ClickException("No such alias")
|
||||||
|
|
||||||
|
|
||||||
@alias.command(name="default")
|
def _resolve_instance_key(alias_or_url, config):
|
||||||
@click.argument("name", required=False, default=None)
|
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")
|
@click.option("--clear", is_flag=True, help="Clear default instance")
|
||||||
def alias_default(name, clear):
|
def default_instance(alias_or_url, clear):
|
||||||
"""
|
"""
|
||||||
Set or show the default instance
|
Set or show the default instance
|
||||||
|
|
||||||
Example usage:
|
Example usage:
|
||||||
|
|
||||||
\b
|
\b
|
||||||
dclient alias default prod
|
dclient default instance prod
|
||||||
dclient alias default
|
dclient default instance https://myapp.datasette.cloud
|
||||||
dclient alias default --clear
|
dclient default instance
|
||||||
|
dclient default instance --clear
|
||||||
"""
|
"""
|
||||||
config_file = get_config_dir() / "config.json"
|
config_file = get_config_dir() / "config.json"
|
||||||
config = _load_config(config_file)
|
config = _load_config(config_file)
|
||||||
if clear:
|
if clear:
|
||||||
config["default_instance"] = None
|
config["default_instance"] = None
|
||||||
_save_config(config_file, config)
|
_save_config(config_file, config)
|
||||||
elif name:
|
elif alias_or_url:
|
||||||
if name not in config.get("instances", {}):
|
if alias_or_url.startswith("http://") or alias_or_url.startswith("https://"):
|
||||||
raise click.ClickException(f"No such alias: {name}")
|
try:
|
||||||
config["default_instance"] = name
|
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)
|
_save_config(config_file, config)
|
||||||
else:
|
else:
|
||||||
default = config.get("default_instance")
|
default = config.get("default_instance")
|
||||||
|
|
@ -934,37 +1319,37 @@ def alias_default(name, clear):
|
||||||
click.echo("No default instance set")
|
click.echo("No default instance set")
|
||||||
|
|
||||||
|
|
||||||
@alias.command(name="default-db")
|
@default.command(name="database")
|
||||||
@click.argument("alias_name")
|
@click.argument("alias_or_url")
|
||||||
@click.argument("db", required=False, default=None)
|
@click.argument("db", required=False, default=None)
|
||||||
@click.option("--clear", is_flag=True, help="Clear default database for this alias")
|
@click.option("--clear", is_flag=True, help="Clear default database for this instance")
|
||||||
def alias_default_db(alias_name, db, clear):
|
def default_database(alias_or_url, db, clear):
|
||||||
"""
|
"""
|
||||||
Set or show the default database for an alias
|
Set or show the default database for an instance
|
||||||
|
|
||||||
Example usage:
|
Example usage:
|
||||||
|
|
||||||
\b
|
\b
|
||||||
dclient alias default-db prod main
|
dclient default database prod main
|
||||||
dclient alias default-db prod
|
dclient default database https://myapp.datasette.cloud main
|
||||||
dclient alias default-db prod --clear
|
dclient default database prod
|
||||||
|
dclient default database prod --clear
|
||||||
"""
|
"""
|
||||||
config_file = get_config_dir() / "config.json"
|
config_file = get_config_dir() / "config.json"
|
||||||
config = _load_config(config_file)
|
config = _load_config(config_file)
|
||||||
if alias_name not in config.get("instances", {}):
|
instance_key = _resolve_instance_key(alias_or_url, config)
|
||||||
raise click.ClickException(f"No such alias: {alias_name}")
|
|
||||||
if clear:
|
if clear:
|
||||||
config["instances"][alias_name]["default_database"] = None
|
config["instances"][instance_key]["default_database"] = None
|
||||||
_save_config(config_file, config)
|
_save_config(config_file, config)
|
||||||
elif db:
|
elif db:
|
||||||
config["instances"][alias_name]["default_database"] = db
|
config["instances"][instance_key]["default_database"] = db
|
||||||
_save_config(config_file, config)
|
_save_config(config_file, config)
|
||||||
else:
|
else:
|
||||||
default_db = config["instances"][alias_name].get("default_database")
|
default_db = config["instances"][instance_key].get("default_database")
|
||||||
if default_db:
|
if default_db:
|
||||||
click.echo(default_db)
|
click.echo(default_db)
|
||||||
else:
|
else:
|
||||||
click.echo(f"No default database set for {alias_name}")
|
click.echo(f"No default database set for {instance_key}")
|
||||||
|
|
||||||
|
|
||||||
# -- auth command group --
|
# -- auth command group --
|
||||||
|
|
@ -1065,10 +1450,72 @@ def auth_status(instance, token):
|
||||||
# -- login command (OAuth device flow) --
|
# -- 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()
|
@cli.command()
|
||||||
@click.argument("alias_or_url", required=False, default=None)
|
@click.argument("alias_or_url", required=False, default=None)
|
||||||
@click.option("--scope", default=None, help="JSON scope array")
|
@click.option("--scope", default=None, help="JSON scope array")
|
||||||
def login(alias_or_url, scope):
|
@click.option("--read-all", is_flag=True, help="Request instance-wide read access")
|
||||||
|
@click.option("--write-all", is_flag=True, help="Request instance-wide write access")
|
||||||
|
@click.option(
|
||||||
|
"--read", multiple=True, help="Request read access for a database or database/table"
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--write",
|
||||||
|
multiple=True,
|
||||||
|
help="Request write access for a database or database/table",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--token-only",
|
||||||
|
is_flag=True,
|
||||||
|
help="Output the token to stdout instead of saving it",
|
||||||
|
)
|
||||||
|
def login(alias_or_url, scope, read_all, write_all, read, write, token_only):
|
||||||
"""
|
"""
|
||||||
Authenticate with a Datasette instance using OAuth
|
Authenticate with a Datasette instance using OAuth
|
||||||
|
|
||||||
|
|
@ -1081,7 +1528,13 @@ def login(alias_or_url, scope):
|
||||||
dclient login https://simon.datasette.cloud/
|
dclient login https://simon.datasette.cloud/
|
||||||
dclient login myalias
|
dclient login myalias
|
||||||
dclient login
|
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 = get_config_dir()
|
||||||
config_dir.mkdir(parents=True, exist_ok=True)
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
config_file = config_dir / "config.json"
|
config_file = config_dir / "config.json"
|
||||||
|
|
@ -1120,7 +1573,7 @@ def login(alias_or_url, scope):
|
||||||
interval = device_data.get("interval", 5)
|
interval = device_data.get("interval", 5)
|
||||||
|
|
||||||
# Step 2: Show instructions
|
# Step 2: Show instructions
|
||||||
click.echo(f"\nOpen this URL in your browser:\n")
|
click.echo("\nOpen this URL in your browser:\n")
|
||||||
click.echo(f" {verification_uri}\n")
|
click.echo(f" {verification_uri}\n")
|
||||||
click.echo(f"Enter this code: {user_code}\n")
|
click.echo(f"Enter this code: {user_code}\n")
|
||||||
click.echo("Waiting for authorization...", nl=False)
|
click.echo("Waiting for authorization...", nl=False)
|
||||||
|
|
@ -1154,15 +1607,67 @@ def login(alias_or_url, scope):
|
||||||
click.echo()
|
click.echo()
|
||||||
raise click.ClickException(f"Unexpected error: {error}")
|
raise click.ClickException(f"Unexpected error: {error}")
|
||||||
|
|
||||||
# Step 4: Save token
|
# Step 4: Save token (or print it)
|
||||||
click.echo()
|
click.echo()
|
||||||
access_token = token_data["access_token"]
|
access_token = token_data["access_token"]
|
||||||
|
if token_only:
|
||||||
|
click.echo(access_token)
|
||||||
|
return
|
||||||
auth_file = config_dir / "auth.json"
|
auth_file = config_dir / "auth.json"
|
||||||
auths = _load_auths(auth_file)
|
auths = _load_auths(auth_file)
|
||||||
auths[auth_key] = access_token
|
auths[auth_key] = access_token
|
||||||
auth_file.write_text(json.dumps(auths, indent=4))
|
auth_file.write_text(json.dumps(auths, indent=4))
|
||||||
click.echo(f"Login successful. Token saved for {auth_key}")
|
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 --
|
# -- v1 → v2 migration --
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,28 +13,7 @@ Once registered, you can pass an alias to commands using the `-i` flag:
|
||||||
|
|
||||||
dclient query fixtures "select * from news limit 1" -i latest
|
dclient query fixtures "select * from news limit 1" -i latest
|
||||||
|
|
||||||
## Default instance
|
See [Defaults](defaults.md) for default instance and default database settings.
|
||||||
|
|
||||||
Set a default instance so you don't need `-i` every time:
|
|
||||||
|
|
||||||
dclient alias default latest
|
|
||||||
|
|
||||||
Now commands will use `latest` automatically:
|
|
||||||
|
|
||||||
dclient databases
|
|
||||||
dclient tables -d fixtures
|
|
||||||
|
|
||||||
## Default database
|
|
||||||
|
|
||||||
Set a default database for an alias:
|
|
||||||
|
|
||||||
dclient alias default-db latest fixtures
|
|
||||||
|
|
||||||
Now you can run bare SQL queries directly:
|
|
||||||
|
|
||||||
dclient "select * from facetable limit 5"
|
|
||||||
|
|
||||||
This uses the default instance and default database.
|
|
||||||
|
|
||||||
## dclient alias --help
|
## dclient alias --help
|
||||||
<!-- [[[cog
|
<!-- [[[cog
|
||||||
|
|
@ -57,11 +36,9 @@ Options:
|
||||||
--help Show this message and exit.
|
--help Show this message and exit.
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
add Add an alias for a Datasette instance
|
add Add an alias for a Datasette instance
|
||||||
default Set or show the default instance
|
list List aliases
|
||||||
default-db Set or show the default database for an alias
|
remove Remove an alias
|
||||||
list List aliases
|
|
||||||
remove Remove an alias
|
|
||||||
|
|
||||||
```
|
```
|
||||||
<!-- [[[end]]] -->
|
<!-- [[[end]]] -->
|
||||||
|
|
@ -137,59 +114,3 @@ Options:
|
||||||
|
|
||||||
```
|
```
|
||||||
<!-- [[[end]]] -->
|
<!-- [[[end]]] -->
|
||||||
|
|
||||||
## dclient alias default --help
|
|
||||||
|
|
||||||
<!-- [[[cog
|
|
||||||
import cog
|
|
||||||
result = runner.invoke(cli.cli, ["alias", "default", "--help"])
|
|
||||||
help = result.output.replace("Usage: cli", "Usage: dclient")
|
|
||||||
cog.out(
|
|
||||||
"```\n{}\n```".format(help)
|
|
||||||
)
|
|
||||||
]]] -->
|
|
||||||
```
|
|
||||||
Usage: dclient alias default [OPTIONS] [NAME]
|
|
||||||
|
|
||||||
Set or show the default instance
|
|
||||||
|
|
||||||
Example usage:
|
|
||||||
|
|
||||||
dclient alias default prod
|
|
||||||
dclient alias default
|
|
||||||
dclient alias default --clear
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--clear Clear default instance
|
|
||||||
--help Show this message and exit.
|
|
||||||
|
|
||||||
```
|
|
||||||
<!-- [[[end]]] -->
|
|
||||||
|
|
||||||
## dclient alias default-db --help
|
|
||||||
|
|
||||||
<!-- [[[cog
|
|
||||||
import cog
|
|
||||||
result = runner.invoke(cli.cli, ["alias", "default-db", "--help"])
|
|
||||||
help = result.output.replace("Usage: cli", "Usage: dclient")
|
|
||||||
cog.out(
|
|
||||||
"```\n{}\n```".format(help)
|
|
||||||
)
|
|
||||||
]]] -->
|
|
||||||
```
|
|
||||||
Usage: dclient alias default-db [OPTIONS] ALIAS_NAME [DB]
|
|
||||||
|
|
||||||
Set or show the default database for an alias
|
|
||||||
|
|
||||||
Example usage:
|
|
||||||
|
|
||||||
dclient alias default-db prod main
|
|
||||||
dclient alias default-db prod
|
|
||||||
dclient alias default-db prod --clear
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--clear Clear default database for this alias
|
|
||||||
--help Show this message and exit.
|
|
||||||
|
|
||||||
```
|
|
||||||
<!-- [[[end]]] -->
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,108 @@ dclient query fixtures "select * from facetable" --token dstok_mytoken -i latest
|
||||||
|
|
||||||
A more convenient way to handle this is to store tokens for your aliases.
|
A more convenient way to handle this is to store tokens for your aliases.
|
||||||
|
|
||||||
|
## Logging in with OAuth
|
||||||
|
|
||||||
|
The easiest way to authenticate is using the `dclient login` command, which uses the OAuth device flow to obtain and store a token.
|
||||||
|
|
||||||
|
This requires the Datasette instance to be running the [datasette-oauth](https://github.com/datasette/datasette-oauth) plugin with [device flow enabled](https://github.com/datasette/datasette-oauth?tab=readme-ov-file#plugin-configuration).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dclient login https://my-datasette.example.com/
|
||||||
|
dclient login myalias
|
||||||
|
dclient login
|
||||||
|
```
|
||||||
|
|
||||||
|
This will display a URL and a code. Open the URL in your browser, enter the code to approve access, and the resulting API token will be saved automatically. If you run `dclient login` without an argument, you will be prompted for the instance URL or alias.
|
||||||
|
|
||||||
|
### Requesting scoped tokens
|
||||||
|
|
||||||
|
By default, `dclient login` requests an unrestricted token. You can request a token with limited permissions using the shorthand options:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instance-wide read or write access
|
||||||
|
dclient login --read-all
|
||||||
|
dclient login --write-all
|
||||||
|
|
||||||
|
# Database-level access
|
||||||
|
dclient login --read db1
|
||||||
|
dclient login --write db3
|
||||||
|
|
||||||
|
# Table-level access
|
||||||
|
dclient login --read db1/dogs
|
||||||
|
dclient login --write db3/submissions
|
||||||
|
|
||||||
|
# Mixed
|
||||||
|
dclient login --read db1 --write db3/dogs
|
||||||
|
```
|
||||||
|
|
||||||
|
`--read-all` grants: `view-instance`, `view-table`, `view-database`, `view-query`, `execute-sql`.
|
||||||
|
|
||||||
|
`--write-all` grants all read scopes plus: `insert-row`, `delete-row`, `update-row`, `create-table`, `alter-table`, `drop-table`.
|
||||||
|
|
||||||
|
`--read` and `--write` accept a database name or `database/table` and can be specified multiple times. `--write` implies read access for the same target.
|
||||||
|
|
||||||
|
For advanced use, you can also pass raw scope JSON with `--scope`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dclient login myalias --scope '[["view-instance"]]'
|
||||||
|
```
|
||||||
|
|
||||||
|
All scope options can be combined — the shorthand options append to whatever `--scope` provides.
|
||||||
|
|
||||||
|
### Outputting the token directly
|
||||||
|
|
||||||
|
Use `--token-only` to print the token to stdout instead of saving it. This is useful for creating debug tokens or piping them into other tools:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dclient login --token-only --read mydb
|
||||||
|
dclient login --token-only --write-all
|
||||||
|
```
|
||||||
|
|
||||||
|
## dclient login --help
|
||||||
|
|
||||||
|
<!-- [[[cog
|
||||||
|
import cog
|
||||||
|
from dclient import cli
|
||||||
|
from click.testing import CliRunner
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli.cli, ["login", "--help"])
|
||||||
|
help = result.output.replace("Usage: cli", "Usage: dclient")
|
||||||
|
cog.out(
|
||||||
|
"```\n{}\n```".format(help)
|
||||||
|
)
|
||||||
|
]]] -->
|
||||||
|
```
|
||||||
|
Usage: dclient login [OPTIONS] [ALIAS_OR_URL]
|
||||||
|
|
||||||
|
Authenticate with a Datasette instance using OAuth
|
||||||
|
|
||||||
|
Uses the OAuth device flow: opens a URL in your browser where you approve
|
||||||
|
access, then saves the resulting API token.
|
||||||
|
|
||||||
|
Example usage:
|
||||||
|
|
||||||
|
dclient login https://simon.datasette.cloud/
|
||||||
|
dclient login myalias
|
||||||
|
dclient login
|
||||||
|
dclient login --read-all
|
||||||
|
dclient login --write-all
|
||||||
|
dclient login --read db1
|
||||||
|
dclient login --write db3/submissions
|
||||||
|
dclient login --read db1 --write db3/dogs
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--scope TEXT JSON scope array
|
||||||
|
--read-all Request instance-wide read access
|
||||||
|
--write-all Request instance-wide write access
|
||||||
|
--read TEXT Request read access for a database or database/table
|
||||||
|
--write TEXT Request write access for a database or database/table
|
||||||
|
--token-only Output the token to stdout instead of saving it
|
||||||
|
--help Show this message and exit.
|
||||||
|
|
||||||
|
```
|
||||||
|
<!-- [[[end]]] -->
|
||||||
|
|
||||||
## Using stored tokens
|
## Using stored tokens
|
||||||
|
|
||||||
To store a token for an alias:
|
To store a token for an alias:
|
||||||
|
|
|
||||||
106
docs/defaults.md
Normal file
106
docs/defaults.md
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
# Defaults
|
||||||
|
|
||||||
|
Set a default instance so you don't need `-i` every time:
|
||||||
|
|
||||||
|
dclient default instance latest
|
||||||
|
|
||||||
|
Now commands will use `latest` automatically:
|
||||||
|
|
||||||
|
dclient databases
|
||||||
|
dclient tables -d fixtures
|
||||||
|
|
||||||
|
Set a default database for an alias or instance URL:
|
||||||
|
|
||||||
|
dclient default database latest fixtures
|
||||||
|
dclient default database https://latest.datasette.io fixtures
|
||||||
|
|
||||||
|
Now you can run bare SQL queries directly:
|
||||||
|
|
||||||
|
dclient "select * from facetable limit 5"
|
||||||
|
|
||||||
|
This uses the default instance and default database.
|
||||||
|
|
||||||
|
## dclient default --help
|
||||||
|
<!-- [[[cog
|
||||||
|
import cog
|
||||||
|
from dclient import cli
|
||||||
|
from click.testing import CliRunner
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli.cli, ["default", "--help"])
|
||||||
|
help = result.output.replace("Usage: cli", "Usage: dclient")
|
||||||
|
cog.out(
|
||||||
|
"```\n{}\n```".format(help)
|
||||||
|
)
|
||||||
|
]]] -->
|
||||||
|
```
|
||||||
|
Usage: dclient default [OPTIONS] COMMAND [ARGS]...
|
||||||
|
|
||||||
|
Manage default instance and database
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--help Show this message and exit.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
database Set or show the default database for an instance
|
||||||
|
instance Set or show the default instance
|
||||||
|
|
||||||
|
```
|
||||||
|
<!-- [[[end]]] -->
|
||||||
|
|
||||||
|
## dclient default instance --help
|
||||||
|
|
||||||
|
<!-- [[[cog
|
||||||
|
import cog
|
||||||
|
result = runner.invoke(cli.cli, ["default", "instance", "--help"])
|
||||||
|
help = result.output.replace("Usage: cli", "Usage: dclient")
|
||||||
|
cog.out(
|
||||||
|
"```\n{}\n```".format(help)
|
||||||
|
)
|
||||||
|
]]] -->
|
||||||
|
```
|
||||||
|
Usage: dclient default instance [OPTIONS] [ALIAS_OR_URL]
|
||||||
|
|
||||||
|
Set or show the default instance
|
||||||
|
|
||||||
|
Example usage:
|
||||||
|
|
||||||
|
dclient default instance prod
|
||||||
|
dclient default instance https://myapp.datasette.cloud
|
||||||
|
dclient default instance
|
||||||
|
dclient default instance --clear
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--clear Clear default instance
|
||||||
|
--help Show this message and exit.
|
||||||
|
|
||||||
|
```
|
||||||
|
<!-- [[[end]]] -->
|
||||||
|
|
||||||
|
## dclient default database --help
|
||||||
|
|
||||||
|
<!-- [[[cog
|
||||||
|
import cog
|
||||||
|
result = runner.invoke(cli.cli, ["default", "database", "--help"])
|
||||||
|
help = result.output.replace("Usage: cli", "Usage: dclient")
|
||||||
|
cog.out(
|
||||||
|
"```\n{}\n```".format(help)
|
||||||
|
)
|
||||||
|
]]] -->
|
||||||
|
```
|
||||||
|
Usage: dclient default database [OPTIONS] ALIAS_OR_URL [DB]
|
||||||
|
|
||||||
|
Set or show the default database for an instance
|
||||||
|
|
||||||
|
Example usage:
|
||||||
|
|
||||||
|
dclient default database prod main
|
||||||
|
dclient default database https://myapp.datasette.cloud main
|
||||||
|
dclient default database prod
|
||||||
|
dclient default database prod --clear
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--clear Clear default database for this instance
|
||||||
|
--help Show this message and exit.
|
||||||
|
|
||||||
|
```
|
||||||
|
<!-- [[[end]]] -->
|
||||||
|
|
@ -35,6 +35,7 @@ maxdepth: 3
|
||||||
---
|
---
|
||||||
queries
|
queries
|
||||||
aliases
|
aliases
|
||||||
|
defaults
|
||||||
authentication
|
authentication
|
||||||
inserting
|
inserting
|
||||||
environment
|
environment
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,43 @@
|
||||||
# Inserting data
|
# Inserting data
|
||||||
|
|
||||||
|
## Creating tables
|
||||||
|
|
||||||
|
The `dclient create-table` command creates a new empty table with an explicit schema. Define columns with `--column name type` and optionally set primary keys with `--pk`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dclient create-table mydb dogs \
|
||||||
|
--column id integer \
|
||||||
|
--column name text \
|
||||||
|
--column age integer \
|
||||||
|
--pk id \
|
||||||
|
-i myapp
|
||||||
|
```
|
||||||
|
|
||||||
|
This hits the Datasette [create API](https://docs.datasette.io/en/latest/json_api.html#the-json-write-api) with a `columns` array. The response includes the generated schema:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"database": "mydb",
|
||||||
|
"table": "dogs",
|
||||||
|
"schema": "CREATE TABLE [dogs] (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT,\n [age] INTEGER\n)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Compound primary keys are supported by passing `--pk` multiple times:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dclient create-table mydb events \
|
||||||
|
--column user_id integer \
|
||||||
|
--column event_id integer \
|
||||||
|
--column data text \
|
||||||
|
--pk user_id --pk event_id
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to create a table and populate it with data in one step, use `dclient insert --create` instead.
|
||||||
|
|
||||||
|
## Inserting rows
|
||||||
|
|
||||||
The `dclient insert` command can be used to insert data from a local file directly into a Datasette instance, via the [Write API](https://docs.datasette.io/en/latest/json_api.html#the-json-write-api) introduced in the Datasette 1.0 alphas.
|
The `dclient insert` command can be used to insert data from a local file directly into a Datasette instance, via the [Write API](https://docs.datasette.io/en/latest/json_api.html#the-json-write-api) introduced in the Datasette 1.0 alphas.
|
||||||
|
|
||||||
First you'll need to {ref}`authenticate <authentication>` with the instance.
|
First you'll need to {ref}`authenticate <authentication>` with the instance.
|
||||||
|
|
@ -132,7 +170,7 @@ Options:
|
||||||
table
|
table
|
||||||
--batch-size INTEGER Send rows in batches of this size
|
--batch-size INTEGER Send rows in batches of this size
|
||||||
--interval FLOAT Send batch at least every X seconds
|
--interval FLOAT Send batch at least every X seconds
|
||||||
-t, --token TEXT API token
|
--token TEXT API token
|
||||||
--silent Don't output progress
|
--silent Don't output progress
|
||||||
-v, --verbose Verbose output: show HTTP request and response
|
-v, --verbose Verbose output: show HTTP request and response
|
||||||
--replace Replace rows with a matching primary key
|
--replace Replace rows with a matching primary key
|
||||||
|
|
@ -174,10 +212,41 @@ Options:
|
||||||
table
|
table
|
||||||
--batch-size INTEGER Send rows in batches of this size
|
--batch-size INTEGER Send rows in batches of this size
|
||||||
--interval FLOAT Send batch at least every X seconds
|
--interval FLOAT Send batch at least every X seconds
|
||||||
-t, --token TEXT API token
|
--token TEXT API token
|
||||||
--silent Don't output progress
|
--silent Don't output progress
|
||||||
-v, --verbose Verbose output: show HTTP request and response
|
-v, --verbose Verbose output: show HTTP request and response
|
||||||
--help Show this message and exit.
|
--help Show this message and exit.
|
||||||
|
|
||||||
```
|
```
|
||||||
<!-- [[[end]]] -->
|
<!-- [[[end]]] -->
|
||||||
|
|
||||||
|
## dclient create-table --help
|
||||||
|
<!-- [[[cog
|
||||||
|
import cog
|
||||||
|
result = runner.invoke(cli.cli, ["create-table", "--help"])
|
||||||
|
help = result.output.replace("Usage: cli", "Usage: dclient")
|
||||||
|
cog.out(
|
||||||
|
"```\n{}\n```".format(help)
|
||||||
|
)
|
||||||
|
]]] -->
|
||||||
|
```
|
||||||
|
Usage: dclient create-table [OPTIONS] DATABASE TABLE_NAME
|
||||||
|
|
||||||
|
Create a new empty table with an explicit schema
|
||||||
|
|
||||||
|
Example usage:
|
||||||
|
|
||||||
|
dclient create-table mydb dogs \
|
||||||
|
--column id integer --column name text --pk id
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-c, --column TEXT... Column definition: name type (e.g. --column id integer
|
||||||
|
--column name text)
|
||||||
|
--pk TEXT Column(s) to use as primary key
|
||||||
|
-i, --instance TEXT Datasette instance URL or alias
|
||||||
|
--token TEXT API token
|
||||||
|
-v, --verbose Verbose output: show HTTP request and response
|
||||||
|
--help Show this message and exit.
|
||||||
|
|
||||||
|
```
|
||||||
|
<!-- [[[end]]] -->
|
||||||
|
|
|
||||||
164
docs/queries.md
164
docs/queries.md
|
|
@ -36,6 +36,166 @@ You can override just the database with `-d`:
|
||||||
dclient "select * from counters" -d counters
|
dclient "select * from counters" -d counters
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Browsing rows
|
||||||
|
|
||||||
|
The `dclient rows` command lets you browse table data without writing SQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dclient rows fixtures facet_cities -i https://latest.datasette.io -t
|
||||||
|
```
|
||||||
|
```
|
||||||
|
id name
|
||||||
|
-- -------------
|
||||||
|
3 Detroit
|
||||||
|
2 Los Angeles
|
||||||
|
4 Memnonia
|
||||||
|
1 San Francisco
|
||||||
|
```
|
||||||
|
|
||||||
|
If you have a default instance and database configured, you can just pass the table name:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dclient rows facet_cities -t
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filtering
|
||||||
|
|
||||||
|
Use `-f` / `--filter` with three arguments: column, operation, value:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dclient rows facet_cities -f id gte 3 -t
|
||||||
|
dclient rows facet_cities -f name eq Detroit
|
||||||
|
dclient rows facet_cities -f name contains M -f id gt 2
|
||||||
|
```
|
||||||
|
|
||||||
|
The operation is passed directly to Datasette as a column filter suffix. Built-in Datasette operations include `exact`, `not`, `gt`, `gte`, `lt`, `lte`, `contains`, `like`, `startswith`, `endswith`, `glob`, `isnull`, `notnull`, and more. `eq` is a convenience alias for `exact`. Operations added by Datasette plugins will work too.
|
||||||
|
|
||||||
|
### Sorting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dclient rows dogs --sort age
|
||||||
|
dclient rows dogs --sort-desc age
|
||||||
|
```
|
||||||
|
|
||||||
|
### Column selection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dclient rows dogs --col name --col age
|
||||||
|
dclient rows dogs --nocol id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
Full-text search (requires an FTS index on the table):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dclient rows dogs --search "retriever"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pagination
|
||||||
|
|
||||||
|
By default only one page of results is returned. Use `--all` to auto-paginate through all rows, and `--limit` to cap the total:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dclient rows dogs --all
|
||||||
|
dclient rows dogs --all --limit 500
|
||||||
|
dclient rows dogs --size 50
|
||||||
|
```
|
||||||
|
|
||||||
|
### dclient rows --help
|
||||||
|
<!-- [[[cog
|
||||||
|
import cog
|
||||||
|
from dclient import cli
|
||||||
|
from click.testing import CliRunner
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli.cli, ["rows", "--help"])
|
||||||
|
help = result.output.replace("Usage: cli", "Usage: dclient")
|
||||||
|
cog.out(
|
||||||
|
"```\n{}\n```".format(help)
|
||||||
|
)
|
||||||
|
]]] -->
|
||||||
|
```
|
||||||
|
Usage: dclient rows [OPTIONS] DB_OR_TABLE [TABLE]
|
||||||
|
|
||||||
|
Browse rows in a table with filtering and sorting
|
||||||
|
|
||||||
|
If only one positional argument is given, it is treated as the table name and
|
||||||
|
the default database is used. Pass two arguments for database and table.
|
||||||
|
|
||||||
|
Example usage:
|
||||||
|
|
||||||
|
dclient rows facet_cities
|
||||||
|
dclient rows fixtures facet_cities -i https://latest.datasette.io
|
||||||
|
dclient rows facet_cities -f id gte 3 --sort name -t
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-i, --instance TEXT Datasette instance URL or alias
|
||||||
|
-d, --database TEXT Database name
|
||||||
|
--token TEXT API token
|
||||||
|
-f, --filter TEXT... Filter: column operation value (e.g. -f age gte 3)
|
||||||
|
--search TEXT Full-text search query
|
||||||
|
--sort TEXT Sort by column (ascending)
|
||||||
|
--sort-desc TEXT Sort by column (descending)
|
||||||
|
--col TEXT Include only these columns
|
||||||
|
--nocol TEXT Exclude these columns
|
||||||
|
--size INTEGER Number of rows per page
|
||||||
|
--limit INTEGER Maximum total rows to return
|
||||||
|
--all Fetch all pages
|
||||||
|
-v, --verbose Verbose output: show HTTP request
|
||||||
|
--csv Output as CSV
|
||||||
|
--tsv Output as TSV
|
||||||
|
--nl Output as newline-delimited JSON
|
||||||
|
-t, --table Output as ASCII table
|
||||||
|
--help Show this message and exit.
|
||||||
|
|
||||||
|
```
|
||||||
|
<!-- [[[end]]] -->
|
||||||
|
|
||||||
|
## Output formats
|
||||||
|
|
||||||
|
By default, results are returned as JSON. Use these flags to change the output format:
|
||||||
|
|
||||||
|
- `--csv` — CSV
|
||||||
|
- `--tsv` — TSV
|
||||||
|
- `-t` / `--table` — ASCII table
|
||||||
|
- `--nl` — newline-delimited JSON (one JSON object per line)
|
||||||
|
|
||||||
|
These flags work with `dclient query`, `dclient rows`, and the bare SQL shortcut.
|
||||||
|
|
||||||
|
CSV output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dclient query fixtures "select * from facetable limit 2" -i latest --csv
|
||||||
|
```
|
||||||
|
```
|
||||||
|
pk,created,planet_int,on_earth,state,_city_id,_neighborhood
|
||||||
|
1,2019-01-14 08:00:00,1,1,CA,1,Mission
|
||||||
|
2,2019-01-15 08:00:00,1,1,CA,1,Dogpatch
|
||||||
|
```
|
||||||
|
|
||||||
|
ASCII table output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dclient query fixtures "select pk, state, _neighborhood from facetable limit 3" -i latest -t
|
||||||
|
```
|
||||||
|
```
|
||||||
|
pk state _neighborhood
|
||||||
|
-- ----- -------------
|
||||||
|
1 CA Mission
|
||||||
|
2 CA Dogpatch
|
||||||
|
3 CA SOMA
|
||||||
|
```
|
||||||
|
|
||||||
|
Newline-delimited JSON, useful for piping into `jq` or other line-oriented tools:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dclient query fixtures "select pk, state from facetable limit 2" -i latest --nl
|
||||||
|
```
|
||||||
|
```
|
||||||
|
{"pk": 1, "state": "CA"}
|
||||||
|
{"pk": 2, "state": "CA"}
|
||||||
|
```
|
||||||
|
|
||||||
## dclient query --help
|
## dclient query --help
|
||||||
<!-- [[[cog
|
<!-- [[[cog
|
||||||
import cog
|
import cog
|
||||||
|
|
@ -64,6 +224,10 @@ Options:
|
||||||
-i, --instance TEXT Datasette instance URL or alias
|
-i, --instance TEXT Datasette instance URL or alias
|
||||||
--token TEXT API token
|
--token TEXT API token
|
||||||
-v, --verbose Verbose output: show HTTP request
|
-v, --verbose Verbose output: show HTTP request
|
||||||
|
--csv Output as CSV
|
||||||
|
--tsv Output as TSV
|
||||||
|
--nl Output as newline-delimited JSON
|
||||||
|
-t, --table Output as ASCII table
|
||||||
--help Show this message and exit.
|
--help Show this message and exit.
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "dclient"
|
name = "dclient"
|
||||||
version = "0.5a0"
|
version = "0.5a3"
|
||||||
description = "A client CLI utility for Datasette instances"
|
description = "A client CLI utility for Datasette instances"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{name = "Simon Willison"}]
|
authors = [{name = "Simon Willison"}]
|
||||||
|
|
@ -9,7 +9,7 @@ requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"click",
|
"click",
|
||||||
"click-default-group",
|
"click-default-group",
|
||||||
"httpx",
|
"httpx<1.0",
|
||||||
"sqlite-utils",
|
"sqlite-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -26,7 +26,7 @@ dclient = "dclient.cli:cli"
|
||||||
client = "dclient.plugin"
|
client = "dclient.plugin"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
test = [
|
||||||
"pytest",
|
"pytest",
|
||||||
"pytest-asyncio",
|
"pytest-asyncio",
|
||||||
"pytest-httpx",
|
"pytest-httpx",
|
||||||
|
|
@ -34,6 +34,17 @@ dev = [
|
||||||
"pytest-mock",
|
"pytest-mock",
|
||||||
"datasette>=1.0a25",
|
"datasette>=1.0a25",
|
||||||
]
|
]
|
||||||
|
docs = [
|
||||||
|
"furo",
|
||||||
|
"sphinx-autobuild",
|
||||||
|
"sphinx-copybutton",
|
||||||
|
"myst-parser",
|
||||||
|
"cogapp",
|
||||||
|
]
|
||||||
|
dev = [
|
||||||
|
{include-group = "test"},
|
||||||
|
{include-group = "docs"},
|
||||||
|
]
|
||||||
|
|
||||||
[tool.uv.build-backend]
|
[tool.uv.build-backend]
|
||||||
module-root = ""
|
module-root = ""
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Tests for v2 alias command: default, default-db subcommands."""
|
"""Tests for v2 defaults command: instance and database subcommands."""
|
||||||
|
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
from dclient.cli import cli
|
from dclient.cli import cli
|
||||||
|
|
@ -15,16 +15,16 @@ def test_alias_default_workflow(mocker, tmpdir):
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
# No default yet
|
# No default yet
|
||||||
result = runner.invoke(cli, ["alias", "default"])
|
result = runner.invoke(cli, ["default", "instance"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "No default instance set" in result.output
|
assert "No default instance set" in result.output
|
||||||
|
|
||||||
# Set default
|
# Set default
|
||||||
result = runner.invoke(cli, ["alias", "default", "prod"])
|
result = runner.invoke(cli, ["default", "instance", "prod"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
# Show default
|
# Show default
|
||||||
result = runner.invoke(cli, ["alias", "default"])
|
result = runner.invoke(cli, ["default", "instance"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert result.output.strip() == "prod"
|
assert result.output.strip() == "prod"
|
||||||
|
|
||||||
|
|
@ -34,17 +34,17 @@ def test_alias_default_workflow(mocker, tmpdir):
|
||||||
assert "* prod" in result.output
|
assert "* prod" in result.output
|
||||||
|
|
||||||
# Clear default
|
# Clear default
|
||||||
result = runner.invoke(cli, ["alias", "default", "--clear"])
|
result = runner.invoke(cli, ["default", "instance", "--clear"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
result = runner.invoke(cli, ["alias", "default"])
|
result = runner.invoke(cli, ["default", "instance"])
|
||||||
assert "No default instance set" in result.output
|
assert "No default instance set" in result.output
|
||||||
|
|
||||||
|
|
||||||
def test_alias_default_unknown_alias(mocker, tmpdir):
|
def test_alias_default_unknown_alias(mocker, tmpdir):
|
||||||
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli, ["alias", "default", "nonexistent"])
|
result = runner.invoke(cli, ["default", "instance", "nonexistent"])
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "No such alias" in result.output
|
assert "No such alias" in result.output
|
||||||
|
|
||||||
|
|
@ -58,16 +58,16 @@ def test_alias_default_db_workflow(mocker, tmpdir):
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
# No default database yet
|
# No default database yet
|
||||||
result = runner.invoke(cli, ["alias", "default-db", "prod"])
|
result = runner.invoke(cli, ["default", "database", "prod"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "No default database set" in result.output
|
assert "No default database set" in result.output
|
||||||
|
|
||||||
# Set default database
|
# Set default database
|
||||||
result = runner.invoke(cli, ["alias", "default-db", "prod", "main"])
|
result = runner.invoke(cli, ["default", "database", "prod", "main"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
# Show default database
|
# Show default database
|
||||||
result = runner.invoke(cli, ["alias", "default-db", "prod"])
|
result = runner.invoke(cli, ["default", "database", "prod"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert result.output.strip() == "main"
|
assert result.output.strip() == "main"
|
||||||
|
|
||||||
|
|
@ -76,17 +76,17 @@ def test_alias_default_db_workflow(mocker, tmpdir):
|
||||||
assert "(db: main)" in result.output
|
assert "(db: main)" in result.output
|
||||||
|
|
||||||
# Clear default database
|
# Clear default database
|
||||||
result = runner.invoke(cli, ["alias", "default-db", "prod", "--clear"])
|
result = runner.invoke(cli, ["default", "database", "prod", "--clear"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
result = runner.invoke(cli, ["alias", "default-db", "prod"])
|
result = runner.invoke(cli, ["default", "database", "prod"])
|
||||||
assert "No default database set" in result.output
|
assert "No default database set" in result.output
|
||||||
|
|
||||||
|
|
||||||
def test_alias_default_db_unknown_alias(mocker, tmpdir):
|
def test_alias_default_db_unknown_alias(mocker, tmpdir):
|
||||||
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli, ["alias", "default-db", "nonexistent", "main"])
|
result = runner.invoke(cli, ["default", "database", "nonexistent", "main"])
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "No such alias" in result.output
|
assert "No such alias" in result.output
|
||||||
|
|
||||||
|
|
@ -97,7 +97,7 @@ def test_alias_remove_clears_default(mocker, tmpdir):
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|
||||||
runner.invoke(cli, ["alias", "add", "prod", "https://prod.example.com"])
|
runner.invoke(cli, ["alias", "add", "prod", "https://prod.example.com"])
|
||||||
runner.invoke(cli, ["alias", "default", "prod"])
|
runner.invoke(cli, ["default", "instance", "prod"])
|
||||||
|
|
||||||
# Verify it's set
|
# Verify it's set
|
||||||
config = json.loads((pathlib.Path(tmpdir) / "config.json").read_text())
|
config = json.loads((pathlib.Path(tmpdir) / "config.json").read_text())
|
||||||
|
|
@ -109,3 +109,31 @@ def test_alias_remove_clears_default(mocker, tmpdir):
|
||||||
config = json.loads((pathlib.Path(tmpdir) / "config.json").read_text())
|
config = json.loads((pathlib.Path(tmpdir) / "config.json").read_text())
|
||||||
assert config["default_instance"] is None
|
assert config["default_instance"] is None
|
||||||
assert "prod" not in config["instances"]
|
assert "prod" not in config["instances"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_instance_accepts_url(mocker, tmpdir):
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
runner.invoke(cli, ["alias", "add", "prod", "https://prod.example.com"])
|
||||||
|
result = runner.invoke(cli, ["default", "instance", "https://prod.example.com"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
result = runner.invoke(cli, ["default", "instance"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.output.strip() == "prod"
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_database_accepts_url(mocker, tmpdir):
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
runner.invoke(cli, ["alias", "add", "prod", "https://prod.example.com"])
|
||||||
|
result = runner.invoke(
|
||||||
|
cli, ["default", "database", "https://prod.example.com", "main"]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
result = runner.invoke(cli, ["default", "database", "prod"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.output.strip() == "main"
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ def test_login_with_url(httpx_mock, mocker, tmpdir):
|
||||||
mocker.patch("dclient.cli.time.sleep")
|
mocker.patch("dclient.cli.time.sleep")
|
||||||
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
|
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
|
||||||
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
||||||
|
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli, ["login", "https://example.com/"])
|
result = runner.invoke(cli, ["login", "https://example.com/"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
@ -80,6 +81,7 @@ def test_login_adds_trailing_slash(httpx_mock, mocker, tmpdir):
|
||||||
mocker.patch("dclient.cli.time.sleep")
|
mocker.patch("dclient.cli.time.sleep")
|
||||||
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
|
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
|
||||||
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
||||||
|
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli, ["login", "https://example.com"])
|
result = runner.invoke(cli, ["login", "https://example.com"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
@ -108,6 +110,7 @@ def test_login_with_alias(httpx_mock, mocker, tmpdir):
|
||||||
)
|
)
|
||||||
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
|
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
|
||||||
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
||||||
|
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli, ["login", "prod"])
|
result = runner.invoke(cli, ["login", "prod"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
@ -123,6 +126,7 @@ def test_login_interactive_prompt(httpx_mock, mocker, tmpdir):
|
||||||
mocker.patch("dclient.cli.time.sleep")
|
mocker.patch("dclient.cli.time.sleep")
|
||||||
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
|
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
|
||||||
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
||||||
|
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli, ["login"], input="https://example.com/\n")
|
result = runner.invoke(cli, ["login"], input="https://example.com/\n")
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
@ -160,6 +164,7 @@ def test_login_pending_then_success(httpx_mock, mocker, tmpdir):
|
||||||
httpx_mock.add_response(json={"error": "authorization_pending"}, status_code=400)
|
httpx_mock.add_response(json={"error": "authorization_pending"}, status_code=400)
|
||||||
# Second poll: success
|
# Second poll: success
|
||||||
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
||||||
|
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli, ["login", "https://example.com/"])
|
result = runner.invoke(cli, ["login", "https://example.com/"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
@ -176,3 +181,343 @@ def test_login_device_endpoint_error(httpx_mock, mocker, tmpdir):
|
||||||
result = runner.invoke(cli, ["login", "https://example.com/"])
|
result = runner.invoke(cli, ["login", "https://example.com/"])
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "Failed to start login flow" in result.output
|
assert "Failed to start login flow" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
# -- login sets defaults --
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"databases_response,expected_db",
|
||||||
|
[
|
||||||
|
([{"name": "mydata", "is_mutable": True}], "mydata"),
|
||||||
|
(
|
||||||
|
[
|
||||||
|
{"name": "fixtures", "is_mutable": False},
|
||||||
|
{"name": "data", "is_mutable": True},
|
||||||
|
{"name": "extra", "is_mutable": True},
|
||||||
|
],
|
||||||
|
"data",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[
|
||||||
|
{"name": "alpha", "is_mutable": True},
|
||||||
|
{"name": "beta", "is_mutable": True},
|
||||||
|
{"name": "gamma", "is_mutable": False},
|
||||||
|
],
|
||||||
|
"alpha",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ids=["single_db", "prefers_data", "first_when_no_data"],
|
||||||
|
)
|
||||||
|
def test_login_sets_defaults(
|
||||||
|
httpx_mock, mocker, tmpdir, databases_response, expected_db
|
||||||
|
):
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
mocker.patch("dclient.cli.time.sleep")
|
||||||
|
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
|
||||||
|
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
||||||
|
httpx_mock.add_response(json=databases_response, status_code=200)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["login", "https://example.com/"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
config = json.loads((pathlib.Path(tmpdir) / "config.json").read_text())
|
||||||
|
assert config["default_instance"] == "https://example.com/"
|
||||||
|
assert (
|
||||||
|
config["instances"]["https://example.com/"]["default_database"] == expected_db
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_does_not_override_existing_defaults(httpx_mock, mocker, tmpdir):
|
||||||
|
"""When defaults are already configured, login should not change them."""
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
mocker.patch("dclient.cli.time.sleep")
|
||||||
|
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||||
|
config_file.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"default_instance": "prod",
|
||||||
|
"instances": {
|
||||||
|
"prod": {
|
||||||
|
"url": "https://prod.example.com",
|
||||||
|
"default_database": "main",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
|
||||||
|
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["login", "https://other.example.com/"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
config = json.loads(config_file.read_text())
|
||||||
|
assert config["default_instance"] == "prod"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_with_alias_sets_defaults(httpx_mock, mocker, tmpdir):
|
||||||
|
"""When logging in with an alias that has no default_database, set it."""
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
mocker.patch("dclient.cli.time.sleep")
|
||||||
|
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||||
|
config_file.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"default_instance": None,
|
||||||
|
"instances": {
|
||||||
|
"prod": {
|
||||||
|
"url": "https://prod.example.com",
|
||||||
|
"default_database": None,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
|
||||||
|
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
||||||
|
httpx_mock.add_response(
|
||||||
|
json=[
|
||||||
|
{"name": "fixtures", "is_mutable": False},
|
||||||
|
{"name": "data", "is_mutable": True},
|
||||||
|
{"name": "extra", "is_mutable": True},
|
||||||
|
],
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["login", "prod"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
config = json.loads(config_file.read_text())
|
||||||
|
assert config["default_instance"] == "prod"
|
||||||
|
assert config["instances"]["prod"]["default_database"] == "data"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_read_all(httpx_mock, mocker, tmpdir):
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
mocker.patch("dclient.cli.time.sleep")
|
||||||
|
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
|
||||||
|
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
||||||
|
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["login", "https://example.com/", "--read-all"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
request = httpx_mock.get_requests()[0]
|
||||||
|
body = parse_qs(request.content.decode())
|
||||||
|
scope = json.loads(body["scope"][0])
|
||||||
|
assert scope == [
|
||||||
|
["view-instance"],
|
||||||
|
["view-table"],
|
||||||
|
["view-database"],
|
||||||
|
["view-query"],
|
||||||
|
["execute-sql"],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_write_all(httpx_mock, mocker, tmpdir):
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
mocker.patch("dclient.cli.time.sleep")
|
||||||
|
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
|
||||||
|
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
||||||
|
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["login", "https://example.com/", "--write-all"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
request = httpx_mock.get_requests()[0]
|
||||||
|
body = parse_qs(request.content.decode())
|
||||||
|
scope = json.loads(body["scope"][0])
|
||||||
|
assert scope == [
|
||||||
|
["view-instance"],
|
||||||
|
["view-table"],
|
||||||
|
["view-database"],
|
||||||
|
["view-query"],
|
||||||
|
["execute-sql"],
|
||||||
|
["insert-row"],
|
||||||
|
["delete-row"],
|
||||||
|
["update-row"],
|
||||||
|
["create-table"],
|
||||||
|
["alter-table"],
|
||||||
|
["drop-table"],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_read_database(httpx_mock, mocker, tmpdir):
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
mocker.patch("dclient.cli.time.sleep")
|
||||||
|
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
|
||||||
|
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
||||||
|
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["login", "https://example.com/", "--read", "db1"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
request = httpx_mock.get_requests()[0]
|
||||||
|
body = parse_qs(request.content.decode())
|
||||||
|
scope = json.loads(body["scope"][0])
|
||||||
|
assert scope == [
|
||||||
|
["view-instance", "db1"],
|
||||||
|
["view-table", "db1"],
|
||||||
|
["view-database", "db1"],
|
||||||
|
["view-query", "db1"],
|
||||||
|
["execute-sql", "db1"],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_write_table(httpx_mock, mocker, tmpdir):
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
mocker.patch("dclient.cli.time.sleep")
|
||||||
|
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
|
||||||
|
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
||||||
|
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli, ["login", "https://example.com/", "--write", "db3/submissions"]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
request = httpx_mock.get_requests()[0]
|
||||||
|
body = parse_qs(request.content.decode())
|
||||||
|
scope = json.loads(body["scope"][0])
|
||||||
|
assert scope == [
|
||||||
|
["view-instance", "db3", "submissions"],
|
||||||
|
["view-table", "db3", "submissions"],
|
||||||
|
["view-database", "db3", "submissions"],
|
||||||
|
["view-query", "db3", "submissions"],
|
||||||
|
["execute-sql", "db3", "submissions"],
|
||||||
|
["insert-row", "db3", "submissions"],
|
||||||
|
["delete-row", "db3", "submissions"],
|
||||||
|
["update-row", "db3", "submissions"],
|
||||||
|
["create-table", "db3", "submissions"],
|
||||||
|
["alter-table", "db3", "submissions"],
|
||||||
|
["drop-table", "db3", "submissions"],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_mixed_read_write(httpx_mock, mocker, tmpdir):
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
mocker.patch("dclient.cli.time.sleep")
|
||||||
|
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
|
||||||
|
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
||||||
|
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
["login", "https://example.com/", "--read", "db1", "--write", "db3/dogs"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
request = httpx_mock.get_requests()[0]
|
||||||
|
body = parse_qs(request.content.decode())
|
||||||
|
scope = json.loads(body["scope"][0])
|
||||||
|
assert scope == [
|
||||||
|
["view-instance", "db1"],
|
||||||
|
["view-table", "db1"],
|
||||||
|
["view-database", "db1"],
|
||||||
|
["view-query", "db1"],
|
||||||
|
["execute-sql", "db1"],
|
||||||
|
["view-instance", "db3", "dogs"],
|
||||||
|
["view-table", "db3", "dogs"],
|
||||||
|
["view-database", "db3", "dogs"],
|
||||||
|
["view-query", "db3", "dogs"],
|
||||||
|
["execute-sql", "db3", "dogs"],
|
||||||
|
["insert-row", "db3", "dogs"],
|
||||||
|
["delete-row", "db3", "dogs"],
|
||||||
|
["update-row", "db3", "dogs"],
|
||||||
|
["create-table", "db3", "dogs"],
|
||||||
|
["alter-table", "db3", "dogs"],
|
||||||
|
["drop-table", "db3", "dogs"],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_scope_combined_with_shortcuts(httpx_mock, mocker, tmpdir):
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
mocker.patch("dclient.cli.time.sleep")
|
||||||
|
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
|
||||||
|
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
||||||
|
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"login",
|
||||||
|
"https://example.com/",
|
||||||
|
"--scope",
|
||||||
|
'[["view-instance"]]',
|
||||||
|
"--write",
|
||||||
|
"db1/dogs",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
request = httpx_mock.get_requests()[0]
|
||||||
|
body = parse_qs(request.content.decode())
|
||||||
|
scope = json.loads(body["scope"][0])
|
||||||
|
assert scope == [
|
||||||
|
["view-instance"],
|
||||||
|
["view-instance", "db1", "dogs"],
|
||||||
|
["view-table", "db1", "dogs"],
|
||||||
|
["view-database", "db1", "dogs"],
|
||||||
|
["view-query", "db1", "dogs"],
|
||||||
|
["execute-sql", "db1", "dogs"],
|
||||||
|
["insert-row", "db1", "dogs"],
|
||||||
|
["delete-row", "db1", "dogs"],
|
||||||
|
["update-row", "db1", "dogs"],
|
||||||
|
["create-table", "db1", "dogs"],
|
||||||
|
["alter-table", "db1", "dogs"],
|
||||||
|
["drop-table", "db1", "dogs"],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_no_scope_sends_no_scope(httpx_mock, mocker, tmpdir):
|
||||||
|
"""Without any scope options, no scope field should be sent."""
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
mocker.patch("dclient.cli.time.sleep")
|
||||||
|
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
|
||||||
|
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
||||||
|
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["login", "https://example.com/"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
request = httpx_mock.get_requests()[0]
|
||||||
|
assert request.content == b""
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_token_only(httpx_mock, mocker, tmpdir):
|
||||||
|
"""--token-only prints the token to stdout and does not save it."""
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
mocker.patch("dclient.cli.time.sleep")
|
||||||
|
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
|
||||||
|
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli, ["login", "https://example.com/", "--token-only", "--read", "foo/bar"]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
# Last line of output should be the raw token
|
||||||
|
assert result.output.strip().endswith("dstok_abc123")
|
||||||
|
# Should NOT have "Login successful" message
|
||||||
|
assert "Login successful" not in result.output
|
||||||
|
# auth.json should not exist
|
||||||
|
auth_file = pathlib.Path(tmpdir) / "auth.json"
|
||||||
|
assert not auth_file.exists()
|
||||||
|
# config.json should not exist (no defaults set)
|
||||||
|
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||||
|
assert not config_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_databases_error_still_succeeds(httpx_mock, mocker, tmpdir):
|
||||||
|
"""If the databases check fails, login should still succeed."""
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
mocker.patch("dclient.cli.time.sleep")
|
||||||
|
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
|
||||||
|
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
|
||||||
|
httpx_mock.add_response(status_code=500)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["login", "https://example.com/"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "Login successful" in result.output
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ from click.testing import CliRunner
|
||||||
from dclient.cli import cli
|
from dclient.cli import cli
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
import pytest
|
|
||||||
|
|
||||||
# -- databases command --
|
# -- databases command --
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ from dclient.cli import (
|
||||||
_resolve_instance,
|
_resolve_instance,
|
||||||
_resolve_database,
|
_resolve_database,
|
||||||
_resolve_token,
|
_resolve_token,
|
||||||
get_config_dir,
|
|
||||||
)
|
)
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
|
||||||
203
tests/test_create_table.py
Normal file
203
tests/test_create_table.py
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
"""Tests for the create-table command."""
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
from dclient.cli import cli
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_table_basic(httpx_mock, mocker, tmpdir):
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
httpx_mock.add_response(
|
||||||
|
json={
|
||||||
|
"ok": True,
|
||||||
|
"database": "mydb",
|
||||||
|
"table": "dogs",
|
||||||
|
"table_url": "http://example.com/mydb/dogs",
|
||||||
|
"table_api_url": "http://example.com/mydb/dogs.json",
|
||||||
|
"schema": "CREATE TABLE [dogs] (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT\n)",
|
||||||
|
},
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"create-table",
|
||||||
|
"mydb",
|
||||||
|
"dogs",
|
||||||
|
"--column",
|
||||||
|
"id",
|
||||||
|
"integer",
|
||||||
|
"--column",
|
||||||
|
"name",
|
||||||
|
"text",
|
||||||
|
"--pk",
|
||||||
|
"id",
|
||||||
|
"-i",
|
||||||
|
"https://example.com",
|
||||||
|
"--token",
|
||||||
|
"tok",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
data = json.loads(result.output)
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert data["table"] == "dogs"
|
||||||
|
|
||||||
|
# Verify request
|
||||||
|
request = httpx_mock.get_request()
|
||||||
|
assert request.url.path == "/mydb/-/create"
|
||||||
|
assert request.headers["authorization"] == "Bearer tok"
|
||||||
|
body = json.loads(request.read())
|
||||||
|
assert body["table"] == "dogs"
|
||||||
|
assert body["columns"] == [
|
||||||
|
{"name": "id", "type": "integer"},
|
||||||
|
{"name": "name", "type": "text"},
|
||||||
|
]
|
||||||
|
assert body["pk"] == "id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_table_compound_pk(httpx_mock, mocker, tmpdir):
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
httpx_mock.add_response(json={"ok": True}, status_code=201)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"create-table",
|
||||||
|
"mydb",
|
||||||
|
"events",
|
||||||
|
"--column",
|
||||||
|
"user_id",
|
||||||
|
"integer",
|
||||||
|
"-c",
|
||||||
|
"event_id",
|
||||||
|
"integer",
|
||||||
|
"--column",
|
||||||
|
"data",
|
||||||
|
"text",
|
||||||
|
"--pk",
|
||||||
|
"user_id",
|
||||||
|
"--pk",
|
||||||
|
"event_id",
|
||||||
|
"-i",
|
||||||
|
"https://example.com",
|
||||||
|
"--token",
|
||||||
|
"tok",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
body = json.loads(httpx_mock.get_request().read())
|
||||||
|
assert body["pks"] == ["user_id", "event_id"]
|
||||||
|
assert "pk" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_table_no_pk(httpx_mock, mocker, tmpdir):
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
httpx_mock.add_response(json={"ok": True}, status_code=201)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"create-table",
|
||||||
|
"mydb",
|
||||||
|
"logs",
|
||||||
|
"--column",
|
||||||
|
"message",
|
||||||
|
"text",
|
||||||
|
"--column",
|
||||||
|
"level",
|
||||||
|
"integer",
|
||||||
|
"-i",
|
||||||
|
"https://example.com",
|
||||||
|
"--token",
|
||||||
|
"tok",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
body = json.loads(httpx_mock.get_request().read())
|
||||||
|
assert "pk" not in body
|
||||||
|
assert "pks" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_table_no_columns_error(mocker, tmpdir):
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"create-table",
|
||||||
|
"mydb",
|
||||||
|
"empty",
|
||||||
|
"-i",
|
||||||
|
"https://example.com",
|
||||||
|
"--token",
|
||||||
|
"tok",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "at least one --column" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_table_api_error(httpx_mock, mocker, tmpdir):
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
httpx_mock.add_response(
|
||||||
|
json={"ok": False, "errors": ["Table already exists: dogs"]},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"create-table",
|
||||||
|
"mydb",
|
||||||
|
"dogs",
|
||||||
|
"--column",
|
||||||
|
"id",
|
||||||
|
"integer",
|
||||||
|
"-i",
|
||||||
|
"https://example.com",
|
||||||
|
"--token",
|
||||||
|
"tok",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Table already exists" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_table_uses_default_instance(httpx_mock, mocker, tmpdir):
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||||
|
config_file.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"default_instance": "prod",
|
||||||
|
"instances": {
|
||||||
|
"prod": {
|
||||||
|
"url": "https://prod.example.com",
|
||||||
|
"default_database": "main",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
httpx_mock.add_response(json={"ok": True}, status_code=201)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"create-table",
|
||||||
|
"mydb",
|
||||||
|
"t1",
|
||||||
|
"--column",
|
||||||
|
"id",
|
||||||
|
"integer",
|
||||||
|
"--token",
|
||||||
|
"tok",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
request = httpx_mock.get_request()
|
||||||
|
assert request.url.host == "prod.example.com"
|
||||||
|
assert request.url.path == "/mydb/-/create"
|
||||||
267
tests/test_output_formats.py
Normal file
267
tests/test_output_formats.py
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
"""Tests for multiple output formats on the query and default_query commands."""
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
from dclient.cli import cli
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
QUERY_RESPONSE = {
|
||||||
|
"ok": True,
|
||||||
|
"database": "fixtures",
|
||||||
|
"query_name": None,
|
||||||
|
"rows": [
|
||||||
|
{"id": 1, "name": "Cleo", "age": 5},
|
||||||
|
{"id": 2, "name": "Pancakes", "age": 3},
|
||||||
|
],
|
||||||
|
"truncated": False,
|
||||||
|
"columns": ["id", "name", "age"],
|
||||||
|
"query": {"sql": "select * from dogs", "params": {}},
|
||||||
|
"error": None,
|
||||||
|
"private": False,
|
||||||
|
"allow_execute_sql": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_and_invoke(httpx_mock, extra_args=None):
|
||||||
|
httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200)
|
||||||
|
runner = CliRunner()
|
||||||
|
args = ["query", "fixtures", "select * from dogs", "-i", "https://example.com"]
|
||||||
|
if extra_args:
|
||||||
|
args.extend(extra_args)
|
||||||
|
return runner.invoke(cli, args)
|
||||||
|
|
||||||
|
|
||||||
|
# -- query --csv --
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_csv(httpx_mock):
|
||||||
|
result = _mock_and_invoke(httpx_mock, ["--csv"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
lines = result.output.strip().split("\n")
|
||||||
|
assert lines[0] == "id,name,age"
|
||||||
|
assert lines[1] == "1,Cleo,5"
|
||||||
|
assert lines[2] == "2,Pancakes,3"
|
||||||
|
|
||||||
|
|
||||||
|
# -- query --tsv --
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_tsv(httpx_mock):
|
||||||
|
result = _mock_and_invoke(httpx_mock, ["--tsv"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
lines = result.output.strip().split("\n")
|
||||||
|
assert lines[0] == "id\tname\tage"
|
||||||
|
assert lines[1] == "1\tCleo\t5"
|
||||||
|
assert lines[2] == "2\tPancakes\t3"
|
||||||
|
|
||||||
|
|
||||||
|
# -- query --nl --
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_nl(httpx_mock):
|
||||||
|
result = _mock_and_invoke(httpx_mock, ["--nl"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
lines = result.output.strip().split("\n")
|
||||||
|
assert len(lines) == 2
|
||||||
|
assert json.loads(lines[0]) == {"id": 1, "name": "Cleo", "age": 5}
|
||||||
|
assert json.loads(lines[1]) == {"id": 2, "name": "Pancakes", "age": 3}
|
||||||
|
|
||||||
|
|
||||||
|
# -- query --table --
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_table(httpx_mock):
|
||||||
|
result = _mock_and_invoke(httpx_mock, ["--table"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
lines = result.output.strip().split("\n")
|
||||||
|
# Should have a header row, a separator row, and 2 data rows
|
||||||
|
assert len(lines) == 4
|
||||||
|
# Header should contain column names
|
||||||
|
assert "id" in lines[0]
|
||||||
|
assert "name" in lines[0]
|
||||||
|
assert "age" in lines[0]
|
||||||
|
# Data rows should contain values
|
||||||
|
assert "Cleo" in lines[2]
|
||||||
|
assert "Pancakes" in lines[3]
|
||||||
|
|
||||||
|
|
||||||
|
# -- query -t shortcut for --table --
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_table_shortcut(httpx_mock):
|
||||||
|
result = _mock_and_invoke(httpx_mock, ["-t"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
lines = result.output.strip().split("\n")
|
||||||
|
assert len(lines) == 4
|
||||||
|
assert "Cleo" in lines[2]
|
||||||
|
|
||||||
|
|
||||||
|
# -- default JSON (no flag) stays the same --
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_default_json(httpx_mock):
|
||||||
|
result = _mock_and_invoke(httpx_mock)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
data = json.loads(result.output)
|
||||||
|
assert data == [
|
||||||
|
{"id": 1, "name": "Cleo", "age": 5},
|
||||||
|
{"id": 2, "name": "Pancakes", "age": 3},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# -- default_query also supports output formats --
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_default_query(httpx_mock, mocker, tmpdir, extra_args=None):
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||||
|
config_file.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"default_instance": "prod",
|
||||||
|
"instances": {
|
||||||
|
"prod": {
|
||||||
|
"url": "https://prod.example.com",
|
||||||
|
"default_database": "main",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200)
|
||||||
|
runner = CliRunner()
|
||||||
|
args = ["select * from dogs"]
|
||||||
|
if extra_args:
|
||||||
|
args.extend(extra_args)
|
||||||
|
return runner.invoke(cli, args)
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_query_csv(httpx_mock, mocker, tmpdir):
|
||||||
|
result = _mock_default_query(httpx_mock, mocker, tmpdir, ["--csv"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
lines = result.output.strip().split("\n")
|
||||||
|
assert lines[0] == "id,name,age"
|
||||||
|
assert lines[1] == "1,Cleo,5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_query_table(httpx_mock, mocker, tmpdir):
|
||||||
|
result = _mock_default_query(httpx_mock, mocker, tmpdir, ["--table"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Cleo" in result.output
|
||||||
|
assert "Pancakes" in result.output
|
||||||
|
lines = result.output.strip().split("\n")
|
||||||
|
assert len(lines) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_query_nl(httpx_mock, mocker, tmpdir):
|
||||||
|
result = _mock_default_query(httpx_mock, mocker, tmpdir, ["--nl"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
lines = result.output.strip().split("\n")
|
||||||
|
assert json.loads(lines[0]) == {"id": 1, "name": "Cleo", "age": 5}
|
||||||
|
|
||||||
|
|
||||||
|
# -- edge cases --
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_csv_with_commas_in_values(httpx_mock):
|
||||||
|
httpx_mock.add_response(
|
||||||
|
json={
|
||||||
|
"ok": True,
|
||||||
|
"rows": [{"name": "Smith, John", "note": 'He said "hi"'}],
|
||||||
|
"columns": ["name", "note"],
|
||||||
|
},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"query",
|
||||||
|
"db",
|
||||||
|
"select * from t",
|
||||||
|
"-i",
|
||||||
|
"https://example.com",
|
||||||
|
"--csv",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
lines = result.output.strip().split("\n")
|
||||||
|
assert lines[0] == "name,note"
|
||||||
|
# CSV should properly quote fields with commas/quotes
|
||||||
|
assert '"Smith, John"' in lines[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_table_empty_results(httpx_mock):
|
||||||
|
httpx_mock.add_response(
|
||||||
|
json={
|
||||||
|
"ok": True,
|
||||||
|
"rows": [],
|
||||||
|
"columns": ["id", "name"],
|
||||||
|
},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"query",
|
||||||
|
"db",
|
||||||
|
"select * from t",
|
||||||
|
"-i",
|
||||||
|
"https://example.com",
|
||||||
|
"--table",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_csv_empty_results(httpx_mock):
|
||||||
|
httpx_mock.add_response(
|
||||||
|
json={
|
||||||
|
"ok": True,
|
||||||
|
"rows": [],
|
||||||
|
"columns": ["id", "name"],
|
||||||
|
},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"query",
|
||||||
|
"db",
|
||||||
|
"select * from t",
|
||||||
|
"-i",
|
||||||
|
"https://example.com",
|
||||||
|
"--csv",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
lines = result.output.strip().split("\n")
|
||||||
|
assert lines[0] == "id,name"
|
||||||
|
assert len(lines) == 1 # header only, no data rows
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_nl_empty_results(httpx_mock):
|
||||||
|
httpx_mock.add_response(
|
||||||
|
json={
|
||||||
|
"ok": True,
|
||||||
|
"rows": [],
|
||||||
|
"columns": ["id", "name"],
|
||||||
|
},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"query",
|
||||||
|
"db",
|
||||||
|
"select * from t",
|
||||||
|
"-i",
|
||||||
|
"https://example.com",
|
||||||
|
"--nl",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.output.strip() == ""
|
||||||
410
tests/test_rows.py
Normal file
410
tests/test_rows.py
Normal file
|
|
@ -0,0 +1,410 @@
|
||||||
|
"""Tests for the rows command."""
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
from dclient.cli import cli
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
TABLE_RESPONSE = {
|
||||||
|
"ok": True,
|
||||||
|
"rows": [
|
||||||
|
{"id": 1, "name": "Cleo", "age": 5},
|
||||||
|
{"id": 2, "name": "Pancakes", "age": 3},
|
||||||
|
{"id": 3, "name": "Fido", "age": 7},
|
||||||
|
],
|
||||||
|
"columns": ["id", "name", "age"],
|
||||||
|
"next": None,
|
||||||
|
"next_url": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _invoke(httpx_mock, extra_args=None, response=None):
|
||||||
|
httpx_mock.add_response(json=response or TABLE_RESPONSE, status_code=200)
|
||||||
|
runner = CliRunner()
|
||||||
|
args = ["rows", "fixtures", "dogs", "-i", "https://example.com"]
|
||||||
|
if extra_args:
|
||||||
|
args.extend(extra_args)
|
||||||
|
return runner.invoke(cli, args)
|
||||||
|
|
||||||
|
|
||||||
|
# -- basic usage --
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_default_json(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
data = json.loads(result.output)
|
||||||
|
assert len(data) == 3
|
||||||
|
assert data[0]["name"] == "Cleo"
|
||||||
|
# Verify request URL
|
||||||
|
request = httpx_mock.get_request()
|
||||||
|
assert request.url.path == "/fixtures/dogs.json"
|
||||||
|
assert "_shape" in dict(request.url.params)
|
||||||
|
assert dict(request.url.params)["_shape"] == "objects"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_table_format(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["-t"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
lines = result.output.strip().split("\n")
|
||||||
|
assert len(lines) == 5 # header + separator + 3 data rows
|
||||||
|
assert "Cleo" in lines[2]
|
||||||
|
assert "Pancakes" in lines[3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_csv(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["--csv"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
lines = result.output.strip().split("\n")
|
||||||
|
assert lines[0] == "id,name,age"
|
||||||
|
assert lines[1] == "1,Cleo,5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_nl(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["--nl"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
lines = result.output.strip().split("\n")
|
||||||
|
assert json.loads(lines[0]) == {"id": 1, "name": "Cleo", "age": 5}
|
||||||
|
|
||||||
|
|
||||||
|
# -- single argument uses default database --
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_single_arg_uses_default_database(httpx_mock, mocker, tmpdir):
|
||||||
|
"""dclient rows tablename uses default database."""
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||||
|
config_file.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"default_instance": "prod",
|
||||||
|
"instances": {
|
||||||
|
"prod": {
|
||||||
|
"url": "https://prod.example.com",
|
||||||
|
"default_database": "data",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
httpx_mock.add_response(json=TABLE_RESPONSE, status_code=200)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["rows", "dogs"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
request = httpx_mock.get_request()
|
||||||
|
assert request.url.host == "prod.example.com"
|
||||||
|
assert request.url.path == "/data/dogs.json"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_single_arg_no_default_database_errors(mocker, tmpdir):
|
||||||
|
"""dclient rows tablename without default database gives error."""
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||||
|
config_file.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"default_instance": "prod",
|
||||||
|
"instances": {
|
||||||
|
"prod": {
|
||||||
|
"url": "https://prod.example.com",
|
||||||
|
"default_database": None,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["rows", "dogs"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "No database specified" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
# -- filters --
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_filter_eq(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["-f", "name", "eq", "Cleo"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
request = httpx_mock.get_request()
|
||||||
|
params = dict(request.url.params)
|
||||||
|
assert params["name__exact"] == "Cleo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_filter_gt(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["--filter", "age", "gt", "3"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert dict(httpx_mock.get_request().url.params)["age__gt"] == "3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_filter_gte(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["-f", "age", "gte", "5"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert dict(httpx_mock.get_request().url.params)["age__gte"] == "5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_filter_lt(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["-f", "age", "lt", "5"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert dict(httpx_mock.get_request().url.params)["age__lt"] == "5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_filter_lte(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["-f", "age", "lte", "5"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert dict(httpx_mock.get_request().url.params)["age__lte"] == "5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_filter_not(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["-f", "name", "not", "Cleo"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert dict(httpx_mock.get_request().url.params)["name__not"] == "Cleo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_filter_contains(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["-f", "name", "contains", "leo"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert dict(httpx_mock.get_request().url.params)["name__contains"] == "leo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_filter_like(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["-f", "name", "like", "%leo%"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert dict(httpx_mock.get_request().url.params)["name__like"] == "%leo%"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_filter_startswith(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["-f", "name", "startswith", "Cl"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert dict(httpx_mock.get_request().url.params)["name__startswith"] == "Cl"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_filter_endswith(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["-f", "name", "endswith", "eo"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert dict(httpx_mock.get_request().url.params)["name__endswith"] == "eo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_filter_glob(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["-f", "name", "glob", "C*"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert dict(httpx_mock.get_request().url.params)["name__glob"] == "C*"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_filter_isnull(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["-f", "name", "isnull", "1"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert dict(httpx_mock.get_request().url.params)["name__isnull"] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_filter_notnull(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["-f", "name", "notnull", "1"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert dict(httpx_mock.get_request().url.params)["name__notnull"] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_multiple_filters(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["-f", "name", "eq", "Cleo", "-f", "age", "gte", "3"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
params = list(httpx_mock.get_request().url.params.multi_items())
|
||||||
|
param_dict = {k: v for k, v in params}
|
||||||
|
assert param_dict["name__exact"] == "Cleo"
|
||||||
|
assert param_dict["age__gte"] == "3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_custom_filter_op_passthrough(httpx_mock):
|
||||||
|
"""Unknown ops are passed through to Datasette, supporting plugin-added filters."""
|
||||||
|
result = _invoke(httpx_mock, ["-f", "name", "custom_plugin_op", "x"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert dict(httpx_mock.get_request().url.params)["name__custom_plugin_op"] == "x"
|
||||||
|
|
||||||
|
|
||||||
|
# -- sorting --
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_sort(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["--sort", "age"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert dict(httpx_mock.get_request().url.params)["_sort"] == "age"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_sort_desc(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["--sort-desc", "age"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert dict(httpx_mock.get_request().url.params)["_sort_desc"] == "age"
|
||||||
|
|
||||||
|
|
||||||
|
# -- column selection --
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_col(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["--col", "name", "--col", "age"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
params = list(httpx_mock.get_request().url.params.multi_items())
|
||||||
|
col_params = [v for k, v in params if k == "_col"]
|
||||||
|
assert col_params == ["name", "age"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_nocol(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["--nocol", "id"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert dict(httpx_mock.get_request().url.params)["_nocol"] == "id"
|
||||||
|
|
||||||
|
|
||||||
|
# -- search --
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_search(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["--search", "pancakes"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert dict(httpx_mock.get_request().url.params)["_search"] == "pancakes"
|
||||||
|
|
||||||
|
|
||||||
|
# -- size --
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_size(httpx_mock):
|
||||||
|
result = _invoke(httpx_mock, ["--size", "10"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert dict(httpx_mock.get_request().url.params)["_size"] == "10"
|
||||||
|
|
||||||
|
|
||||||
|
# -- limit --
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_limit(httpx_mock):
|
||||||
|
response = {
|
||||||
|
"ok": True,
|
||||||
|
"rows": [{"id": 1}, {"id": 2}, {"id": 3}],
|
||||||
|
"columns": ["id"],
|
||||||
|
"next": None,
|
||||||
|
}
|
||||||
|
result = _invoke(httpx_mock, ["--limit", "2"], response=response)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
data = json.loads(result.output)
|
||||||
|
assert len(data) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# -- pagination with --all --
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_all_pagination(httpx_mock):
|
||||||
|
page1 = {
|
||||||
|
"ok": True,
|
||||||
|
"rows": [{"id": 1}, {"id": 2}],
|
||||||
|
"columns": ["id"],
|
||||||
|
"next": "2",
|
||||||
|
"next_url": "https://example.com/fixtures/dogs.json?_next=2&_shape=objects",
|
||||||
|
}
|
||||||
|
page2 = {
|
||||||
|
"ok": True,
|
||||||
|
"rows": [{"id": 3}],
|
||||||
|
"columns": ["id"],
|
||||||
|
"next": None,
|
||||||
|
"next_url": None,
|
||||||
|
}
|
||||||
|
httpx_mock.add_response(json=page1, status_code=200)
|
||||||
|
httpx_mock.add_response(json=page2, status_code=200)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
["rows", "fixtures", "dogs", "-i", "https://example.com", "--all"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
data = json.loads(result.output)
|
||||||
|
assert len(data) == 3
|
||||||
|
assert [r["id"] for r in data] == [1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_all_with_limit(httpx_mock):
|
||||||
|
page1 = {
|
||||||
|
"ok": True,
|
||||||
|
"rows": [{"id": 1}, {"id": 2}],
|
||||||
|
"columns": ["id"],
|
||||||
|
"next": "2",
|
||||||
|
"next_url": "https://example.com/fixtures/dogs.json?_next=2&_shape=objects",
|
||||||
|
}
|
||||||
|
page2 = {
|
||||||
|
"ok": True,
|
||||||
|
"rows": [{"id": 3}, {"id": 4}],
|
||||||
|
"columns": ["id"],
|
||||||
|
"next": None,
|
||||||
|
}
|
||||||
|
httpx_mock.add_response(json=page1, status_code=200)
|
||||||
|
httpx_mock.add_response(json=page2, status_code=200)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"rows",
|
||||||
|
"fixtures",
|
||||||
|
"dogs",
|
||||||
|
"-i",
|
||||||
|
"https://example.com",
|
||||||
|
"--all",
|
||||||
|
"--limit",
|
||||||
|
"3",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
data = json.loads(result.output)
|
||||||
|
assert len(data) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_no_all_ignores_next(httpx_mock):
|
||||||
|
"""Without --all, pagination is not followed even if next is present."""
|
||||||
|
response = {
|
||||||
|
"ok": True,
|
||||||
|
"rows": [{"id": 1}, {"id": 2}],
|
||||||
|
"columns": ["id"],
|
||||||
|
"next": "2",
|
||||||
|
"next_url": "https://example.com/fixtures/dogs.json?_next=2&_shape=objects",
|
||||||
|
}
|
||||||
|
result = _invoke(httpx_mock, response=response)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
data = json.loads(result.output)
|
||||||
|
assert len(data) == 2 # only first page
|
||||||
|
|
||||||
|
|
||||||
|
# -- error handling --
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_api_error(httpx_mock):
|
||||||
|
httpx_mock.add_response(
|
||||||
|
json={"ok": False, "error": "Table not found: dogs"},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
["rows", "fixtures", "dogs", "-i", "https://example.com"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Table not found" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
# -- uses default instance --
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_default_instance(httpx_mock, mocker, tmpdir):
|
||||||
|
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||||
|
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||||
|
config_file.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"default_instance": "prod",
|
||||||
|
"instances": {
|
||||||
|
"prod": {
|
||||||
|
"url": "https://prod.example.com",
|
||||||
|
"default_database": "main",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
httpx_mock.add_response(json=TABLE_RESPONSE, status_code=200)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["rows", "data", "dogs"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
request = httpx_mock.get_request()
|
||||||
|
assert request.url.host == "prod.example.com"
|
||||||
|
assert request.url.path == "/data/dogs.json"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue