diff --git a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts index 7d2cb0276..d04d0920e 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts @@ -14,6 +14,10 @@ import { SUCCESS_REAL_RUN_WITH_WARNINGS, ERROR_NO_TARGET, } from "../models/fixtures/setupLocalResults"; +import { + PythonSetupErrorAction, + UV_INSTALL_DOCS_URL, +} from "../utils/errorMessages"; import {SetupLocalInvocation} from "../utils/setupLocalArgs"; import { PythonSetupAttempt, @@ -333,6 +337,63 @@ describe("PythonSetupEnvironmentSetup.setup", () => { expect(shown[0].detail).to.contain("E_NO_TARGET"); }); + it("offers an Install uv action when the CLI reports uv is missing", async () => { + const shown: { + message: string; + action?: PythonSetupErrorAction; + }[] = []; + const uvMissing: PythonSetupResult = { + schemaVersion: 1, + command: "environments setup-local", + ok: false, + mode: "default", + dryRun: false, + greenfield: false, + phases: [], + warnings: [], + durationMs: 0, + error: { + code: "E_UV_MISSING", + failurePhase: "preflight", + message: "uv not found on PATH", + diskMutated: false, + }, + }; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + cli: makeCli({resolve: uvMissing}), + showError: async (message, _detail, action) => { + shown.push({message, action}); + }, + }) + ); + + await setup.setup(); + + expect(shown).to.have.length(1); + expect(shown[0].action).to.deep.equal({ + label: "Install uv", + url: UV_INSTALL_DOCS_URL, + }); + }); + + it("passes no remediation action for failures other than uv-missing", async () => { + const shown: {action?: PythonSetupErrorAction}[] = []; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + cli: makeCli({resolve: ERROR_NO_TARGET}), + showError: async (_message, _detail, action) => { + shown.push({action}); + }, + }) + ); + + await setup.setup(); + + expect(shown).to.have.length(1); + expect(shown[0].action).to.equal(undefined); + }); + it("surfaces the raw error message when the CLI run rejects", async () => { const shownErrors: string[] = []; const setup = new PythonSetupEnvironmentSetup( diff --git a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts index 3f627302d..8afdbcdf2 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts @@ -12,8 +12,10 @@ import { } from "../models/PythonSetupResult"; import { formatSetupFailureDetail, + getPythonSetupErrorAction, getPythonSetupErrorMessage, NO_COMPUTE_TARGET_MESSAGE, + PythonSetupErrorAction, } from "../utils/errorMessages"; import {SetupLocalInvocation} from "../utils/setupLocalArgs"; import { @@ -132,8 +134,15 @@ export interface PythonSetupSetupDeps { * action that reveals the setup output channel. `detail`, when given, is * written to that channel first (see `formatSetupFailureDetail`), so the * button leads to the CLI's full explanation instead of an empty log. + * `action`, when given, adds one more button that opens an external URL — + * e.g. "Install uv" pointing at uv's install guide (see + * `getPythonSetupErrorAction`). */ - showError: (message: string, detail?: string) => Promise; + showError: ( + message: string, + detail?: string, + action?: PythonSetupErrorAction + ) => Promise; showSuccess: (result: PythonSetupResult) => Promise; @@ -375,7 +384,8 @@ export class PythonSetupEnvironmentSetup implements Disposable { this.present( this.deps.showError( getPythonSetupErrorMessage(result), - formatSetupFailureDetail(result) + formatSetupFailureDetail(result), + getPythonSetupErrorAction(result) ) ); return; diff --git a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts index c92d61b7b..abc4eeb4d 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts @@ -1,5 +1,5 @@ import {expect} from "chai"; -import {Uri, window} from "vscode"; +import {env, Uri, window} from "vscode"; import { makePythonSetupDeps, makePythonSetupVisibility, @@ -520,6 +520,159 @@ describe("makePythonSetupDeps showError", () => { expect(appended).to.have.length(0); expect(shownWith[0].actions).to.contain("Show Logs"); }); + + it("offers the given action button and opens its URL when picked", async () => { + const originalOpen = env.openExternal; + const opened: string[] = []; + (env as unknown as {openExternal: unknown}).openExternal = async ( + uri: Uri + ) => { + opened.push(uri.toString(true)); + return true; + }; + try { + const deps = makePythonSetupDeps( + makeWiring({log: {append: () => {}, show: () => {}}}) + ); + reply = "Install uv"; + + await deps.showError("uv missing", "detail", { + label: "Install uv", + url: "https://docs.astral.sh/uv/getting-started/installation/", + }); + + // Order matters: the remediation button leads, "Show Logs" follows. + expect(shownWith[0].actions).to.deep.equal([ + "Install uv", + "Show Logs", + ]); + expect(opened).to.deep.equal([ + "https://docs.astral.sh/uv/getting-started/installation/", + ]); + } finally { + (env as unknown as {openExternal: unknown}).openExternal = + originalOpen; + } + }); + + it("ignores a remediation action that reuses the reserved Show Logs label", async () => { + // Defensive: the picked value comes back as a bare label string, so two + // buttons sharing "Show Logs" would be indistinguishable and the URL + // branch would be dead. Such an action is dropped (log-only) rather than + // silently mis-dispatched. + const originalOpen = env.openExternal; + const opened: string[] = []; + (env as unknown as {openExternal: unknown}).openExternal = async ( + uri: Uri + ) => { + opened.push(uri.toString(true)); + return true; + }; + try { + const deps = makePythonSetupDeps( + makeWiring({log: {append: () => {}, show: () => {}}}) + ); + reply = "Show Logs"; + + await deps.showError("uv missing", "detail", { + label: "Show Logs", + url: "https://docs.astral.sh/uv/getting-started/installation/", + }); + + // Only the single, unambiguous Show Logs button is offered... + expect(shownWith[0].actions).to.deep.equal(["Show Logs"]); + // ...and clicking it reveals the log, never opening the URL. + expect(opened).to.have.length(0); + } finally { + (env as unknown as {openExternal: unknown}).openExternal = + originalOpen; + } + }); + + it("logs when the browser cannot open the remediation URL (resolves false)", async () => { + // env.openExternal resolves false (rather than rejecting) when VS Code + // cannot open the URI; that ineffective click must still be recorded. + const originalOpen = env.openExternal; + (env as unknown as {openExternal: unknown}).openExternal = async () => + false; + const appended: string[] = []; + try { + const deps = makePythonSetupDeps( + makeWiring({ + log: {append: (c) => appended.push(c), show: () => {}}, + }) + ); + reply = "Install uv"; + + await deps.showError("uv missing", "detail", { + label: "Install uv", + url: "https://docs.astral.sh/uv/getting-started/installation/", + }); + + expect(appended.join("")).to.match(/could not open/i); + } finally { + (env as unknown as {openExternal: unknown}).openExternal = + originalOpen; + } + }); + + it("does not reject when opening the remediation URL fails", async () => { + // The popup is the failure-reporting path; a failed browser launch must + // not turn it into a rejected promise (the caller does not wrap it). + const originalOpen = env.openExternal; + (env as unknown as {openExternal: unknown}).openExternal = async () => { + throw new Error("no browser available"); + }; + const appended: string[] = []; + try { + const deps = makePythonSetupDeps( + makeWiring({ + log: {append: (c) => appended.push(c), show: () => {}}, + }) + ); + reply = "Install uv"; + + // Must resolve, not throw. + await deps.showError("uv missing", "detail", { + label: "Install uv", + url: "https://docs.astral.sh/uv/getting-started/installation/", + }); + + // The failure is recorded to the log channel rather than swallowed + // silently. + expect(appended.join("")).to.contain("no browser available"); + } finally { + (env as unknown as {openExternal: unknown}).openExternal = + originalOpen; + } + }); + + it("does not open the URL when the action button is not picked", async () => { + const originalOpen = env.openExternal; + const opened: string[] = []; + (env as unknown as {openExternal: unknown}).openExternal = async ( + uri: Uri + ) => { + opened.push(uri.toString(true)); + return true; + }; + try { + const deps = makePythonSetupDeps( + makeWiring({log: {append: () => {}, show: () => {}}}) + ); + reply = "Show Logs"; + + await deps.showError("uv missing", "detail", { + label: "Install uv", + url: "https://docs.astral.sh/uv/getting-started/installation/", + }); + + expect(opened).to.have.length(0); + } finally { + (env as unknown as {openExternal: unknown}).openExternal = + originalOpen; + } + }); }); describe("makePythonSetupDeps showSuccess", () => { diff --git a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts index 82f1b40fe..2e294ab39 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts @@ -5,6 +5,8 @@ import {PackageManagerDetection} from "../../language/packageManagerDetection"; import {Telemetry} from "../../telemetry"; import "../../telemetry/pythonSetupExtensions"; import {PythonSetupState} from "../../vscode-objs/StateStorage"; +import {openExternal} from "../../utils/urlUtils"; +import {PythonSetupErrorAction} from "../utils/errorMessages"; import {shouldShowPythonSetup} from "../utils/pythonSetupGate"; import {formatSetupLog, formatSetupNotification} from "../utils/setupSummary"; import {venvInterpreterPath} from "../utils/venvInterpreterPath"; @@ -208,7 +210,11 @@ export function makePythonSetupDeps( // revealing the (empty) output channel. await window.showWarningMessage(message); }, - showError: async (message: string, detail?: string) => { + showError: async ( + message: string, + detail?: string, + action?: PythonSetupErrorAction + ) => { // The mapped one-liner is deliberately concise and drops the CLI's // own explanation; write that detail into the channel so the log the // popup points at actually contains it (under `--output json` the CLI @@ -221,9 +227,38 @@ export function makePythonSetupDeps( // notification (with its jump-to-logs button) as before. wiring.log.show(); const showLogs = "Show Logs"; - const picked = await window.showErrorMessage(message, showLogs); + // `showErrorMessage` hands the picked value back as a bare label + // string, so two buttons sharing a label are indistinguishable. Drop + // a remediation action that reuses the reserved "Show Logs" label + // rather than offer an ambiguous button whose URL branch is dead. + const remediation = + action && action.label !== showLogs ? action : undefined; + // Lead with the remediation button (e.g. "Install uv") when one is + // attached, so the action the user most likely wants comes first. + const actions = remediation + ? [remediation.label, showLogs] + : [showLogs]; + const picked = await window.showErrorMessage(message, ...actions); if (picked === showLogs) { wiring.log.show(); + } else if (remediation && picked === remediation.label) { + // showError is the failure-reporting path and its one caller does + // not wrap it, so neither a rejected launch nor a false "could not + // open" result may escape here — contain both and record them. + try { + const opened = await openExternal(remediation.url); + if (!opened) { + wiring.log.append( + `\nCould not open ${remediation.url} in a browser.\n` + ); + } + } catch (e) { + wiring.log.append( + `\nFailed to open ${remediation.url}: ${ + e instanceof Error ? e.message : String(e) + }\n` + ); + } } }, showSuccess: async (result) => { diff --git a/packages/databricks-vscode/src/python-setup/utils/errorMessages.test.ts b/packages/databricks-vscode/src/python-setup/utils/errorMessages.test.ts index 6641b6ace..712733145 100644 --- a/packages/databricks-vscode/src/python-setup/utils/errorMessages.test.ts +++ b/packages/databricks-vscode/src/python-setup/utils/errorMessages.test.ts @@ -1,7 +1,9 @@ import {expect} from "chai"; import { formatSetupFailureDetail, + getPythonSetupErrorAction, getPythonSetupErrorMessage, + UV_INSTALL_DOCS_URL, } from "./errorMessages"; import { PythonSetupResult, @@ -205,6 +207,30 @@ describe("getPythonSetupErrorMessage", () => { }); }); +describe("getPythonSetupErrorAction", () => { + it("offers an Install uv action pointing at the uv docs for E_UV_MISSING", () => { + const action = getPythonSetupErrorAction( + failure("E_UV_MISSING", {failurePhase: "preflight"}) + ); + expect(action).to.deep.equal({ + label: "Install uv", + url: UV_INSTALL_DOCS_URL, + }); + }); + + it("offers no action for error codes other than E_UV_MISSING", () => { + expect(getPythonSetupErrorAction(failure("E_PROVISION"))).to.equal( + undefined + ); + }); + + it("offers no action when the result carries no error", () => { + const ok = failure("E_UV_MISSING"); + ok.error = null; + expect(getPythonSetupErrorAction(ok)).to.equal(undefined); + }); +}); + describe("formatSetupFailureDetail", () => { it("names the failing phase and error code", () => { const detail = formatSetupFailureDetail( diff --git a/packages/databricks-vscode/src/python-setup/utils/errorMessages.ts b/packages/databricks-vscode/src/python-setup/utils/errorMessages.ts index 3e75fd1ef..d372cf82c 100644 --- a/packages/databricks-vscode/src/python-setup/utils/errorMessages.ts +++ b/packages/databricks-vscode/src/python-setup/utils/errorMessages.ts @@ -25,6 +25,25 @@ import { export const NO_COMPUTE_TARGET_MESSAGE = "Select a cluster or serverless compute before setting up the environment."; +/** + * uv's official installation guide. Single-sourced here so the popup action and + * its test point at the same page; the extension deliberately links to the docs + * (which pick the right installer per platform) rather than running an installer + * itself. + */ +export const UV_INSTALL_DOCS_URL = + "https://docs.astral.sh/uv/getting-started/installation/"; + +/** + * An optional remediation button to attach to a failure popup: a label and the + * external URL it opens. Kept alongside {@link getPythonSetupErrorMessage} so the + * copy and its call-to-action live together. + */ +export interface PythonSetupErrorAction { + label: string; + url: string; +} + /* eslint-disable @typescript-eslint/naming-convention */ const BASE_MESSAGE: Record< PythonSetupErrorCode, @@ -76,6 +95,21 @@ export function getPythonSetupErrorMessage(result: PythonSetupResult): string { return base + diskStateSuffix(result, err); } +/** + * The remediation button, if any, for a failed setup result. Only `E_UV_MISSING` + * carries one today: the CLI could neither find nor auto-install uv, so we point + * the user at uv's install guide. All other codes are actionable from the message + * and logs alone, so they get no extra button. + */ +export function getPythonSetupErrorAction( + result: PythonSetupResult +): PythonSetupErrorAction | undefined { + if (result.error?.code === "E_UV_MISSING") { + return {label: "Install uv", url: UV_INSTALL_DOCS_URL}; + } + return undefined; +} + /** * The detailed failure text for the "Databricks Python Environment Setup" output * channel — the counterpart to {@link getPythonSetupErrorMessage}'s concise diff --git a/packages/databricks-vscode/src/utils/urlUtils.ts b/packages/databricks-vscode/src/utils/urlUtils.ts index 5bb703229..59494ca99 100644 --- a/packages/databricks-vscode/src/utils/urlUtils.ts +++ b/packages/databricks-vscode/src/utils/urlUtils.ts @@ -3,8 +3,10 @@ import {env, Uri} from "vscode"; export function addHttpsIfNoProtocol(url: string) { return `${url}`.startsWith("http") ? `${url}` : `https://${url}`; } -export async function openExternal(url: string) { - await env.openExternal(Uri.parse(addHttpsIfNoProtocol(url), true)); +export async function openExternal(url: string): Promise { + // Forward VS Code's result: it resolves false when the URI could not be + // opened (rather than rejecting), which some callers need to observe. + return env.openExternal(Uri.parse(addHttpsIfNoProtocol(url), true)); } export class UrlError extends Error {