Skip to content
12 changes: 8 additions & 4 deletions src/ucode/databricks.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,11 @@ def has_valid_databricks_auth(workspace: str, profile: str | None = None) -> boo
def get_databricks_profiles() -> list[tuple[str, str]]:
"""Return [(host_url, profile_name), ...] from Databricks CLI profiles.

Each non-PAT profile is returned individually — duplicate hosts (multiple
profiles pointing at the same workspace) appear as separate entries so the
workspace picker can offer each profile by name. Order matches the CLI's
own ordering.

Returns ``[]`` on any failure (CLI missing, timeout, non-zero exit, JSON
decode error). When ``UCODE_DEBUG=1`` each dropout path logs *why* the
result was empty so a silently-disappearing workspace picker is
Expand All @@ -438,8 +443,7 @@ def get_databricks_profiles() -> list[tuple[str, str]]:
_debug("get_databricks_profiles", f"json decode error: {exc.msg}")
return []

# dict dedupes by host (first non-PAT profile wins).
out: dict[str, str] = {}
out: list[tuple[str, str]] = []
Comment thread
anthonyivn2 marked this conversation as resolved.
pat = 0
for p in profiles:
host = (p.get("host") or "").rstrip("/")
Expand All @@ -449,13 +453,13 @@ def get_databricks_profiles() -> list[tuple[str, str]]:
if p.get("auth_type") == "pat":
pat += 1
continue
out.setdefault(host, name)
out.append((host, name))

_debug(
"get_databricks_profiles",
f"returned={len(out)} total={len(profiles)} pat={pat}",
)
return list(out.items())
return out


def find_profile_name_for_host(workspace: str) -> str | None:
Comment thread
anthonyivn2 marked this conversation as resolved.
Expand Down
21 changes: 16 additions & 5 deletions src/ucode/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,23 +181,34 @@ def prompt_for_workspace(
"""Ask the user for a workspace URL, offering profiles as quick-select.

`profiles` is a list of (host_url, profile_name) tuples. Caller fetches
them — `ui.py` stays Databricks-agnostic. Returns ``(url, profile_name)``;
profile_name is ``None`` when the user typed a URL manually.
them — `ui.py` stays Databricks-agnostic. Duplicate hosts (multiple
profiles pointing at the same workspace) are shown separately; the picker
returns the exact (host, profile_name) the user selected. Returns
``(url, profile_name)``; profile_name is ``None`` when the user typed a
URL manually.
"""
console.print()
console.print(Panel(description, title="ucode setup", style="bold blue", expand=False))

if profiles:
choices = [
questionary.Choice(title=host, value=(host, profile_name))
for host, profile_name in profiles
name_header = "Profile Name"
url_header = "Workspace URL"
name_width = max(len(name_header), *(len(name) for _, name in profiles))
Comment thread
anthonyivn2 marked this conversation as resolved.
Outdated
# Match the 2-char cursor gutter so the header line aligns with rows.
header_title = f" {name_header.ljust(name_width)} {url_header}"
choices: list[questionary.Choice | questionary.Separator] = [
questionary.Separator(header_title)
]
for host, profile_name in profiles:
row_title = f"{profile_name.ljust(name_width)} {host}"
choices.append(questionary.Choice(title=row_title, value=(host, profile_name)))
choices.append(questionary.Choice(title="Enter a different URL", value=None))
style = questionary.Style(
[
("highlighted", "fg:cyan bold"),
("pointer", "fg:cyan bold"),
("answer", "fg:cyan"),
("separator", "fg:white bold"),
]
)
choice = questionary.select(
Expand Down
58 changes: 58 additions & 0 deletions tests/test_databricks.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
build_shared_base_urls,
build_tool_base_url,
ensure_databricks_cli_version,
get_databricks_profiles,
get_databricks_token,
list_databricks_apps,
list_databricks_connections,
Expand Down Expand Up @@ -342,6 +343,63 @@ def test_error_suggests_logout_when_matching_profile_exists(self, tmp_path, monk
assert f"databricks auth login --host {WS} --profile example-profile" in message


class TestGetDatabricksProfiles:
def _patched_run(self, monkeypatch, payload: dict, returncode: int = 0) -> None:
def fake_run(args, **kwargs):
return subprocess.CompletedProcess(args, returncode, stdout=json.dumps(payload))

monkeypatch.setattr(db_mod, "run", fake_run)

def test_keeps_duplicate_hosts_as_separate_entries(self, monkeypatch):
self._patched_run(
monkeypatch,
{
"profiles": [
{"host": WS, "name": "first", "auth_type": "databricks-cli"},
{"host": WS, "name": "second", "auth_type": "databricks-cli"},
{
"host": "https://other.databricks.com",
"name": "third",
"auth_type": "databricks-cli",
},
]
},
)
profiles = get_databricks_profiles()
assert profiles == [
(WS, "first"),
(WS, "second"),
("https://other.databricks.com", "third"),
]

def test_skips_pat_profiles(self, monkeypatch):
self._patched_run(
monkeypatch,
{
"profiles": [
{"host": WS, "name": "oauth", "auth_type": "databricks-cli"},
{"host": WS, "name": "tokenized", "auth_type": "pat"},
]
},
)
assert get_databricks_profiles() == [(WS, "oauth")]

def test_strips_trailing_slash_on_host(self, monkeypatch):
self._patched_run(
monkeypatch,
{
"profiles": [
{"host": f"{WS}/", "name": "p", "auth_type": "databricks-cli"},
]
},
)
assert get_databricks_profiles() == [(WS, "p")]

def test_returns_empty_on_non_zero_exit(self, monkeypatch):
self._patched_run(monkeypatch, {"profiles": []}, returncode=1)
assert get_databricks_profiles() == []


class TestListDatabricksConnections:
def test_lists_paginated_connections_with_workspace_env(self, monkeypatch):
calls: list[dict] = []
Expand Down
80 changes: 80 additions & 0 deletions tests/test_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
from datetime import timedelta

import pytest
import questionary

from ucode import ui as ui_mod
from ucode.ui import (
format_duration,
format_token_count,
normalize_workspace_url,
prompt_for_workspace,
render_box_table,
status_badge,
)
Expand Down Expand Up @@ -144,3 +147,80 @@ def test_cell_wraps_when_max_width_set(self):
def test_dash_for_empty_cell(self):
result = render_box_table(["A"], [[""]])
assert "-" in result


class _StubQuestion:
def __init__(self, answer):
self._answer = answer

def ask(self):
return self._answer


class TestPromptForWorkspace:
"""Capture the choices passed to ``questionary.select`` so we can assert on
layout (header alignment + duplicate-host preservation) without driving
real keyboard I/O."""

def _capture_select(self, monkeypatch, answer):
captured: dict = {}

def fake_select(message, choices, **kwargs):
captured["message"] = message
captured["choices"] = choices
captured["kwargs"] = kwargs
return _StubQuestion(answer)

monkeypatch.setattr(questionary, "select", fake_select)
monkeypatch.setattr(ui_mod.questionary, "select", fake_select)
return captured

def test_shows_header_and_each_profile_row(self, monkeypatch):
profiles = [
("https://a.cloud.databricks.com", "alpha"),
("https://b.cloud.databricks.com", "beta-profile-name"),
]
captured = self._capture_select(monkeypatch, answer=profiles[0])
url, profile = prompt_for_workspace("setup", profiles)

assert (url, profile) == profiles[0]
choices = captured["choices"]
# Header (separator), 2 rows, "Enter a different URL" entry.
assert len(choices) == 4
assert isinstance(choices[0], questionary.Separator)
header = choices[0].title
assert "Profile Name" in header
assert "Workspace URL" in header
# Profile names ljust-padded to the longest name (17 chars).
name_width = max(len(name) for _, name in profiles)
assert "alpha".ljust(name_width) in choices[1].title
assert profiles[0][0] in choices[1].title
assert "beta-profile-name".ljust(name_width) in choices[2].title
assert profiles[1][0] in choices[2].title
# Final fallback entry still present.
assert choices[3].title == "Enter a different URL"

def test_keeps_duplicate_hosts_as_separate_rows(self, monkeypatch):
profiles = [
("https://shared.cloud.databricks.com", "first"),
("https://shared.cloud.databricks.com", "second"),
]
captured = self._capture_select(monkeypatch, answer=profiles[1])
url, profile = prompt_for_workspace("setup", profiles)

assert (url, profile) == profiles[1]
# Both rows present — duplicates not collapsed.
choices = captured["choices"]
# Filter to choices whose value is a (host, profile) tuple — drops the
# header separator and the trailing "Enter a different URL" entry.
host_choices = [c for c in choices if isinstance(getattr(c, "value", None), tuple)]
assert [c.value for c in host_choices] == profiles

def test_returns_normalized_url_with_profile(self, monkeypatch):
# Picker handed back a URL with a trailing slash — normalize_workspace_url
# should strip it before returning.
profiles = [("https://example.cloud.databricks.com/", "p")]
self._capture_select(monkeypatch, answer=profiles[0])
url, profile = prompt_for_workspace("setup", profiles)
assert url == "https://example.cloud.databricks.com"
assert profile == "p"