mirror of
https://github.com/simonw/dclient.git
synced 2026-07-23 17:34:32 +02:00
Initial oauth flow for use with datasette-oauth
This commit is contained in:
parent
7be5d4a53f
commit
4df076d1f0
7 changed files with 503 additions and 102 deletions
372
dclient/cli.py
372
dclient/cli.py
|
|
@ -153,7 +153,9 @@ def get(path, instance, token):
|
|||
"""
|
||||
config_dir = get_config_dir()
|
||||
url = _resolve_instance(instance, config_dir / "config.json")
|
||||
token = _resolve_token(token, url, config_dir / "auth.json", config_dir / "config.json")
|
||||
token = _resolve_token(
|
||||
token, url, config_dir / "auth.json", config_dir / "config.json"
|
||||
)
|
||||
full_url = url.rstrip("/") + "/" + path.lstrip("/")
|
||||
response = _make_request(url, token, "/" + path.lstrip("/"))
|
||||
if response.status_code != 200:
|
||||
|
|
@ -180,7 +182,9 @@ def databases(instance, _json, token):
|
|||
"""
|
||||
config_dir = get_config_dir()
|
||||
url = _resolve_instance(instance, config_dir / "config.json")
|
||||
token = _resolve_token(token, url, config_dir / "auth.json", config_dir / "config.json")
|
||||
token = _resolve_token(
|
||||
token, url, config_dir / "auth.json", config_dir / "config.json"
|
||||
)
|
||||
response = _make_request(url, token, "/.json")
|
||||
if response.status_code != 200:
|
||||
raise click.ClickException(f"{response.status_code} error")
|
||||
|
|
@ -219,10 +223,21 @@ def tables(instance, database, views, views_only, hidden, _json, token):
|
|||
"""
|
||||
config_dir = get_config_dir()
|
||||
url = _resolve_instance(instance, config_dir / "config.json")
|
||||
instance_alias = _instance_alias_for_url(url, config_dir / "config.json") if not (instance and (instance.startswith("http://") or instance.startswith("https://"))) else None
|
||||
if instance and not (instance.startswith("http://") or instance.startswith("https://")):
|
||||
instance_alias = (
|
||||
_instance_alias_for_url(url, config_dir / "config.json")
|
||||
if not (
|
||||
instance
|
||||
and (instance.startswith("http://") or instance.startswith("https://"))
|
||||
)
|
||||
else None
|
||||
)
|
||||
if instance and not (
|
||||
instance.startswith("http://") or instance.startswith("https://")
|
||||
):
|
||||
instance_alias = instance
|
||||
token = _resolve_token(token, url, config_dir / "auth.json", config_dir / "config.json")
|
||||
token = _resolve_token(
|
||||
token, url, config_dir / "auth.json", config_dir / "config.json"
|
||||
)
|
||||
db = _resolve_database(database, instance_alias, config_dir / "config.json")
|
||||
response = _make_request(url, token, f"/{db}.json")
|
||||
if response.status_code != 200:
|
||||
|
|
@ -277,7 +292,9 @@ def query(database, sql, instance, token, verbose):
|
|||
"""
|
||||
config_dir = get_config_dir()
|
||||
url = _resolve_instance(instance, config_dir / "config.json")
|
||||
token = _resolve_token(token, url, config_dir / "auth.json", config_dir / "config.json")
|
||||
token = _resolve_token(
|
||||
token, url, config_dir / "auth.json", config_dir / "config.json"
|
||||
)
|
||||
query_url = url.rstrip("/") + "/" + database + ".json"
|
||||
headers = {}
|
||||
if token:
|
||||
|
|
@ -285,7 +302,9 @@ def query(database, sql, instance, token, verbose):
|
|||
params = {"sql": sql, "_shape": "objects"}
|
||||
if verbose:
|
||||
click.echo(query_url + "?" + urllib.parse.urlencode(params), err=True)
|
||||
response = httpx.get(query_url, params=params, headers=headers, follow_redirects=True)
|
||||
response = httpx.get(
|
||||
query_url, params=params, headers=headers, follow_redirects=True
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
try:
|
||||
|
|
@ -322,14 +341,35 @@ def query(database, sql, instance, token, verbose):
|
|||
click.echo(json.dumps(response.json()["rows"], indent=2))
|
||||
|
||||
|
||||
def _do_insert(database, table, filepath, format_csv, format_tsv, format_json,
|
||||
format_nl, encoding, no_detect_types, replace, ignore, create,
|
||||
alter, pks, batch_size, interval, token, silent, verbose,
|
||||
instance, endpoint="insert"):
|
||||
def _do_insert(
|
||||
database,
|
||||
table,
|
||||
filepath,
|
||||
format_csv,
|
||||
format_tsv,
|
||||
format_json,
|
||||
format_nl,
|
||||
encoding,
|
||||
no_detect_types,
|
||||
replace,
|
||||
ignore,
|
||||
create,
|
||||
alter,
|
||||
pks,
|
||||
batch_size,
|
||||
interval,
|
||||
token,
|
||||
silent,
|
||||
verbose,
|
||||
instance,
|
||||
endpoint="insert",
|
||||
):
|
||||
"""Shared implementation for insert and upsert commands."""
|
||||
config_dir = get_config_dir()
|
||||
url = _resolve_instance(instance, config_dir / "config.json")
|
||||
token = _resolve_token(token, url, config_dir / "auth.json", config_dir / "config.json")
|
||||
token = _resolve_token(
|
||||
token, url, config_dir / "auth.json", config_dir / "config.json"
|
||||
)
|
||||
|
||||
format = None
|
||||
if format_csv:
|
||||
|
|
@ -420,20 +460,29 @@ _insert_options = [
|
|||
click.argument("database"),
|
||||
click.argument("table"),
|
||||
click.argument(
|
||||
"filepath", type=click.Path("rb", readable=True, allow_dash=True, dir_okay=False)
|
||||
"filepath",
|
||||
type=click.Path("rb", readable=True, allow_dash=True, dir_okay=False),
|
||||
),
|
||||
click.option(
|
||||
"-i", "--instance", default=None, help="Datasette instance URL or alias"
|
||||
),
|
||||
click.option("-i", "--instance", default=None, help="Datasette instance URL or alias"),
|
||||
click.option("format_csv", "--csv", is_flag=True, help="Input is CSV"),
|
||||
click.option("format_tsv", "--tsv", is_flag=True, help="Input is TSV"),
|
||||
click.option("format_json", "--json", is_flag=True, help="Input is JSON"),
|
||||
click.option("format_nl", "--nl", is_flag=True, help="Input is newline-delimited JSON"),
|
||||
click.option(
|
||||
"format_nl", "--nl", is_flag=True, help="Input is newline-delimited JSON"
|
||||
),
|
||||
click.option("--encoding", help="Character encoding for CSV/TSV"),
|
||||
click.option(
|
||||
"--no-detect-types", is_flag=True, help="Don't detect column types for CSV/TSV"
|
||||
),
|
||||
click.option("--alter", is_flag=True, help="Alter table to add any missing columns"),
|
||||
click.option(
|
||||
"pks", "--pk", multiple=True,
|
||||
"--alter", is_flag=True, help="Alter table to add any missing columns"
|
||||
),
|
||||
click.option(
|
||||
"pks",
|
||||
"--pk",
|
||||
multiple=True,
|
||||
help="Columns to use as the primary key when creating the table",
|
||||
),
|
||||
click.option(
|
||||
|
|
@ -445,7 +494,9 @@ _insert_options = [
|
|||
click.option("--token", "-t", help="API token"),
|
||||
click.option("--silent", is_flag=True, help="Don't output progress"),
|
||||
click.option(
|
||||
"-v", "--verbose", is_flag=True,
|
||||
"-v",
|
||||
"--verbose",
|
||||
is_flag=True,
|
||||
help="Verbose output: show HTTP request and response",
|
||||
),
|
||||
]
|
||||
|
|
@ -456,17 +507,39 @@ def _apply_options(options):
|
|||
for option in reversed(options):
|
||||
func = option(func)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@cli.command()
|
||||
@_apply_options(_insert_options)
|
||||
@click.option("--replace", is_flag=True, help="Replace rows with a matching primary key")
|
||||
@click.option(
|
||||
"--replace", is_flag=True, help="Replace rows with a matching primary key"
|
||||
)
|
||||
@click.option("--ignore", is_flag=True, help="Ignore rows with a matching primary key")
|
||||
@click.option("--create", is_flag=True, help="Create table if it does not exist")
|
||||
def insert(database, table, filepath, instance, format_csv, format_tsv, format_json,
|
||||
format_nl, encoding, no_detect_types, alter, pks, batch_size, interval,
|
||||
token, silent, verbose, replace, ignore, create):
|
||||
def insert(
|
||||
database,
|
||||
table,
|
||||
filepath,
|
||||
instance,
|
||||
format_csv,
|
||||
format_tsv,
|
||||
format_json,
|
||||
format_nl,
|
||||
encoding,
|
||||
no_detect_types,
|
||||
alter,
|
||||
pks,
|
||||
batch_size,
|
||||
interval,
|
||||
token,
|
||||
silent,
|
||||
verbose,
|
||||
replace,
|
||||
ignore,
|
||||
create,
|
||||
):
|
||||
"""
|
||||
Insert data into a remote Datasette instance
|
||||
|
||||
|
|
@ -476,17 +549,52 @@ def insert(database, table, filepath, instance, format_csv, format_tsv, format_j
|
|||
dclient insert main mytable data.csv --csv -i myapp
|
||||
dclient insert main mytable data.csv --csv --create --pk id
|
||||
"""
|
||||
_do_insert(database, table, filepath, format_csv, format_tsv, format_json,
|
||||
format_nl, encoding, no_detect_types, replace, ignore, create,
|
||||
alter, pks, batch_size, interval, token, silent, verbose,
|
||||
instance, endpoint="insert")
|
||||
_do_insert(
|
||||
database,
|
||||
table,
|
||||
filepath,
|
||||
format_csv,
|
||||
format_tsv,
|
||||
format_json,
|
||||
format_nl,
|
||||
encoding,
|
||||
no_detect_types,
|
||||
replace,
|
||||
ignore,
|
||||
create,
|
||||
alter,
|
||||
pks,
|
||||
batch_size,
|
||||
interval,
|
||||
token,
|
||||
silent,
|
||||
verbose,
|
||||
instance,
|
||||
endpoint="insert",
|
||||
)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@_apply_options(_insert_options)
|
||||
def upsert(database, table, filepath, instance, format_csv, format_tsv, format_json,
|
||||
format_nl, encoding, no_detect_types, alter, pks, batch_size, interval,
|
||||
token, silent, verbose):
|
||||
def upsert(
|
||||
database,
|
||||
table,
|
||||
filepath,
|
||||
instance,
|
||||
format_csv,
|
||||
format_tsv,
|
||||
format_json,
|
||||
format_nl,
|
||||
encoding,
|
||||
no_detect_types,
|
||||
alter,
|
||||
pks,
|
||||
batch_size,
|
||||
interval,
|
||||
token,
|
||||
silent,
|
||||
verbose,
|
||||
):
|
||||
"""
|
||||
Upsert data into a remote Datasette instance
|
||||
|
||||
|
|
@ -495,10 +603,29 @@ def upsert(database, table, filepath, instance, format_csv, format_tsv, format_j
|
|||
\b
|
||||
dclient upsert main mytable data.csv --csv -i myapp
|
||||
"""
|
||||
_do_insert(database, table, filepath, format_csv, format_tsv, format_json,
|
||||
format_nl, encoding, no_detect_types, False, False, False,
|
||||
alter, pks, batch_size, interval, token, silent, verbose,
|
||||
instance, endpoint="upsert")
|
||||
_do_insert(
|
||||
database,
|
||||
table,
|
||||
filepath,
|
||||
format_csv,
|
||||
format_tsv,
|
||||
format_json,
|
||||
format_nl,
|
||||
encoding,
|
||||
no_detect_types,
|
||||
False,
|
||||
False,
|
||||
False,
|
||||
alter,
|
||||
pks,
|
||||
batch_size,
|
||||
interval,
|
||||
token,
|
||||
silent,
|
||||
verbose,
|
||||
instance,
|
||||
endpoint="upsert",
|
||||
)
|
||||
|
||||
|
||||
@cli.command()
|
||||
|
|
@ -520,10 +647,21 @@ def schema(table_name, instance, database, _json, token):
|
|||
"""
|
||||
config_dir = get_config_dir()
|
||||
url = _resolve_instance(instance, config_dir / "config.json")
|
||||
instance_alias = _instance_alias_for_url(url, config_dir / "config.json") if not (instance and (instance.startswith("http://") or instance.startswith("https://"))) else None
|
||||
if instance and not (instance.startswith("http://") or instance.startswith("https://")):
|
||||
instance_alias = (
|
||||
_instance_alias_for_url(url, config_dir / "config.json")
|
||||
if not (
|
||||
instance
|
||||
and (instance.startswith("http://") or instance.startswith("https://"))
|
||||
)
|
||||
else None
|
||||
)
|
||||
if instance and not (
|
||||
instance.startswith("http://") or instance.startswith("https://")
|
||||
):
|
||||
instance_alias = instance
|
||||
token = _resolve_token(token, url, config_dir / "auth.json", config_dir / "config.json")
|
||||
token = _resolve_token(
|
||||
token, url, config_dir / "auth.json", config_dir / "config.json"
|
||||
)
|
||||
db = _resolve_database(database, instance_alias, config_dir / "config.json")
|
||||
if table_name:
|
||||
response = _make_request(url, token, f"/{db}/{table_name}/-/schema.json")
|
||||
|
|
@ -554,7 +692,9 @@ def plugins(instance, _json, token):
|
|||
"""
|
||||
config_dir = get_config_dir()
|
||||
url = _resolve_instance(instance, config_dir / "config.json")
|
||||
token = _resolve_token(token, url, config_dir / "auth.json", config_dir / "config.json")
|
||||
token = _resolve_token(
|
||||
token, url, config_dir / "auth.json", config_dir / "config.json"
|
||||
)
|
||||
response = _make_request(url, token, "/-/plugins.json")
|
||||
if response.status_code != 200:
|
||||
raise click.ClickException(f"{response.status_code} error")
|
||||
|
|
@ -582,7 +722,9 @@ def actor(instance, token):
|
|||
"""
|
||||
config_dir = get_config_dir()
|
||||
url = _resolve_instance(instance, config_dir / "config.json")
|
||||
token = _resolve_token(token, url, config_dir / "auth.json", config_dir / "config.json")
|
||||
token = _resolve_token(
|
||||
token, url, config_dir / "auth.json", config_dir / "config.json"
|
||||
)
|
||||
response = _make_request(url, token, "/-/actor.json")
|
||||
response.raise_for_status()
|
||||
click.echo(json.dumps(response.json(), indent=4))
|
||||
|
|
@ -598,10 +740,21 @@ def default_query(sql, instance, database, token, verbose):
|
|||
"""Run a SQL query using default instance and database."""
|
||||
config_dir = get_config_dir()
|
||||
url = _resolve_instance(instance, config_dir / "config.json")
|
||||
instance_alias = _instance_alias_for_url(url, config_dir / "config.json") if not (instance and (instance.startswith("http://") or instance.startswith("https://"))) else None
|
||||
if instance and not (instance.startswith("http://") or instance.startswith("https://")):
|
||||
instance_alias = (
|
||||
_instance_alias_for_url(url, config_dir / "config.json")
|
||||
if not (
|
||||
instance
|
||||
and (instance.startswith("http://") or instance.startswith("https://"))
|
||||
)
|
||||
else None
|
||||
)
|
||||
if instance and not (
|
||||
instance.startswith("http://") or instance.startswith("https://")
|
||||
):
|
||||
instance_alias = instance
|
||||
token = _resolve_token(token, url, config_dir / "auth.json", config_dir / "config.json")
|
||||
token = _resolve_token(
|
||||
token, url, config_dir / "auth.json", config_dir / "config.json"
|
||||
)
|
||||
db = _resolve_database(database, instance_alias, config_dir / "config.json")
|
||||
query_url = url.rstrip("/") + "/" + db + ".json"
|
||||
headers = {}
|
||||
|
|
@ -610,7 +763,9 @@ def default_query(sql, instance, database, token, verbose):
|
|||
params = {"sql": sql, "_shape": "objects"}
|
||||
if verbose:
|
||||
click.echo(query_url + "?" + urllib.parse.urlencode(params), err=True)
|
||||
response = httpx.get(query_url, params=params, headers=headers, follow_redirects=True)
|
||||
response = httpx.get(
|
||||
query_url, params=params, headers=headers, follow_redirects=True
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
try:
|
||||
|
|
@ -668,7 +823,11 @@ def instances(_json):
|
|||
else:
|
||||
for name, inst in inst_map.items():
|
||||
marker = "* " if name == default else " "
|
||||
db_info = f" (db: {inst['default_database']})" if inst.get("default_database") else ""
|
||||
db_info = (
|
||||
f" (db: {inst['default_database']})"
|
||||
if inst.get("default_database")
|
||||
else ""
|
||||
)
|
||||
click.echo(f"{marker}{name} = {inst['url']}{db_info}")
|
||||
|
||||
|
||||
|
|
@ -693,7 +852,11 @@ def alias_list(_json):
|
|||
else:
|
||||
for name, inst in instances.items():
|
||||
marker = "* " if name == default else " "
|
||||
db_info = f" (db: {inst['default_database']})" if inst.get("default_database") else ""
|
||||
db_info = (
|
||||
f" (db: {inst['default_database']})"
|
||||
if inst.get("default_database")
|
||||
else ""
|
||||
)
|
||||
click.echo(f"{marker}{name} = {inst['url']}{db_info}")
|
||||
|
||||
|
||||
|
|
@ -891,12 +1054,116 @@ def auth_status(instance, token):
|
|||
"""
|
||||
config_dir = get_config_dir()
|
||||
url = _resolve_instance(instance, config_dir / "config.json")
|
||||
token = _resolve_token(token, url, config_dir / "auth.json", config_dir / "config.json")
|
||||
token = _resolve_token(
|
||||
token, url, config_dir / "auth.json", config_dir / "config.json"
|
||||
)
|
||||
response = _make_request(url, token, "/-/actor.json")
|
||||
response.raise_for_status()
|
||||
click.echo(json.dumps(response.json(), indent=4))
|
||||
|
||||
|
||||
# -- login command (OAuth device flow) --
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("alias_or_url", required=False, default=None)
|
||||
@click.option("--scope", default=None, help="JSON scope array")
|
||||
def login(alias_or_url, scope):
|
||||
"""
|
||||
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:
|
||||
|
||||
\b
|
||||
dclient login https://simon.datasette.cloud/
|
||||
dclient login myalias
|
||||
dclient login
|
||||
"""
|
||||
config_dir = get_config_dir()
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_file = config_dir / "config.json"
|
||||
|
||||
if alias_or_url is None:
|
||||
click.echo("Enter the URL of your Datasette instance, or an alias you have")
|
||||
click.echo("already configured with 'dclient alias add'.\n")
|
||||
alias_or_url = click.prompt("Instance URL or alias")
|
||||
|
||||
# Resolve alias to URL if needed
|
||||
if alias_or_url.startswith("http://") or alias_or_url.startswith("https://"):
|
||||
url = alias_or_url
|
||||
auth_key = alias_or_url
|
||||
else:
|
||||
url = _resolve_instance(alias_or_url, config_file)
|
||||
auth_key = alias_or_url
|
||||
|
||||
# Ensure trailing slash
|
||||
if not url.endswith("/"):
|
||||
url += "/"
|
||||
|
||||
# Step 1: Request device code
|
||||
device_url = url + "-/oauth/device"
|
||||
data = {}
|
||||
if scope:
|
||||
data["scope"] = scope
|
||||
response = httpx.post(device_url, data=data, timeout=30.0)
|
||||
if response.status_code != 200:
|
||||
raise click.ClickException(
|
||||
f"Failed to start login flow: {response.status_code} from {device_url}"
|
||||
)
|
||||
device_data = response.json()
|
||||
device_code = device_data["device_code"]
|
||||
user_code = device_data["user_code"]
|
||||
verification_uri = device_data["verification_uri"]
|
||||
interval = device_data.get("interval", 5)
|
||||
|
||||
# Step 2: Show instructions
|
||||
click.echo(f"\nOpen this URL in your browser:\n")
|
||||
click.echo(f" {verification_uri}\n")
|
||||
click.echo(f"Enter this code: {user_code}\n")
|
||||
click.echo("Waiting for authorization...", nl=False)
|
||||
|
||||
# Step 3: Poll for token
|
||||
token_url = url + "-/oauth/token"
|
||||
while True:
|
||||
time.sleep(interval)
|
||||
click.echo(".", nl=False)
|
||||
token_response = httpx.post(
|
||||
token_url,
|
||||
data={
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||
"device_code": device_code,
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
token_data = token_response.json()
|
||||
if "access_token" in token_data:
|
||||
break
|
||||
error = token_data.get("error")
|
||||
if error == "authorization_pending":
|
||||
continue
|
||||
elif error == "access_denied":
|
||||
click.echo()
|
||||
raise click.ClickException("Authorization denied.")
|
||||
elif error == "expired_token":
|
||||
click.echo()
|
||||
raise click.ClickException("Device code expired. Run login again.")
|
||||
else:
|
||||
click.echo()
|
||||
raise click.ClickException(f"Unexpected error: {error}")
|
||||
|
||||
# Step 4: Save token
|
||||
click.echo()
|
||||
access_token = token_data["access_token"]
|
||||
auth_file = config_dir / "auth.json"
|
||||
auths = _load_auths(auth_file)
|
||||
auths[auth_key] = access_token
|
||||
auth_file.write_text(json.dumps(auths, indent=4))
|
||||
click.echo(f"Login successful. Token saved for {auth_key}")
|
||||
|
||||
|
||||
# -- v1 → v2 migration --
|
||||
|
||||
|
||||
|
|
@ -947,7 +1214,6 @@ def _migrate_v1_to_v2(config_dir):
|
|||
aliases_file.rename(config_dir / "aliases.json.bak")
|
||||
|
||||
|
||||
|
||||
def _batches(iterable, size, interval=None):
|
||||
iterable = iter(iterable)
|
||||
last_yield_time = time.time()
|
||||
|
|
@ -967,8 +1233,18 @@ def _batches(iterable, size, interval=None):
|
|||
|
||||
|
||||
def _insert_batch(
|
||||
*, url, table, batch, token, create, alter, pks, replace, ignore, verbose,
|
||||
endpoint="insert"
|
||||
*,
|
||||
url,
|
||||
table,
|
||||
batch,
|
||||
token,
|
||||
create,
|
||||
alter,
|
||||
pks,
|
||||
replace,
|
||||
ignore,
|
||||
verbose,
|
||||
endpoint="insert",
|
||||
):
|
||||
if create:
|
||||
data = {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from click.testing import CliRunner
|
|||
from dclient.cli import cli
|
||||
import pathlib
|
||||
import json
|
||||
import pytest
|
||||
|
||||
|
||||
def test_auth(mocker, tmpdir):
|
||||
|
|
@ -39,3 +40,139 @@ def test_auth(mocker, tmpdir):
|
|||
# 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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import json
|
|||
import pathlib
|
||||
import pytest
|
||||
|
||||
|
||||
# -- databases command --
|
||||
|
||||
|
||||
|
|
@ -22,9 +21,7 @@ def test_databases_json(httpx_mock, mocker, tmpdir):
|
|||
status_code=200,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli, ["databases", "-i", "https://example.com", "--json"]
|
||||
)
|
||||
result = runner.invoke(cli, ["databases", "-i", "https://example.com", "--json"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert len(data) == 2
|
||||
|
|
@ -175,9 +172,7 @@ def test_tables_hidden(httpx_mock, mocker, tmpdir):
|
|||
)
|
||||
runner = CliRunner()
|
||||
# Without --hidden
|
||||
result = runner.invoke(
|
||||
cli, ["tables", "-i", "https://example.com", "-d", "db"]
|
||||
)
|
||||
result = runner.invoke(cli, ["tables", "-i", "https://example.com", "-d", "db"])
|
||||
assert "visible" in result.output
|
||||
assert "hidden_t" not in result.output
|
||||
|
||||
|
|
@ -213,9 +208,7 @@ def test_plugins_json(httpx_mock, mocker, tmpdir):
|
|||
status_code=200,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli, ["plugins", "-i", "https://example.com", "--json"]
|
||||
)
|
||||
result = runner.invoke(cli, ["plugins", "-i", "https://example.com", "--json"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert len(data) == 2
|
||||
|
|
@ -262,9 +255,7 @@ def test_schema_all_tables(httpx_mock, mocker, tmpdir):
|
|||
status_code=200,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli, ["schema", "-i", "https://example.com", "-d", "main"]
|
||||
)
|
||||
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
|
||||
|
|
@ -372,9 +363,7 @@ def test_default_query_with_database_override(httpx_mock, mocker, tmpdir):
|
|||
status_code=200,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli, ["select count(*) from events", "-d", "analytics"]
|
||||
)
|
||||
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"
|
||||
|
|
@ -410,9 +399,7 @@ def test_default_query_with_instance_override(httpx_mock, mocker, tmpdir):
|
|||
status_code=200,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli, ["select count(*) from users", "-i", "staging"]
|
||||
)
|
||||
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"
|
||||
|
|
@ -528,9 +515,7 @@ def test_get_command(httpx_mock, mocker, tmpdir):
|
|||
status_code=200,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli, ["get", "/-/plugins.json", "-i", "https://example.com"]
|
||||
)
|
||||
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"}
|
||||
|
|
@ -549,8 +534,14 @@ def test_instances_plain(mocker, tmpdir):
|
|||
{
|
||||
"default_instance": "prod",
|
||||
"instances": {
|
||||
"prod": {"url": "https://prod.example.com", "default_database": "main"},
|
||||
"staging": {"url": "https://staging.example.com", "default_database": None},
|
||||
"prod": {
|
||||
"url": "https://prod.example.com",
|
||||
"default_database": "main",
|
||||
},
|
||||
"staging": {
|
||||
"url": "https://staging.example.com",
|
||||
"default_database": None,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -570,7 +561,10 @@ def test_instances_json(mocker, tmpdir):
|
|||
{
|
||||
"default_instance": "prod",
|
||||
"instances": {
|
||||
"prod": {"url": "https://prod.example.com", "default_database": "main"},
|
||||
"prod": {
|
||||
"url": "https://prod.example.com",
|
||||
"default_database": "main",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import json
|
|||
import pathlib
|
||||
import pytest
|
||||
|
||||
|
||||
# -- Config loading/saving --
|
||||
|
||||
|
||||
|
|
@ -196,7 +195,9 @@ 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)
|
||||
token = _resolve_token(
|
||||
"explicit-token", "https://example.com", auth_file, config_file
|
||||
)
|
||||
assert token == "explicit-token"
|
||||
|
||||
|
||||
|
|
@ -218,7 +219,9 @@ def test_resolve_token_from_auth_by_alias(tmpdir):
|
|||
}
|
||||
)
|
||||
)
|
||||
token = _resolve_token(None, "https://myapp.datasette.cloud", auth_file, config_file)
|
||||
token = _resolve_token(
|
||||
None, "https://myapp.datasette.cloud", auth_file, config_file
|
||||
)
|
||||
assert token == "tok123"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ from dclient.cli import cli
|
|||
import json
|
||||
import pathlib
|
||||
|
||||
|
||||
QUERY_RESPONSE = {
|
||||
"ok": True,
|
||||
"database": "data",
|
||||
|
|
@ -42,9 +41,13 @@ def test_token_flag_overrides_datasette_token(httpx_mock, mocker, tmpdir):
|
|||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"query", "data", "select 1",
|
||||
"-i", "https://example.com",
|
||||
"--token", "flag-token",
|
||||
"query",
|
||||
"data",
|
||||
"select 1",
|
||||
"-i",
|
||||
"https://example.com",
|
||||
"--token",
|
||||
"flag-token",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
|
|
|||
|
|
@ -9,9 +9,7 @@ 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"})
|
||||
)
|
||||
aliases_file.write_text(json.dumps({"content": "https://datasette.io/content"}))
|
||||
|
||||
_migrate_v1_to_v2(config_dir)
|
||||
|
||||
|
|
@ -29,9 +27,7 @@ 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"})
|
||||
)
|
||||
aliases_file.write_text(json.dumps({"local": "http://localhost:8001"}))
|
||||
|
||||
_migrate_v1_to_v2(config_dir)
|
||||
|
||||
|
|
@ -44,13 +40,9 @@ 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"})
|
||||
)
|
||||
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"})
|
||||
)
|
||||
auth_file.write_text(json.dumps({"https://datasette.io/content": "tok123"}))
|
||||
|
||||
_migrate_v1_to_v2(config_dir)
|
||||
|
||||
|
|
@ -68,9 +60,7 @@ def test_migration_auth_url_fallback(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"})
|
||||
)
|
||||
auth_file.write_text(json.dumps({"https://other.example.com": "tok456"}))
|
||||
|
||||
_migrate_v1_to_v2(config_dir)
|
||||
|
||||
|
|
@ -105,9 +95,7 @@ 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"})
|
||||
)
|
||||
aliases_file.write_text(json.dumps({"deep": "https://example.com/a/b/c"}))
|
||||
|
||||
_migrate_v1_to_v2(config_dir)
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,9 @@ def test_query(httpx_mock, with_token):
|
|||
|
||||
# Check the request
|
||||
request = httpx_mock.get_request()
|
||||
assert str(request.url) == "https://example.com/content.json?sql=hello&_shape=objects"
|
||||
assert (
|
||||
str(request.url) == "https://example.com/content.json?sql=hello&_shape=objects"
|
||||
)
|
||||
if with_token:
|
||||
assert request.headers["authorization"] == "Bearer xyz"
|
||||
else:
|
||||
|
|
@ -67,9 +69,7 @@ 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"])
|
||||
assert result.exit_code == 0
|
||||
assert result.output == ""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue