From 6bc35f46c22cbe32e394be42dcb9d8d118e11ff7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 21 Nov 2022 18:53:12 -0800 Subject: [PATCH] dclient aliases command, closes #2 --- README.md | 73 +++++++++++++++++++++++++++++++++++++++------ dclient/cli.py | 45 ++++++++++++++++++++++++++++ setup.py | 4 +-- tests/test_query.py | 45 ++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c2958f1..565de0b 100644 --- a/README.md +++ b/README.md @@ -13,18 +13,33 @@ Install this tool using `pip`: pip install dclient -## Usage +## Running queries -For help, run: +You can run SQL queries against a Datasette instance like so: - dclient --help - -You can also use: - - python -m dclient --help - -### dclient query +``` +$ dclient query https://latest.datasette.io/fixtures "select * from facetable limit 1" +``` +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" + } +] +``` +### dclient query --help +## 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 + + +``` +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 + +``` + + ## Development To contribute to this tool, first checkout the code. Then create a new virtual environment: diff --git a/dclient/cli.py b/dclient/cli.py index 9e436bc..062af17 100644 --- a/dclient/cli.py +++ b/dclient/cli.py @@ -1,6 +1,12 @@ import click import httpx import json +import appdirs +import pathlib + + +def get_config_dir(): + return pathlib.Path(appdirs.user_config_dir("dclient")) @click.group() @@ -18,6 +24,10 @@ def query(url, sql): Returns a JSON array of objects """ + aliases_file = get_config_dir() / "aliases.json" + aliases = _load_aliases(aliases_file) + if url in aliases: + url = aliases[url] if not url.endswith(".json"): url += ".json" response = httpx.get(url, params={"sql": sql, "_shape": "objects"}) @@ -31,3 +41,38 @@ def query(url, sql): raise click.ClickException(": ".join(bits)) else: click.echo(json.dumps(response.json()["rows"], indent=2)) + + +@cli.group() +def alias(): + "Manage aliases for different instances" + + +@alias.command(name="list") +def list_(): + "List aliases" + aliases_file = get_config_dir() / "aliases.json" + aliases = _load_aliases(aliases_file) + for alias, url in aliases.items(): + click.echo(f"{alias} = {url}") + + +@alias.command() +@click.argument("name") +@click.argument("url") +def add(name, url): + "Add an alias" + config_dir = get_config_dir() + config_dir.mkdir(parents=True, exist_ok=True) + aliases_file = config_dir / "aliases.json" + aliases = _load_aliases(aliases_file) + aliases[name] = url + aliases_file.write_text(json.dumps(aliases, indent=4)) + + +def _load_aliases(aliases_file): + if aliases_file.exists(): + aliases = json.loads(aliases_file.read_text()) + else: + aliases = {} + return aliases diff --git a/setup.py b/setup.py index a083ca4..075b5c9 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ setup( [console_scripts] dclient=dclient.cli:cli """, - install_requires=["click", "httpx"], - extras_require={"test": ["pytest", "pytest-httpx", "cogapp"]}, + install_requires=["click", "httpx", "appdirs"], + extras_require={"test": ["pytest", "pytest-httpx", "cogapp", "pytest-mock"]}, python_requires=">=3.7", ) diff --git a/tests/test_query.py b/tests/test_query.py index d89bccb..2621845 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -1,6 +1,7 @@ from click.testing import CliRunner from dclient.cli import cli import json +import pathlib def test_query_error(httpx_mock): @@ -39,3 +40,47 @@ def test_query(httpx_mock): result = runner.invoke(cli, ["query", "https://example.com", "hello"]) assert result.exit_code == 0 assert json.loads(result.output) == [{"5 * 2": 10}] + + +def test_aliases(mocker, tmpdir, httpx_mock): + mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir)) + runner = CliRunner() + result = runner.invoke(cli, ["alias", "list"]) + assert result.exit_code == 0 + assert result.output == "" + + 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 result.output == "foo = https://example.com/foo\n" + + # 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": "foo", + "query_name": None, + "rows": [{"11 * 3": 33}], + "truncated": False, + "columns": ["11 * 3"], + "query": {"sql": "select 11 * 3", "params": {}}, + "error": None, + "private": False, + "allow_execute_sql": True, + }, + status_code=200, + ) + 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/foo.json + url = httpx_mock.get_request().url + assert url == "https://example.com/foo.json?sql=select+11+%2A+3&_shape=objects"