Set defaults on login, if not already set

This commit is contained in:
Simon Willison 2026-02-26 15:25:06 -08:00
commit 9df1828d1d
2 changed files with 174 additions and 0 deletions

View file

@ -1163,6 +1163,55 @@ def login(alias_or_url, scope):
auth_file.write_text(json.dumps(auths, indent=4))
click.echo(f"Login successful. Token saved for {auth_key}")
# Step 5: Set defaults if not already configured
config = _load_config(config_file)
default_alias = config.get("default_instance")
has_default_instance = default_alias is not None
has_default_db = bool(
config.get("instances", {}).get(default_alias or "", {}).get("default_database")
)
if has_default_instance and has_default_db:
return
# Find alias for this instance, or use the auth_key (URL) as the instance key
instance_key = _instance_alias_for_url(url, config_file) or auth_key
# Ensure instance entry exists in config
if instance_key not in config.get("instances", {}):
config.setdefault("instances", {})[instance_key] = {
"url": url.rstrip("/"),
"default_database": None,
}
# Set as default instance if none configured
if not has_default_instance:
config["default_instance"] = instance_key
click.echo(f"Set default instance to {instance_key}")
# Query databases and set default database if none configured
if not has_default_db:
try:
db_response = _make_request(url, access_token, "/.json")
if db_response.status_code == 200:
db_data = db_response.json()
if isinstance(db_data, list):
databases_list = db_data
else:
databases_list = db_data.get("databases", [])
if isinstance(databases_list, dict):
databases_list = list(databases_list.values())
db_names = [
db["name"] if isinstance(db, dict) else db for db in databases_list
]
if db_names:
if len(db_names) == 1:
default_db = db_names[0]
elif "data" in db_names:
default_db = "data"
else:
default_db = db_names[0]
config["instances"][instance_key]["default_database"] = default_db
click.echo(f"Set default database to {default_db}")
except Exception:
pass # Don't fail login if databases check fails
_save_config(config_file, config)
# -- v1 → v2 migration --

View file

@ -64,6 +64,7 @@ def test_login_with_url(httpx_mock, mocker, 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
@ -80,6 +81,7 @@ def test_login_adds_trailing_slash(httpx_mock, mocker, 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
@ -108,6 +110,7 @@ def test_login_with_alias(httpx_mock, mocker, tmpdir):
)
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
@ -123,6 +126,7 @@ def test_login_interactive_prompt(httpx_mock, mocker, 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
@ -160,6 +164,7 @@ def test_login_pending_then_success(httpx_mock, mocker, tmpdir):
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
@ -176,3 +181,123 @@ def test_login_device_endpoint_error(httpx_mock, mocker, tmpdir):
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_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