mirror of
https://github.com/simonw/dclient.git
synced 2026-07-23 09:24:31 +02:00
Compare commits
1 commit
main
...
claude/sum
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0efd08fa3 |
2 changed files with 384 additions and 0 deletions
112
dclient/cli.py
112
dclient/cli.py
|
|
@ -181,6 +181,118 @@ def query(url_or_alias, sql, token, verbose):
|
|||
click.echo(json.dumps(response.json()["rows"], indent=2))
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("url_or_alias")
|
||||
@click.argument("table")
|
||||
@click.option(
|
||||
"-f",
|
||||
"--filter",
|
||||
"filters",
|
||||
nargs=3,
|
||||
multiple=True,
|
||||
help="Filter: column operator value. Example: -f age gte 5",
|
||||
)
|
||||
@click.option(
|
||||
"--where",
|
||||
"where_clauses",
|
||||
multiple=True,
|
||||
help="SQL where clause. Can be specified multiple times.",
|
||||
)
|
||||
@click.option("--sort", "sort_column", default=None, help="Sort by column (ascending)")
|
||||
@click.option(
|
||||
"--sort-desc",
|
||||
"sort_desc_column",
|
||||
default=None,
|
||||
help="Sort by column (descending)",
|
||||
)
|
||||
@click.option("--token", help="API token")
|
||||
@click.option("-v", "--verbose", is_flag=True, help="Verbose output: show HTTP request")
|
||||
def rows(url_or_alias, table, filters, where_clauses, sort_column, sort_desc_column, token, verbose):
|
||||
"""
|
||||
Fetch rows from a Datasette table
|
||||
|
||||
Returns a JSON array of row objects
|
||||
|
||||
Example usage:
|
||||
|
||||
\b
|
||||
dclient rows content creatures
|
||||
dclient rows content creatures -f age gte 5
|
||||
dclient rows content creatures -f species in dog,cat --sort name
|
||||
dclient rows content creatures --where "id > 5" --sort-desc age
|
||||
"""
|
||||
if sort_column and sort_desc_column:
|
||||
raise click.ClickException("Cannot use both --sort and --sort-desc")
|
||||
|
||||
url = _resolve_url(url_or_alias)
|
||||
token = _resolve_token(token, url)
|
||||
|
||||
table_url = url.rstrip("/") + "/" + table + ".json"
|
||||
|
||||
headers = {}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
# Build params as list of tuples to support repeated keys (_where)
|
||||
params = [("_shape", "objects")]
|
||||
|
||||
for col, op, val in filters:
|
||||
if op == "eq":
|
||||
op = "exact"
|
||||
params.append((f"{col}__{op}", val))
|
||||
|
||||
for clause in where_clauses:
|
||||
params.append(("_where", clause))
|
||||
|
||||
if sort_column:
|
||||
params.append(("_sort", sort_column))
|
||||
|
||||
if sort_desc_column:
|
||||
params.append(("_sort_desc", sort_desc_column))
|
||||
|
||||
if verbose:
|
||||
click.echo(table_url + "?" + urllib.parse.urlencode(params), err=True)
|
||||
|
||||
response = httpx.get(
|
||||
table_url, params=params, headers=headers, follow_redirects=True, timeout=30.0
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
try:
|
||||
data = response.json()
|
||||
except json.JSONDecodeError:
|
||||
raise click.ClickException(
|
||||
"{} status code. Response was not valid JSON".format(
|
||||
response.status_code
|
||||
)
|
||||
)
|
||||
bits = []
|
||||
if data.get("title"):
|
||||
bits.append(data["title"])
|
||||
if data.get("error"):
|
||||
bits.append(data["error"])
|
||||
raise click.ClickException(
|
||||
"{} status code. {}".format(response.status_code, ": ".join(bits))
|
||||
)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except json.JSONDecodeError:
|
||||
raise click.ClickException("Response was not valid JSON")
|
||||
|
||||
if not data.get("ok"):
|
||||
bits = []
|
||||
if data.get("title"):
|
||||
bits.append(data["title"])
|
||||
if data.get("error"):
|
||||
bits.append(data["error"])
|
||||
if not bits:
|
||||
bits = [json.dumps(data)]
|
||||
raise click.ClickException(": ".join(bits))
|
||||
|
||||
click.echo(json.dumps(data["rows"], indent=2))
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("url_or_alias")
|
||||
@click.argument("table")
|
||||
|
|
|
|||
272
tests/test_rows.py
Normal file
272
tests/test_rows.py
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
from click.testing import CliRunner
|
||||
from dclient.cli import cli
|
||||
import json
|
||||
import pathlib
|
||||
import pytest
|
||||
|
||||
|
||||
def test_rows_basic(httpx_mock):
|
||||
httpx_mock.add_response(
|
||||
json={
|
||||
"ok": True,
|
||||
"rows": [{"id": 1, "name": "Cleo", "age": 4}],
|
||||
"next": None,
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["rows", "https://example.com/db", "creatures"])
|
||||
assert result.exit_code == 0
|
||||
assert json.loads(result.output) == [{"id": 1, "name": "Cleo", "age": 4}]
|
||||
request = httpx_mock.get_request()
|
||||
assert str(request.url) == "https://example.com/db/creatures.json?_shape=objects"
|
||||
|
||||
|
||||
def test_rows_single_filter(httpx_mock):
|
||||
httpx_mock.add_response(
|
||||
json={"ok": True, "rows": [{"id": 2, "name": "Luna", "age": 7}]},
|
||||
status_code=200,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["rows", "https://example.com/db", "creatures", "-f", "age", "gte", "5"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
request = httpx_mock.get_request()
|
||||
assert "age__gte=5" in str(request.url)
|
||||
assert "_shape=objects" in str(request.url)
|
||||
|
||||
|
||||
def test_rows_eq_maps_to_exact(httpx_mock):
|
||||
httpx_mock.add_response(
|
||||
json={"ok": True, "rows": [{"id": 1, "name": "Cleo"}]},
|
||||
status_code=200,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["rows", "https://example.com/db", "creatures", "-f", "name", "eq", "Cleo"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
request = httpx_mock.get_request()
|
||||
assert "name__exact=Cleo" in str(request.url)
|
||||
|
||||
|
||||
def test_rows_multiple_filters(httpx_mock):
|
||||
httpx_mock.add_response(
|
||||
json={"ok": True, "rows": []},
|
||||
status_code=200,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"rows",
|
||||
"https://example.com/db",
|
||||
"creatures",
|
||||
"-f",
|
||||
"species",
|
||||
"in",
|
||||
"dog,cat",
|
||||
"-f",
|
||||
"age",
|
||||
"gt",
|
||||
"3",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
request = httpx_mock.get_request()
|
||||
url_str = str(request.url)
|
||||
assert "species__in=" in url_str
|
||||
assert "age__gt=3" in url_str
|
||||
|
||||
|
||||
def test_rows_where_clause(httpx_mock):
|
||||
httpx_mock.add_response(
|
||||
json={"ok": True, "rows": [{"id": 6}]},
|
||||
status_code=200,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["rows", "https://example.com/db", "creatures", "--where", "id > 5"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
request = httpx_mock.get_request()
|
||||
assert "_where=" in str(request.url)
|
||||
|
||||
|
||||
def test_rows_multiple_where_clauses(httpx_mock):
|
||||
httpx_mock.add_response(
|
||||
json={"ok": True, "rows": []},
|
||||
status_code=200,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"rows",
|
||||
"https://example.com/db",
|
||||
"creatures",
|
||||
"--where",
|
||||
"id > 5",
|
||||
"--where",
|
||||
"age < 10",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
request = httpx_mock.get_request()
|
||||
url_str = str(request.url)
|
||||
assert url_str.count("_where=") == 2
|
||||
|
||||
|
||||
def test_rows_sort(httpx_mock):
|
||||
httpx_mock.add_response(
|
||||
json={"ok": True, "rows": [{"id": 1}]},
|
||||
status_code=200,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["rows", "https://example.com/db", "creatures", "--sort", "name"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
request = httpx_mock.get_request()
|
||||
assert "_sort=name" in str(request.url)
|
||||
|
||||
|
||||
def test_rows_sort_desc(httpx_mock):
|
||||
httpx_mock.add_response(
|
||||
json={"ok": True, "rows": [{"id": 1}]},
|
||||
status_code=200,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["rows", "https://example.com/db", "creatures", "--sort-desc", "age"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
request = httpx_mock.get_request()
|
||||
assert "_sort_desc=age" in str(request.url)
|
||||
|
||||
|
||||
def test_rows_sort_mutual_exclusion():
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"rows",
|
||||
"https://example.com/db",
|
||||
"creatures",
|
||||
"--sort",
|
||||
"name",
|
||||
"--sort-desc",
|
||||
"age",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "Cannot use both --sort and --sort-desc" in result.output
|
||||
|
||||
|
||||
@pytest.mark.parametrize("with_token", (False, True))
|
||||
def test_rows_with_token(httpx_mock, with_token):
|
||||
httpx_mock.add_response(
|
||||
json={"ok": True, "rows": [{"id": 1}]},
|
||||
status_code=200,
|
||||
)
|
||||
runner = CliRunner()
|
||||
args = ["rows", "https://example.com/db", "creatures"]
|
||||
if with_token:
|
||||
args.extend(["--token", "xyz"])
|
||||
result = runner.invoke(cli, args)
|
||||
assert result.exit_code == 0
|
||||
request = httpx_mock.get_request()
|
||||
if with_token:
|
||||
assert request.headers["authorization"] == "Bearer xyz"
|
||||
else:
|
||||
assert "authorization" not in request.headers
|
||||
|
||||
|
||||
def test_rows_http_error(httpx_mock):
|
||||
httpx_mock.add_response(
|
||||
json={"title": "Not Found", "error": "Table not found: creatures"},
|
||||
status_code=404,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["rows", "https://example.com/db", "creatures"])
|
||||
assert result.exit_code == 1
|
||||
assert "404 status code" in result.output
|
||||
assert "Table not found: creatures" in result.output
|
||||
|
||||
|
||||
def test_rows_ok_false_error(httpx_mock):
|
||||
httpx_mock.add_response(
|
||||
json={"ok": False, "error": "Something went wrong"},
|
||||
status_code=200,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["rows", "https://example.com/db", "creatures"])
|
||||
assert result.exit_code == 1
|
||||
assert "Something went wrong" in result.output
|
||||
|
||||
|
||||
def test_rows_with_alias(httpx_mock, mocker, tmpdir):
|
||||
mocker.patch("dclient.cli.get_config_dir", return_value=pathlib.Path(tmpdir))
|
||||
aliases_file = pathlib.Path(tmpdir) / "aliases.json"
|
||||
aliases_file.write_text(json.dumps({"content": "https://datasette.io/content"}))
|
||||
httpx_mock.add_response(
|
||||
json={"ok": True, "rows": [{"id": 1}]},
|
||||
status_code=200,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["rows", "content", "creatures"])
|
||||
assert result.exit_code == 0
|
||||
request = httpx_mock.get_request()
|
||||
assert request.url.host == "datasette.io"
|
||||
assert request.url.path == "/content/creatures.json"
|
||||
|
||||
|
||||
def test_rows_verbose(httpx_mock):
|
||||
httpx_mock.add_response(
|
||||
json={"ok": True, "rows": []},
|
||||
status_code=200,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["rows", "https://example.com/db", "creatures", "-v"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "creatures.json" in result.output
|
||||
|
||||
|
||||
def test_rows_combined(httpx_mock):
|
||||
"""Test filters, where, and sort all together."""
|
||||
httpx_mock.add_response(
|
||||
json={"ok": True, "rows": [{"id": 1, "name": "Cleo", "age": 4}]},
|
||||
status_code=200,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"rows",
|
||||
"https://example.com/db",
|
||||
"creatures",
|
||||
"-f",
|
||||
"species",
|
||||
"eq",
|
||||
"dog",
|
||||
"--where",
|
||||
"age > 2",
|
||||
"--sort-desc",
|
||||
"age",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
request = httpx_mock.get_request()
|
||||
url_str = str(request.url)
|
||||
assert "species__exact=dog" in url_str
|
||||
assert "_where=" in url_str
|
||||
assert "_sort_desc=age" in url_str
|
||||
Loading…
Add table
Add a link
Reference in a new issue