v2 config system, CLI rewrite, and updated core commands

Replace aliases.json with config.json storing instances with
default_database. Instance resolution: -i flag → config default →
DATASETTE_URL. Database resolution: -d flag → instance default_database →
DATASETTE_DATABASE. Token resolution: --token → auth.json by alias →
auth.json by URL → DATASETTE_TOKEN.

Add click-default-group dependency for bare SQL shortcut support.
Add DCLIENT_CONFIG_DIR env var to override config directory.

Rewrite query, insert, get, and actor commands for v2 API where
instance is always -i flag. Add upsert command sharing insert
implementation. Add databases, tables, schema, plugins commands.
Add default_query hidden command for bare SQL shortcut.

Add alias default, alias default-db subcommands.
Add auth status subcommand.

refs #29

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon Willison 2026-02-24 15:21:52 -08:00
commit 3621f13263
10 changed files with 1049 additions and 314 deletions

File diff suppressed because it is too large Load diff

View file

@ -2,37 +2,39 @@
# Environment variables
`dclient` supports two environment variables for convenient access to a Datasette instance without needing aliases or repeated URLs.
`dclient` supports several environment variables for convenient access to Datasette instances.
## DATASETTE_URL
Set this to the base URL of your Datasette instance:
Set this to the base URL of your Datasette instance. It is used as a fallback when no instance is specified via `-i` and no default instance is configured:
```bash
export DATASETTE_URL=https://my-instance.datasette.cloud
```
Then pass just the database name as the first argument to any command:
Then you can omit the `-i` flag:
```bash
dclient databases
dclient query data "select * from my_table limit 10"
```
This is equivalent to:
Aliases and the `-i` flag always take priority over `DATASETTE_URL`.
## DATASETTE_DATABASE
Set this to a default database name. It is used as a fallback when no database is specified via `-d` and the current instance has no `default_database` configured:
```bash
dclient query https://my-instance.datasette.cloud/data "select * from my_table limit 10"
export DATASETTE_DATABASE=data
```
It works with all commands:
Then you can use the bare SQL shortcut:
```bash
dclient insert data my_table data.csv --csv
dclient actor data
dclient "select * from my_table limit 10"
```
Full URLs and aliases always take priority over `DATASETTE_URL`. If the argument starts with `http://` or `https://`, it is used as-is. If it matches an alias in `aliases.json`, the alias is used.
## DATASETTE_TOKEN
Set this to an API token:
@ -46,23 +48,34 @@ The token will be used automatically for any request that doesn't have a more sp
The precedence order for tokens is:
1. `--token` CLI flag (highest priority)
2. Stored token from `auth.json` (matched by URL prefix)
2. Stored token from `auth.json` (matched by alias name, then URL prefix)
3. `DATASETTE_TOKEN` environment variable (lowest priority)
## Using both together
## DCLIENT_CONFIG_DIR
Override the config directory (default `~/.config/io.datasette.dclient` or platform equivalent):
```bash
export DCLIENT_CONFIG_DIR=/path/to/config
```
This is useful for testing or running multiple configurations side by side.
## Using them together
These variables work well together for quick access to a single instance:
```bash
export DATASETTE_URL=https://my-instance.datasette.cloud
export DATASETTE_DATABASE=data
export DATASETTE_TOKEN=dstok_abc123
# Query the "data" database
dclient query data "select * from my_table"
# Query
dclient "select * from my_table"
# Insert into the "data" database
# Insert
cat records.json | dclient insert data my_table - --json
# Check your actor identity
dclient actor data
dclient actor
```

View file

@ -4,19 +4,23 @@ The `dclient insert` command can be used to insert data from a local file direct
First you'll need to {ref}`authenticate <authentication>` with the instance.
To insert data from a `data.csv` file into a table called `my_table`, creating that table if it does not exist:
To insert data from a `data.csv` file into a table called `my_table` in the `data` database:
```bash
dclient insert \
https://my-private-space.datasette.cloud/data \
my_table data.csv --create
dclient insert data my_table data.csv --create -i myapp
```
You can also pipe data into standard input:
```bash
curl -s 'https://api.github.com/repos/simonw/dclient/issues' | \
dclient insert \
https://my-private-space.datasette.cloud/data \
issues - --create
dclient insert data issues - --create -i myapp
```
## Upserting data
The `dclient upsert` command works exactly like `insert` but uses the upsert endpoint, which will update existing rows with matching primary keys rather than raising an error.
```bash
dclient upsert data my_table data.csv --csv -i myapp
```
## Streaming data
@ -26,7 +30,7 @@ curl -s 'https://api.github.com/repos/simonw/dclient/issues' | \
If you have a log file containing newline-delimited JSON you can tail it and send it to a Datasette instance like this:
```bash
tail -f log.jsonl | \
dclient insert https://my-private-space.datasette.cloud/data logs - --nl
dclient insert data logs - --nl -i myapp
```
When reading from standard input (filename `-`) you are required to specify the format. In this example that's `--nl` for newline-delimited JSON. `--csv` and `--tsv` are supported for streaming as well, but `--json` is not.
@ -34,7 +38,7 @@ In streaming mode records default to being sent to the server every 100 records
```bash
tail -f log.jsonl | dclient insert \
https://my-private-space.datasette.cloud/data logs - --nl --create \
data logs - --nl --create -i myapp \
--batch-size 10 \
--interval 5
```
@ -106,26 +110,65 @@ cog.out(
)
]]] -->
```
Usage: dclient insert [OPTIONS] URL_OR_ALIAS TABLE FILEPATH
Usage: dclient insert [OPTIONS] DATABASE TABLE FILEPATH
Insert data into a remote Datasette instance
Example usage:
dclient insert \
https://private.datasette.cloud/data \
mytable data.csv --pk id --create
dclient insert main mytable data.csv --csv -i myapp
dclient insert main mytable data.csv --csv --create --pk id
Options:
-i, --instance TEXT Datasette instance URL or alias
--csv Input is CSV
--tsv Input is TSV
--json Input is JSON
--nl Input is newline-delimited JSON
--encoding TEXT Character encoding for CSV/TSV
--no-detect-types Don't detect column types for CSV/TSV
--alter Alter table to add any missing columns
--pk TEXT Columns to use as the primary key when creating the
table
--batch-size INTEGER Send rows in batches of this size
--interval FLOAT Send batch at least every X seconds
-t, --token TEXT API token
--silent Don't output progress
-v, --verbose Verbose output: show HTTP request and response
--replace Replace rows with a matching primary key
--ignore Ignore rows with a matching primary key
--create Create table if it does not exist
--help Show this message and exit.
```
<!-- [[[end]]] -->
## dclient upsert --help
<!-- [[[cog
import cog
result = runner.invoke(cli.cli, ["upsert", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(help)
)
]]] -->
```
Usage: dclient upsert [OPTIONS] DATABASE TABLE FILEPATH
Upsert data into a remote Datasette instance
Example usage:
dclient upsert main mytable data.csv --csv -i myapp
Options:
-i, --instance TEXT Datasette instance URL or alias
--csv Input is CSV
--tsv Input is TSV
--json Input is JSON
--nl Input is newline-delimited JSON
--encoding TEXT Character encoding for CSV/TSV
--no-detect-types Don't detect column types for CSV/TSV
--alter Alter table to add any missing columns
--pk TEXT Columns to use as the primary key when creating the
table

View file

@ -3,7 +3,7 @@
You can run SQL queries against a Datasette instance like this:
```bash
dclient query https://latest.datasette.io/fixtures "select * from facetable limit 1"
dclient query fixtures "select * from facetable limit 1" -i https://latest.datasette.io
```
Output:
```json
@ -24,6 +24,18 @@ Output:
]
```
The `query` command takes a database name and SQL string as positional arguments. Use `-i` to specify the instance (alias or URL). If you have a default instance and default database configured, you can use the bare SQL shortcut instead:
```bash
dclient "select * from facetable limit 1"
```
You can override just the database with `-d`:
```bash
dclient "select * from counters" -d counters
```
## dclient query --help
<!-- [[[cog
import cog
@ -37,22 +49,22 @@ cog.out(
)
]]] -->
```
Usage: dclient query [OPTIONS] URL_OR_ALIAS SQL
Usage: dclient query [OPTIONS] DATABASE SQL
Run a SQL query against a Datasette database URL
Run a SQL query against a Datasette database
Returns a JSON array of objects
Requires both a database name and a SQL string.
Example usage:
dclient query \
https://datasette.io/content \
'select * from news limit 10'
dclient query fixtures "select * from facetable limit 5"
dclient query analytics "select count(*) from events" -i staging
Options:
--token TEXT API token
-v, --verbose Verbose output: show HTTP request
--help Show this message and exit.
-i, --instance TEXT Datasette instance URL or alias
--token TEXT API token
-v, --verbose Verbose output: show HTTP request
--help Show this message and exit.
```
<!-- [[[end]]] -->

View file

@ -8,6 +8,7 @@ license = "Apache-2.0"
requires-python = ">=3.10"
dependencies = [
"click",
"click-default-group",
"httpx",
"sqlite-utils",
]

View file

@ -13,26 +13,26 @@ def test_auth(mocker, tmpdir):
# Should only have one line
assert len([line for line in result.output.split("\n") if line.strip()]) == 1
# Now add a token
result2 = runner.invoke(cli, ["auth", "add", "https://example.com"], input="xyz\n")
# Now add a token (keys are now alias names or URLs)
result2 = runner.invoke(cli, ["auth", "add", "prod"], input="xyz\n")
assert result2.exit_code == 0
# Check the tokens file
auth_file = pathlib.Path(tmpdir) / "auth.json"
assert json.loads(auth_file.read_text()) == {"https://example.com": "xyz"}
assert json.loads(auth_file.read_text()) == {"prod": "xyz"}
# auth list should show that now
result3 = runner.invoke(cli, ["auth", "list"])
assert result3.output.startswith("Tokens file:")
assert "https://example.com" in result3.output
assert "prod" in result3.output
# Remove should fail with an incorrect URL
result4 = runner.invoke(cli, ["auth", "remove", "https://example.com/foo"])
# Remove should fail with an incorrect key
result4 = runner.invoke(cli, ["auth", "remove", "nonexistent"])
assert result4.exit_code == 1
assert result4.output == "Error: No such URL or alias\n"
# Remove should work with the correct URL
result5 = runner.invoke(cli, ["auth", "remove", "https://example.com"])
# Remove should work with the correct key
result5 = runner.invoke(cli, ["auth", "remove", "prod"])
assert result5.exit_code == 0
assert result5.output == ""

249
tests/test_config.py Normal file
View file

@ -0,0 +1,249 @@
"""Tests for the v2 config system: config.json format, instance resolution, database resolution."""
from dclient.cli import (
_load_config,
_save_config,
_resolve_instance,
_resolve_database,
_resolve_token,
get_config_dir,
)
import json
import pathlib
import pytest
# -- Config loading/saving --
def test_load_config_empty(tmpdir):
"""Loading config when no file exists returns empty defaults."""
config_file = pathlib.Path(tmpdir) / "config.json"
config = _load_config(config_file)
assert config == {"default_instance": None, "instances": {}}
def test_load_config_existing(tmpdir):
"""Loading config reads the JSON file."""
config_file = pathlib.Path(tmpdir) / "config.json"
config_file.write_text(
json.dumps(
{
"default_instance": "prod",
"instances": {
"prod": {
"url": "https://myapp.datasette.cloud",
"default_database": "main",
}
},
}
)
)
config = _load_config(config_file)
assert config["default_instance"] == "prod"
assert config["instances"]["prod"]["url"] == "https://myapp.datasette.cloud"
assert config["instances"]["prod"]["default_database"] == "main"
def test_save_config(tmpdir):
"""Saving config writes JSON to disk."""
config_file = pathlib.Path(tmpdir) / "config.json"
config = {
"default_instance": "local",
"instances": {
"local": {
"url": "http://localhost:8001",
"default_database": None,
}
},
}
_save_config(config_file, config)
assert json.loads(config_file.read_text()) == config
# -- Instance resolution --
def test_resolve_instance_from_flag(tmpdir):
"""An explicit -i flag with a URL is used directly."""
config_file = pathlib.Path(tmpdir) / "config.json"
url = _resolve_instance("https://example.com", config_file)
assert url == "https://example.com"
def test_resolve_instance_from_flag_alias(tmpdir):
"""An explicit -i flag with an alias name resolves via config."""
config_file = pathlib.Path(tmpdir) / "config.json"
config_file.write_text(
json.dumps(
{
"default_instance": None,
"instances": {
"prod": {
"url": "https://myapp.datasette.cloud",
"default_database": None,
}
},
}
)
)
url = _resolve_instance("prod", config_file)
assert url == "https://myapp.datasette.cloud"
def test_resolve_instance_from_config_default(tmpdir):
"""When no -i flag, uses config.default_instance."""
config_file = pathlib.Path(tmpdir) / "config.json"
config_file.write_text(
json.dumps(
{
"default_instance": "prod",
"instances": {
"prod": {
"url": "https://myapp.datasette.cloud",
"default_database": None,
}
},
}
)
)
url = _resolve_instance(None, config_file)
assert url == "https://myapp.datasette.cloud"
def test_resolve_instance_from_env(tmpdir, monkeypatch):
"""When no -i flag and no config default, falls back to DATASETTE_URL."""
config_file = pathlib.Path(tmpdir) / "config.json"
monkeypatch.setenv("DATASETTE_URL", "https://env.example.com")
url = _resolve_instance(None, config_file)
assert url == "https://env.example.com"
def test_resolve_instance_from_env_strips_trailing_slash(tmpdir, monkeypatch):
"""DATASETTE_URL trailing slash is stripped."""
config_file = pathlib.Path(tmpdir) / "config.json"
monkeypatch.setenv("DATASETTE_URL", "https://env.example.com/")
url = _resolve_instance(None, config_file)
assert url == "https://env.example.com"
def test_resolve_instance_error(tmpdir, monkeypatch):
"""When nothing is configured, raises an error."""
config_file = pathlib.Path(tmpdir) / "config.json"
monkeypatch.delenv("DATASETTE_URL", raising=False)
with pytest.raises(Exception, match="No instance specified"):
_resolve_instance(None, config_file)
def test_resolve_instance_unknown_alias(tmpdir):
"""An -i flag with an unknown alias name raises an error."""
config_file = pathlib.Path(tmpdir) / "config.json"
config_file.write_text(json.dumps({"default_instance": None, "instances": {}}))
with pytest.raises(Exception, match="Unknown instance"):
_resolve_instance("nonexistent", config_file)
# -- Database resolution (optional -d flag mode) --
def test_resolve_database_from_flag(tmpdir):
"""An explicit -d flag is used directly."""
config_file = pathlib.Path(tmpdir) / "config.json"
db = _resolve_database("mydb", None, config_file)
assert db == "mydb"
def test_resolve_database_from_instance_default(tmpdir):
"""When no -d flag, uses the instance's default_database from config."""
config_file = pathlib.Path(tmpdir) / "config.json"
config_file.write_text(
json.dumps(
{
"default_instance": "prod",
"instances": {
"prod": {
"url": "https://myapp.datasette.cloud",
"default_database": "main",
}
},
}
)
)
db = _resolve_database(None, "prod", config_file)
assert db == "main"
def test_resolve_database_from_env(tmpdir, monkeypatch):
"""When no -d flag and no instance default, falls back to DATASETTE_DATABASE."""
config_file = pathlib.Path(tmpdir) / "config.json"
monkeypatch.setenv("DATASETTE_DATABASE", "envdb")
db = _resolve_database(None, None, config_file)
assert db == "envdb"
def test_resolve_database_error(tmpdir, monkeypatch):
"""When nothing is configured, raises an error."""
config_file = pathlib.Path(tmpdir) / "config.json"
monkeypatch.delenv("DATASETTE_DATABASE", raising=False)
with pytest.raises(Exception, match="No database specified"):
_resolve_database(None, None, config_file)
# -- Token resolution --
def test_resolve_token_from_flag(tmpdir):
"""An explicit --token flag is used directly."""
auth_file = pathlib.Path(tmpdir) / "auth.json"
config_file = pathlib.Path(tmpdir) / "config.json"
token = _resolve_token("explicit-token", "https://example.com", auth_file, config_file)
assert token == "explicit-token"
def test_resolve_token_from_auth_by_alias(tmpdir):
"""Auth token looked up by alias name."""
auth_file = pathlib.Path(tmpdir) / "auth.json"
auth_file.write_text(json.dumps({"prod": "tok123"}))
config_file = pathlib.Path(tmpdir) / "config.json"
config_file.write_text(
json.dumps(
{
"default_instance": None,
"instances": {
"prod": {
"url": "https://myapp.datasette.cloud",
"default_database": None,
}
},
}
)
)
token = _resolve_token(None, "https://myapp.datasette.cloud", auth_file, config_file)
assert token == "tok123"
def test_resolve_token_from_auth_by_url_fallback(tmpdir):
"""Auth token falls back to URL prefix matching when no alias match."""
auth_file = pathlib.Path(tmpdir) / "auth.json"
auth_file.write_text(json.dumps({"https://example.com": "url-tok"}))
config_file = pathlib.Path(tmpdir) / "config.json"
token = _resolve_token(None, "https://example.com/db", auth_file, config_file)
assert token == "url-tok"
def test_resolve_token_from_env(tmpdir, monkeypatch):
"""Falls back to DATASETTE_TOKEN env var."""
auth_file = pathlib.Path(tmpdir) / "auth.json"
config_file = pathlib.Path(tmpdir) / "config.json"
monkeypatch.setenv("DATASETTE_TOKEN", "env-tok")
token = _resolve_token(None, "https://example.com", auth_file, config_file)
assert token == "env-tok"
def test_resolve_token_none(tmpdir, monkeypatch):
"""Returns None when nothing is configured."""
auth_file = pathlib.Path(tmpdir) / "auth.json"
config_file = pathlib.Path(tmpdir) / "config.json"
monkeypatch.delenv("DATASETTE_TOKEN", raising=False)
token = _resolve_token(None, "https://example.com", auth_file, config_file)
assert token is None

View file

@ -26,7 +26,9 @@ def test_datasette_token_used_as_fallback(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200)
runner = CliRunner(env={"DATASETTE_TOKEN": "env-token-123"})
result = runner.invoke(cli, ["query", "https://example.com", "select 1"])
result = runner.invoke(
cli, ["query", "data", "select 1", "-i", "https://example.com"]
)
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.headers["authorization"] == "Bearer env-token-123"
@ -38,7 +40,12 @@ def test_token_flag_overrides_datasette_token(httpx_mock, mocker, tmpdir):
httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200)
runner = CliRunner(env={"DATASETTE_TOKEN": "env-token"})
result = runner.invoke(
cli, ["query", "https://example.com", "select 1", "--token", "flag-token"]
cli,
[
"query", "data", "select 1",
"-i", "https://example.com",
"--token", "flag-token",
],
)
assert result.exit_code == 0
request = httpx_mock.get_request()
@ -52,7 +59,9 @@ def test_auth_json_overrides_datasette_token(httpx_mock, mocker, tmpdir):
auth_file.write_text(json.dumps({"https://example.com": "stored-token"}))
httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200)
runner = CliRunner(env={"DATASETTE_TOKEN": "env-token"})
result = runner.invoke(cli, ["query", "https://example.com", "select 1"])
result = runner.invoke(
cli, ["query", "data", "select 1", "-i", "https://example.com"]
)
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.headers["authorization"] == "Bearer stored-token"
@ -61,8 +70,8 @@ def test_auth_json_overrides_datasette_token(httpx_mock, mocker, tmpdir):
# -- DATASETTE_URL tests --
def test_datasette_url_combines_with_database_name(httpx_mock, mocker, tmpdir):
"""DATASETTE_URL + database name arg → combined URL."""
def test_datasette_url_used_as_instance(httpx_mock, mocker, tmpdir):
"""DATASETTE_URL provides the instance when no -i flag."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200)
runner = CliRunner(env={"DATASETTE_URL": "https://my-instance.datasette.cloud"})
@ -84,12 +93,14 @@ def test_datasette_url_with_trailing_slash(httpx_mock, mocker, tmpdir):
assert request.url.path == "/data.json"
def test_full_url_ignores_datasette_url(httpx_mock, mocker, tmpdir):
"""A full URL argument is used as-is, ignoring DATASETTE_URL."""
def test_explicit_instance_ignores_datasette_url(httpx_mock, mocker, tmpdir):
"""An explicit -i flag is used, ignoring DATASETTE_URL."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200)
runner = CliRunner(env={"DATASETTE_URL": "https://should-be-ignored.com"})
result = runner.invoke(cli, ["query", "https://other.example.com/db", "select 1"])
result = runner.invoke(
cli, ["query", "db", "select 1", "-i", "https://other.example.com"]
)
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.url.host == "other.example.com"
@ -97,13 +108,25 @@ def test_full_url_ignores_datasette_url(httpx_mock, mocker, tmpdir):
def test_alias_takes_priority_over_datasette_url(httpx_mock, mocker, tmpdir):
"""Alias match takes priority over DATASETTE_URL."""
"""Alias match via -i takes priority over DATASETTE_URL."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
aliases_file = pathlib.Path(tmpdir) / "aliases.json"
aliases_file.write_text(json.dumps({"myalias": "https://aliased.example.com/db"}))
config_file = pathlib.Path(tmpdir) / "config.json"
config_file.write_text(
json.dumps(
{
"default_instance": None,
"instances": {
"myalias": {
"url": "https://aliased.example.com",
"default_database": None,
}
},
}
)
)
httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200)
runner = CliRunner(env={"DATASETTE_URL": "https://should-be-ignored.com"})
result = runner.invoke(cli, ["query", "myalias", "select 1"])
result = runner.invoke(cli, ["query", "db", "select 1", "-i", "myalias"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.url.host == "aliased.example.com"
@ -150,7 +173,7 @@ def test_datasette_url_with_actor(httpx_mock, mocker, tmpdir):
"DATASETTE_TOKEN": "env-token",
}
)
result = runner.invoke(cli, ["actor", "data"])
result = runner.invoke(cli, ["actor"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.url.host == "my-instance.datasette.cloud"
@ -176,3 +199,23 @@ def test_datasette_url_and_token_together(httpx_mock, mocker, tmpdir):
assert request.url.host == "my-instance.datasette.cloud"
assert request.url.path == "/mydb.json"
assert request.headers["authorization"] == "Bearer env-token-456"
# -- DATASETTE_DATABASE tests --
def test_datasette_database_with_default_query(httpx_mock, mocker, tmpdir):
"""DATASETTE_DATABASE is used by default_query shortcut."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200)
runner = CliRunner(
env={
"DATASETTE_URL": "https://my-instance.datasette.cloud",
"DATASETTE_DATABASE": "mydb",
}
)
result = runner.invoke(cli, ["select 1"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.url.host == "my-instance.datasette.cloud"
assert request.url.path == "/mydb.json"

View file

@ -44,12 +44,14 @@ def test_insert_mocked(httpx_mock, tmpdir):
cli,
[
"insert",
"https://datasette.example.com/data",
"data",
"table1",
str(path),
"--csv",
"--token",
"x",
"-i",
"https://datasette.example.com",
],
catch_exceptions=False,
)
@ -311,11 +313,13 @@ async def test_insert_against_datasette(
cli,
[
"insert",
"http://datasette.example.com/data",
"data",
"table1",
str(path),
"--token",
token,
"-i",
"http://datasette.example.com",
]
+ cmd_args,
)

View file

@ -16,7 +16,9 @@ def test_query_error(httpx_mock):
status_code=400,
)
runner = CliRunner()
result = runner.invoke(cli, ["query", "https://example.com", "hello"])
result = runner.invoke(
cli, ["query", "content", "hello", "-i", "https://example.com"]
)
assert result.exit_code == 1
assert (
result.output
@ -42,17 +44,16 @@ def test_query(httpx_mock, with_token):
status_code=200,
)
runner = CliRunner()
args = ["query", "https://example.com", "hello"]
args = ["query", "content", "hello", "-i", "https://example.com"]
if with_token:
args.append("--token")
args.append("xyz")
args.extend(["--token", "xyz"])
result = runner.invoke(cli, args)
assert result.exit_code == 0
assert json.loads(result.output) == [{"5 * 2": 10}]
# Check the request
request = httpx_mock.get_request()
assert str(request.url) == "https://example.com.json?sql=hello&_shape=objects"
assert str(request.url) == "https://example.com/content.json?sql=hello&_shape=objects"
if with_token:
assert request.headers["authorization"] == "Bearer xyz"
else:
@ -66,28 +67,32 @@ def test_aliases(mocker, tmpdir, httpx_mock):
assert result.exit_code == 0
assert result.output == ""
result = runner.invoke(cli, ["alias", "add", "foo", "https://example.com/foo"])
result = runner.invoke(
cli, ["alias", "add", "foo", "https://example.com"]
)
assert result.exit_code == 0
assert result.output == ""
result = runner.invoke(cli, ["alias", "list"])
assert result.exit_code == 0
assert result.output == "foo = https://example.com/foo\n"
assert "foo = https://example.com" in result.output
# --json mode:
result = runner.invoke(cli, ["alias", "list", "--json"])
assert result.exit_code == 0
assert json.loads(result.output) == {"foo": "https://example.com/foo"}
data = json.loads(result.output)
assert data["instances"]["foo"]["url"] == "https://example.com"
# Check the aliases file
aliases_file = pathlib.Path(tmpdir) / "aliases.json"
assert json.loads(aliases_file.read_text()) == {"foo": "https://example.com/foo"}
# Check the config file
config_file = pathlib.Path(tmpdir) / "config.json"
config = json.loads(config_file.read_text())
assert config["instances"]["foo"]["url"] == "https://example.com"
# Try a query against that alias
httpx_mock.add_response(
json={
"ok": True,
"database": "foo",
"database": "mydb",
"query_name": None,
"rows": [{"11 * 3": 33}],
"truncated": False,
@ -99,14 +104,14 @@ def test_aliases(mocker, tmpdir, httpx_mock):
},
status_code=200,
)
result = runner.invoke(cli, ["query", "foo", "select 11 * 3"])
result = runner.invoke(cli, ["query", "mydb", "select 11 * 3", "-i", "foo"])
assert result.exit_code == 0
assert json.loads(result.output) == [{"11 * 3": 33}]
# Should have hit https://example.com/foo.json
# Should have hit https://example.com/mydb.json
url = httpx_mock.get_request().url
assert url.host == "example.com"
assert url.path == "/foo.json"
assert url.path == "/mydb.json"
assert dict(url.params) == {"sql": "select 11 * 3", "_shape": "objects"}
# Remove alias
@ -117,4 +122,5 @@ def test_aliases(mocker, tmpdir, httpx_mock):
result = runner.invoke(cli, ["alias", "remove", "foo"])
assert result.exit_code == 0
assert result.output == ""
assert json.loads(aliases_file.read_text()) == {}
config = json.loads(config_file.read_text())
assert config["instances"] == {}