From 32124a0bf36d7667afc2b7341f5da21b825d91c6 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 23 Feb 2026 22:10:59 -0800 Subject: [PATCH] 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 --- dclient/cli.py | 56 +++++++------- docs/environment.md | 68 +++++++++++++++++ tests/test_env.py | 178 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 275 insertions(+), 27 deletions(-) create mode 100644 docs/environment.md create mode 100644 tests/test_env.py diff --git a/dclient/cli.py b/dclient/cli.py index a8b3376..208238f 100644 --- a/dclient/cli.py +++ b/dclient/cli.py @@ -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() diff --git a/docs/environment.md b/docs/environment.md new file mode 100644 index 0000000..f597617 --- /dev/null +++ b/docs/environment.md @@ -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 +``` diff --git a/tests/test_env.py b/tests/test_env.py new file mode 100644 index 0000000..5afd593 --- /dev/null +++ b/tests/test_env.py @@ -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"