Compare commits

..

No commits in common. "main" and "0.1a2" have entirely different histories.

37 changed files with 295 additions and 6932 deletions

View file

@ -4,48 +4,44 @@ on:
release:
types: [created]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.7", "3.8", "3.9", "3.10"]
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: pyproject.toml
cache-dependency-path: '**/setup.py'
- name: Install dependencies
run: |
pip install . --group dev
pip install -e '.[test]'
- name: Run tests
run: |
pytest
deploy:
runs-on: ubuntu-latest
needs: [test]
environment: release
permissions:
id-token: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v2
with:
python-version: "3.13"
python-version: '3.10'
cache: pip
cache-dependency-path: pyproject.toml
cache-dependency-path: '**/setup.py'
- name: Install dependencies
run: |
pip install build
- name: Build
pip install setuptools wheel twine build
- name: Publish
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: |
python -m build
- name: Publish
uses: pypa/gh-action-pypi-publish@release/v1
twine upload dist/*

View file

@ -1,30 +1,29 @@
name: Test
on: [push, pull_request]
permissions:
contents: read
on:
push:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.7", "3.8", "3.9", "3.10"]
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
if: hashFiles('setup.py')
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: pyproject.toml
cache-dependency-path: '**/setup.py'
- name: Install dependencies
if: hashFiles('setup.py')
run: |
pip install . --group dev
pip install -e '.[test]'
- name: Run tests
if: hashFiles('setup.py')
run: |
pytest
- name: Check if cog needs to run
run: |
cog --check docs/*.md

1
.gitignore vendored
View file

@ -1,5 +1,4 @@
.venv
uv.lock
__pycache__/
*.py[cod]
*$py.class

View file

@ -1,17 +0,0 @@
version: 2
build:
os: ubuntu-22.04
tools:
python: "3.11"
sphinx:
configuration: docs/conf.py
formats:
- pdf
- epub
python:
install:
- requirements: docs/requirements.txt

View file

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

235
README.md
View file

@ -5,80 +5,199 @@
[![Tests](https://github.com/simonw/dclient/workflows/Test/badge.svg)](https://github.com/simonw/dclient/actions?query=workflow%3ATest)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/dclient/blob/master/LICENSE)
A client CLI utility for [Datasette](https://datasette.io/) instances.
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 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)
A client CLI utility for [Datasette](https://datasette.io/) instances
## Installation
Install this tool using `pip`:
```bash
pip install dclient
```
pip install dclient
If you want to install it in the same virtual environment as Datasette (to use it as a plugin) you can instead run:
```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
datasette install dclient
## Standalone v.s. plugin
Once installed you can use this tool like so:
dclient --help
If you also have Datasette installed in the same environment it will register itself as a command plugin.
This means you can run any of these commands using `datasette client` instead, like this:
datasette client --help
datasette client query https://latest.datasette.io/fixtures "select * from facetable limit 1"
## Running queries
You can run SQL queries against a Datasette instance like this:
```
Now run queries directly:
```bash
dclient "select * from facetable limit 1"
$ dclient query https://latest.datasette.io/fixtures "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
Output:
```json
[
{
"pk": 1,
"created": "2019-01-14 08:00:00",
"planet_int": 1,
"on_earth": 1,
"state": "CA",
"_city_id": 1,
"_neighborhood": "Mission",
"tags": "[\"tag1\", \"tag2\"]",
"complex_array": "[{\"foo\": \"bar\"}]",
"distinct_some_null": "one",
"n": "n1"
}
]
```
## Introspection
```bash
dclient databases
dclient tables
dclient plugins
dclient schema facetable
### dclient query --help
<!-- [[[cog
import cog
from dclient import cli
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(cli.cli, ["query", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(help)
)
]]] -->
```
Usage: dclient query [OPTIONS] URL SQL
## Documentation
Run a SQL query against a Datasette database URL
Visit **[dclient.datasette.io](https://dclient.datasette.io)** for full documentation on using this tool.
Returns a JSON array of objects
Options:
--help Show this message and exit.
```
<!-- [[[end]]] -->
## Aliases
You can assign an alias to a Datasette database using the `dclient alias` command:
dclient alias add content https://datasette.io/content
You can list aliases with `dclient alias list`:
$ dclient alias list
content = https://datasette.io/content
Once registered, you can pass an alias to commands such as `dclient query`:
dclient query content "select * from news limit 1"
### dclient alias --help
<!-- [[[cog
import cog
result = runner.invoke(cli.cli, ["alias", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(help)
)
]]] -->
```
Usage: dclient alias [OPTIONS] COMMAND [ARGS]...
Manage aliases for different instances
Options:
--help Show this message and exit.
Commands:
add Add an alias
list List aliases
remove Remove an alias
```
<!-- [[[end]]] -->
### dclient alias list --help
<!-- [[[cog
import cog
result = runner.invoke(cli.cli, ["alias", "list", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(help)
)
]]] -->
```
Usage: dclient alias list [OPTIONS]
List aliases
Options:
--json Output raw JSON
--help Show this message and exit.
```
<!-- [[[end]]] -->
### dclient alias add --help
<!-- [[[cog
import cog
result = runner.invoke(cli.cli, ["alias", "add", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(help)
)
]]] -->
```
Usage: dclient alias add [OPTIONS] NAME URL
Add an alias
Options:
--help Show this message and exit.
```
<!-- [[[end]]] -->
### dclient alias remove --help
<!-- [[[cog
import cog
result = runner.invoke(cli.cli, ["alias", "remove", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(help)
)
]]] -->
```
Usage: dclient alias remove [OPTIONS] NAME
Remove an alias
Options:
--help Show this message and exit.
```
<!-- [[[end]]] -->
## Development
To contribute to this tool, first checkout the code. Then create a new virtual environment:
```bash
cd dclient
python -m venv venv
source venv/bin/activate
```
cd dclient
python -m venv venv
source venv/bin/activate
Now install the dependencies and test dependencies:
```bash
pip install -e '.[test]'
```
pip install -e '.[test]'
To run the tests:
```bash
pytest
```
pytest

File diff suppressed because it is too large Load diff

View file

@ -5,4 +5,4 @@ from datasette import hookimpl
def register_commands(cli):
from .cli import cli as dclient_cli
cli.add_command(dclient_cli, name="dc")
cli.add_command(dclient_cli, name="client")

View file

@ -1,42 +0,0 @@
from urllib.parse import urlparse
def url_matches_prefix(url: str, prefix: str) -> bool:
url_parsed = urlparse(url)
prefix_parsed = urlparse(prefix)
if (
url_parsed.scheme != prefix_parsed.scheme
or url_parsed.netloc != prefix_parsed.netloc
):
return False
if url_parsed.path == prefix_parsed.path:
return True
url_path = url_parsed.path
# Add '/' to the end of the paths if they don't already end with '/'
prefix_path = prefix_parsed.path
# Special treatment for /foo.json
if url_path.endswith(".json"):
if prefix_path + ".json" == url_path:
return True
if not prefix_path.endswith("/"):
prefix_path += "/"
return url_path.startswith(prefix_path)
def token_for_url(url: str, tokens: dict) -> str:
matches = []
for prefix_url, token in tokens.items():
if url_matches_prefix(url, prefix_url):
matches.append((prefix_url, token))
# Sort by length of prefix_url, so that the longest match is first
matches.sort(key=lambda x: len(x[0]), reverse=True)
if matches:
return matches[0][1]
return None

1
docs/.gitignore vendored
View file

@ -1 +0,0 @@
_build

View file

@ -1,23 +0,0 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
SPHINXPROJ = sqlite-utils
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
livehtml:
sphinx-autobuild -b html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(0)

View file

@ -1,6 +0,0 @@
{%- extends "!base.html" %}
{% block site_meta %}
{{ super() }}
<script defer data-domain="dclient.datasette.io" src="https://plausible.io/js/plausible.js"></script>
{% endblock %}

View file

@ -1,116 +0,0 @@
# Aliases
You can assign an alias to a Datasette instance using the `dclient alias add` command:
dclient alias add latest https://latest.datasette.io
You can list aliases with `dclient alias list`:
$ dclient alias list
latest = https://latest.datasette.io
Once registered, you can pass an alias to commands using the `-i` flag:
dclient query fixtures "select * from news limit 1" -i latest
See [Defaults](defaults.md) for default instance and default database settings.
## dclient alias --help
<!-- [[[cog
import cog
from dclient import cli
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(cli.cli, ["alias", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(help)
)
]]] -->
```
Usage: dclient alias [OPTIONS] COMMAND [ARGS]...
Manage aliases for different instances
Options:
--help Show this message and exit.
Commands:
add Add an alias for a Datasette instance
list List aliases
remove Remove an alias
```
<!-- [[[end]]] -->
## dclient alias list --help
<!-- [[[cog
import cog
result = runner.invoke(cli.cli, ["alias", "list", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(help)
)
]]] -->
```
Usage: dclient alias list [OPTIONS]
List aliases
Options:
--json Output raw JSON
--help Show this message and exit.
```
<!-- [[[end]]] -->
## dclient alias add --help
<!-- [[[cog
import cog
result = runner.invoke(cli.cli, ["alias", "add", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(help)
)
]]] -->
```
Usage: dclient alias add [OPTIONS] NAME URL
Add an alias for a Datasette instance
Example usage:
dclient alias add prod https://myapp.datasette.cloud
Options:
--help Show this message and exit.
```
<!-- [[[end]]] -->
## dclient alias remove --help
<!-- [[[cog
import cog
result = runner.invoke(cli.cli, ["alias", "remove", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(help)
)
]]] -->
```
Usage: dclient alias remove [OPTIONS] NAME
Remove an alias
Example usage:
dclient alias remove prod
Options:
--help Show this message and exit.
```
<!-- [[[end]]] -->

View file

@ -1,333 +0,0 @@
(authentication)=
# Authentication
`dclient` can handle API tokens for Datasette instances that require authentication.
You can pass an API token to any command using `--token` like this:
```bash
dclient query fixtures "select * from facetable" --token dstok_mytoken -i latest
```
A more convenient way to handle this is to store tokens for your aliases.
## Logging in with OAuth
The easiest way to authenticate is using the `dclient login` command, which uses the OAuth device flow to obtain and store a token.
This requires the Datasette instance to be running the [datasette-oauth](https://github.com/datasette/datasette-oauth) plugin with [device flow enabled](https://github.com/datasette/datasette-oauth?tab=readme-ov-file#plugin-configuration).
```bash
dclient login https://my-datasette.example.com/
dclient login myalias
dclient login
```
This will display a URL and a code. Open the URL in your browser, enter the code to approve access, and the resulting API token will be saved automatically. If you run `dclient login` without an argument, you will be prompted for the instance URL or alias.
### Requesting scoped tokens
By default, `dclient login` requests an unrestricted token. You can request a token with limited permissions using the shorthand options:
```bash
# Instance-wide read or write access
dclient login --read-all
dclient login --write-all
# Database-level access
dclient login --read db1
dclient login --write db3
# Table-level access
dclient login --read db1/dogs
dclient login --write db3/submissions
# Mixed
dclient login --read db1 --write db3/dogs
```
`--read-all` grants: `view-instance`, `view-table`, `view-database`, `view-query`, `execute-sql`.
`--write-all` grants all read scopes plus: `insert-row`, `delete-row`, `update-row`, `create-table`, `alter-table`, `drop-table`.
`--read` and `--write` accept a database name or `database/table` and can be specified multiple times. `--write` implies read access for the same target.
For advanced use, you can also pass raw scope JSON with `--scope`:
```bash
dclient login myalias --scope '[["view-instance"]]'
```
All scope options can be combined — the shorthand options append to whatever `--scope` provides.
### Outputting the token directly
Use `--token-only` to print the token to stdout instead of saving it. This is useful for creating debug tokens or piping them into other tools:
```bash
dclient login --token-only --read mydb
dclient login --token-only --write-all
```
## dclient login --help
<!-- [[[cog
import cog
from dclient import cli
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(cli.cli, ["login", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(help)
)
]]] -->
```
Usage: dclient login [OPTIONS] [ALIAS_OR_URL]
Authenticate with a Datasette instance using OAuth
Uses the OAuth device flow: opens a URL in your browser where you approve
access, then saves the resulting API token.
Example usage:
dclient login https://simon.datasette.cloud/
dclient login myalias
dclient login
dclient login --read-all
dclient login --write-all
dclient login --read db1
dclient login --write db3/submissions
dclient login --read db1 --write db3/dogs
Options:
--scope TEXT JSON scope array
--read-all Request instance-wide read access
--write-all Request instance-wide write access
--read TEXT Request read access for a database or database/table
--write TEXT Request write access for a database or database/table
--token-only Output the token to stdout instead of saving it
--help Show this message and exit.
```
<!-- [[[end]]] -->
## Using stored tokens
To store a token for an alias:
```bash
dclient auth add latest
```
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:
```bash
dclient auth list
```
To delete the token for a specific alias or URL, run `auth remove`:
```bash
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 auth status` command can be used to verify authentication by calling `/-/actor.json`:
```bash
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
{
"actor": {
"id": "root",
"token": "dstok"
}
}
```
## dclient auth --help
<!-- [[[cog
import cog
from dclient import cli
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(cli.cli, ["auth", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(help)
)
]]] -->
```
Usage: dclient auth [OPTIONS] COMMAND [ARGS]...
Manage authentication for different instances
Options:
--help Show this message and exit.
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]]] -->
## dclient auth add --help
<!-- [[[cog
import cog
result = runner.invoke(cli.cli, ["auth", "add", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(help)
)
]]] -->
```
Usage: dclient auth add [OPTIONS] ALIAS_OR_URL
Add an authentication token for an alias or URL
Example usage:
dclient auth add prod
dclient auth add https://datasette.io
Paste in the token when prompted.
Options:
--token TEXT
--help Show this message and exit.
```
<!-- [[[end]]] -->
## dclient auth list --help
<!-- [[[cog
import cog
result = runner.invoke(cli.cli, ["auth", "list", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(help)
)
]]] -->
```
Usage: dclient auth list [OPTIONS]
List stored API tokens
Example usage:
dclient auth list
Options:
--help Show this message and exit.
```
<!-- [[[end]]] -->
## dclient auth remove --help
<!-- [[[cog
import cog
result = runner.invoke(cli.cli, ["auth", "remove", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(help)
)
]]] -->
```
Usage: dclient auth remove [OPTIONS] ALIAS_OR_URL
Remove the API token for an alias or URL
Example usage:
dclient auth remove prod
Options:
--help Show this message and exit.
```
<!-- [[[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
import cog
result = runner.invoke(cli.cli, ["actor", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(help)
)
]]] -->
```
Usage: dclient actor [OPTIONS]
Show the actor represented by an API token
Example usage:
dclient actor
dclient actor -i prod
Options:
-i, --instance TEXT Datasette instance URL or alias
--token TEXT API token
--help Show this message and exit.
```
<!-- [[[end]]] -->

View file

@ -1,173 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from subprocess import PIPE, Popen
# This file is execfile()d with the current directory set to its
# containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
# import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))
# -- General configuration ------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#
# needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = ["myst_parser", "sphinx_copybutton"]
myst_enable_extensions = ["colon_fence"]
# Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"]
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
source_suffix = [".rst", ".md"]
# The master toctree document.
master_doc = "index"
# General information about the project.
project = "dclient"
copyright = "2023, Simon Willison"
author = "Simon Willison"
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
pipe = Popen("git describe --tags --always", stdout=PIPE, shell=True)
git_version = pipe.stdout.read().decode("utf8")
if git_version:
version = git_version.rsplit("-", 1)[0]
release = git_version
else:
version = ""
release = ""
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = "en"
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This patterns also effect to html_static_path and html_extra_path
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = "sphinx"
# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = False
# -- Options for HTML output ----------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = "furo"
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
html_theme_options = {}
html_title = "dclient"
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = []
# -- Options for HTMLHelp output ------------------------------------------
# Output file base name for HTML help builder.
htmlhelp_basename = "dclient-doc"
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(
master_doc,
"dclient.tex",
"dclient documentation",
"Simon Willison",
"manual",
)
]
# -- Options for manual page output ---------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(
master_doc,
"dclient",
"dclient documentation",
[author],
1,
)
]
# -- Options for Texinfo output -------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(
master_doc,
"dclient",
"dclient documentation",
author,
"dclient",
" A client CLI utility for Datasette instances ",
"Miscellaneous",
)
]

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

@ -1,81 +0,0 @@
(environment-variables)=
# Environment variables
`dclient` supports several environment variables for convenient access to Datasette instances.
## 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:
```bash
export DATASETTE_URL=https://my-instance.datasette.cloud
```
Then you can omit the `-i` flag:
```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:
```bash
export DATASETTE_DATABASE=data
```
Then you can use the bare SQL shortcut:
```bash
dclient "select * from my_table limit 10"
```
## DATASETTE_TOKEN
Set this to an API token:
```bash
export DATASETTE_TOKEN=dstok_abc123
```
The token will be used automatically for any request that doesn't have a more specific token configured.
The precedence order for tokens is:
1. `--token` CLI flag (highest priority)
2. Stored token from `auth.json` (matched by alias name, then 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
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"
# Insert
cat records.json | dclient insert data my_table - --json
# Check your actor identity
dclient actor
```

View file

@ -1,42 +0,0 @@
# dclient
[![PyPI](https://img.shields.io/pypi/v/dclient.svg)](https://pypi.org/project/dclient/)
[![Changelog](https://img.shields.io/github/v/release/simonw/dclient?include_prereleases&label=changelog)](https://github.com/simonw/dclient/releases)
[![Tests](https://github.com/simonw/dclient/workflows/Test/badge.svg)](https://github.com/simonw/dclient/actions?query=workflow%3ATest)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/dclient/blob/master/LICENSE)
A client CLI utility for [Datasette](https://datasette.io/) instances
## Installation
Install `dclient` using `pip` (or [pipx](https://pipxproject.github.io/pipx/):
```bash
pip install dclient
```
### As a Datasette plugin
If you also have Datasette installed in the same environment it will register itself as a command plugin:
```bash
datasette install dclient
```
This means you can run any of these commands using `datasette dc` instead, like this:
```bash
datasette dc --help
datasette dc query fixtures "select * from facetable limit 1" -i latest
```
## Contents
```{toctree}
---
maxdepth: 3
---
queries
aliases
defaults
authentication
inserting
environment
```

View file

@ -1,252 +0,0 @@
# 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:
```bash
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 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
`dclient insert` works for streaming data as well.
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
```
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.
In streaming mode records default to being sent to the server every 100 records or every 10 seconds, whichever comes first. You can adjust these values using the `--batch-size` and `--interval` settings. For example, here's how to send every 10 records or if 5 seconds has passed since the last time data was sent to the server:
```bash
tail -f log.jsonl | dclient insert \
data logs - --nl --create -i myapp \
--batch-size 10 \
--interval 5
```
## Supported formats
Data can be inserted from CSV, TSV, JSON or newline-delimited JSON files.
The format of the file will be automatically detected. You can override this by using one of the following options:
- `--csv`
- `--tsv`
- `--json`
- `--nl` for newline-delimited JSON
Use `--encoding <encoding>` to specify the encoding of the file. The default is `utf-8`.
### JSON
JSON files should be formatted like this:
```json
[
{
"id": 1
"column1": "value1",
"column2": "value2"
},
{
"id": 2
"column1": "value1",
"column2": "value2"
}
]
```
Newline-delimited files like this:
```
{"id": 1, "column1": "value1", "column2": "value2"}
{"id": 2, "column1": "value1", "column2": "value2"}
```
### CSV and TSV
CSV and TSV files should have a header row containing the names of the columns.
By default, `dclient` will attempt to detect the types of the different columns in the CSV and TSV files - so if a column only ever contains numeric integers it will be stored as integers in the SQLite database.
You can disable this and have every value treated as a string using `--no-detect-types`.
### Other options
- `--create` - create the table if it doesn't already exist
- `--replace` - replace any rows with a matching primary key
- `--ignore` - ignore any rows with a matching existing primary key
- `--alter` - alter table to add any columns that are missing
- `--pk id` - set a primary key (for if the table is being created)
If you use `--create` a table will be created with rows to match the columns in your uploaded data - using the correctly detected types, unless you use `--no-detect-types` in which case every column will be of type `text`.
## dclient insert --help
<!-- [[[cog
import cog
from dclient import cli
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(cli.cli, ["insert", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(help)
)
]]] -->
```
Usage: dclient insert [OPTIONS] DATABASE 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
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
--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

@ -1,234 +0,0 @@
# Running queries
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
```
Output:
```json
[
{
"pk": 1,
"created": "2019-01-14 08:00:00",
"planet_int": 1,
"on_earth": 1,
"state": "CA",
"_city_id": 1,
"_neighborhood": "Mission",
"tags": "[\"tag1\", \"tag2\"]",
"complex_array": "[{\"foo\": \"bar\"}]",
"distinct_some_null": "one",
"n": "n1"
}
]
```
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
from dclient import cli
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(cli.cli, ["query", "--help"])
help = result.output.replace("Usage: cli", "Usage: dclient")
cog.out(
"```\n{}\n```".format(help)
)
]]] -->
```
Usage: dclient query [OPTIONS] DATABASE SQL
Run a SQL query against a Datasette database
Requires both a database name and a SQL string.
Example usage:
dclient query fixtures "select * from facetable limit 5"
dclient query analytics "select count(*) from events" -i staging
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.
```
<!-- [[[end]]] -->

View file

@ -1,5 +0,0 @@
furo==2022.06.21
sphinx-autobuild
sphinx-copybutton
myst-parser
cogapp

View file

@ -1,54 +0,0 @@
[project]
name = "dclient"
version = "0.5a3"
description = "A client CLI utility for Datasette instances"
readme = "README.md"
authors = [{name = "Simon Willison"}]
license = "Apache-2.0"
requires-python = ">=3.10"
dependencies = [
"click",
"click-default-group",
"httpx<1.0",
"sqlite-utils",
]
[project.urls]
Homepage = "https://github.com/simonw/dclient"
Issues = "https://github.com/simonw/dclient/issues"
CI = "https://github.com/simonw/dclient/actions"
Changelog = "https://github.com/simonw/dclient/releases"
[project.scripts]
dclient = "dclient.cli:cli"
[project.entry-points.datasette]
client = "dclient.plugin"
[dependency-groups]
test = [
"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"},
]
[tool.uv.build-backend]
module-root = ""
[build-system]
requires = ["uv_build>=0.9.18,<0.10.0"]
build-backend = "uv_build"

37
setup.py Normal file
View file

@ -0,0 +1,37 @@
from setuptools import setup
import os
VERSION = "0.1a2"
def get_long_description():
with open(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "README.md"),
encoding="utf8",
) as fp:
return fp.read()
setup(
name="dclient",
description="A client CLI utility for Datasette instances",
long_description=get_long_description(),
long_description_content_type="text/markdown",
author="Simon Willison",
url="https://github.com/simonw/dclient",
project_urls={
"Issues": "https://github.com/simonw/dclient/issues",
"CI": "https://github.com/simonw/dclient/actions",
"Changelog": "https://github.com/simonw/dclient/releases",
},
license="Apache License, Version 2.0",
version=VERSION,
packages=["dclient"],
entry_points={
"datasette": ["client = dclient.plugin"],
"console_scripts": ["dclient = dclient.cli:cli"],
},
install_requires=["click", "httpx", "appdirs"],
extras_require={"test": ["pytest", "pytest-httpx", "cogapp", "pytest-mock"]},
python_requires=">=3.7",
)

View file

@ -1,408 +0,0 @@
# dclient v2 — Changes from v1
## Summary of changes
1. Aliases point at Datasette instances, not database URLs
2. Instance is always a flag (`-i`/`--instance`), never a positional argument
3. `query` and `insert`/`upsert` take database as a required positional argument
4. Introspection commands (`tables`, `schema`, etc.) use `-d`/`--database` with defaults
5. Default instance and default database reduce typing
6. Bare SQL defaults to `query` command via `click-default-group`, using defaults + `-d` override
7. New commands: `databases`, `tables`, `schema`, `plugins`
8. Config and auth remain separate files, with a new config format
9. New `DATASETTE_DATABASE` environment variable
---
## Config changes
### config.json (replaces aliases.json)
```json
{
"default_instance": "prod",
"instances": {
"prod": {
"url": "https://myapp.datasette.cloud",
"default_database": "main"
},
"local": {
"url": "http://localhost:8001",
"default_database": null
}
}
}
```
### auth.json (same file, new key structure)
Keys are now alias names instead of URLs:
```json
{
"prod": "dstok_abc123",
"local": "dstok_def456"
}
```
When no alias exists (using `-i` with a raw URL), tokens are looked up by URL with the existing prefix-matching logic as a fallback.
### Migration
On first run, if `config.json` doesn't exist but `aliases.json` does:
- Parse each v1 alias URL. If it has a single path segment (e.g., `https://example.com/mydb`), split into instance URL + default database. Otherwise store the URL as-is.
- Migrate auth.json keys from URLs to alias names where a match exists, keep URL-keyed entries as fallbacks.
- Write new files, rename originals to `.bak`.
### Environment variables
| Variable | Purpose | Precedence |
|----------|---------|------------|
| `DATASETTE_URL` | Default instance URL (existing) | Below config default, above nothing |
| `DATASETTE_DATABASE` | Default database name (new) | Below config default, above auto-detect |
| `DATASETTE_TOKEN` | Auth token (existing) | Below stored token, above nothing |
---
## Two ways to query: explicit and shortcut
The design provides two distinct interfaces for running queries, optimized for different workflows.
### Explicit: `dclient query <database> <sql>`
Database is a required positional argument. No defaults involved. This is unambiguous and works well for zero-config usage and when switching between databases frequently.
```bash
dclient query fixtures "select * from facetable limit 5"
dclient query analytics "select count(*) from events" -i staging
```
### Shortcut: `dclient <sql>`
When the first argument doesn't match any subcommand, `click-default-group` routes it to a default command that uses the configured default instance and default database. Override either with flags.
```bash
dclient "select count(*) from users" # default instance + default db
dclient "select count(*) from users" -d analytics # override database
dclient "select count(*) from users" -i staging # override instance
```
This is the daily-driver mode for people with a configured default instance and database.
### Implementation
```python
from click_default_group import DefaultGroup
@click.group(cls=DefaultGroup, default="default_query", default_if_no_args=False)
@click.version_option()
def cli():
"A client CLI utility for Datasette instances"
@cli.command()
@click.argument("database")
@click.argument("sql")
@click.option("-i", "--instance")
@click.option("--token")
@click.option("-v", "--verbose", is_flag=True)
# ... output format options ...
def query(database, sql, instance, token, verbose, **kwargs):
"""Run a SQL query against a Datasette database
Requires both a database name and a SQL string.
Example:
dclient query fixtures "select * from facetable limit 5"
"""
...
@cli.command(name="default_query", hidden=True)
@click.argument("sql")
@click.option("-i", "--instance")
@click.option("-d", "--database")
@click.option("--token")
@click.option("-v", "--verbose", is_flag=True)
# ... output format options ...
def default_query(sql, instance, database, token, verbose, **kwargs):
"""Run a SQL query using default instance and database."""
# Resolve instance and database from defaults/env vars
# Error if no default database can be resolved
...
```
The `default_query` command is hidden from help output. Users see `query` in the help text; the bare-SQL shortcut just works without being documented as a separate command.
---
## Instance resolution
Every command that talks to a Datasette server accepts `-i, --instance TEXT`. Resolution order:
1. `-i` flag (alias name or URL)
2. `config.default_instance`
3. `DATASETTE_URL` environment variable
4. Error
If the value starts with `http://` or `https://`, use it as a URL directly. Otherwise look it up as an alias name in config.
---
## Database resolution
There are two modes of database resolution depending on the command.
### Commands with required positional database
`query`, `insert`, `upsert`: the database is always the first positional argument. No resolution logic, no defaults. You must name the database.
### Commands with optional `-d` flag
`tables`, `schema`, and the default query shortcut: resolve in order:
1. `-d` flag
2. Instance's `default_database` from config
3. `DATASETTE_DATABASE` environment variable
4. Auto-detect if instance has exactly one (non-internal) database
5. Error with a helpful message listing available databases
### Commands that don't need a database
`databases`, `plugins`, `actor`, `alias`, `auth`: no database argument or flag.
---
## New commands
### `dclient databases`
List databases on an instance.
```bash
$ dclient databases
main
extra
$ dclient databases --json
[{"name": "main", "tables_count": 12, ...}, ...]
$ dclient databases -i https://latest.datasette.io
fixtures
```
Options: `-i`, `--json`.
Implementation: `GET <instance>/.json``databases` key.
### `dclient tables`
List tables (and optionally views) in a database.
```bash
$ dclient tables
facetable 15 rows
facet_cities 4 rows
$ dclient tables -d analytics
events 1503 rows
$ dclient tables --views --json
[{"name": "facetable", "columns": [...], "count": 15, ...}, ...]
```
Options: `-i`, `-d`, `--views`, `--views-only`, `--hidden`, `--json`.
Implementation: `GET <instance>/<database>.json``tables` and `views` keys.
### `dclient schema`
Show SQL schema for a database or a specific table.
```bash
$ dclient schema
# all CREATE TABLE/VIEW statements for default database
$ dclient schema -d analytics
# all schemas for the analytics database
$ dclient schema facetable
# just that table's schema (in default database)
$ dclient schema facetable -d analytics
# that table in a specific database
```
The optional table name is a positional argument. Options: `-i`, `-d`, `--json`.
### `dclient plugins`
List installed plugins on an instance.
```bash
$ dclient plugins
datasette-files
datasette-auth-tokens
$ dclient plugins --json
[{"name": "datasette-files", "version": "0.3.1", ...}, ...]
```
Options: `-i`, `--json`.
Implementation: `GET <instance>/-/plugins.json`.
---
## Changed commands
### `dclient query`
```bash
# v1
dclient query https://datasette.io/content "select * from news"
# v2: database is required positional, instance is a flag
dclient query content "select * from news" -i https://datasette.io
dclient query fixtures "select * from facetable" # uses default instance
```
Signature: `dclient query <database> <sql> [-i instance] [--csv|--tsv|--nl|--table] [-o file] [--token TOKEN] [-v]`
### `dclient insert`
```bash
# v1
dclient insert https://myapp.datasette.cloud/data mytable data.csv --csv
# v2: database and table are required positionals, instance is a flag
dclient insert main mytable data.csv --csv -i myapp
dclient insert main mytable data.csv --csv # uses default instance
```
Signature: `dclient insert <database> <table> <file> [-i instance] [--csv|--tsv|--json|--nl] [--create] [--replace] [--ignore] [--alter] [--pk col] [--batch-size N] [--interval N] [--token TOKEN] [-v] [--silent]`
### `dclient upsert`
New command. Same shape as `insert` but hits `/-/upsert`.
Signature: `dclient upsert <database> <table> <file> [-i instance] [--csv|--tsv|--json|--nl] [--alter] [--pk col] [--batch-size N] [--interval N] [--token TOKEN] [-v] [--silent]`
### `dclient alias`
```bash
dclient alias add <name> <url> # url is the instance root, stored as-is
dclient alias remove <name>
dclient alias list # shows * for default, (db: x) for default db
dclient alias default [name] # set/show default instance
dclient alias default --clear
dclient alias default-db <alias> [db] # set/show default database for an alias
dclient alias default-db <alias> --clear
```
**Tip: multiple aliases for the same instance.** You can create several aliases pointing at the same instance URL with different default databases. This gives you short names for databases you switch between frequently:
```bash
dclient alias add prod https://myapp.datasette.cloud
dclient alias add prod-analytics https://myapp.datasette.cloud
dclient alias default-db prod main
dclient alias default-db prod-analytics analytics
dclient tables -i prod # → main
dclient tables -i prod-analytics # → analytics
```
Auth tokens are resolved by alias name first, then by URL match as a fallback, so a token stored for either alias will work for both.
### `dclient auth`
```bash
dclient auth add <alias-or-url> # prompt for token
dclient auth add <alias-or-url> --token TOKEN
dclient auth remove <alias-or-url>
dclient auth list # shows which aliases have tokens, never values
dclient auth status [-i instance] # calls /-/actor.json to verify
```
### `dclient actor`
Uses `-i` flag instead of positional URL:
```bash
dclient actor # default instance
dclient actor -i prod
```
---
## Worked examples for the three usage modes
### Mode 1: Zero config, public instance
```bash
# Explicit database, instance as flag
dclient databases -i https://latest.datasette.io
dclient tables -i https://latest.datasette.io -d fixtures
dclient query fixtures "select * from facetable limit 3" -i https://latest.datasette.io
# Or set env vars for a session
export DATASETTE_URL=https://latest.datasette.io
export DATASETTE_DATABASE=fixtures
dclient tables
dclient "select * from facetable limit 3"
```
### Mode 2: Single default instance
```bash
# One-time setup
dclient alias add work https://myapp.datasette.cloud
dclient alias default work
dclient alias default-db work main
dclient auth add work
# Daily use — bare SQL shortcut uses defaults
dclient tables
dclient "select count(*) from users"
dclient "select * from events" -d analytics
# Explicit query when switching databases frequently
dclient query main "select count(*) from users"
dclient query analytics "select count(*) from events"
# Insert always names the database
dclient insert main events data.csv --csv --create --pk id
dclient plugins
```
### Mode 3: Multiple aliases
```bash
dclient alias add prod https://prod.datasette.cloud
dclient alias add staging https://staging.datasette.cloud
dclient alias default prod
dclient alias default-db prod main
dclient alias default-db staging main
# Bare shortcut hits prod/main
dclient "select count(*) from users"
# Explicit query — name the database, override instance with -i
dclient query main "select count(*) from users" -i staging
dclient query analytics "select * from events" -i staging
# Insert always explicit
dclient insert main events data.csv --csv -i staging
```
---
## Not in this version (reserved)
These command names are reserved for future work:
- `dclient cloud` — Datasette Cloud integration
- `dclient files` — datasette-files management
- `dclient get`, `dclient rows`, `dclient update`, `dclient delete` — row-level CRUD
- `dclient create-table`, `dclient drop-table` — DDL
- `dclient queries` — canned queries

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

@ -1,523 +0,0 @@
from click.testing import CliRunner
from dclient.cli import cli
import pathlib
import json
import pytest
def test_auth(mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
runner = CliRunner()
result = runner.invoke(cli, ["auth", "list"])
assert result.exit_code == 0
assert result.output.startswith("Tokens file:")
# 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")
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"}
# auth list should show that now
result3 = runner.invoke(cli, ["auth", "list"])
assert result3.output.startswith("Tokens file:")
assert "prod" in result3.output
# 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 key
result5 = runner.invoke(cli, ["auth", "remove", "prod"])
assert result5.exit_code == 0
assert result5.output == ""
# Check the tokens file
auth_file = pathlib.Path(tmpdir) / "auth.json"
assert json.loads(auth_file.read_text()) == {}
# -- login command (OAuth device flow) --
DEVICE_RESPONSE = {
"device_code": "devcode123",
"user_code": "ABCD-EFGH",
"verification_uri": "https://example.com/-/oauth/device/verify",
"expires_in": 900,
"interval": 0,
}
TOKEN_SUCCESS = {
"access_token": "dstok_abc123",
"token_type": "bearer",
"expires_in": 3600,
}
def test_login_with_url(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/"])
assert result.exit_code == 0
assert "ABCD-EFGH" in result.output
assert "Login successful" in result.output
# Token should be saved
auth_file = pathlib.Path(tmpdir) / "auth.json"
auths = json.loads(auth_file.read_text())
assert auths["https://example.com/"] == "dstok_abc123"
def test_login_adds_trailing_slash(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com"])
assert result.exit_code == 0
# Check that the device request went to the right URL
requests = httpx_mock.get_requests()
assert str(requests[0].url) == "https://example.com/-/oauth/device"
def test_login_with_alias(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
# Set up an alias first
config_file = pathlib.Path(tmpdir) / "config.json"
config_file.write_text(
json.dumps(
{
"default_instance": None,
"instances": {
"prod": {
"url": "https://prod.example.com",
"default_database": None,
}
},
}
)
)
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "prod"])
assert result.exit_code == 0
assert "Login successful" in result.output
# Token should be saved by alias name
auth_file = pathlib.Path(tmpdir) / "auth.json"
auths = json.loads(auth_file.read_text())
assert auths["prod"] == "dstok_abc123"
def test_login_interactive_prompt(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login"], input="https://example.com/\n")
assert result.exit_code == 0
assert "Instance URL or alias" in result.output
assert "Login successful" in result.output
def test_login_access_denied(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json={"error": "access_denied"}, status_code=400)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/"])
assert result.exit_code == 1
assert "Authorization denied" in result.output
def test_login_expired_token(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json={"error": "expired_token"}, status_code=400)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/"])
assert result.exit_code == 1
assert "expired" in result.output
def test_login_pending_then_success(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
# First poll: pending
httpx_mock.add_response(json={"error": "authorization_pending"}, status_code=400)
# Second poll: success
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/"])
assert result.exit_code == 0
assert "Login successful" in result.output
auth_file = pathlib.Path(tmpdir) / "auth.json"
auths = json.loads(auth_file.read_text())
assert auths["https://example.com/"] == "dstok_abc123"
def test_login_device_endpoint_error(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
httpx_mock.add_response(status_code=403)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/"])
assert result.exit_code == 1
assert "Failed to start login flow" in result.output
# -- login sets defaults --
@pytest.mark.parametrize(
"databases_response,expected_db",
[
([{"name": "mydata", "is_mutable": True}], "mydata"),
(
[
{"name": "fixtures", "is_mutable": False},
{"name": "data", "is_mutable": True},
{"name": "extra", "is_mutable": True},
],
"data",
),
(
[
{"name": "alpha", "is_mutable": True},
{"name": "beta", "is_mutable": True},
{"name": "gamma", "is_mutable": False},
],
"alpha",
),
],
ids=["single_db", "prefers_data", "first_when_no_data"],
)
def test_login_sets_defaults(
httpx_mock, mocker, tmpdir, databases_response, expected_db
):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=databases_response, status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/"])
assert result.exit_code == 0, result.output
config = json.loads((pathlib.Path(tmpdir) / "config.json").read_text())
assert config["default_instance"] == "https://example.com/"
assert (
config["instances"]["https://example.com/"]["default_database"] == expected_db
)
def test_login_does_not_override_existing_defaults(httpx_mock, mocker, tmpdir):
"""When defaults are already configured, login should not change them."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
config_file = pathlib.Path(tmpdir) / "config.json"
config_file.write_text(
json.dumps(
{
"default_instance": "prod",
"instances": {
"prod": {
"url": "https://prod.example.com",
"default_database": "main",
}
},
}
)
)
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://other.example.com/"])
assert result.exit_code == 0, result.output
config = json.loads(config_file.read_text())
assert config["default_instance"] == "prod"
def test_login_with_alias_sets_defaults(httpx_mock, mocker, tmpdir):
"""When logging in with an alias that has no default_database, set it."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
config_file = pathlib.Path(tmpdir) / "config.json"
config_file.write_text(
json.dumps(
{
"default_instance": None,
"instances": {
"prod": {
"url": "https://prod.example.com",
"default_database": None,
}
},
}
)
)
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(
json=[
{"name": "fixtures", "is_mutable": False},
{"name": "data", "is_mutable": True},
{"name": "extra", "is_mutable": True},
],
status_code=200,
)
runner = CliRunner()
result = runner.invoke(cli, ["login", "prod"])
assert result.exit_code == 0, result.output
config = json.loads(config_file.read_text())
assert config["default_instance"] == "prod"
assert config["instances"]["prod"]["default_database"] == "data"
def test_login_read_all(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/", "--read-all"])
assert result.exit_code == 0, result.output
from urllib.parse import parse_qs
request = httpx_mock.get_requests()[0]
body = parse_qs(request.content.decode())
scope = json.loads(body["scope"][0])
assert scope == [
["view-instance"],
["view-table"],
["view-database"],
["view-query"],
["execute-sql"],
]
def test_login_write_all(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/", "--write-all"])
assert result.exit_code == 0, result.output
from urllib.parse import parse_qs
request = httpx_mock.get_requests()[0]
body = parse_qs(request.content.decode())
scope = json.loads(body["scope"][0])
assert scope == [
["view-instance"],
["view-table"],
["view-database"],
["view-query"],
["execute-sql"],
["insert-row"],
["delete-row"],
["update-row"],
["create-table"],
["alter-table"],
["drop-table"],
]
def test_login_read_database(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/", "--read", "db1"])
assert result.exit_code == 0, result.output
from urllib.parse import parse_qs
request = httpx_mock.get_requests()[0]
body = parse_qs(request.content.decode())
scope = json.loads(body["scope"][0])
assert scope == [
["view-instance", "db1"],
["view-table", "db1"],
["view-database", "db1"],
["view-query", "db1"],
["execute-sql", "db1"],
]
def test_login_write_table(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(
cli, ["login", "https://example.com/", "--write", "db3/submissions"]
)
assert result.exit_code == 0, result.output
from urllib.parse import parse_qs
request = httpx_mock.get_requests()[0]
body = parse_qs(request.content.decode())
scope = json.loads(body["scope"][0])
assert scope == [
["view-instance", "db3", "submissions"],
["view-table", "db3", "submissions"],
["view-database", "db3", "submissions"],
["view-query", "db3", "submissions"],
["execute-sql", "db3", "submissions"],
["insert-row", "db3", "submissions"],
["delete-row", "db3", "submissions"],
["update-row", "db3", "submissions"],
["create-table", "db3", "submissions"],
["alter-table", "db3", "submissions"],
["drop-table", "db3", "submissions"],
]
def test_login_mixed_read_write(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(
cli,
["login", "https://example.com/", "--read", "db1", "--write", "db3/dogs"],
)
assert result.exit_code == 0, result.output
from urllib.parse import parse_qs
request = httpx_mock.get_requests()[0]
body = parse_qs(request.content.decode())
scope = json.loads(body["scope"][0])
assert scope == [
["view-instance", "db1"],
["view-table", "db1"],
["view-database", "db1"],
["view-query", "db1"],
["execute-sql", "db1"],
["view-instance", "db3", "dogs"],
["view-table", "db3", "dogs"],
["view-database", "db3", "dogs"],
["view-query", "db3", "dogs"],
["execute-sql", "db3", "dogs"],
["insert-row", "db3", "dogs"],
["delete-row", "db3", "dogs"],
["update-row", "db3", "dogs"],
["create-table", "db3", "dogs"],
["alter-table", "db3", "dogs"],
["drop-table", "db3", "dogs"],
]
def test_login_scope_combined_with_shortcuts(httpx_mock, mocker, tmpdir):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(
cli,
[
"login",
"https://example.com/",
"--scope",
'[["view-instance"]]',
"--write",
"db1/dogs",
],
)
assert result.exit_code == 0, result.output
from urllib.parse import parse_qs
request = httpx_mock.get_requests()[0]
body = parse_qs(request.content.decode())
scope = json.loads(body["scope"][0])
assert scope == [
["view-instance"],
["view-instance", "db1", "dogs"],
["view-table", "db1", "dogs"],
["view-database", "db1", "dogs"],
["view-query", "db1", "dogs"],
["execute-sql", "db1", "dogs"],
["insert-row", "db1", "dogs"],
["delete-row", "db1", "dogs"],
["update-row", "db1", "dogs"],
["create-table", "db1", "dogs"],
["alter-table", "db1", "dogs"],
["drop-table", "db1", "dogs"],
]
def test_login_no_scope_sends_no_scope(httpx_mock, mocker, tmpdir):
"""Without any scope options, no scope field should be sent."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(json=[{"name": "data"}], status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/"])
assert result.exit_code == 0, result.output
request = httpx_mock.get_requests()[0]
assert request.content == b""
def test_login_token_only(httpx_mock, mocker, tmpdir):
"""--token-only prints the token to stdout and does not save it."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
runner = CliRunner()
result = runner.invoke(
cli, ["login", "https://example.com/", "--token-only", "--read", "foo/bar"]
)
assert result.exit_code == 0, result.output
# Last line of output should be the raw token
assert result.output.strip().endswith("dstok_abc123")
# Should NOT have "Login successful" message
assert "Login successful" not in result.output
# auth.json should not exist
auth_file = pathlib.Path(tmpdir) / "auth.json"
assert not auth_file.exists()
# config.json should not exist (no defaults set)
config_file = pathlib.Path(tmpdir) / "config.json"
assert not config_file.exists()
def test_login_databases_error_still_succeeds(httpx_mock, mocker, tmpdir):
"""If the databases check fails, login should still succeed."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
mocker.patch("dclient.cli.time.sleep")
httpx_mock.add_response(json=DEVICE_RESPONSE, status_code=200)
httpx_mock.add_response(json=TOKEN_SUCCESS, status_code=200)
httpx_mock.add_response(status_code=500)
runner = CliRunner()
result = runner.invoke(cli, ["login", "https://example.com/"])
assert result.exit_code == 0, result.output
assert "Login successful" in result.output

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

@ -1,23 +0,0 @@
from click.testing import CliRunner
from datasette.app import Datasette
from datasette.cli import cli
import pytest
def test_plugin_help():
runner = CliRunner()
result = runner.invoke(cli, ["dc", "--help"])
assert result.exit_code == 0
assert "Usage: cli dc" in result.output
bits = result.output.split("Commands:")
assert "alias" in bits[1]
assert "query" in bits[1]
@pytest.mark.asyncio
async def test_plugin_installed():
ds = Datasette(memory=True)
await ds.invoke_startup()
response = await ds.client.get("/-/plugins.json")
assert response.status_code == 200
assert any(p["name"] == "dclient" for p in response.json())

View file

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

@ -1,334 +0,0 @@
import asyncio
from collections import namedtuple
from concurrent.futures import ThreadPoolExecutor
from click.testing import CliRunner
from datasette.app import Datasette
from dclient.cli import cli
import httpx
import json
import pathlib
import pytest
@pytest.fixture
def assert_all_responses_were_requested() -> bool:
return False
pytestmark = pytest.mark.httpx_mock(assert_all_responses_were_requested=False)
@pytest.fixture
def non_mocked_hosts():
# This ensures httpx-mock will not affect Datasette's own
# httpx calls made in the tests by datasette.client:
return ["localhost"]
def test_insert_mocked(httpx_mock, tmpdir):
httpx_mock.add_response(
json={
"ok": True,
"database": "data",
"table": "table1",
"table_url": "http://datasette.example.com/data/table1",
"table_api_url": "http://datasette.example.com/data/table1.json",
"schema": "CREATE TABLE [table1] (...)",
"row_count": 100,
}
)
path = pathlib.Path(tmpdir) / "data.csv"
path.write_text("a,b,c\n1,2,3\n")
runner = CliRunner()
result = runner.invoke(
cli,
[
"insert",
"data",
"table1",
str(path),
"--csv",
"--token",
"x",
"-i",
"https://datasette.example.com",
],
catch_exceptions=False,
)
assert result.exit_code == 0
assert result.output == "Inserting rows\n"
request = httpx_mock.get_request()
assert request.headers["authorization"] == "Bearer x"
assert json.loads(request.read()) == {"rows": [{"a": 1, "b": 2, "c": 3}]}
SIMPLE_CSV = "a,b,c\n1,2,3\n"
SIMPLE_TSV = "a\tb\tc\n1\t2\t3\n"
SIMPLE_JSON = json.dumps(
[
{
"a": 1,
"b": 2,
"c": 3,
}
]
)
SIMPLE_JSON_NL = '{"a": 1, "b": 2, "c": 3}\n'
LATIN1_CSV = (
b"date,name,latitude,longitude\n"
b"2020-01-01,Barra da Lagoa,-27.574,-48.422\n"
b"2020-03-04,S\xe3o Paulo,-23.561,-46.645\n"
b"2020-04-05,Salta,-24.793:-65.408"
)
InsertTest = namedtuple(
"InsertTest",
(
"input_data",
"cmd_args",
"table_exists",
"expected_output",
"should_error",
"expected_table_json",
),
)
def make_format_test(content, arg):
return InsertTest(
input_data=content,
# Using --silent to force no display of progress bar, since it won't
# be shown for the JSON formats anyway
cmd_args=["--silent", "--create"] + ([arg] if arg is not None else []),
table_exists=False,
expected_output="",
should_error=False,
expected_table_json=[{"rowid": 1, "a": 1, "b": 2, "c": 3}],
)
@pytest.mark.parametrize(
"input_data,cmd_args,table_exists,expected_output,should_error,expected_table_json",
(
# Auto-detect formats
make_format_test(SIMPLE_CSV, None),
make_format_test(SIMPLE_TSV, None),
make_format_test(SIMPLE_JSON, None),
make_format_test(SIMPLE_JSON_NL, None),
# Explicit formats
make_format_test(SIMPLE_CSV, "--csv"),
make_format_test(SIMPLE_TSV, "--tsv"),
make_format_test(SIMPLE_JSON, "--json"),
make_format_test(SIMPLE_JSON_NL, "--nl"),
# No --create option should error:
InsertTest(
input_data=SIMPLE_CSV,
cmd_args=[],
table_exists=False,
expected_output="Inserting rows\nError: Table not found\n",
should_error=True,
expected_table_json=None,
),
# --no-detect-types
InsertTest(
input_data=SIMPLE_CSV,
cmd_args=["--no-detect-types", "--create"],
table_exists=False,
expected_output="Inserting rows\n",
should_error=False,
expected_table_json=[{"rowid": 1, "a": "1", "b": "2", "c": "3"}],
),
# --encoding - without it this should error:
InsertTest(
input_data=LATIN1_CSV,
cmd_args=["--no-detect-types", "--create", "--csv"],
table_exists=False,
expected_output="Inserting rows\n",
should_error=True,
expected_table_json=None,
),
# --encoding - with it this should work:
InsertTest(
input_data=LATIN1_CSV,
cmd_args=[
"--no-detect-types",
"--create",
"--encoding",
"latin-1",
"--csv",
],
table_exists=False,
expected_output="Inserting rows\n",
should_error=False,
expected_table_json=[
{
"rowid": 1,
"date": "2020-01-01",
"name": "Barra da Lagoa",
"latitude": "-27.574",
"longitude": "-48.422",
},
{
"rowid": 2,
"date": "2020-03-04",
"name": "São Paulo",
"latitude": "-23.561",
"longitude": "-46.645",
},
{
"rowid": 3,
"date": "2020-04-05",
"name": "Salta",
"latitude": "-24.793:-65.408",
"longitude": None,
},
],
),
# Existing table, conflicting pk
InsertTest(
input_data=SIMPLE_CSV,
cmd_args=[],
table_exists=True,
expected_output="Inserting rows\nUNIQUE constraint failed: table1.a\nError: UNIQUE constraint failed: table1.a\n",
should_error=True,
expected_table_json=[{"a": 1, "b": 2, "c": 3}, {"a": 4, "b": 5, "c": 6}],
),
# Existing table, --replace
InsertTest(
input_data="a,b,c\n1,2,4\n",
cmd_args=["--replace"],
table_exists=True,
expected_output="Inserting rows\n",
should_error=False,
expected_table_json=[{"a": 1, "b": 2, "c": 4}, {"a": 4, "b": 5, "c": 6}],
),
# Existing table, --ignore
InsertTest(
input_data="a,b,c\n1,2,4\n",
cmd_args=["--ignore"],
table_exists=True,
expected_output="Inserting rows\n",
should_error=False,
expected_table_json=[{"a": 1, "b": 2, "c": 3}, {"a": 4, "b": 5, "c": 6}],
),
# Existing table without alter should fail
InsertTest(
input_data="a,b,c,d\n1,2,4,5\n",
cmd_args=["--ignore"],
table_exists=True,
expected_output="Inserting rows\nError: Row 0 has invalid columns: d\n",
should_error=True,
expected_table_json=[{"a": 1, "b": 2, "c": 3}, {"a": 4, "b": 5, "c": 6}],
),
# Existing table with --alter should work
InsertTest(
input_data="a,b,c,d\n1,2,4,5\n",
cmd_args=["--replace", "--alter"],
table_exists=True,
expected_output="Inserting rows\n",
should_error=False,
expected_table_json=[
{"a": 1, "b": 2, "c": 4, "d": 5},
{"a": 4, "b": 5, "c": 6, "d": None},
],
),
),
)
@pytest.mark.asyncio
async def test_insert_against_datasette(
httpx_mock,
tmpdir,
input_data,
cmd_args,
table_exists,
expected_output,
should_error,
expected_table_json,
):
ds = Datasette(
config={
"permissions": {
"create-table": {"id": "*"},
"insert-row": {"id": "*"},
"update-row": {"id": "*"},
"alter-table": {"id": "*"},
}
}
)
db = ds.add_memory_database("data")
# Drop all tables in the database each time, because in-memory
# databases persist in between test runs
for table in await db.table_names():
await db.execute_write("drop table {}".format(table))
if table_exists:
await db.execute_write(
"create table table1 (a integer primary key, b integer, c integer)"
)
await db.execute_write(
"insert into table1 (a, b, c) values (1, 2, 3), (4, 5, 6)"
)
token = await ds.create_token("actor")
# These are useful with pytest --pdb to see what happened
datasette_requests = []
datasette_responses = []
def custom_response(request: httpx.Request):
# Need to run this in a new event loop, because dclient itself uses
# sync HTTPX and not async HTTPX, and the test's event loop is
# already running
async def run():
datasette_requests.append(request)
response = await ds.client.request(
request.method,
request.url.path,
json=json.loads(request.read()),
headers=request.headers,
)
# Create a fresh response to avoid an error where stream has been consumed
response = httpx.Response(
status_code=response.status_code,
headers=response.headers,
content=response.content,
)
datasette_responses.append(response)
return response
with ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(asyncio.run, run()).result()
httpx_mock.add_callback(custom_response)
path = pathlib.Path(tmpdir) / "data.txt"
if isinstance(input_data, str):
path.write_text(input_data)
else:
path.write_bytes(input_data)
runner = CliRunner()
result = runner.invoke(
cli,
[
"insert",
"data",
"table1",
str(path),
"--token",
token,
"-i",
"http://datasette.example.com",
]
+ cmd_args,
)
if not should_error:
assert result.exit_code == 0
else:
assert result.exit_code != 0
assert result.output == expected_output
if expected_table_json:
response = await ds.client.get("/data/table1.json?_shape=array")
assert response.json() == expected_table_json

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

@ -2,7 +2,6 @@ from click.testing import CliRunner
from dclient.cli import cli
import json
import pathlib
import pytest
def test_query_error(httpx_mock):
@ -16,18 +15,12 @@ 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
== "Error: 400 status code. Invalid SQL: Statement must be a SELECT\n"
)
assert result.output == "Error: Invalid SQL: Statement must be a SELECT\n"
@pytest.mark.parametrize("with_token", (False, True))
def test_query(httpx_mock, with_token):
def test_query(httpx_mock):
httpx_mock.add_response(
json={
"ok": True,
@ -44,23 +37,10 @@ def test_query(httpx_mock, with_token):
status_code=200,
)
runner = CliRunner()
args = ["query", "content", "hello", "-i", "https://example.com"]
if with_token:
args.extend(["--token", "xyz"])
result = runner.invoke(cli, args)
result = runner.invoke(cli, ["query", "https://example.com", "hello"])
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"
)
if with_token:
assert request.headers["authorization"] == "Bearer xyz"
else:
assert "authorization" not in request.headers
def test_aliases(mocker, tmpdir, httpx_mock):
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
@ -69,30 +49,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,15 +82,13 @@ 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 dict(url.params) == {"sql": "select 11 * 3", "_shape": "objects"}
assert url == "https://example.com/foo.json?sql=select+11+%2A+3&_shape=objects"
# Remove alias
result = runner.invoke(cli, ["alias", "remove", "invalid"])
@ -122,5 +98,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 +0,0 @@
"""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,
}
def _invoke(httpx_mock, extra_args=None, response=None):
httpx_mock.add_response(json=response or TABLE_RESPONSE, status_code=200)
runner = CliRunner()
args = ["rows", "fixtures", "dogs", "-i", "https://example.com"]
if extra_args:
args.extend(extra_args)
return runner.invoke(cli, args)
# -- basic usage --
def test_rows_default_json(httpx_mock):
result = _invoke(httpx_mock)
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert len(data) == 3
assert data[0]["name"] == "Cleo"
# Verify request URL
request = httpx_mock.get_request()
assert request.url.path == "/fixtures/dogs.json"
assert "_shape" in dict(request.url.params)
assert dict(request.url.params)["_shape"] == "objects"
def test_rows_table_format(httpx_mock):
result = _invoke(httpx_mock, ["-t"])
assert result.exit_code == 0
lines = result.output.strip().split("\n")
assert len(lines) == 5 # header + separator + 3 data rows
assert "Cleo" in lines[2]
assert "Pancakes" in lines[3]
def test_rows_csv(httpx_mock):
result = _invoke(httpx_mock, ["--csv"])
assert result.exit_code == 0
lines = result.output.strip().split("\n")
assert lines[0] == "id,name,age"
assert lines[1] == "1,Cleo,5"
def test_rows_nl(httpx_mock):
result = _invoke(httpx_mock, ["--nl"])
assert result.exit_code == 0
lines = result.output.strip().split("\n")
assert json.loads(lines[0]) == {"id": 1, "name": "Cleo", "age": 5}
# -- single argument uses default database --
def test_rows_single_arg_uses_default_database(httpx_mock, mocker, tmpdir):
"""dclient rows tablename uses default database."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
config_file = pathlib.Path(tmpdir) / "config.json"
config_file.write_text(
json.dumps(
{
"default_instance": "prod",
"instances": {
"prod": {
"url": "https://prod.example.com",
"default_database": "data",
}
},
}
)
)
httpx_mock.add_response(json=TABLE_RESPONSE, status_code=200)
runner = CliRunner()
result = runner.invoke(cli, ["rows", "dogs"])
assert result.exit_code == 0, result.output
request = httpx_mock.get_request()
assert request.url.host == "prod.example.com"
assert request.url.path == "/data/dogs.json"
def test_rows_single_arg_no_default_database_errors(mocker, tmpdir):
"""dclient rows tablename without default database gives error."""
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
config_file = pathlib.Path(tmpdir) / "config.json"
config_file.write_text(
json.dumps(
{
"default_instance": "prod",
"instances": {
"prod": {
"url": "https://prod.example.com",
"default_database": None,
}
},
}
)
)
runner = CliRunner()
result = runner.invoke(cli, ["rows", "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"])
assert result.exit_code == 0
request = httpx_mock.get_request()
params = dict(request.url.params)
assert params["name__exact"] == "Cleo"
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)
runner = CliRunner()
result = runner.invoke(
cli,
["rows", "fixtures", "dogs", "-i", "https://example.com", "--all"],
)
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data) == 3
assert [r["id"] for r in data] == [1, 2, 3]
def test_rows_all_with_limit(httpx_mock):
page1 = {
"ok": True,
"rows": [{"id": 1}, {"id": 2}],
"columns": ["id"],
"next": "2",
"next_url": "https://example.com/fixtures/dogs.json?_next=2&_shape=objects",
}
page2 = {
"ok": True,
"rows": [{"id": 3}, {"id": 4}],
"columns": ["id"],
"next": None,
}
httpx_mock.add_response(json=page1, status_code=200)
httpx_mock.add_response(json=page2, status_code=200)
runner = CliRunner()
result = runner.invoke(
cli,
[
"rows",
"fixtures",
"dogs",
"-i",
"https://example.com",
"--all",
"--limit",
"3",
],
)
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data) == 3
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):
httpx_mock.add_response(
json={"ok": False, "error": "Table not found: dogs"},
status_code=404,
)
runner = CliRunner()
result = runner.invoke(
cli,
["rows", "fixtures", "dogs", "-i", "https://example.com"],
)
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"

View file

@ -1,67 +0,0 @@
from dclient.utils import token_for_url, url_matches_prefix
import pytest
@pytest.mark.parametrize(
"url,prefix_url,expected",
(
("https://example.com/foo/bar", "https://example.com/foo", True),
("https://example.com/foo/bar2", "https://example.com/foo/bar", False),
("https://example.com/foo/bar/baz", "https://example.com/foo/bar", True),
("https://example.com/foo/bar/baz", "https://example.com/foo", True),
("https://example.com/foo.json", "https://example.com/foo", True),
# different scheme
(
"http://example.com/foo/bar",
"https://example.com/foo",
False,
),
# different netloc
(
"https://example.org/foo/bar",
"https://example.com/foo",
False,
),
# exactly the same
("https://example.com/foo", "https://example.com/foo", True),
# trailing '/'
(
"https://example.com/foo/bar",
"https://example.com/foo/bar/",
False,
),
(
"https://example.com/foo/bar/baz",
"https://example.com/foo/bar/",
True,
),
),
)
def test_url_matches_prefix(url, prefix_url, expected):
assert url_matches_prefix(url, prefix_url) == expected
@pytest.mark.parametrize(
"url,tokens,expected",
(
("https://foo.com/bar", {"https://foo.com": "foo"}, "foo"),
("https://foo.com/bar", {"https://foo.com/baz": "baz"}, None),
(
"https://foo.com/bar",
{"https://foo.com": "foo", "https://foo.com/bar": "bar"},
"bar",
),
(
"https://foo.com/bar/baz",
{
"https://foo.com": "foo",
"https://foo.com/bar/baz": "baz",
"https://foo.com/bar": "bar",
},
"baz",
),
),
)
def test_token_for_url(url, tokens, expected):
# Should always return longest matching of the available options
assert token_for_url(url, tokens) == expected