Compare commits

..

23 commits

Author SHA1 Message Date
Simon Willison
f6cbf95b01 Upgrade to actions/checkout@v7 2026-06-23 16:51:17 -07:00
Simon Willison
80786dadaf
Release 0.5a3
Pin to httpx<1.0
2026-03-07 07:15:10 -08:00
Simon Willison
ed589d34cf
Release 0.5a2
Refs #30, #31, #32, #33, #34
2026-02-26 21:55:56 -08:00
Simon Willison
54df24db5e --read/--write options plus --token-only, closes #34 2026-02-26 21:45:58 -08:00
Simon Willison
e8d80a6495 Logging in with OAuth docs 2026-02-26 21:25:56 -08:00
Simon Willison
0b590d1a6f dclient rows command, closes #33 2026-02-26 21:19:32 -08:00
Simon Willison
1340f68255 Ran cog 2026-02-26 21:04:25 -08:00
Simon Willison
021c595de4 dclient create-table, closes #32 2026-02-26 21:03:17 -08:00
Simon Willison
f2aea1b108 --csv, --tsv, --nl, -t/--table output formats, closes #31 2026-02-26 20:56:22 -08:00
Simon Willison
2f7f2a7378 Moved default commands to dclient default ..., closes #30 2026-02-26 20:44:39 -08:00
Simon Willison
7a8643f72f Justfile now uses uv, docs gets a dependency group 2026-02-26 20:43:18 -08:00
Simon Willison
04e396b020 Release 0.5a1 2026-02-26 15:25:22 -08:00
Simon Willison
9df1828d1d Set defaults on login, if not already set 2026-02-26 15:25:06 -08:00
Simon Willison
9b03289e3a Tests now use Datasette 1.0a25+ 2026-02-26 15:05:43 -08:00
Simon Willison
ebb8268d51
Release 0.5a0
Refs #29
2026-02-26 15:00:49 -08:00
Simon Willison
4df076d1f0 Initial oauth flow for use with datasette-oauth 2026-02-26 15:00:15 -08:00
Simon Willison
7be5d4a53f Merge branch 'v2'
# Conflicts:
#	docs/authentication.md
2026-02-26 14:39:57 -08:00
Simon Willison
7b3a15027e Add instances command, improve error messages with actionable hints
Add "dclient instances" command to list known instances from config,
with --json support.

Improve "No instance specified" and "No database specified" error
messages to show the exact commands needed to fix the problem.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:25:57 -08:00
Simon Willison
3f3221fdd3 Update README and docs index for v2
Update examples to use v2 command syntax with -i flag and alias
defaults. Add quick start section showing alias add/default/default-db
workflow. Add introspection commands section. Add environment.md to
docs toctree.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:22:12 -08:00
Simon Willison
742d46de05 v1 to v2 config migration with tests
Add _migrate_v1_to_v2() to convert aliases.json to config.json format.
Single-path-segment URLs are split into instance URL + default_database.
Auth keys are migrated from URLs to alias names. Original files are
backed up as .bak.

7 tests covering simple migration, no path segment, auth migration,
URL fallback for unmatched auth, skip if config exists, skip if no
aliases, and multi-path-segment URLs.

refs #29

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:22:05 -08:00
Simon Willison
92c0d2a5ba Tests and docs for v2 introspection commands and alias features
Add test_commands_v2.py with 28 tests covering databases, tables,
plugins, schema, default_query, upsert, auth status, actor, and
get commands.

Add test_alias_v2.py with 5 tests for alias default, alias default-db,
and removing the default alias.

Update aliases.md and authentication.md docs for v2 API.

refs #29

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:21:58 -08:00
Simon Willison
3621f13263 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>
2026-02-24 15:21:52 -08:00
Simon Willison
ff95969d10 Ran cog 2026-02-24 14:59:23 -08:00
24 changed files with 4552 additions and 683 deletions

View file

@ -14,7 +14,7 @@ jobs:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
@ -34,7 +34,7 @@ jobs:
permissions:
id-token: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
with:

View file

@ -12,7 +12,7 @@ jobs:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:

View file

@ -1,37 +1,29 @@
# Run tests and linters
@default: test lint
# Install dependencies and test dependencies
@init:
pipenv run pip install -e '.[test]'
# Run pytest with supplied options
@test *options:
pipenv run pytest {{options}}
uv run pytest {{options}}
# Run linters
@lint:
echo "Linters..."
echo " Black"
pipenv run black . --check
echo " cog"
pipenv run cog --check README.md docs/*.md
echo " ruff"
pipenv run ruff .
uv run cog --check README.md docs/*.md
echo " ruff check"
uv run ruff check .
echo " ruff format"
uv run ruff format . --check
# Rebuild docs with cog
@cog:
pipenv run cog -r docs/*.md
uv run cog -r docs/*.md
# Serve live docs on localhost:8000
@docs: cog
cd docs && pipenv run make livehtml
cd docs && uv run make livehtml
# Apply Black
@black:
pipenv run black .
# Run automatic fixes
# Apply ruff fixes and formatting
@fix: cog
pipenv run ruff . --fix
pipenv run black .
uv run ruff check . --fix
uv run ruff format .

View file

@ -11,10 +11,12 @@ Much of the functionality requires Datasette 1.0a2 or higher.
## Things you can do with dclient
- Run SQL queries against Datasette and returning 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
- Run queries against authenticated Datasette instances
- Create aliases and store authentication tokens for convenient access to Datasette
- Insert data into Datasette using the [insert API](https://docs.datasette.io/en/latest/json_api.html#the-json-write-api) (Datasette 1.0 alpha or higher)
- Create aliases and set default instances/databases for convenient access
- Insert and upsert data using the [write API](https://docs.datasette.io/en/latest/json_api.html#the-json-write-api) (Datasette 1.0 alpha or higher)
## Installation
@ -26,19 +28,40 @@ If you want to install it in the same virtual environment as Datasette (to use i
```bash
datasette install dclient
```
## Running a query
## Quick start
Add an alias for a Datasette instance:
```bash
dclient alias add latest https://latest.datasette.io
dclient default instance latest
dclient default database latest fixtures
```
Now run queries directly:
```bash
dclient "select * from facetable limit 1"
```
Or be explicit:
```bash
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
```bash
dclient query https://latest.datasette.io/fixtures "select * from facetable limit 1"
```
To shorten that, create an alias:
```bash
dclient alias add fixtures https://latest.datasette.io/fixtures
```
Then run it like this instead:
```bash
dclient query fixtures "select * from facetable limit 1"
dclient databases
dclient tables
dclient plugins
dclient schema facetable
```
## Documentation
Visit **[dclient.datasette.io](https://dclient.datasette.io)** for full documentation on using this tool.

File diff suppressed because it is too large Load diff

View file

@ -1,17 +1,19 @@
# Aliases
You can assign an alias to a Datasette database using the `dclient alias` command:
You can assign an alias to a Datasette instance using the `dclient alias add` command:
dclient alias add content https://datasette.io/content
dclient alias add latest https://latest.datasette.io
You can list aliases with `dclient alias list`:
$ dclient alias list
content = https://datasette.io/content
latest = https://latest.datasette.io
Once registered, you can pass an alias to commands such as `dclient query`:
Once registered, you can pass an alias to commands using the `-i` flag:
dclient query content "select * from news limit 1"
dclient query fixtures "select * from news limit 1" -i latest
See [Defaults](defaults.md) for default instance and default database settings.
## dclient alias --help
<!-- [[[cog
@ -34,7 +36,7 @@ Options:
--help Show this message and exit.
Commands:
add Add an alias
add Add an alias for a Datasette instance
list List aliases
remove Remove an alias
@ -56,10 +58,6 @@ Usage: dclient alias list [OPTIONS]
List aliases
Example usage:
dclient aliases list
Options:
--json Output raw JSON
--help Show this message and exit.
@ -80,15 +78,11 @@ cog.out(
```
Usage: dclient alias add [OPTIONS] NAME URL
Add an alias
Add an alias for a Datasette instance
Example usage:
dclient alias add content https://datasette.io/content
Then:
dclient query content 'select * from news limit 3'
dclient alias add prod https://myapp.datasette.cloud
Options:
--help Show this message and exit.
@ -113,7 +107,7 @@ Usage: dclient alias remove [OPTIONS] NAME
Example usage:
dclient alias remove content
dclient alias remove prod
Options:
--help Show this message and exit.

View file

@ -4,35 +4,159 @@
`dclient` can handle API tokens for Datasette instances that require authentication.
You can pass an API token to `query` using `-t/--token` like this:
You can pass an API token to any command using `--token` like this:
```bash
dclient query https://latest.datasette.io/fixtures "select * from facetable" -t dstok_mytoken
dclient query fixtures "select * from facetable" --token dstok_mytoken -i latest
```
A more convenient way to handle this is to store tokens to be used with different URL prefixes.
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
To always use `dstok_mytoken` for any URL on the `https://latest.datasette.io/` instance you can run this:
To store a token for an alias:
```bash
dclient auth add https://latest.datasette.io/
dclient auth add latest
```
Then paste in the token and hit enter when prompted to do so.
To list which URLs you have set tokens for, run the `auth list` command:
Tokens can also be stored for direct URLs:
```bash
dclient auth add https://latest.datasette.io
```
To list which aliases/URLs you have set tokens for, run the `auth list` command:
```bash
dclient auth list
```
To delete the token for a specific URL, run `auth remove`:
To delete the token for a specific alias or URL, run `auth remove`:
```bash
dclient auth remove https://latest.datasette.io/
dclient auth remove latest
```
## Token resolution order
When making a request, dclient resolves the token in this order:
1. `--token` CLI flag (highest priority)
2. Token stored by alias name in `auth.json`
3. Token stored by URL prefix in `auth.json`
4. `DATASETTE_TOKEN` environment variable (lowest priority)
## Testing a token
The `dclient actor` command can be used to test a token, retrieving the actor that the token represents.
The `dclient auth status` command can be used to verify authentication by calling `/-/actor.json`:
```bash
dclient actor https://latest.datasette.io/content
dclient auth status
dclient auth status -i prod
```
The `dclient actor` command also shows the actor:
```bash
dclient actor
dclient actor -i prod
```
The output looks like this:
```json
@ -68,6 +192,7 @@ Commands:
add Add an authentication token for an alias or URL
list List stored API tokens
remove Remove the API token for an alias or URL
status Verify authentication by calling /-/actor.json
```
<!-- [[[end]]] -->
@ -89,7 +214,8 @@ Usage: dclient auth add [OPTIONS] ALIAS_OR_URL
Example usage:
dclient auth add https://datasette.io/content
dclient auth add prod
dclient auth add https://datasette.io
Paste in the token when prompted.
@ -142,7 +268,7 @@ Usage: dclient auth remove [OPTIONS] ALIAS_OR_URL
Example usage:
dclient auth remove https://datasette.io/content
dclient auth remove prod
Options:
--help Show this message and exit.
@ -150,6 +276,34 @@ Options:
```
<!-- [[[end]]] -->
## dclient auth status --help
<!-- [[[cog
import cog
result = runner.invoke(cli.cli, ["auth", "status", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(help)
)
]]] -->
```
Usage: dclient auth status [OPTIONS]
Verify authentication by calling /-/actor.json
Example usage:
dclient auth status
dclient auth status -i prod
Options:
-i, --instance TEXT Datasette instance URL or alias
--token TEXT API token
--help Show this message and exit.
```
<!-- [[[end]]] -->
## dclient actor --help
<!-- [[[cog
@ -161,17 +315,19 @@ cog.out(
)
]]] -->
```
Usage: dclient actor [OPTIONS] [URL_OR_ALIAS]
Usage: dclient actor [OPTIONS]
Show the actor represented by an API token
Example usage:
dclient actor https://latest.datasette.io/fixtures
dclient actor
dclient actor -i prod
Options:
--token TEXT API token
--help Show this message and exit.
-i, --instance TEXT Datasette instance URL or alias
--token TEXT API token
--help Show this message and exit.
```
<!-- [[[end]]] -->
<!-- [[[end]]] -->

106
docs/defaults.md Normal file
View 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]]] -->

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

@ -21,15 +21,10 @@ If you also have Datasette installed in the same environment it will register it
```bash
datasette install dclient
```
This means you can run any of these commands using `datasette dt` instead, like this:
This means you can run any of these commands using `datasette dc` instead, like this:
```bash
datasette dc --help
datasette dc query https://latest.datasette.io/fixtures "select * from facetable limit 1"
```
You can install it into Datasette this way using:
```bash
datasette install dclient
datasette dc query fixtures "select * from facetable limit 1" -i latest
```
## Contents
@ -40,6 +35,8 @@ maxdepth: 3
---
queries
aliases
defaults
authentication
inserting
environment
```

View file

@ -1,22 +1,64 @@
# 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.
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 +68,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 +76,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,35 +148,105 @@ 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
--replace Replace rows with a matching primary key
--ignore Ignore rows with a matching primary key
--create Create table if it does not exist
--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
--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
--batch-size INTEGER Send rows in batches of this size
--interval FLOAT Send batch at least every X seconds
--token TEXT API token
--silent Don't output progress
-v, --verbose Verbose output: show HTTP request and response
--help Show this message and exit.
```
<!-- [[[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]]] -->

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,178 @@ 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
```
## 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
<!-- [[[cog
import cog
@ -37,22 +209,26 @@ 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
--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]]] -->

View file

@ -1,6 +1,6 @@
[project]
name = "dclient"
version = "0.4"
version = "0.5a3"
description = "A client CLI utility for Datasette instances"
readme = "README.md"
authors = [{name = "Simon Willison"}]
@ -8,7 +8,8 @@ license = "Apache-2.0"
requires-python = ">=3.10"
dependencies = [
"click",
"httpx",
"click-default-group",
"httpx<1.0",
"sqlite-utils",
]
@ -25,13 +26,24 @@ dclient = "dclient.cli:cli"
client = "dclient.plugin"
[dependency-groups]
dev = [
test = [
"pytest",
"pytest-asyncio",
"pytest-httpx",
"cogapp",
"pytest-mock",
"datasette>=1.0a2",
"datasette>=1.0a25",
]
docs = [
"furo",
"sphinx-autobuild",
"sphinx-copybutton",
"myst-parser",
"cogapp",
]
dev = [
{include-group = "test"},
{include-group = "docs"},
]
[tool.uv.build-backend]

139
tests/test_alias_v2.py Normal file
View file

@ -0,0 +1,139 @@
"""Tests for v2 defaults command: instance and database subcommands."""
from click.testing import CliRunner
from dclient.cli import cli
import json
import pathlib
def test_alias_default_workflow(mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
runner = CliRunner()
# Add an alias
result = runner.invoke(cli, ["alias", "add", "prod", "https://prod.example.com"])
assert result.exit_code == 0
# No default yet
result = runner.invoke(cli, ["default", "instance"])
assert result.exit_code == 0
assert "No default instance set" in result.output
# Set default
result = runner.invoke(cli, ["default", "instance", "prod"])
assert result.exit_code == 0
# Show default
result = runner.invoke(cli, ["default", "instance"])
assert result.exit_code == 0
assert result.output.strip() == "prod"
# List should show * marker
result = runner.invoke(cli, ["alias", "list"])
assert result.exit_code == 0
assert "* prod" in result.output
# Clear default
result = runner.invoke(cli, ["default", "instance", "--clear"])
assert result.exit_code == 0
result = runner.invoke(cli, ["default", "instance"])
assert "No default instance set" in result.output
def test_alias_default_unknown_alias(mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
runner = CliRunner()
result = runner.invoke(cli, ["default", "instance", "nonexistent"])
assert result.exit_code == 1
assert "No such alias" in result.output
def test_alias_default_db_workflow(mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
runner = CliRunner()
# Add an alias
result = runner.invoke(cli, ["alias", "add", "prod", "https://prod.example.com"])
assert result.exit_code == 0
# No default database yet
result = runner.invoke(cli, ["default", "database", "prod"])
assert result.exit_code == 0
assert "No default database set" in result.output
# Set default database
result = runner.invoke(cli, ["default", "database", "prod", "main"])
assert result.exit_code == 0
# Show default database
result = runner.invoke(cli, ["default", "database", "prod"])
assert result.exit_code == 0
assert result.output.strip() == "main"
# List should show db info
result = runner.invoke(cli, ["alias", "list"])
assert "(db: main)" in result.output
# Clear default database
result = runner.invoke(cli, ["default", "database", "prod", "--clear"])
assert result.exit_code == 0
result = runner.invoke(cli, ["default", "database", "prod"])
assert "No default database set" in result.output
def test_alias_default_db_unknown_alias(mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
runner = CliRunner()
result = runner.invoke(cli, ["default", "database", "nonexistent", "main"])
assert result.exit_code == 1
assert "No such alias" in result.output
def test_alias_remove_clears_default(mocker, tmpdir):
"""Removing the default alias also clears default_instance."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
runner = CliRunner()
runner.invoke(cli, ["alias", "add", "prod", "https://prod.example.com"])
runner.invoke(cli, ["default", "instance", "prod"])
# Verify it's set
config = json.loads((pathlib.Path(tmpdir) / "config.json").read_text())
assert config["default_instance"] == "prod"
# Remove alias
runner.invoke(cli, ["alias", "remove", "prod"])
config = json.loads((pathlib.Path(tmpdir) / "config.json").read_text())
assert config["default_instance"] is None
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"

View file

@ -2,6 +2,7 @@ from click.testing import CliRunner
from dclient.cli import cli
import pathlib
import json
import pytest
def test_auth(mocker, tmpdir):
@ -13,29 +14,510 @@ 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 == ""
# Check the tokens file
auth_file = pathlib.Path(tmpdir) / "auth.json"
assert json.loads(auth_file.read_text()) == {}
# -- login command (OAuth device flow) --
DEVICE_RESPONSE = {
"device_code": "devcode123",
"user_code": "ABCD-EFGH",
"verification_uri": "https://example.com/-/oauth/device/verify",
"expires_in": 900,
"interval": 0,
}
TOKEN_SUCCESS = {
"access_token": "dstok_abc123",
"token_type": "bearer",
"expires_in": 3600,
}
def test_login_with_url(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/"])
assert result.exit_code == 0
assert "ABCD-EFGH" in result.output
assert "Login successful" in result.output
# Token should be saved
auth_file = pathlib.Path(tmpdir) / "auth.json"
auths = json.loads(auth_file.read_text())
assert auths["https://example.com/"] == "dstok_abc123"
def test_login_adds_trailing_slash(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"])
assert result.exit_code == 0
# Check that the device request went to the right URL
requests = httpx_mock.get_requests()
assert str(requests[0].url) == "https://example.com/-/oauth/device"
def test_login_with_alias(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
# Set up an alias first
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": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "prod"])
assert result.exit_code == 0
assert "Login successful" in result.output
# Token should be saved by alias name
auth_file = pathlib.Path(tmpdir) / "auth.json"
auths = json.loads(auth_file.read_text())
assert auths["prod"] == "dstok_abc123"
def test_login_interactive_prompt(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"], input="https://example.com/\n")
assert result.exit_code == 0
assert "Instance URL or alias" in result.output
assert "Login successful" in result.output
def test_login_access_denied(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={"error": "access_denied"}, status_code=400)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/"])
assert result.exit_code == 1
assert "Authorization denied" in result.output
def test_login_expired_token(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={"error": "expired_token"}, status_code=400)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/"])
assert result.exit_code == 1
assert "expired" in result.output
def test_login_pending_then_success(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)
# First poll: pending
httpx_mock.add_response(json={"error": "authorization_pending"}, status_code=400)
# Second poll: success
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
assert "Login successful" in result.output
auth_file = pathlib.Path(tmpdir) / "auth.json"
auths = json.loads(auth_file.read_text())
assert auths["https://example.com/"] == "dstok_abc123"
def test_login_device_endpoint_error(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(status_code=403)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/"])
assert result.exit_code == 1
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

585
tests/test_commands_v2.py Normal file
View file

@ -0,0 +1,585 @@
"""Tests for v2 commands: databases, tables, plugins, schema, default_query, upsert."""
from click.testing import CliRunner
from dclient.cli import cli
import json
import pathlib
# -- databases command --
def test_databases_json(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(
json={
"databases": [
{"name": "main", "tables_count": 12},
{"name": "extra", "tables_count": 3},
]
},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(cli, ["databases", "-i", "https://example.com", "--json"])
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data) == 2
assert data[0]["name"] == "main"
def test_databases_plain(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(
json={
"databases": [
{"name": "main", "tables_count": 12},
{"name": "extra", "tables_count": 3},
]
},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(cli, ["databases", "-i", "https://example.com"])
assert result.exit_code == 0
assert "main\n" in result.output
assert "extra\n" in result.output
def test_databases_url(httpx_mock, mocker, tmpdir):
"""databases command hits /.json on the instance."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(
json={"databases": [{"name": "db1"}]},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(cli, ["databases", "-i", "https://example.com"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.url.path == "/.json"
# -- tables command --
def test_tables_json(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(
json={
"tables": [
{"name": "facetable", "count": 15, "hidden": False},
{"name": "facet_cities", "count": 4, "hidden": False},
],
"views": [],
},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(
cli,
["tables", "-i", "https://example.com", "-d", "fixtures", "--json"],
)
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data) == 2
def test_tables_plain(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(
json={
"tables": [
{"name": "facetable", "count": 15, "hidden": False},
],
"views": [],
},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(
cli, ["tables", "-i", "https://example.com", "-d", "fixtures"]
)
assert result.exit_code == 0
assert "facetable" in result.output
assert "15 rows" in result.output
def test_tables_url(httpx_mock, mocker, tmpdir):
"""tables command hits /<database>.json."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(
json={"tables": [], "views": []},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(
cli, ["tables", "-i", "https://example.com", "-d", "fixtures"]
)
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.url.path == "/fixtures.json"
def test_tables_with_views(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(
json={
"tables": [{"name": "t1", "count": 5, "hidden": False}],
"views": [{"name": "v1"}],
},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(
cli, ["tables", "-i", "https://example.com", "-d", "db", "--views"]
)
assert result.exit_code == 0
assert "t1" in result.output
assert "v1" in result.output
def test_tables_views_only(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(
json={
"tables": [{"name": "t1", "count": 5, "hidden": False}],
"views": [{"name": "v1"}],
},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(
cli,
["tables", "-i", "https://example.com", "-d", "db", "--views-only"],
)
assert result.exit_code == 0
assert "t1" not in result.output
assert "v1" in result.output
def test_tables_hidden(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(
json={
"tables": [
{"name": "visible", "count": 5, "hidden": False},
{"name": "hidden_t", "count": 2, "hidden": True},
],
"views": [],
},
status_code=200,
)
runner = CliRunner()
# Without --hidden
result = runner.invoke(cli, ["tables", "-i", "https://example.com", "-d", "db"])
assert "visible" in result.output
assert "hidden_t" not in result.output
# With --hidden
httpx_mock.add_response(
json={
"tables": [
{"name": "visible", "count": 5, "hidden": False},
{"name": "hidden_t", "count": 2, "hidden": True},
],
"views": [],
},
status_code=200,
)
result = runner.invoke(
cli,
["tables", "-i", "https://example.com", "-d", "db", "--hidden"],
)
assert "visible" in result.output
assert "hidden_t" in result.output
# -- plugins command --
def test_plugins_json(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(
json=[
{"name": "datasette-files", "version": "0.3.1"},
{"name": "datasette-auth-tokens", "version": "0.4"},
],
status_code=200,
)
runner = CliRunner()
result = runner.invoke(cli, ["plugins", "-i", "https://example.com", "--json"])
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data) == 2
def test_plugins_plain(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(
json=[
{"name": "datasette-files", "version": "0.3.1"},
{"name": "datasette-auth-tokens", "version": "0.4"},
],
status_code=200,
)
runner = CliRunner()
result = runner.invoke(cli, ["plugins", "-i", "https://example.com"])
assert result.exit_code == 0
assert "datasette-files\n" in result.output
assert "datasette-auth-tokens\n" in result.output
def test_plugins_url(httpx_mock, mocker, tmpdir):
"""plugins command hits /-/plugins.json."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(json=[], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["plugins", "-i", "https://example.com"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.url.path == "/-/plugins.json"
# -- schema command --
def test_schema_all_tables(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
full_schema = (
"CREATE TABLE users (id integer primary key, name text);\n"
"CREATE VIEW user_count AS SELECT count(*) FROM users;"
)
httpx_mock.add_response(
json={"database": "main", "schema": full_schema},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(cli, ["schema", "-i", "https://example.com", "-d", "main"])
assert result.exit_code == 0
assert "CREATE TABLE users" in result.output
assert "CREATE VIEW user_count" in result.output
request = httpx_mock.get_request()
assert request.url.path == "/main/-/schema.json"
def test_schema_specific_table(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(
json={
"database": "main",
"table": "users",
"schema": "CREATE TABLE users (id integer primary key, name text)",
},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(
cli, ["schema", "users", "-i", "https://example.com", "-d", "main"]
)
assert result.exit_code == 0
assert "CREATE TABLE users" in result.output
request = httpx_mock.get_request()
assert request.url.path == "/main/users/-/schema.json"
def test_schema_json(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
full_schema = "CREATE TABLE users (id integer primary key);"
httpx_mock.add_response(
json={"database": "main", "schema": full_schema},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(
cli,
["schema", "-i", "https://example.com", "-d", "main", "--json"],
)
assert result.exit_code == 0
data = json.loads(result.output)
assert "schema" in data
# -- default_query (bare SQL shortcut) --
def test_default_query_with_defaults(httpx_mock, mocker, tmpdir):
"""Bare SQL uses default instance + 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": "main",
}
},
}
)
)
httpx_mock.add_response(
json={
"ok": True,
"rows": [{"count(*)": 42}],
"columns": ["count(*)"],
},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(cli, ["select count(*) from users"])
assert result.exit_code == 0
assert json.loads(result.output) == [{"count(*)": 42}]
request = httpx_mock.get_request()
assert request.url.host == "prod.example.com"
assert request.url.path == "/main.json"
def test_default_query_with_database_override(httpx_mock, mocker, tmpdir):
"""Bare SQL with -d flag overrides the 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": "main",
}
},
}
)
)
httpx_mock.add_response(
json={
"ok": True,
"rows": [{"count(*)": 100}],
"columns": ["count(*)"],
},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(cli, ["select count(*) from events", "-d", "analytics"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.url.path == "/analytics.json"
def test_default_query_with_instance_override(httpx_mock, mocker, tmpdir):
"""Bare SQL with -i flag overrides the instance."""
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",
},
"staging": {
"url": "https://staging.example.com",
"default_database": "main",
},
},
}
)
)
httpx_mock.add_response(
json={
"ok": True,
"rows": [{"count(*)": 5}],
"columns": ["count(*)"],
},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(cli, ["select count(*) from users", "-i", "staging"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.url.host == "staging.example.com"
def test_default_query_no_database_error(mocker, tmpdir):
"""Bare SQL without a default database gives an 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, ["select 1"])
assert result.exit_code == 1
assert "No database specified" in result.output
# -- upsert command --
def test_upsert_mocked(httpx_mock, tmpdir, mocker):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(
json={
"ok": True,
}
)
path = pathlib.Path(tmpdir) / "data.csv"
path.write_text("a,b,c\n1,2,3\n")
runner = CliRunner()
result = runner.invoke(
cli,
[
"upsert",
"data",
"table1",
str(path),
"--csv",
"--token",
"x",
"-i",
"https://datasette.example.com",
],
catch_exceptions=False,
)
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.headers["authorization"] == "Bearer x"
# Should hit /-/upsert endpoint
assert "/table1/-/upsert" in str(request.url)
assert json.loads(request.read()) == {"rows": [{"a": 1, "b": 2, "c": 3}]}
# -- auth status command --
def test_auth_status(httpx_mock, mocker, tmpdir):
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()
result = runner.invoke(
cli, ["auth", "status", "-i", "https://example.com", "--token", "tok"]
)
assert result.exit_code == 0
data = json.loads(result.output)
assert data["actor"]["id"] == "root"
request = httpx_mock.get_request()
assert request.url.path == "/-/actor.json"
# -- actor command --
def test_actor_with_instance_flag(httpx_mock, mocker, tmpdir):
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()
result = runner.invoke(
cli, ["actor", "-i", "https://example.com", "--token", "tok"]
)
assert result.exit_code == 0
data = json.loads(result.output)
assert data["actor"]["id"] == "root"
request = httpx_mock.get_request()
assert request.url.path == "/-/actor.json"
assert request.headers["authorization"] == "Bearer tok"
# -- get command --
def test_get_command(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(
json={"hello": "world"},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(cli, ["get", "/-/plugins.json", "-i", "https://example.com"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data == {"hello": "world"}
request = httpx_mock.get_request()
assert request.url.path == "/-/plugins.json"
# -- instances command --
def test_instances_plain(mocker, tmpdir):
config_dir = pathlib.Path(tmpdir)
mocker.patch("dclient.cli.get_config_dir", return_value=config_dir)
(config_dir / "config.json").write_text(
json.dumps(
{
"default_instance": "prod",
"instances": {
"prod": {
"url": "https://prod.example.com",
"default_database": "main",
},
"staging": {
"url": "https://staging.example.com",
"default_database": None,
},
},
}
)
)
runner = CliRunner()
result = runner.invoke(cli, ["instances"])
assert result.exit_code == 0
assert "* prod = https://prod.example.com (db: main)" in result.output
assert " staging = https://staging.example.com" in result.output
def test_instances_json(mocker, tmpdir):
config_dir = pathlib.Path(tmpdir)
mocker.patch("dclient.cli.get_config_dir", return_value=config_dir)
(config_dir / "config.json").write_text(
json.dumps(
{
"default_instance": "prod",
"instances": {
"prod": {
"url": "https://prod.example.com",
"default_database": "main",
},
},
}
)
)
runner = CliRunner()
result = runner.invoke(cli, ["instances", "--json"])
assert result.exit_code == 0
data = json.loads(result.output)
assert "prod" in data["instances"]
assert data["default_instance"] == "prod"
def test_instances_empty(mocker, tmpdir):
config_dir = pathlib.Path(tmpdir)
mocker.patch("dclient.cli.get_config_dir", return_value=config_dir)
runner = CliRunner()
result = runner.invoke(cli, ["instances"])
assert result.exit_code == 0
assert result.output.strip() == ""

251
tests/test_config.py Normal file
View file

@ -0,0 +1,251 @@
"""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,
)
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

203
tests/test_create_table.py Normal file
View 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"

View file

@ -3,7 +3,6 @@ from dclient.cli import cli
import json
import pathlib
QUERY_RESPONSE = {
"ok": True,
"database": "data",
@ -26,7 +25,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 +39,16 @@ 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 +62,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 +73,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 +96,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 +111,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 +176,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 +202,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,
)
@ -269,7 +271,7 @@ async def test_insert_against_datasette(
"insert into table1 (a, b, c) values (1, 2, 3), (4, 5, 6)"
)
token = ds.create_token("actor")
token = await ds.create_token("actor")
# These are useful with pytest --pdb to see what happened
datasette_requests = []
@ -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,
)

104
tests/test_migration.py Normal file
View file

@ -0,0 +1,104 @@
"""Tests for v1 → v2 config migration."""
from dclient.cli import _migrate_v1_to_v2
import json
import pathlib
def test_migration_simple(tmpdir):
"""Migrate a simple aliases.json with a database-in-URL alias."""
config_dir = pathlib.Path(tmpdir)
aliases_file = config_dir / "aliases.json"
aliases_file.write_text(json.dumps({"content": "https://datasette.io/content"}))
_migrate_v1_to_v2(config_dir)
config = json.loads((config_dir / "config.json").read_text())
assert config["instances"]["content"]["url"] == "https://datasette.io"
assert config["instances"]["content"]["default_database"] == "content"
assert config["default_instance"] is None
# Original should be renamed
assert (config_dir / "aliases.json.bak").exists()
assert not (config_dir / "aliases.json").exists()
def test_migration_no_path_segment(tmpdir):
"""Migrate an alias that has no database in the URL."""
config_dir = pathlib.Path(tmpdir)
aliases_file = config_dir / "aliases.json"
aliases_file.write_text(json.dumps({"local": "http://localhost:8001"}))
_migrate_v1_to_v2(config_dir)
config = json.loads((config_dir / "config.json").read_text())
assert config["instances"]["local"]["url"] == "http://localhost:8001"
assert config["instances"]["local"]["default_database"] is None
def test_migration_with_auth(tmpdir):
"""Auth keys are migrated from URLs to alias names."""
config_dir = pathlib.Path(tmpdir)
aliases_file = config_dir / "aliases.json"
aliases_file.write_text(json.dumps({"content": "https://datasette.io/content"}))
auth_file = config_dir / "auth.json"
auth_file.write_text(json.dumps({"https://datasette.io/content": "tok123"}))
_migrate_v1_to_v2(config_dir)
new_auths = json.loads((config_dir / "auth.json").read_text())
assert "content" in new_auths
assert new_auths["content"] == "tok123"
# Old auth should be backed up
assert (config_dir / "auth.json.bak").exists()
def test_migration_auth_url_fallback(tmpdir):
"""Auth entries without matching aliases are kept as URL keys."""
config_dir = pathlib.Path(tmpdir)
aliases_file = config_dir / "aliases.json"
aliases_file.write_text(json.dumps({}))
auth_file = config_dir / "auth.json"
auth_file.write_text(json.dumps({"https://other.example.com": "tok456"}))
_migrate_v1_to_v2(config_dir)
new_auths = json.loads((config_dir / "auth.json").read_text())
assert new_auths["https://other.example.com"] == "tok456"
def test_migration_skips_if_config_exists(tmpdir):
"""If config.json already exists, migration is skipped."""
config_dir = pathlib.Path(tmpdir)
(config_dir / "config.json").write_text(json.dumps({"existing": True}))
(config_dir / "aliases.json").write_text(json.dumps({"foo": "https://bar.com"}))
_migrate_v1_to_v2(config_dir)
# config.json should be untouched
assert json.loads((config_dir / "config.json").read_text()) == {"existing": True}
# aliases.json should NOT be renamed
assert (config_dir / "aliases.json").exists()
def test_migration_skips_if_no_aliases(tmpdir):
"""If aliases.json doesn't exist, migration is skipped."""
config_dir = pathlib.Path(tmpdir)
_migrate_v1_to_v2(config_dir)
assert not (config_dir / "config.json").exists()
def test_migration_multi_path_segments(tmpdir):
"""URL with multiple path segments stores URL as-is."""
config_dir = pathlib.Path(tmpdir)
aliases_file = config_dir / "aliases.json"
aliases_file.write_text(json.dumps({"deep": "https://example.com/a/b/c"}))
_migrate_v1_to_v2(config_dir)
config = json.loads((config_dir / "config.json").read_text())
assert config["instances"]["deep"]["url"] == "https://example.com/a/b/c"
assert config["instances"]["deep"]["default_database"] is None

View 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() == ""

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,18 @@ 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 +69,30 @@ 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"] == {}

View file

@ -1,272 +1,410 @@
"""Tests for the rows command."""
from click.testing import CliRunner
from dclient.cli import cli
import json
import pathlib
import pytest
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 test_rows_basic(httpx_mock):
httpx_mock.add_response(
json={
"ok": True,
"rows": [{"id": 1, "name": "Cleo", "age": 4}],
"next": None,
},
status_code=200,
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", "https://example.com/db", "creatures"])
assert result.exit_code == 0
assert json.loads(result.output) == [{"id": 1, "name": "Cleo", "age": 4}]
request = httpx_mock.get_request()
assert str(request.url) == "https://example.com/db/creatures.json?_shape=objects"
result = runner.invoke(cli, ["rows", "dogs"])
assert result.exit_code == 1
assert "No database specified" in result.output
def test_rows_single_filter(httpx_mock):
httpx_mock.add_response(
json={"ok": True, "rows": [{"id": 2, "name": "Luna", "age": 7}]},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(
cli,
["rows", "https://example.com/db", "creatures", "-f", "age", "gte", "5"],
)
# -- 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()
assert "age__gte=5" in str(request.url)
assert "_shape=objects" in str(request.url)
params = dict(request.url.params)
assert params["name__exact"] == "Cleo"
def test_rows_eq_maps_to_exact(httpx_mock):
httpx_mock.add_response(
json={"ok": True, "rows": [{"id": 1, "name": "Cleo"}]},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(
cli,
["rows", "https://example.com/db", "creatures", "-f", "name", "eq", "Cleo"],
)
def test_rows_filter_gt(httpx_mock):
result = _invoke(httpx_mock, ["--filter", "age", "gt", "3"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert "name__exact=Cleo" in str(request.url)
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):
httpx_mock.add_response(
json={"ok": True, "rows": []},
status_code=200,
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",
"https://example.com/db",
"creatures",
"-f",
"species",
"in",
"dog,cat",
"-f",
"age",
"gt",
"fixtures",
"dogs",
"-i",
"https://example.com",
"--all",
"--limit",
"3",
],
)
assert result.exit_code == 0
request = httpx_mock.get_request()
url_str = str(request.url)
assert "species__in=" in url_str
assert "age__gt=3" in url_str
data = json.loads(result.output)
assert len(data) == 3
def test_rows_where_clause(httpx_mock):
httpx_mock.add_response(
json={"ok": True, "rows": [{"id": 6}]},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(
cli,
["rows", "https://example.com/db", "creatures", "--where", "id > 5"],
)
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
request = httpx_mock.get_request()
assert "_where=" in str(request.url)
data = json.loads(result.output)
assert len(data) == 2 # only first page
def test_rows_multiple_where_clauses(httpx_mock):
# -- error handling --
def test_rows_api_error(httpx_mock):
httpx_mock.add_response(
json={"ok": True, "rows": []},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(
cli,
[
"rows",
"https://example.com/db",
"creatures",
"--where",
"id > 5",
"--where",
"age < 10",
],
)
assert result.exit_code == 0
request = httpx_mock.get_request()
url_str = str(request.url)
assert url_str.count("_where=") == 2
def test_rows_sort(httpx_mock):
httpx_mock.add_response(
json={"ok": True, "rows": [{"id": 1}]},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(
cli,
["rows", "https://example.com/db", "creatures", "--sort", "name"],
)
assert result.exit_code == 0
request = httpx_mock.get_request()
assert "_sort=name" in str(request.url)
def test_rows_sort_desc(httpx_mock):
httpx_mock.add_response(
json={"ok": True, "rows": [{"id": 1}]},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(
cli,
["rows", "https://example.com/db", "creatures", "--sort-desc", "age"],
)
assert result.exit_code == 0
request = httpx_mock.get_request()
assert "_sort_desc=age" in str(request.url)
def test_rows_sort_mutual_exclusion():
runner = CliRunner()
result = runner.invoke(
cli,
[
"rows",
"https://example.com/db",
"creatures",
"--sort",
"name",
"--sort-desc",
"age",
],
)
assert result.exit_code == 1
assert "Cannot use both --sort and --sort-desc" in result.output
@pytest.mark.parametrize("with_token", (False, True))
def test_rows_with_token(httpx_mock, with_token):
httpx_mock.add_response(
json={"ok": True, "rows": [{"id": 1}]},
status_code=200,
)
runner = CliRunner()
args = ["rows", "https://example.com/db", "creatures"]
if with_token:
args.extend(["--token", "xyz"])
result = runner.invoke(cli, args)
assert result.exit_code == 0
request = httpx_mock.get_request()
if with_token:
assert request.headers["authorization"] == "Bearer xyz"
else:
assert "authorization" not in request.headers
def test_rows_http_error(httpx_mock):
httpx_mock.add_response(
json={"title": "Not Found", "error": "Table not found: creatures"},
json={"ok": False, "error": "Table not found: dogs"},
status_code=404,
)
runner = CliRunner()
result = runner.invoke(cli, ["rows", "https://example.com/db", "creatures"])
assert result.exit_code == 1
assert "404 status code" in result.output
assert "Table not found: creatures" in result.output
def test_rows_ok_false_error(httpx_mock):
httpx_mock.add_response(
json={"ok": False, "error": "Something went wrong"},
status_code=200,
result = runner.invoke(
cli,
["rows", "fixtures", "dogs", "-i", "https://example.com"],
)
runner = CliRunner()
result = runner.invoke(cli, ["rows", "https://example.com/db", "creatures"])
assert result.exit_code == 1
assert "Something went wrong" in result.output
assert "Table not found" in result.output
def test_rows_with_alias(httpx_mock, mocker, tmpdir):
# -- uses default instance --
def test_rows_default_instance(httpx_mock, mocker, tmpdir):
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({"content": "https://datasette.io/content"}))
httpx_mock.add_response(
json={"ok": True, "rows": [{"id": 1}]},
status_code=200,
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", "content", "creatures"])
result = runner.invoke(cli, ["rows", "data", "dogs"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.url.host == "datasette.io"
assert request.url.path == "/content/creatures.json"
def test_rows_verbose(httpx_mock):
httpx_mock.add_response(
json={"ok": True, "rows": []},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(
cli,
["rows", "https://example.com/db", "creatures", "-v"],
)
assert result.exit_code == 0
assert "creatures.json" in result.output
def test_rows_combined(httpx_mock):
"""Test filters, where, and sort all together."""
httpx_mock.add_response(
json={"ok": True, "rows": [{"id": 1, "name": "Cleo", "age": 4}]},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(
cli,
[
"rows",
"https://example.com/db",
"creatures",
"-f",
"species",
"eq",
"dog",
"--where",
"age > 2",
"--sort-desc",
"age",
],
)
assert result.exit_code == 0
request = httpx_mock.get_request()
url_str = str(request.url)
assert "species__exact=dog" in url_str
assert "_where=" in url_str
assert "_sort_desc=age" in url_str
assert request.url.host == "prod.example.com"
assert request.url.path == "/data/dogs.json"