Support DATASETTE_URL and DATASETTE_TOKEN environment variables

Allow configuring a base Datasette instance URL and API token via
environment variables, so users can run commands like
`dclient query data "select ..."` without specifying a full URL each time.

DATASETTE_URL is combined with the argument as a path segment.
DATASETTE_TOKEN is used as a lowest-priority fallback for auth.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon Willison 2026-02-23 22:10:59 -08:00
commit 32124a0bf3
3 changed files with 275 additions and 27 deletions

View file

@ -1,6 +1,7 @@
import click
import httpx
import json
import os
import pathlib
from sqlite_utils.utils import rows_from_file, Format, TypeTracker, progressbar
import sys
@ -38,17 +39,10 @@ def query(url_or_alias, sql, token, verbose):
https://datasette.io/content \\
'select * from news limit 10'
"""
aliases_file = get_config_dir() / "aliases.json"
aliases = _load_aliases(aliases_file)
if url_or_alias in aliases:
url = aliases[url_or_alias]
else:
url = url_or_alias
if not url_or_alias.endswith(".json"):
url = _resolve_url(url_or_alias)
token = _resolve_token(token, url)
if not url.endswith(".json"):
url += ".json"
if token is None:
# Maybe there's a token in auth.json?
token = token_for_url(url, _load_auths(get_config_dir() / "auth.json"))
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
@ -167,15 +161,8 @@ def insert(
https://private.datasette.cloud/data \\
mytable data.csv --pk id --create
"""
aliases_file = get_config_dir() / "aliases.json"
aliases = _load_aliases(aliases_file)
if url_or_alias in aliases:
url = aliases[url_or_alias]
else:
url = url_or_alias
if token is None:
token = token_for_url(url, _load_auths(get_config_dir() / "auth.json"))
url = _resolve_url(url_or_alias)
token = _resolve_token(token, url)
format = None
if format_csv:
@ -276,18 +263,12 @@ def actor(url_or_alias, token):
\b
dclient actor https://latest.datasette.io/fixtures
"""
aliases_file = get_config_dir() / "aliases.json"
aliases = _load_aliases(aliases_file)
if url_or_alias in aliases:
url = aliases[url_or_alias]
else:
url = url_or_alias
url = _resolve_url(url_or_alias)
if not (url.startswith("http://") or url.startswith("https://")):
raise click.ClickException("Invalid URL: " + url)
if token is None:
token = token_for_url(url, _load_auths(get_config_dir() / "auth.json"))
token = _resolve_token(token, url)
url_bits = url.split("/")
url_bits[-1] = "-/actor.json"
@ -457,6 +438,27 @@ def _load_auths(auth_file):
return auths
def _resolve_url(url_or_alias):
aliases = _load_aliases(get_config_dir() / "aliases.json")
if url_or_alias in aliases:
return aliases[url_or_alias]
if url_or_alias.startswith("http://") or url_or_alias.startswith("https://"):
return url_or_alias
base_url = os.environ.get("DATASETTE_URL")
if base_url:
return base_url.rstrip("/") + "/" + url_or_alias
return url_or_alias
def _resolve_token(token, url):
if token is not None:
return token
stored = token_for_url(url, _load_auths(get_config_dir() / "auth.json"))
if stored is not None:
return stored
return os.environ.get("DATASETTE_TOKEN")
def _batches(iterable, size, interval=None):
iterable = iter(iterable)
last_yield_time = time.time()

68
docs/environment.md Normal file
View file

@ -0,0 +1,68 @@
(environment-variables)=
# Environment variables
`dclient` supports two environment variables for convenient access to a Datasette instance without needing aliases or repeated URLs.
## DATASETTE_URL
Set this to the base URL of your Datasette instance:
```bash
export DATASETTE_URL=https://my-instance.datasette.cloud
```
Then pass just the database name as the first argument to any command:
```bash
dclient query data "select * from my_table limit 10"
```
This is equivalent to:
```bash
dclient query https://my-instance.datasette.cloud/data "select * from my_table limit 10"
```
It works with all commands:
```bash
dclient insert data my_table data.csv --csv
dclient actor data
```
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:
```bash
export DATASETTE_TOKEN=dstok_abc123
```
The token will be used automatically for any request that doesn't have a more specific token configured.
The precedence order for tokens is:
1. `--token` CLI flag (highest priority)
2. Stored token from `auth.json` (matched by URL prefix)
3. `DATASETTE_TOKEN` environment variable (lowest priority)
## Using both together
These variables work well together for quick access to a single instance:
```bash
export DATASETTE_URL=https://my-instance.datasette.cloud
export DATASETTE_TOKEN=dstok_abc123
# Query the "data" database
dclient query data "select * from my_table"
# Insert into the "data" database
cat records.json | dclient insert data my_table - --json
# Check your actor identity
dclient actor data
```

178
tests/test_env.py Normal file
View file

@ -0,0 +1,178 @@
from click.testing import CliRunner
from dclient.cli import cli
import json
import pathlib
QUERY_RESPONSE = {
"ok": True,
"database": "data",
"query_name": None,
"rows": [{"id": 1}],
"truncated": False,
"columns": ["id"],
"query": {"sql": "select 1", "params": {}},
"error": None,
"private": False,
"allow_execute_sql": True,
}
# -- DATASETTE_TOKEN tests --
def test_datasette_token_used_as_fallback(httpx_mock, mocker, tmpdir):
"""DATASETTE_TOKEN is used when no --token flag and no auth.json match."""
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"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.headers["authorization"] == "Bearer env-token-123"
def test_token_flag_overrides_datasette_token(httpx_mock, mocker, tmpdir):
"""--token flag takes priority over DATASETTE_TOKEN."""
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"})
result = runner.invoke(
cli, ["query", "https://example.com", "select 1", "--token", "flag-token"]
)
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.headers["authorization"] == "Bearer flag-token"
def test_auth_json_overrides_datasette_token(httpx_mock, mocker, tmpdir):
"""Stored auth.json token takes priority over DATASETTE_TOKEN."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
auth_file = pathlib.Path(tmpdir) / "auth.json"
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"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.headers["authorization"] == "Bearer stored-token"
# -- DATASETTE_URL tests --
def test_datasette_url_combines_with_database_name(httpx_mock, mocker, tmpdir):
"""DATASETTE_URL + database name arg → combined 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://my-instance.datasette.cloud"})
result = runner.invoke(cli, ["query", "data", "select 1"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.url.host == "my-instance.datasette.cloud"
assert request.url.path == "/data.json"
def test_datasette_url_with_trailing_slash(httpx_mock, mocker, tmpdir):
"""DATASETTE_URL with trailing slash still works correctly."""
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/"})
result = runner.invoke(cli, ["query", "data", "select 1"])
assert result.exit_code == 0
request = httpx_mock.get_request()
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."""
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"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.url.host == "other.example.com"
assert request.url.path == "/db.json"
def test_alias_takes_priority_over_datasette_url(httpx_mock, mocker, tmpdir):
"""Alias match 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"}))
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"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.url.host == "aliased.example.com"
assert request.url.path == "/db.json"
# -- DATASETTE_URL with other commands --
def test_datasette_url_with_insert(httpx_mock, mocker, tmpdir):
"""DATASETTE_URL works with the insert command."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(json={"ok": True}, status_code=200)
csv_path = pathlib.Path(tmpdir) / "data.csv"
csv_path.write_text("id,name\n1,hello\n")
runner = CliRunner(
env={
"DATASETTE_URL": "https://my-instance.datasette.cloud",
"DATASETTE_TOKEN": "env-token",
}
)
result = runner.invoke(
cli,
["insert", "data", "my_table", str(csv_path), "--csv", "--create"],
catch_exceptions=False,
)
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.url.host == "my-instance.datasette.cloud"
assert "/-/create" in str(request.url.path)
assert request.headers["authorization"] == "Bearer env-token"
def test_datasette_url_with_actor(httpx_mock, mocker, tmpdir):
"""DATASETTE_URL works with the actor command."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(
json={"actor": {"id": "root"}},
status_code=200,
)
runner = CliRunner(
env={
"DATASETTE_URL": "https://my-instance.datasette.cloud",
"DATASETTE_TOKEN": "env-token",
}
)
result = runner.invoke(cli, ["actor", "data"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.url.host == "my-instance.datasette.cloud"
assert request.headers["authorization"] == "Bearer env-token"
# -- Both together --
def test_datasette_url_and_token_together(httpx_mock, mocker, tmpdir):
"""DATASETTE_URL and DATASETTE_TOKEN work together for a complete config."""
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_TOKEN": "env-token-456",
}
)
result = runner.invoke(cli, ["query", "mydb", "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"
assert request.headers["authorization"] == "Bearer env-token-456"