mirror of
https://github.com/simonw/dclient.git
synced 2026-08-01 15:04:11 +02:00
Merge branch 'v2'
# Conflicts: # docs/authentication.md
This commit is contained in:
commit
7be5d4a53f
17 changed files with 2086 additions and 365 deletions
40
README.md
40
README.md
|
|
@ -11,10 +11,11 @@ Much of the functionality requires Datasette 1.0a2 or higher.
|
|||
|
||||
## Things you can do with dclient
|
||||
|
||||
- Run SQL queries against Datasette and returning the results as JSON
|
||||
- Run SQL queries against Datasette and return the results as JSON
|
||||
- Introspect databases, tables, plugins, and schema
|
||||
- Run queries against authenticated Datasette instances
|
||||
- Create aliases and store authentication tokens for convenient access to Datasette
|
||||
- Insert data into Datasette using the [insert API](https://docs.datasette.io/en/latest/json_api.html#the-json-write-api) (Datasette 1.0 alpha or higher)
|
||||
- Create aliases and set default instances/databases for convenient access
|
||||
- Insert and upsert data using the [write API](https://docs.datasette.io/en/latest/json_api.html#the-json-write-api) (Datasette 1.0 alpha or higher)
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
@ -26,19 +27,32 @@ If you want to install it in the same virtual environment as Datasette (to use i
|
|||
```bash
|
||||
datasette install dclient
|
||||
```
|
||||
## Running a query
|
||||
## Quick start
|
||||
|
||||
Add an alias for a Datasette instance:
|
||||
```bash
|
||||
dclient alias add latest https://latest.datasette.io
|
||||
dclient alias default latest
|
||||
dclient alias default-db 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
|
||||
```
|
||||
|
||||
## Introspection
|
||||
|
||||
```bash
|
||||
dclient query https://latest.datasette.io/fixtures "select * from facetable limit 1"
|
||||
```
|
||||
To shorten that, create an alias:
|
||||
```bash
|
||||
dclient alias add fixtures https://latest.datasette.io/fixtures
|
||||
```
|
||||
Then run it like this instead:
|
||||
```bash
|
||||
dclient query fixtures "select * from facetable limit 1"
|
||||
dclient databases
|
||||
dclient tables
|
||||
dclient plugins
|
||||
dclient schema facetable
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Visit **[dclient.datasette.io](https://dclient.datasette.io)** for full documentation on using this tool.
|
||||
|
|
|
|||
864
dclient/cli.py
864
dclient/cli.py
File diff suppressed because it is too large
Load diff
111
docs/aliases.md
111
docs/aliases.md
|
|
@ -1,17 +1,40 @@
|
|||
# Aliases
|
||||
|
||||
You can assign an alias to a Datasette database using the `dclient alias` command:
|
||||
You can assign an alias to a Datasette instance using the `dclient alias add` command:
|
||||
|
||||
dclient alias add content https://datasette.io/content
|
||||
dclient alias add latest https://latest.datasette.io
|
||||
|
||||
You can list aliases with `dclient alias list`:
|
||||
|
||||
$ dclient alias list
|
||||
content = https://datasette.io/content
|
||||
latest = https://latest.datasette.io
|
||||
|
||||
Once registered, you can pass an alias to commands such as `dclient query`:
|
||||
Once registered, you can pass an alias to commands using the `-i` flag:
|
||||
|
||||
dclient query content "select * from news limit 1"
|
||||
dclient query fixtures "select * from news limit 1" -i latest
|
||||
|
||||
## Default instance
|
||||
|
||||
Set a default instance so you don't need `-i` every time:
|
||||
|
||||
dclient alias default latest
|
||||
|
||||
Now commands will use `latest` automatically:
|
||||
|
||||
dclient databases
|
||||
dclient tables -d fixtures
|
||||
|
||||
## Default database
|
||||
|
||||
Set a default database for an alias:
|
||||
|
||||
dclient alias default-db latest fixtures
|
||||
|
||||
Now you can run bare SQL queries directly:
|
||||
|
||||
dclient "select * from facetable limit 5"
|
||||
|
||||
This uses the default instance and default database.
|
||||
|
||||
## dclient alias --help
|
||||
<!-- [[[cog
|
||||
|
|
@ -34,9 +57,11 @@ Options:
|
|||
--help Show this message and exit.
|
||||
|
||||
Commands:
|
||||
add Add an alias
|
||||
list List aliases
|
||||
remove Remove an alias
|
||||
add Add an alias for a Datasette instance
|
||||
default Set or show the default instance
|
||||
default-db Set or show the default database for an alias
|
||||
list List aliases
|
||||
remove Remove an alias
|
||||
|
||||
```
|
||||
<!-- [[[end]]] -->
|
||||
|
|
@ -56,10 +81,6 @@ Usage: dclient alias list [OPTIONS]
|
|||
|
||||
List aliases
|
||||
|
||||
Example usage:
|
||||
|
||||
dclient aliases list
|
||||
|
||||
Options:
|
||||
--json Output raw JSON
|
||||
--help Show this message and exit.
|
||||
|
|
@ -80,15 +101,11 @@ cog.out(
|
|||
```
|
||||
Usage: dclient alias add [OPTIONS] NAME URL
|
||||
|
||||
Add an alias
|
||||
Add an alias for a Datasette instance
|
||||
|
||||
Example usage:
|
||||
|
||||
dclient alias add content https://datasette.io/content
|
||||
|
||||
Then:
|
||||
|
||||
dclient query content 'select * from news limit 3'
|
||||
dclient alias add prod https://myapp.datasette.cloud
|
||||
|
||||
Options:
|
||||
--help Show this message and exit.
|
||||
|
|
@ -113,10 +130,66 @@ Usage: dclient alias remove [OPTIONS] NAME
|
|||
|
||||
Example usage:
|
||||
|
||||
dclient alias remove content
|
||||
dclient alias remove prod
|
||||
|
||||
Options:
|
||||
--help Show this message and exit.
|
||||
|
||||
```
|
||||
<!-- [[[end]]] -->
|
||||
|
||||
## dclient alias default --help
|
||||
|
||||
<!-- [[[cog
|
||||
import cog
|
||||
result = runner.invoke(cli.cli, ["alias", "default", "--help"])
|
||||
help = result.output.replace("Usage: cli", "Usage: dclient")
|
||||
cog.out(
|
||||
"```\n{}\n```".format(help)
|
||||
)
|
||||
]]] -->
|
||||
```
|
||||
Usage: dclient alias default [OPTIONS] [NAME]
|
||||
|
||||
Set or show the default instance
|
||||
|
||||
Example usage:
|
||||
|
||||
dclient alias default prod
|
||||
dclient alias default
|
||||
dclient alias default --clear
|
||||
|
||||
Options:
|
||||
--clear Clear default instance
|
||||
--help Show this message and exit.
|
||||
|
||||
```
|
||||
<!-- [[[end]]] -->
|
||||
|
||||
## dclient alias default-db --help
|
||||
|
||||
<!-- [[[cog
|
||||
import cog
|
||||
result = runner.invoke(cli.cli, ["alias", "default-db", "--help"])
|
||||
help = result.output.replace("Usage: cli", "Usage: dclient")
|
||||
cog.out(
|
||||
"```\n{}\n```".format(help)
|
||||
)
|
||||
]]] -->
|
||||
```
|
||||
Usage: dclient alias default-db [OPTIONS] ALIAS_NAME [DB]
|
||||
|
||||
Set or show the default database for an alias
|
||||
|
||||
Example usage:
|
||||
|
||||
dclient alias default-db prod main
|
||||
dclient alias default-db prod
|
||||
dclient alias default-db prod --clear
|
||||
|
||||
Options:
|
||||
--clear Clear default database for this alias
|
||||
--help Show this message and exit.
|
||||
|
||||
```
|
||||
<!-- [[[end]]] -->
|
||||
|
|
|
|||
|
|
@ -4,35 +4,57 @@
|
|||
|
||||
`dclient` can handle API tokens for Datasette instances that require authentication.
|
||||
|
||||
You can pass an API token to `query` using `-t/--token` like this:
|
||||
You can pass an API token to any command using `--token` like this:
|
||||
|
||||
```bash
|
||||
dclient query https://latest.datasette.io/fixtures "select * from facetable" -t dstok_mytoken
|
||||
dclient query fixtures "select * from facetable" --token dstok_mytoken -i latest
|
||||
```
|
||||
|
||||
A more convenient way to handle this is to store tokens to be used with different URL prefixes.
|
||||
A more convenient way to handle this is to store tokens for your aliases.
|
||||
|
||||
## Using stored tokens
|
||||
|
||||
To always use `dstok_mytoken` for any URL on the `https://latest.datasette.io/` instance you can run this:
|
||||
To store a token for an alias:
|
||||
```bash
|
||||
dclient auth add https://latest.datasette.io/
|
||||
dclient auth add latest
|
||||
```
|
||||
Then paste in the token and hit enter when prompted to do so.
|
||||
|
||||
To list which URLs you have set tokens for, run the `auth list` command:
|
||||
Tokens can also be stored for direct URLs:
|
||||
```bash
|
||||
dclient auth add https://latest.datasette.io
|
||||
```
|
||||
|
||||
To list which aliases/URLs you have set tokens for, run the `auth list` command:
|
||||
```bash
|
||||
dclient auth list
|
||||
```
|
||||
To delete the token for a specific URL, run `auth remove`:
|
||||
To delete the token for a specific alias or URL, run `auth remove`:
|
||||
```bash
|
||||
dclient auth remove https://latest.datasette.io/
|
||||
dclient auth remove latest
|
||||
```
|
||||
|
||||
## Token resolution order
|
||||
|
||||
When making a request, dclient resolves the token in this order:
|
||||
|
||||
1. `--token` CLI flag (highest priority)
|
||||
2. Token stored by alias name in `auth.json`
|
||||
3. Token stored by URL prefix in `auth.json`
|
||||
4. `DATASETTE_TOKEN` environment variable (lowest priority)
|
||||
|
||||
## Testing a token
|
||||
|
||||
The `dclient actor` command can be used to test a token, retrieving the actor that the token represents.
|
||||
The `dclient auth status` command can be used to verify authentication by calling `/-/actor.json`:
|
||||
```bash
|
||||
dclient actor https://latest.datasette.io/content
|
||||
dclient auth status
|
||||
dclient auth status -i prod
|
||||
```
|
||||
|
||||
The `dclient actor` command also shows the actor:
|
||||
```bash
|
||||
dclient actor
|
||||
dclient actor -i prod
|
||||
```
|
||||
The output looks like this:
|
||||
```json
|
||||
|
|
@ -68,6 +90,7 @@ Commands:
|
|||
add Add an authentication token for an alias or URL
|
||||
list List stored API tokens
|
||||
remove Remove the API token for an alias or URL
|
||||
status Verify authentication by calling /-/actor.json
|
||||
|
||||
```
|
||||
<!-- [[[end]]] -->
|
||||
|
|
@ -89,7 +112,8 @@ Usage: dclient auth add [OPTIONS] ALIAS_OR_URL
|
|||
|
||||
Example usage:
|
||||
|
||||
dclient auth add https://datasette.io/content
|
||||
dclient auth add prod
|
||||
dclient auth add https://datasette.io
|
||||
|
||||
Paste in the token when prompted.
|
||||
|
||||
|
|
@ -142,7 +166,7 @@ Usage: dclient auth remove [OPTIONS] ALIAS_OR_URL
|
|||
|
||||
Example usage:
|
||||
|
||||
dclient auth remove https://datasette.io/content
|
||||
dclient auth remove prod
|
||||
|
||||
Options:
|
||||
--help Show this message and exit.
|
||||
|
|
@ -150,6 +174,34 @@ Options:
|
|||
```
|
||||
<!-- [[[end]]] -->
|
||||
|
||||
## dclient auth status --help
|
||||
|
||||
<!-- [[[cog
|
||||
import cog
|
||||
result = runner.invoke(cli.cli, ["auth", "status", "--help"])
|
||||
help = result.output.replace("Usage: cli", "Usage: dclient")
|
||||
cog.out(
|
||||
"```\n{}\n```".format(help)
|
||||
)
|
||||
]]] -->
|
||||
```
|
||||
Usage: dclient auth status [OPTIONS]
|
||||
|
||||
Verify authentication by calling /-/actor.json
|
||||
|
||||
Example usage:
|
||||
|
||||
dclient auth status
|
||||
dclient auth status -i prod
|
||||
|
||||
Options:
|
||||
-i, --instance TEXT Datasette instance URL or alias
|
||||
--token TEXT API token
|
||||
--help Show this message and exit.
|
||||
|
||||
```
|
||||
<!-- [[[end]]] -->
|
||||
|
||||
## dclient actor --help
|
||||
|
||||
<!-- [[[cog
|
||||
|
|
@ -161,17 +213,19 @@ cog.out(
|
|||
)
|
||||
]]] -->
|
||||
```
|
||||
Usage: dclient actor [OPTIONS] [URL_OR_ALIAS]
|
||||
Usage: dclient actor [OPTIONS]
|
||||
|
||||
Show the actor represented by an API token
|
||||
|
||||
Example usage:
|
||||
|
||||
dclient actor https://latest.datasette.io/fixtures
|
||||
dclient actor
|
||||
dclient actor -i prod
|
||||
|
||||
Options:
|
||||
--token TEXT API token
|
||||
--help Show this message and exit.
|
||||
-i, --instance TEXT Datasette instance URL or alias
|
||||
--token TEXT API token
|
||||
--help Show this message and exit.
|
||||
|
||||
```
|
||||
<!-- [[[end]]] -->
|
||||
<!-- [[[end]]] -->
|
||||
|
|
|
|||
|
|
@ -2,37 +2,39 @@
|
|||
|
||||
# Environment variables
|
||||
|
||||
`dclient` supports two environment variables for convenient access to a Datasette instance without needing aliases or repeated URLs.
|
||||
`dclient` supports several environment variables for convenient access to Datasette instances.
|
||||
|
||||
## DATASETTE_URL
|
||||
|
||||
Set this to the base URL of your Datasette instance:
|
||||
Set this to the base URL of your Datasette instance. It is used as a fallback when no instance is specified via `-i` and no default instance is configured:
|
||||
|
||||
```bash
|
||||
export DATASETTE_URL=https://my-instance.datasette.cloud
|
||||
```
|
||||
|
||||
Then pass just the database name as the first argument to any command:
|
||||
Then you can omit the `-i` flag:
|
||||
|
||||
```bash
|
||||
dclient databases
|
||||
dclient query data "select * from my_table limit 10"
|
||||
```
|
||||
|
||||
This is equivalent to:
|
||||
Aliases and the `-i` flag always take priority over `DATASETTE_URL`.
|
||||
|
||||
## DATASETTE_DATABASE
|
||||
|
||||
Set this to a default database name. It is used as a fallback when no database is specified via `-d` and the current instance has no `default_database` configured:
|
||||
|
||||
```bash
|
||||
dclient query https://my-instance.datasette.cloud/data "select * from my_table limit 10"
|
||||
export DATASETTE_DATABASE=data
|
||||
```
|
||||
|
||||
It works with all commands:
|
||||
Then you can use the bare SQL shortcut:
|
||||
|
||||
```bash
|
||||
dclient insert data my_table data.csv --csv
|
||||
dclient actor data
|
||||
dclient "select * from my_table limit 10"
|
||||
```
|
||||
|
||||
Full URLs and aliases always take priority over `DATASETTE_URL`. If the argument starts with `http://` or `https://`, it is used as-is. If it matches an alias in `aliases.json`, the alias is used.
|
||||
|
||||
## DATASETTE_TOKEN
|
||||
|
||||
Set this to an API token:
|
||||
|
|
@ -46,23 +48,34 @@ The token will be used automatically for any request that doesn't have a more sp
|
|||
The precedence order for tokens is:
|
||||
|
||||
1. `--token` CLI flag (highest priority)
|
||||
2. Stored token from `auth.json` (matched by URL prefix)
|
||||
2. Stored token from `auth.json` (matched by alias name, then URL prefix)
|
||||
3. `DATASETTE_TOKEN` environment variable (lowest priority)
|
||||
|
||||
## Using both together
|
||||
## DCLIENT_CONFIG_DIR
|
||||
|
||||
Override the config directory (default `~/.config/io.datasette.dclient` or platform equivalent):
|
||||
|
||||
```bash
|
||||
export DCLIENT_CONFIG_DIR=/path/to/config
|
||||
```
|
||||
|
||||
This is useful for testing or running multiple configurations side by side.
|
||||
|
||||
## Using them together
|
||||
|
||||
These variables work well together for quick access to a single instance:
|
||||
|
||||
```bash
|
||||
export DATASETTE_URL=https://my-instance.datasette.cloud
|
||||
export DATASETTE_DATABASE=data
|
||||
export DATASETTE_TOKEN=dstok_abc123
|
||||
|
||||
# Query the "data" database
|
||||
dclient query data "select * from my_table"
|
||||
# Query
|
||||
dclient "select * from my_table"
|
||||
|
||||
# Insert into the "data" database
|
||||
# Insert
|
||||
cat records.json | dclient insert data my_table - --json
|
||||
|
||||
# Check your actor identity
|
||||
dclient actor data
|
||||
dclient actor
|
||||
```
|
||||
|
|
|
|||
|
|
@ -21,15 +21,10 @@ If you also have Datasette installed in the same environment it will register it
|
|||
```bash
|
||||
datasette install dclient
|
||||
```
|
||||
This means you can run any of these commands using `datasette dt` instead, like this:
|
||||
This means you can run any of these commands using `datasette dc` instead, like this:
|
||||
```bash
|
||||
datasette dc --help
|
||||
datasette dc query https://latest.datasette.io/fixtures "select * from facetable limit 1"
|
||||
```
|
||||
You can install it into Datasette this way using:
|
||||
|
||||
```bash
|
||||
datasette install dclient
|
||||
datasette dc query fixtures "select * from facetable limit 1" -i latest
|
||||
```
|
||||
|
||||
## Contents
|
||||
|
|
@ -42,4 +37,5 @@ queries
|
|||
aliases
|
||||
authentication
|
||||
inserting
|
||||
environment
|
||||
```
|
||||
|
|
|
|||
|
|
@ -4,19 +4,23 @@ The `dclient insert` command can be used to insert data from a local file direct
|
|||
|
||||
First you'll need to {ref}`authenticate <authentication>` with the instance.
|
||||
|
||||
To insert data from a `data.csv` file into a table called `my_table`, creating that table if it does not exist:
|
||||
To insert data from a `data.csv` file into a table called `my_table` in the `data` database:
|
||||
|
||||
```bash
|
||||
dclient insert \
|
||||
https://my-private-space.datasette.cloud/data \
|
||||
my_table data.csv --create
|
||||
dclient insert data my_table data.csv --create -i myapp
|
||||
```
|
||||
You can also pipe data into standard input:
|
||||
```bash
|
||||
curl -s 'https://api.github.com/repos/simonw/dclient/issues' | \
|
||||
dclient insert \
|
||||
https://my-private-space.datasette.cloud/data \
|
||||
issues - --create
|
||||
dclient insert data issues - --create -i myapp
|
||||
```
|
||||
|
||||
## Upserting data
|
||||
|
||||
The `dclient upsert` command works exactly like `insert` but uses the upsert endpoint, which will update existing rows with matching primary keys rather than raising an error.
|
||||
|
||||
```bash
|
||||
dclient upsert data my_table data.csv --csv -i myapp
|
||||
```
|
||||
|
||||
## Streaming data
|
||||
|
|
@ -26,7 +30,7 @@ curl -s 'https://api.github.com/repos/simonw/dclient/issues' | \
|
|||
If you have a log file containing newline-delimited JSON you can tail it and send it to a Datasette instance like this:
|
||||
```bash
|
||||
tail -f log.jsonl | \
|
||||
dclient insert https://my-private-space.datasette.cloud/data logs - --nl
|
||||
dclient insert data logs - --nl -i myapp
|
||||
```
|
||||
When reading from standard input (filename `-`) you are required to specify the format. In this example that's `--nl` for newline-delimited JSON. `--csv` and `--tsv` are supported for streaming as well, but `--json` is not.
|
||||
|
||||
|
|
@ -34,7 +38,7 @@ In streaming mode records default to being sent to the server every 100 records
|
|||
|
||||
```bash
|
||||
tail -f log.jsonl | dclient insert \
|
||||
https://my-private-space.datasette.cloud/data logs - --nl --create \
|
||||
data logs - --nl --create -i myapp \
|
||||
--batch-size 10 \
|
||||
--interval 5
|
||||
```
|
||||
|
|
@ -106,26 +110,65 @@ cog.out(
|
|||
)
|
||||
]]] -->
|
||||
```
|
||||
Usage: dclient insert [OPTIONS] URL_OR_ALIAS TABLE FILEPATH
|
||||
Usage: dclient insert [OPTIONS] DATABASE TABLE FILEPATH
|
||||
|
||||
Insert data into a remote Datasette instance
|
||||
|
||||
Example usage:
|
||||
|
||||
dclient insert \
|
||||
https://private.datasette.cloud/data \
|
||||
mytable data.csv --pk id --create
|
||||
dclient insert main mytable data.csv --csv -i myapp
|
||||
dclient insert main mytable data.csv --csv --create --pk id
|
||||
|
||||
Options:
|
||||
-i, --instance TEXT Datasette instance URL or alias
|
||||
--csv Input is CSV
|
||||
--tsv Input is TSV
|
||||
--json Input is JSON
|
||||
--nl Input is newline-delimited JSON
|
||||
--encoding TEXT Character encoding for CSV/TSV
|
||||
--no-detect-types Don't detect column types for CSV/TSV
|
||||
--alter Alter table to add any missing columns
|
||||
--pk TEXT Columns to use as the primary key when creating the
|
||||
table
|
||||
--batch-size INTEGER Send rows in batches of this size
|
||||
--interval FLOAT Send batch at least every X seconds
|
||||
-t, --token TEXT API token
|
||||
--silent Don't output progress
|
||||
-v, --verbose Verbose output: show HTTP request and response
|
||||
--replace Replace rows with a matching primary key
|
||||
--ignore Ignore rows with a matching primary key
|
||||
--create Create table if it does not exist
|
||||
--help Show this message and exit.
|
||||
|
||||
```
|
||||
<!-- [[[end]]] -->
|
||||
|
||||
## dclient upsert --help
|
||||
<!-- [[[cog
|
||||
import cog
|
||||
result = runner.invoke(cli.cli, ["upsert", "--help"])
|
||||
help = result.output.replace("Usage: cli", "Usage: dclient")
|
||||
cog.out(
|
||||
"```\n{}\n```".format(help)
|
||||
)
|
||||
]]] -->
|
||||
```
|
||||
Usage: dclient upsert [OPTIONS] DATABASE TABLE FILEPATH
|
||||
|
||||
Upsert data into a remote Datasette instance
|
||||
|
||||
Example usage:
|
||||
|
||||
dclient upsert main mytable data.csv --csv -i myapp
|
||||
|
||||
Options:
|
||||
-i, --instance TEXT Datasette instance URL or alias
|
||||
--csv Input is CSV
|
||||
--tsv Input is TSV
|
||||
--json Input is JSON
|
||||
--nl Input is newline-delimited JSON
|
||||
--encoding TEXT Character encoding for CSV/TSV
|
||||
--no-detect-types Don't detect column types for CSV/TSV
|
||||
--alter Alter table to add any missing columns
|
||||
--pk TEXT Columns to use as the primary key when creating the
|
||||
table
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
You can run SQL queries against a Datasette instance like this:
|
||||
|
||||
```bash
|
||||
dclient query https://latest.datasette.io/fixtures "select * from facetable limit 1"
|
||||
dclient query fixtures "select * from facetable limit 1" -i https://latest.datasette.io
|
||||
```
|
||||
Output:
|
||||
```json
|
||||
|
|
@ -24,6 +24,18 @@ Output:
|
|||
]
|
||||
```
|
||||
|
||||
The `query` command takes a database name and SQL string as positional arguments. Use `-i` to specify the instance (alias or URL). If you have a default instance and default database configured, you can use the bare SQL shortcut instead:
|
||||
|
||||
```bash
|
||||
dclient "select * from facetable limit 1"
|
||||
```
|
||||
|
||||
You can override just the database with `-d`:
|
||||
|
||||
```bash
|
||||
dclient "select * from counters" -d counters
|
||||
```
|
||||
|
||||
## dclient query --help
|
||||
<!-- [[[cog
|
||||
import cog
|
||||
|
|
@ -37,22 +49,22 @@ cog.out(
|
|||
)
|
||||
]]] -->
|
||||
```
|
||||
Usage: dclient query [OPTIONS] URL_OR_ALIAS SQL
|
||||
Usage: dclient query [OPTIONS] DATABASE SQL
|
||||
|
||||
Run a SQL query against a Datasette database URL
|
||||
Run a SQL query against a Datasette database
|
||||
|
||||
Returns a JSON array of objects
|
||||
Requires both a database name and a SQL string.
|
||||
|
||||
Example usage:
|
||||
|
||||
dclient query \
|
||||
https://datasette.io/content \
|
||||
'select * from news limit 10'
|
||||
dclient query fixtures "select * from facetable limit 5"
|
||||
dclient query analytics "select count(*) from events" -i staging
|
||||
|
||||
Options:
|
||||
--token TEXT API token
|
||||
-v, --verbose Verbose output: show HTTP request
|
||||
--help Show this message and exit.
|
||||
-i, --instance TEXT Datasette instance URL or alias
|
||||
--token TEXT API token
|
||||
-v, --verbose Verbose output: show HTTP request
|
||||
--help Show this message and exit.
|
||||
|
||||
```
|
||||
<!-- [[[end]]] -->
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ license = "Apache-2.0"
|
|||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"click",
|
||||
"click-default-group",
|
||||
"httpx",
|
||||
"sqlite-utils",
|
||||
]
|
||||
|
|
|
|||
111
tests/test_alias_v2.py
Normal file
111
tests/test_alias_v2.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"""Tests for v2 alias command: default, default-db 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, ["alias", "default"])
|
||||
assert result.exit_code == 0
|
||||
assert "No default instance set" in result.output
|
||||
|
||||
# Set default
|
||||
result = runner.invoke(cli, ["alias", "default", "prod"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Show default
|
||||
result = runner.invoke(cli, ["alias", "default"])
|
||||
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, ["alias", "default", "--clear"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = runner.invoke(cli, ["alias", "default"])
|
||||
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, ["alias", "default", "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, ["alias", "default-db", "prod"])
|
||||
assert result.exit_code == 0
|
||||
assert "No default database set" in result.output
|
||||
|
||||
# Set default database
|
||||
result = runner.invoke(cli, ["alias", "default-db", "prod", "main"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Show default database
|
||||
result = runner.invoke(cli, ["alias", "default-db", "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, ["alias", "default-db", "prod", "--clear"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = runner.invoke(cli, ["alias", "default-db", "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, ["alias", "default-db", "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, ["alias", "default", "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"]
|
||||
|
|
@ -13,26 +13,26 @@ def test_auth(mocker, tmpdir):
|
|||
# Should only have one line
|
||||
assert len([line for line in result.output.split("\n") if line.strip()]) == 1
|
||||
|
||||
# Now add a token
|
||||
result2 = runner.invoke(cli, ["auth", "add", "https://example.com"], input="xyz\n")
|
||||
# Now add a token (keys are now alias names or URLs)
|
||||
result2 = runner.invoke(cli, ["auth", "add", "prod"], input="xyz\n")
|
||||
assert result2.exit_code == 0
|
||||
|
||||
# Check the tokens file
|
||||
auth_file = pathlib.Path(tmpdir) / "auth.json"
|
||||
assert json.loads(auth_file.read_text()) == {"https://example.com": "xyz"}
|
||||
assert json.loads(auth_file.read_text()) == {"prod": "xyz"}
|
||||
|
||||
# auth list should show that now
|
||||
result3 = runner.invoke(cli, ["auth", "list"])
|
||||
assert result3.output.startswith("Tokens file:")
|
||||
assert "https://example.com" in result3.output
|
||||
assert "prod" in result3.output
|
||||
|
||||
# Remove should fail with an incorrect URL
|
||||
result4 = runner.invoke(cli, ["auth", "remove", "https://example.com/foo"])
|
||||
# Remove should fail with an incorrect key
|
||||
result4 = runner.invoke(cli, ["auth", "remove", "nonexistent"])
|
||||
assert result4.exit_code == 1
|
||||
assert result4.output == "Error: No such URL or alias\n"
|
||||
|
||||
# Remove should work with the correct URL
|
||||
result5 = runner.invoke(cli, ["auth", "remove", "https://example.com"])
|
||||
# Remove should work with the correct key
|
||||
result5 = runner.invoke(cli, ["auth", "remove", "prod"])
|
||||
assert result5.exit_code == 0
|
||||
assert result5.output == ""
|
||||
|
||||
|
|
|
|||
592
tests/test_commands_v2.py
Normal file
592
tests/test_commands_v2.py
Normal file
|
|
@ -0,0 +1,592 @@
|
|||
"""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
|
||||
import pytest
|
||||
|
||||
|
||||
# -- 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() == ""
|
||||
249
tests/test_config.py
Normal file
249
tests/test_config.py
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
"""Tests for the v2 config system: config.json format, instance resolution, database resolution."""
|
||||
|
||||
from dclient.cli import (
|
||||
_load_config,
|
||||
_save_config,
|
||||
_resolve_instance,
|
||||
_resolve_database,
|
||||
_resolve_token,
|
||||
get_config_dir,
|
||||
)
|
||||
import json
|
||||
import pathlib
|
||||
import pytest
|
||||
|
||||
|
||||
# -- Config loading/saving --
|
||||
|
||||
|
||||
def test_load_config_empty(tmpdir):
|
||||
"""Loading config when no file exists returns empty defaults."""
|
||||
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||
config = _load_config(config_file)
|
||||
assert config == {"default_instance": None, "instances": {}}
|
||||
|
||||
|
||||
def test_load_config_existing(tmpdir):
|
||||
"""Loading config reads the JSON file."""
|
||||
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||
config_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"default_instance": "prod",
|
||||
"instances": {
|
||||
"prod": {
|
||||
"url": "https://myapp.datasette.cloud",
|
||||
"default_database": "main",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
config = _load_config(config_file)
|
||||
assert config["default_instance"] == "prod"
|
||||
assert config["instances"]["prod"]["url"] == "https://myapp.datasette.cloud"
|
||||
assert config["instances"]["prod"]["default_database"] == "main"
|
||||
|
||||
|
||||
def test_save_config(tmpdir):
|
||||
"""Saving config writes JSON to disk."""
|
||||
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||
config = {
|
||||
"default_instance": "local",
|
||||
"instances": {
|
||||
"local": {
|
||||
"url": "http://localhost:8001",
|
||||
"default_database": None,
|
||||
}
|
||||
},
|
||||
}
|
||||
_save_config(config_file, config)
|
||||
assert json.loads(config_file.read_text()) == config
|
||||
|
||||
|
||||
# -- Instance resolution --
|
||||
|
||||
|
||||
def test_resolve_instance_from_flag(tmpdir):
|
||||
"""An explicit -i flag with a URL is used directly."""
|
||||
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||
url = _resolve_instance("https://example.com", config_file)
|
||||
assert url == "https://example.com"
|
||||
|
||||
|
||||
def test_resolve_instance_from_flag_alias(tmpdir):
|
||||
"""An explicit -i flag with an alias name resolves via config."""
|
||||
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||
config_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"default_instance": None,
|
||||
"instances": {
|
||||
"prod": {
|
||||
"url": "https://myapp.datasette.cloud",
|
||||
"default_database": None,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
url = _resolve_instance("prod", config_file)
|
||||
assert url == "https://myapp.datasette.cloud"
|
||||
|
||||
|
||||
def test_resolve_instance_from_config_default(tmpdir):
|
||||
"""When no -i flag, uses config.default_instance."""
|
||||
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||
config_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"default_instance": "prod",
|
||||
"instances": {
|
||||
"prod": {
|
||||
"url": "https://myapp.datasette.cloud",
|
||||
"default_database": None,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
url = _resolve_instance(None, config_file)
|
||||
assert url == "https://myapp.datasette.cloud"
|
||||
|
||||
|
||||
def test_resolve_instance_from_env(tmpdir, monkeypatch):
|
||||
"""When no -i flag and no config default, falls back to DATASETTE_URL."""
|
||||
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||
monkeypatch.setenv("DATASETTE_URL", "https://env.example.com")
|
||||
url = _resolve_instance(None, config_file)
|
||||
assert url == "https://env.example.com"
|
||||
|
||||
|
||||
def test_resolve_instance_from_env_strips_trailing_slash(tmpdir, monkeypatch):
|
||||
"""DATASETTE_URL trailing slash is stripped."""
|
||||
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||
monkeypatch.setenv("DATASETTE_URL", "https://env.example.com/")
|
||||
url = _resolve_instance(None, config_file)
|
||||
assert url == "https://env.example.com"
|
||||
|
||||
|
||||
def test_resolve_instance_error(tmpdir, monkeypatch):
|
||||
"""When nothing is configured, raises an error."""
|
||||
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||
monkeypatch.delenv("DATASETTE_URL", raising=False)
|
||||
with pytest.raises(Exception, match="No instance specified"):
|
||||
_resolve_instance(None, config_file)
|
||||
|
||||
|
||||
def test_resolve_instance_unknown_alias(tmpdir):
|
||||
"""An -i flag with an unknown alias name raises an error."""
|
||||
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||
config_file.write_text(json.dumps({"default_instance": None, "instances": {}}))
|
||||
with pytest.raises(Exception, match="Unknown instance"):
|
||||
_resolve_instance("nonexistent", config_file)
|
||||
|
||||
|
||||
# -- Database resolution (optional -d flag mode) --
|
||||
|
||||
|
||||
def test_resolve_database_from_flag(tmpdir):
|
||||
"""An explicit -d flag is used directly."""
|
||||
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||
db = _resolve_database("mydb", None, config_file)
|
||||
assert db == "mydb"
|
||||
|
||||
|
||||
def test_resolve_database_from_instance_default(tmpdir):
|
||||
"""When no -d flag, uses the instance's default_database from config."""
|
||||
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||
config_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"default_instance": "prod",
|
||||
"instances": {
|
||||
"prod": {
|
||||
"url": "https://myapp.datasette.cloud",
|
||||
"default_database": "main",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
db = _resolve_database(None, "prod", config_file)
|
||||
assert db == "main"
|
||||
|
||||
|
||||
def test_resolve_database_from_env(tmpdir, monkeypatch):
|
||||
"""When no -d flag and no instance default, falls back to DATASETTE_DATABASE."""
|
||||
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||
monkeypatch.setenv("DATASETTE_DATABASE", "envdb")
|
||||
db = _resolve_database(None, None, config_file)
|
||||
assert db == "envdb"
|
||||
|
||||
|
||||
def test_resolve_database_error(tmpdir, monkeypatch):
|
||||
"""When nothing is configured, raises an error."""
|
||||
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||
monkeypatch.delenv("DATASETTE_DATABASE", raising=False)
|
||||
with pytest.raises(Exception, match="No database specified"):
|
||||
_resolve_database(None, None, config_file)
|
||||
|
||||
|
||||
# -- Token resolution --
|
||||
|
||||
|
||||
def test_resolve_token_from_flag(tmpdir):
|
||||
"""An explicit --token flag is used directly."""
|
||||
auth_file = pathlib.Path(tmpdir) / "auth.json"
|
||||
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||
token = _resolve_token("explicit-token", "https://example.com", auth_file, config_file)
|
||||
assert token == "explicit-token"
|
||||
|
||||
|
||||
def test_resolve_token_from_auth_by_alias(tmpdir):
|
||||
"""Auth token looked up by alias name."""
|
||||
auth_file = pathlib.Path(tmpdir) / "auth.json"
|
||||
auth_file.write_text(json.dumps({"prod": "tok123"}))
|
||||
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||
config_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"default_instance": None,
|
||||
"instances": {
|
||||
"prod": {
|
||||
"url": "https://myapp.datasette.cloud",
|
||||
"default_database": None,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
token = _resolve_token(None, "https://myapp.datasette.cloud", auth_file, config_file)
|
||||
assert token == "tok123"
|
||||
|
||||
|
||||
def test_resolve_token_from_auth_by_url_fallback(tmpdir):
|
||||
"""Auth token falls back to URL prefix matching when no alias match."""
|
||||
auth_file = pathlib.Path(tmpdir) / "auth.json"
|
||||
auth_file.write_text(json.dumps({"https://example.com": "url-tok"}))
|
||||
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||
token = _resolve_token(None, "https://example.com/db", auth_file, config_file)
|
||||
assert token == "url-tok"
|
||||
|
||||
|
||||
def test_resolve_token_from_env(tmpdir, monkeypatch):
|
||||
"""Falls back to DATASETTE_TOKEN env var."""
|
||||
auth_file = pathlib.Path(tmpdir) / "auth.json"
|
||||
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||
monkeypatch.setenv("DATASETTE_TOKEN", "env-tok")
|
||||
token = _resolve_token(None, "https://example.com", auth_file, config_file)
|
||||
assert token == "env-tok"
|
||||
|
||||
|
||||
def test_resolve_token_none(tmpdir, monkeypatch):
|
||||
"""Returns None when nothing is configured."""
|
||||
auth_file = pathlib.Path(tmpdir) / "auth.json"
|
||||
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||
monkeypatch.delenv("DATASETTE_TOKEN", raising=False)
|
||||
token = _resolve_token(None, "https://example.com", auth_file, config_file)
|
||||
assert token is None
|
||||
|
|
@ -26,7 +26,9 @@ def test_datasette_token_used_as_fallback(httpx_mock, mocker, tmpdir):
|
|||
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||
httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200)
|
||||
runner = CliRunner(env={"DATASETTE_TOKEN": "env-token-123"})
|
||||
result = runner.invoke(cli, ["query", "https://example.com", "select 1"])
|
||||
result = runner.invoke(
|
||||
cli, ["query", "data", "select 1", "-i", "https://example.com"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
request = httpx_mock.get_request()
|
||||
assert request.headers["authorization"] == "Bearer env-token-123"
|
||||
|
|
@ -38,7 +40,12 @@ def test_token_flag_overrides_datasette_token(httpx_mock, mocker, tmpdir):
|
|||
httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200)
|
||||
runner = CliRunner(env={"DATASETTE_TOKEN": "env-token"})
|
||||
result = runner.invoke(
|
||||
cli, ["query", "https://example.com", "select 1", "--token", "flag-token"]
|
||||
cli,
|
||||
[
|
||||
"query", "data", "select 1",
|
||||
"-i", "https://example.com",
|
||||
"--token", "flag-token",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
request = httpx_mock.get_request()
|
||||
|
|
@ -52,7 +59,9 @@ def test_auth_json_overrides_datasette_token(httpx_mock, mocker, tmpdir):
|
|||
auth_file.write_text(json.dumps({"https://example.com": "stored-token"}))
|
||||
httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200)
|
||||
runner = CliRunner(env={"DATASETTE_TOKEN": "env-token"})
|
||||
result = runner.invoke(cli, ["query", "https://example.com", "select 1"])
|
||||
result = runner.invoke(
|
||||
cli, ["query", "data", "select 1", "-i", "https://example.com"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
request = httpx_mock.get_request()
|
||||
assert request.headers["authorization"] == "Bearer stored-token"
|
||||
|
|
@ -61,8 +70,8 @@ def test_auth_json_overrides_datasette_token(httpx_mock, mocker, tmpdir):
|
|||
# -- DATASETTE_URL tests --
|
||||
|
||||
|
||||
def test_datasette_url_combines_with_database_name(httpx_mock, mocker, tmpdir):
|
||||
"""DATASETTE_URL + database name arg → combined URL."""
|
||||
def test_datasette_url_used_as_instance(httpx_mock, mocker, tmpdir):
|
||||
"""DATASETTE_URL provides the instance when no -i flag."""
|
||||
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||
httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200)
|
||||
runner = CliRunner(env={"DATASETTE_URL": "https://my-instance.datasette.cloud"})
|
||||
|
|
@ -84,12 +93,14 @@ def test_datasette_url_with_trailing_slash(httpx_mock, mocker, tmpdir):
|
|||
assert request.url.path == "/data.json"
|
||||
|
||||
|
||||
def test_full_url_ignores_datasette_url(httpx_mock, mocker, tmpdir):
|
||||
"""A full URL argument is used as-is, ignoring DATASETTE_URL."""
|
||||
def test_explicit_instance_ignores_datasette_url(httpx_mock, mocker, tmpdir):
|
||||
"""An explicit -i flag is used, ignoring DATASETTE_URL."""
|
||||
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||
httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200)
|
||||
runner = CliRunner(env={"DATASETTE_URL": "https://should-be-ignored.com"})
|
||||
result = runner.invoke(cli, ["query", "https://other.example.com/db", "select 1"])
|
||||
result = runner.invoke(
|
||||
cli, ["query", "db", "select 1", "-i", "https://other.example.com"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
request = httpx_mock.get_request()
|
||||
assert request.url.host == "other.example.com"
|
||||
|
|
@ -97,13 +108,25 @@ def test_full_url_ignores_datasette_url(httpx_mock, mocker, tmpdir):
|
|||
|
||||
|
||||
def test_alias_takes_priority_over_datasette_url(httpx_mock, mocker, tmpdir):
|
||||
"""Alias match takes priority over DATASETTE_URL."""
|
||||
"""Alias match via -i takes priority over DATASETTE_URL."""
|
||||
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||
aliases_file = pathlib.Path(tmpdir) / "aliases.json"
|
||||
aliases_file.write_text(json.dumps({"myalias": "https://aliased.example.com/db"}))
|
||||
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||
config_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"default_instance": None,
|
||||
"instances": {
|
||||
"myalias": {
|
||||
"url": "https://aliased.example.com",
|
||||
"default_database": None,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200)
|
||||
runner = CliRunner(env={"DATASETTE_URL": "https://should-be-ignored.com"})
|
||||
result = runner.invoke(cli, ["query", "myalias", "select 1"])
|
||||
result = runner.invoke(cli, ["query", "db", "select 1", "-i", "myalias"])
|
||||
assert result.exit_code == 0
|
||||
request = httpx_mock.get_request()
|
||||
assert request.url.host == "aliased.example.com"
|
||||
|
|
@ -150,7 +173,7 @@ def test_datasette_url_with_actor(httpx_mock, mocker, tmpdir):
|
|||
"DATASETTE_TOKEN": "env-token",
|
||||
}
|
||||
)
|
||||
result = runner.invoke(cli, ["actor", "data"])
|
||||
result = runner.invoke(cli, ["actor"])
|
||||
assert result.exit_code == 0
|
||||
request = httpx_mock.get_request()
|
||||
assert request.url.host == "my-instance.datasette.cloud"
|
||||
|
|
@ -176,3 +199,23 @@ def test_datasette_url_and_token_together(httpx_mock, mocker, tmpdir):
|
|||
assert request.url.host == "my-instance.datasette.cloud"
|
||||
assert request.url.path == "/mydb.json"
|
||||
assert request.headers["authorization"] == "Bearer env-token-456"
|
||||
|
||||
|
||||
# -- DATASETTE_DATABASE tests --
|
||||
|
||||
|
||||
def test_datasette_database_with_default_query(httpx_mock, mocker, tmpdir):
|
||||
"""DATASETTE_DATABASE is used by default_query shortcut."""
|
||||
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||
httpx_mock.add_response(json=QUERY_RESPONSE, status_code=200)
|
||||
runner = CliRunner(
|
||||
env={
|
||||
"DATASETTE_URL": "https://my-instance.datasette.cloud",
|
||||
"DATASETTE_DATABASE": "mydb",
|
||||
}
|
||||
)
|
||||
result = runner.invoke(cli, ["select 1"])
|
||||
assert result.exit_code == 0
|
||||
request = httpx_mock.get_request()
|
||||
assert request.url.host == "my-instance.datasette.cloud"
|
||||
assert request.url.path == "/mydb.json"
|
||||
|
|
|
|||
|
|
@ -44,12 +44,14 @@ def test_insert_mocked(httpx_mock, tmpdir):
|
|||
cli,
|
||||
[
|
||||
"insert",
|
||||
"https://datasette.example.com/data",
|
||||
"data",
|
||||
"table1",
|
||||
str(path),
|
||||
"--csv",
|
||||
"--token",
|
||||
"x",
|
||||
"-i",
|
||||
"https://datasette.example.com",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
|
|
@ -311,11 +313,13 @@ async def test_insert_against_datasette(
|
|||
cli,
|
||||
[
|
||||
"insert",
|
||||
"http://datasette.example.com/data",
|
||||
"data",
|
||||
"table1",
|
||||
str(path),
|
||||
"--token",
|
||||
token,
|
||||
"-i",
|
||||
"http://datasette.example.com",
|
||||
]
|
||||
+ cmd_args,
|
||||
)
|
||||
|
|
|
|||
116
tests/test_migration.py
Normal file
116
tests/test_migration.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""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
|
||||
|
|
@ -16,7 +16,9 @@ def test_query_error(httpx_mock):
|
|||
status_code=400,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["query", "https://example.com", "hello"])
|
||||
result = runner.invoke(
|
||||
cli, ["query", "content", "hello", "-i", "https://example.com"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert (
|
||||
result.output
|
||||
|
|
@ -42,17 +44,16 @@ def test_query(httpx_mock, with_token):
|
|||
status_code=200,
|
||||
)
|
||||
runner = CliRunner()
|
||||
args = ["query", "https://example.com", "hello"]
|
||||
args = ["query", "content", "hello", "-i", "https://example.com"]
|
||||
if with_token:
|
||||
args.append("--token")
|
||||
args.append("xyz")
|
||||
args.extend(["--token", "xyz"])
|
||||
result = runner.invoke(cli, args)
|
||||
assert result.exit_code == 0
|
||||
assert json.loads(result.output) == [{"5 * 2": 10}]
|
||||
|
||||
# Check the request
|
||||
request = httpx_mock.get_request()
|
||||
assert str(request.url) == "https://example.com.json?sql=hello&_shape=objects"
|
||||
assert str(request.url) == "https://example.com/content.json?sql=hello&_shape=objects"
|
||||
if with_token:
|
||||
assert request.headers["authorization"] == "Bearer xyz"
|
||||
else:
|
||||
|
|
@ -66,28 +67,32 @@ def test_aliases(mocker, tmpdir, httpx_mock):
|
|||
assert result.exit_code == 0
|
||||
assert result.output == ""
|
||||
|
||||
result = runner.invoke(cli, ["alias", "add", "foo", "https://example.com/foo"])
|
||||
result = runner.invoke(
|
||||
cli, ["alias", "add", "foo", "https://example.com"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert result.output == ""
|
||||
|
||||
result = runner.invoke(cli, ["alias", "list"])
|
||||
assert result.exit_code == 0
|
||||
assert result.output == "foo = https://example.com/foo\n"
|
||||
assert "foo = https://example.com" in result.output
|
||||
|
||||
# --json mode:
|
||||
result = runner.invoke(cli, ["alias", "list", "--json"])
|
||||
assert result.exit_code == 0
|
||||
assert json.loads(result.output) == {"foo": "https://example.com/foo"}
|
||||
data = json.loads(result.output)
|
||||
assert data["instances"]["foo"]["url"] == "https://example.com"
|
||||
|
||||
# Check the aliases file
|
||||
aliases_file = pathlib.Path(tmpdir) / "aliases.json"
|
||||
assert json.loads(aliases_file.read_text()) == {"foo": "https://example.com/foo"}
|
||||
# Check the config file
|
||||
config_file = pathlib.Path(tmpdir) / "config.json"
|
||||
config = json.loads(config_file.read_text())
|
||||
assert config["instances"]["foo"]["url"] == "https://example.com"
|
||||
|
||||
# Try a query against that alias
|
||||
httpx_mock.add_response(
|
||||
json={
|
||||
"ok": True,
|
||||
"database": "foo",
|
||||
"database": "mydb",
|
||||
"query_name": None,
|
||||
"rows": [{"11 * 3": 33}],
|
||||
"truncated": False,
|
||||
|
|
@ -99,14 +104,14 @@ def test_aliases(mocker, tmpdir, httpx_mock):
|
|||
},
|
||||
status_code=200,
|
||||
)
|
||||
result = runner.invoke(cli, ["query", "foo", "select 11 * 3"])
|
||||
result = runner.invoke(cli, ["query", "mydb", "select 11 * 3", "-i", "foo"])
|
||||
assert result.exit_code == 0
|
||||
assert json.loads(result.output) == [{"11 * 3": 33}]
|
||||
|
||||
# Should have hit https://example.com/foo.json
|
||||
# Should have hit https://example.com/mydb.json
|
||||
url = httpx_mock.get_request().url
|
||||
assert url.host == "example.com"
|
||||
assert url.path == "/foo.json"
|
||||
assert url.path == "/mydb.json"
|
||||
assert dict(url.params) == {"sql": "select 11 * 3", "_shape": "objects"}
|
||||
|
||||
# Remove alias
|
||||
|
|
@ -117,4 +122,5 @@ def test_aliases(mocker, tmpdir, httpx_mock):
|
|||
result = runner.invoke(cli, ["alias", "remove", "foo"])
|
||||
assert result.exit_code == 0
|
||||
assert result.output == ""
|
||||
assert json.loads(aliases_file.read_text()) == {}
|
||||
config = json.loads(config_file.read_text())
|
||||
assert config["instances"] == {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue