Compare commits

..

1 commit

Author SHA1 Message Date
Claude
e0efd08fa3
Add rows command with --filter, --where, --sort, --sort-desc options
New `rows` command fetches rows from a Datasette table with support for:
- `-f`/`--filter column op value` mapping to `?column__op=value` query params
- `--where` for raw SQL WHERE clauses (multiple allowed)
- `--sort` and `--sort-desc` for sorting (mutually exclusive)
- `eq` operator alias maps to Datasette's `exact` filter
- All other operators passed through directly to Datasette

https://claude.ai/code/session_01W24ZNk4u2qJRZ51rzx4cN7
2026-02-25 01:58:34 +00:00
24 changed files with 667 additions and 4536 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@v7
- uses: actions/checkout@v6
- 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@v7
- uses: actions/checkout@v6
- 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@v7
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:

View file

@ -1,29 +1,37 @@
# 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:
uv run pytest {{options}}
pipenv run pytest {{options}}
# Run linters
@lint:
echo "Linters..."
echo " Black"
pipenv run black . --check
echo " cog"
uv run cog --check README.md docs/*.md
echo " ruff check"
uv run ruff check .
echo " ruff format"
uv run ruff format . --check
pipenv run cog --check README.md docs/*.md
echo " ruff"
pipenv run ruff .
# Rebuild docs with cog
@cog:
uv run cog -r docs/*.md
pipenv run cog -r docs/*.md
# Serve live docs on localhost:8000
@docs: cog
cd docs && uv run make livehtml
cd docs && pipenv run make livehtml
# Apply ruff fixes and formatting
# Apply Black
@black:
pipenv run black .
# Run automatic fixes
@fix: cog
uv run ruff check . --fix
uv run ruff format .
pipenv run ruff . --fix
pipenv run black .

View file

@ -11,12 +11,10 @@ Much of the functionality requires Datasette 1.0a2 or higher.
## Things you can do with dclient
- 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 SQL queries against Datasette and returning the results as JSON
- Run queries against authenticated Datasette instances
- 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)
- 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)
## Installation
@ -28,40 +26,19 @@ If you want to install it in the same virtual environment as Datasette (to use i
```bash
datasette install dclient
```
## 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
## Running a query
```bash
dclient databases
dclient tables
dclient plugins
dclient schema facetable
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"
```
## 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,19 +1,17 @@
# Aliases
You can assign an alias to a Datasette instance using the `dclient alias add` command:
You can assign an alias to a Datasette database using the `dclient alias` command:
dclient alias add latest https://latest.datasette.io
dclient alias add content https://datasette.io/content
You can list aliases with `dclient alias list`:
$ dclient alias list
latest = https://latest.datasette.io
content = https://datasette.io/content
Once registered, you can pass an alias to commands using the `-i` flag:
Once registered, you can pass an alias to commands such as `dclient query`:
dclient query fixtures "select * from news limit 1" -i latest
See [Defaults](defaults.md) for default instance and default database settings.
dclient query content "select * from news limit 1"
## dclient alias --help
<!-- [[[cog
@ -36,7 +34,7 @@ Options:
--help Show this message and exit.
Commands:
add Add an alias for a Datasette instance
add Add an alias
list List aliases
remove Remove an alias
@ -58,6 +56,10 @@ Usage: dclient alias list [OPTIONS]
List aliases
Example usage:
dclient aliases list
Options:
--json Output raw JSON
--help Show this message and exit.
@ -78,11 +80,15 @@ cog.out(
```
Usage: dclient alias add [OPTIONS] NAME URL
Add an alias for a Datasette instance
Add an alias
Example usage:
dclient alias add prod https://myapp.datasette.cloud
dclient alias add content https://datasette.io/content
Then:
dclient query content 'select * from news limit 3'
Options:
--help Show this message and exit.
@ -107,7 +113,7 @@ Usage: dclient alias remove [OPTIONS] NAME
Example usage:
dclient alias remove prod
dclient alias remove content
Options:
--help Show this message and exit.

View file

@ -4,159 +4,35 @@
`dclient` can handle API tokens for Datasette instances that require authentication.
You can pass an API token to any command using `--token` like this:
You can pass an API token to `query` using `-t/--token` like this:
```bash
dclient query fixtures "select * from facetable" --token dstok_mytoken -i latest
dclient query https://latest.datasette.io/fixtures "select * from facetable" -t dstok_mytoken
```
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]]] -->
A more convenient way to handle this is to store tokens to be used with different URL prefixes.
## Using stored tokens
To store a token for an alias:
To always use `dstok_mytoken` for any URL on the `https://latest.datasette.io/` instance you can run this:
```bash
dclient auth add latest
dclient auth add https://latest.datasette.io/
```
Then paste in the token and hit enter when prompted to do so.
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:
To list which URLs you have set tokens for, run the `auth list` command:
```bash
dclient auth list
```
To delete the token for a specific alias or URL, run `auth remove`:
To delete the token for a specific URL, run `auth remove`:
```bash
dclient auth remove latest
dclient auth remove https://latest.datasette.io/
```
## 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 auth status` command can be used to verify authentication by calling `/-/actor.json`:
The `dclient actor` command can be used to test a token, retrieving the actor that the token represents.
```bash
dclient auth status
dclient auth status -i prod
```
The `dclient actor` command also shows the actor:
```bash
dclient actor
dclient actor -i prod
dclient actor https://latest.datasette.io/content
```
The output looks like this:
```json
@ -192,7 +68,6 @@ 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]]] -->
@ -214,8 +89,7 @@ Usage: dclient auth add [OPTIONS] ALIAS_OR_URL
Example usage:
dclient auth add prod
dclient auth add https://datasette.io
dclient auth add https://datasette.io/content
Paste in the token when prompted.
@ -268,7 +142,7 @@ Usage: dclient auth remove [OPTIONS] ALIAS_OR_URL
Example usage:
dclient auth remove prod
dclient auth remove https://datasette.io/content
Options:
--help Show this message and exit.
@ -276,34 +150,6 @@ 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
@ -315,19 +161,17 @@ cog.out(
)
]]] -->
```
Usage: dclient actor [OPTIONS]
Usage: dclient actor [OPTIONS] [URL_OR_ALIAS]
Show the actor represented by an API token
Example usage:
dclient actor
dclient actor -i prod
dclient actor https://latest.datasette.io/fixtures
Options:
-i, --instance TEXT Datasette instance URL or alias
--token TEXT API token
--help Show this message and exit.
--token TEXT API token
--help Show this message and exit.
```
<!-- [[[end]]] -->
<!-- [[[end]]] -->

View file

@ -1,106 +0,0 @@
# 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,39 +2,37 @@
# Environment variables
`dclient` supports several environment variables for convenient access to Datasette instances.
`dclient` supports two environment variables for convenient access to a Datasette instance without needing aliases or repeated URLs.
## DATASETTE_URL
Set this to the base URL of your Datasette instance. It is used as a fallback when no instance is specified via `-i` and no default instance is configured:
Set this to the base URL of your Datasette instance:
```bash
export DATASETTE_URL=https://my-instance.datasette.cloud
```
Then you can omit the `-i` flag:
Then pass just the database name as the first argument to any command:
```bash
dclient databases
dclient query data "select * from my_table limit 10"
```
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:
This is equivalent to:
```bash
export DATASETTE_DATABASE=data
dclient query https://my-instance.datasette.cloud/data "select * from my_table limit 10"
```
Then you can use the bare SQL shortcut:
It works with all commands:
```bash
dclient "select * from my_table limit 10"
dclient insert data my_table data.csv --csv
dclient actor data
```
Full URLs and aliases always take priority over `DATASETTE_URL`. If the argument starts with `http://` or `https://`, it is used as-is. If it matches an alias in `aliases.json`, the alias is used.
## DATASETTE_TOKEN
Set this to an API token:
@ -48,34 +46,23 @@ 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 alias name, then URL prefix)
2. Stored token from `auth.json` (matched by URL prefix)
3. `DATASETTE_TOKEN` environment variable (lowest priority)
## 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
## Using both together
These variables work well together for quick access to a single instance:
```bash
export DATASETTE_URL=https://my-instance.datasette.cloud
export DATASETTE_DATABASE=data
export DATASETTE_TOKEN=dstok_abc123
# Query
dclient "select * from my_table"
# Query the "data" database
dclient query data "select * from my_table"
# Insert
# Insert into the "data" database
cat records.json | dclient insert data my_table - --json
# Check your actor identity
dclient actor
dclient actor data
```

View file

@ -21,10 +21,15 @@ 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 dc` instead, like this:
This means you can run any of these commands using `datasette dt` instead, like this:
```bash
datasette dc --help
datasette dc query fixtures "select * from facetable limit 1" -i latest
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
```
## Contents
@ -35,8 +40,6 @@ maxdepth: 3
---
queries
aliases
defaults
authentication
inserting
environment
```

View file

@ -1,64 +1,22 @@
# 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` in the `data` database:
To insert data from a `data.csv` file into a table called `my_table`, creating that table if it does not exist:
```bash
dclient insert data my_table data.csv --create -i myapp
dclient insert \
https://my-private-space.datasette.cloud/data \
my_table data.csv --create
```
You can also pipe data into standard input:
```bash
curl -s 'https://api.github.com/repos/simonw/dclient/issues' | \
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
dclient insert \
https://my-private-space.datasette.cloud/data \
issues - --create
```
## Streaming data
@ -68,7 +26,7 @@ dclient upsert data my_table data.csv --csv -i myapp
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 data logs - --nl -i myapp
dclient insert https://my-private-space.datasette.cloud/data logs - --nl
```
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.
@ -76,7 +34,7 @@ In streaming mode records default to being sent to the server every 100 records
```bash
tail -f log.jsonl | dclient insert \
data logs - --nl --create -i myapp \
https://my-private-space.datasette.cloud/data logs - --nl --create \
--batch-size 10 \
--interval 5
```
@ -148,105 +106,35 @@ cog.out(
)
]]] -->
```
Usage: dclient insert [OPTIONS] DATABASE TABLE FILEPATH
Usage: dclient insert [OPTIONS] URL_OR_ALIAS TABLE FILEPATH
Insert data into a remote Datasette instance
Example usage:
dclient insert main mytable data.csv --csv -i myapp
dclient insert main mytable data.csv --csv --create --pk id
dclient insert \
https://private.datasette.cloud/data \
mytable data.csv --pk id --create
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
--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
-t, --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 fixtures "select * from facetable limit 1" -i https://latest.datasette.io
dclient query https://latest.datasette.io/fixtures "select * from facetable limit 1"
```
Output:
```json
@ -24,178 +24,6 @@ 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
@ -209,26 +37,22 @@ cog.out(
)
]]] -->
```
Usage: dclient query [OPTIONS] DATABASE SQL
Usage: dclient query [OPTIONS] URL_OR_ALIAS SQL
Run a SQL query against a Datasette database
Run a SQL query against a Datasette database URL
Requires both a database name and a SQL string.
Returns a JSON array of objects
Example usage:
dclient query fixtures "select * from facetable limit 5"
dclient query analytics "select count(*) from events" -i staging
dclient query \
https://datasette.io/content \
'select * from news limit 10'
Options:
-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.
--token TEXT API token
-v, --verbose Verbose output: show HTTP request
--help Show this message and exit.
```
<!-- [[[end]]] -->

View file

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

View file

@ -1,139 +0,0 @@
"""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,7 +2,6 @@ from click.testing import CliRunner
from dclient.cli import cli
import pathlib
import json
import pytest
def test_auth(mocker, tmpdir):
@ -14,510 +13,29 @@ 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 (keys are now alias names or URLs)
result2 = runner.invoke(cli, ["auth", "add", "prod"], input="xyz\n")
# Now add a token
result2 = runner.invoke(cli, ["auth", "add", "https://example.com"], 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()) == {"prod": "xyz"}
assert json.loads(auth_file.read_text()) == {"https://example.com": "xyz"}
# auth list should show that now
result3 = runner.invoke(cli, ["auth", "list"])
assert result3.output.startswith("Tokens file:")
assert "prod" in result3.output
assert "https://example.com" in result3.output
# Remove should fail with an incorrect key
result4 = runner.invoke(cli, ["auth", "remove", "nonexistent"])
# Remove should fail with an incorrect URL
result4 = runner.invoke(cli, ["auth", "remove", "https://example.com/foo"])
assert result4.exit_code == 1
assert result4.output == "Error: No such URL or alias\n"
# Remove should work with the correct key
result5 = runner.invoke(cli, ["auth", "remove", "prod"])
# Remove should work with the correct URL
result5 = runner.invoke(cli, ["auth", "remove", "https://example.com"])
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

View file

@ -1,585 +0,0 @@
"""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() == ""

View file

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

View file

@ -1,203 +0,0 @@
"""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,6 +3,7 @@ from dclient.cli import cli
import json
import pathlib
QUERY_RESPONSE = {
"ok": True,
"database": "data",
@ -25,9 +26,7 @@ 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", "data", "select 1", "-i", "https://example.com"]
)
result = runner.invoke(cli, ["query", "https://example.com", "select 1"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.headers["authorization"] == "Bearer env-token-123"
@ -39,16 +38,7 @@ 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",
"data",
"select 1",
"-i",
"https://example.com",
"--token",
"flag-token",
],
cli, ["query", "https://example.com", "select 1", "--token", "flag-token"]
)
assert result.exit_code == 0
request = httpx_mock.get_request()
@ -62,9 +52,7 @@ 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", "data", "select 1", "-i", "https://example.com"]
)
result = runner.invoke(cli, ["query", "https://example.com", "select 1"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.headers["authorization"] == "Bearer stored-token"
@ -73,8 +61,8 @@ def test_auth_json_overrides_datasette_token(httpx_mock, mocker, tmpdir):
# -- DATASETTE_URL tests --
def test_datasette_url_used_as_instance(httpx_mock, mocker, tmpdir):
"""DATASETTE_URL provides the instance when no -i flag."""
def test_datasette_url_combines_with_database_name(httpx_mock, mocker, tmpdir):
"""DATASETTE_URL + database name arg → combined URL."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200)
runner = CliRunner(env={"DATASETTE_URL": "https://my-instance.datasette.cloud"})
@ -96,14 +84,12 @@ def test_datasette_url_with_trailing_slash(httpx_mock, mocker, tmpdir):
assert request.url.path == "/data.json"
def test_explicit_instance_ignores_datasette_url(httpx_mock, mocker, tmpdir):
"""An explicit -i flag is used, ignoring DATASETTE_URL."""
def test_full_url_ignores_datasette_url(httpx_mock, mocker, tmpdir):
"""A full URL argument is used as-is, ignoring DATASETTE_URL."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200)
runner = CliRunner(env={"DATASETTE_URL": "https://should-be-ignored.com"})
result = runner.invoke(
cli, ["query", "db", "select 1", "-i", "https://other.example.com"]
)
result = runner.invoke(cli, ["query", "https://other.example.com/db", "select 1"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.url.host == "other.example.com"
@ -111,25 +97,13 @@ def test_explicit_instance_ignores_datasette_url(httpx_mock, mocker, tmpdir):
def test_alias_takes_priority_over_datasette_url(httpx_mock, mocker, tmpdir):
"""Alias match via -i takes priority over DATASETTE_URL."""
"""Alias match takes priority over DATASETTE_URL."""
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": None,
"instances": {
"myalias": {
"url": "https://aliased.example.com",
"default_database": None,
}
},
}
)
)
aliases_file = pathlib.Path(tmpdir) / "aliases.json"
aliases_file.write_text(json.dumps({"myalias": "https://aliased.example.com/db"}))
httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200)
runner = CliRunner(env={"DATASETTE_URL": "https://should-be-ignored.com"})
result = runner.invoke(cli, ["query", "db", "select 1", "-i", "myalias"])
result = runner.invoke(cli, ["query", "myalias", "select 1"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.url.host == "aliased.example.com"
@ -176,7 +150,7 @@ def test_datasette_url_with_actor(httpx_mock, mocker, tmpdir):
"DATASETTE_TOKEN": "env-token",
}
)
result = runner.invoke(cli, ["actor"])
result = runner.invoke(cli, ["actor", "data"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.url.host == "my-instance.datasette.cloud"
@ -202,23 +176,3 @@ 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,14 +44,12 @@ def test_insert_mocked(httpx_mock, tmpdir):
cli,
[
"insert",
"data",
"https://datasette.example.com/data",
"table1",
str(path),
"--csv",
"--token",
"x",
"-i",
"https://datasette.example.com",
],
catch_exceptions=False,
)
@ -271,7 +269,7 @@ async def test_insert_against_datasette(
"insert into table1 (a, b, c) values (1, 2, 3), (4, 5, 6)"
)
token = await ds.create_token("actor")
token = ds.create_token("actor")
# These are useful with pytest --pdb to see what happened
datasette_requests = []
@ -313,13 +311,11 @@ async def test_insert_against_datasette(
cli,
[
"insert",
"data",
"http://datasette.example.com/data",
"table1",
str(path),
"--token",
token,
"-i",
"http://datasette.example.com",
]
+ cmd_args,
)

View file

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

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

View file

@ -1,410 +1,272 @@
"""Tests for the rows command."""
from click.testing import CliRunner
from dclient.cli import cli
import json
import pathlib
TABLE_RESPONSE = {
"ok": True,
"rows": [
{"id": 1, "name": "Cleo", "age": 5},
{"id": 2, "name": "Pancakes", "age": 3},
{"id": 3, "name": "Fido", "age": 7},
],
"columns": ["id", "name", "age"],
"next": None,
"next_url": None,
}
import pytest
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,
}
},
}
)
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,
)
runner = CliRunner()
result = runner.invoke(cli, ["rows", "dogs"])
assert result.exit_code == 1
assert "No database specified" in result.output
# -- filters --
def test_rows_filter_eq(httpx_mock):
result = _invoke(httpx_mock, ["-f", "name", "eq", "Cleo"])
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()
params = dict(request.url.params)
assert params["name__exact"] == "Cleo"
assert str(request.url) == "https://example.com/db/creatures.json?_shape=objects"
def test_rows_filter_gt(httpx_mock):
result = _invoke(httpx_mock, ["--filter", "age", "gt", "3"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["age__gt"] == "3"
def test_rows_filter_gte(httpx_mock):
result = _invoke(httpx_mock, ["-f", "age", "gte", "5"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["age__gte"] == "5"
def test_rows_filter_lt(httpx_mock):
result = _invoke(httpx_mock, ["-f", "age", "lt", "5"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["age__lt"] == "5"
def test_rows_filter_lte(httpx_mock):
result = _invoke(httpx_mock, ["-f", "age", "lte", "5"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["age__lte"] == "5"
def test_rows_filter_not(httpx_mock):
result = _invoke(httpx_mock, ["-f", "name", "not", "Cleo"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["name__not"] == "Cleo"
def test_rows_filter_contains(httpx_mock):
result = _invoke(httpx_mock, ["-f", "name", "contains", "leo"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["name__contains"] == "leo"
def test_rows_filter_like(httpx_mock):
result = _invoke(httpx_mock, ["-f", "name", "like", "%leo%"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["name__like"] == "%leo%"
def test_rows_filter_startswith(httpx_mock):
result = _invoke(httpx_mock, ["-f", "name", "startswith", "Cl"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["name__startswith"] == "Cl"
def test_rows_filter_endswith(httpx_mock):
result = _invoke(httpx_mock, ["-f", "name", "endswith", "eo"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["name__endswith"] == "eo"
def test_rows_filter_glob(httpx_mock):
result = _invoke(httpx_mock, ["-f", "name", "glob", "C*"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["name__glob"] == "C*"
def test_rows_filter_isnull(httpx_mock):
result = _invoke(httpx_mock, ["-f", "name", "isnull", "1"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["name__isnull"] == "1"
def test_rows_filter_notnull(httpx_mock):
result = _invoke(httpx_mock, ["-f", "name", "notnull", "1"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["name__notnull"] == "1"
def test_rows_multiple_filters(httpx_mock):
result = _invoke(httpx_mock, ["-f", "name", "eq", "Cleo", "-f", "age", "gte", "3"])
assert result.exit_code == 0
params = list(httpx_mock.get_request().url.params.multi_items())
param_dict = {k: v for k, v in params}
assert param_dict["name__exact"] == "Cleo"
assert param_dict["age__gte"] == "3"
def test_rows_custom_filter_op_passthrough(httpx_mock):
"""Unknown ops are passed through to Datasette, supporting plugin-added filters."""
result = _invoke(httpx_mock, ["-f", "name", "custom_plugin_op", "x"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["name__custom_plugin_op"] == "x"
# -- sorting --
def test_rows_sort(httpx_mock):
result = _invoke(httpx_mock, ["--sort", "age"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["_sort"] == "age"
def test_rows_sort_desc(httpx_mock):
result = _invoke(httpx_mock, ["--sort-desc", "age"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["_sort_desc"] == "age"
# -- column selection --
def test_rows_col(httpx_mock):
result = _invoke(httpx_mock, ["--col", "name", "--col", "age"])
assert result.exit_code == 0
params = list(httpx_mock.get_request().url.params.multi_items())
col_params = [v for k, v in params if k == "_col"]
assert col_params == ["name", "age"]
def test_rows_nocol(httpx_mock):
result = _invoke(httpx_mock, ["--nocol", "id"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["_nocol"] == "id"
# -- search --
def test_rows_search(httpx_mock):
result = _invoke(httpx_mock, ["--search", "pancakes"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["_search"] == "pancakes"
# -- size --
def test_rows_size(httpx_mock):
result = _invoke(httpx_mock, ["--size", "10"])
assert result.exit_code == 0
assert dict(httpx_mock.get_request().url.params)["_size"] == "10"
# -- limit --
def test_rows_limit(httpx_mock):
response = {
"ok": True,
"rows": [{"id": 1}, {"id": 2}, {"id": 3}],
"columns": ["id"],
"next": None,
}
result = _invoke(httpx_mock, ["--limit", "2"], response=response)
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data) == 2
# -- pagination with --all --
def test_rows_all_pagination(httpx_mock):
page1 = {
"ok": True,
"rows": [{"id": 1}, {"id": 2}],
"columns": ["id"],
"next": "2",
"next_url": "https://example.com/fixtures/dogs.json?_next=2&_shape=objects",
}
page2 = {
"ok": True,
"rows": [{"id": 3}],
"columns": ["id"],
"next": None,
"next_url": None,
}
httpx_mock.add_response(json=page1, status_code=200)
httpx_mock.add_response(json=page2, status_code=200)
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", "fixtures", "dogs", "-i", "https://example.com", "--all"],
["rows", "https://example.com/db", "creatures", "-f", "age", "gte", "5"],
)
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data) == 3
assert [r["id"] for r in data] == [1, 2, 3]
request = httpx_mock.get_request()
assert "age__gte=5" in str(request.url)
assert "_shape=objects" in str(request.url)
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)
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"],
)
assert result.exit_code == 0
request = httpx_mock.get_request()
assert "name__exact=Cleo" in str(request.url)
def test_rows_multiple_filters(httpx_mock):
httpx_mock.add_response(
json={"ok": True, "rows": []},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(
cli,
[
"rows",
"fixtures",
"dogs",
"-i",
"https://example.com",
"--all",
"--limit",
"https://example.com/db",
"creatures",
"-f",
"species",
"in",
"dog,cat",
"-f",
"age",
"gt",
"3",
],
)
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data) == 3
request = httpx_mock.get_request()
url_str = str(request.url)
assert "species__in=" in url_str
assert "age__gt=3" in url_str
def test_rows_no_all_ignores_next(httpx_mock):
"""Without --all, pagination is not followed even if next is present."""
response = {
"ok": True,
"rows": [{"id": 1}, {"id": 2}],
"columns": ["id"],
"next": "2",
"next_url": "https://example.com/fixtures/dogs.json?_next=2&_shape=objects",
}
result = _invoke(httpx_mock, response=response)
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data) == 2 # only first page
# -- error handling --
def test_rows_api_error(httpx_mock):
def test_rows_where_clause(httpx_mock):
httpx_mock.add_response(
json={"ok": False, "error": "Table not found: dogs"},
status_code=404,
json={"ok": True, "rows": [{"id": 6}]},
status_code=200,
)
runner = CliRunner()
result = runner.invoke(
cli,
["rows", "fixtures", "dogs", "-i", "https://example.com"],
["rows", "https://example.com/db", "creatures", "--where", "id > 5"],
)
assert result.exit_code == 1
assert "Table not found" in result.output
# -- uses default instance --
def test_rows_default_instance(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
config_file = pathlib.Path(tmpdir) / "config.json"
config_file.write_text(
json.dumps(
{
"default_instance": "prod",
"instances": {
"prod": {
"url": "https://prod.example.com",
"default_database": "main",
}
},
}
)
)
httpx_mock.add_response(json=TABLE_RESPONSE, status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["rows", "data", "dogs"])
assert result.exit_code == 0
request = httpx_mock.get_request()
assert request.url.host == "prod.example.com"
assert request.url.path == "/data/dogs.json"
assert "_where=" in str(request.url)
def test_rows_multiple_where_clauses(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"},
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,
)
runner = CliRunner()
result = runner.invoke(cli, ["rows", "https://example.com/db", "creatures"])
assert result.exit_code == 1
assert "Something went wrong" in result.output
def test_rows_with_alias(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,
)
runner = CliRunner()
result = runner.invoke(cli, ["rows", "content", "creatures"])
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