Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 29 additions & 29 deletions packages/databricks-vscode/src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
commands,
ConfigurationTarget,
debug,
env,
ExtensionContext,
Expand Down Expand Up @@ -60,6 +59,7 @@ import {PythonSetupDriftManager} from "./python-setup/controllers/PythonSetupDri
import {PythonSetupAdoptionManager} from "./python-setup/controllers/PythonSetupAdoptionManager";
import {SetupCompute} from "./python-setup/controllers/PythonSetupEnvironmentSetup";
import {venvInterpreterPath} from "./python-setup/utils/venvInterpreterPath";
import {optOutOfAutomatedPythonSetup} from "./python-setup/utils/manualSetupOptOut";
import {
INSTALL_UV_COMMAND_ID,
USE_MANUAL_SETUP_COMMAND_ID,
Expand All @@ -76,7 +76,8 @@ import {MsPythonExtensionWrapper} from "./language/MsPythonExtensionWrapper";
import {DatabricksEnvFileManager} from "./file-managers/DatabricksEnvFileManager";
import {getContextMetadata, Telemetry, toUserMetadata} from "./telemetry";
import "./telemetry/commandExtensions";
import {Events, Metadata} from "./telemetry/constants";
import "./telemetry/pythonSetupExtensions";
import {Events, Metadata, PythonSetupOptOutSource} from "./telemetry/constants";
import {EnvironmentDependenciesInstaller} from "./language/EnvironmentDependenciesInstaller";
import {setDbnbCellLimits} from "./language/notebooks/DatabricksNbCellLimits";
import {DbConnectStatusBarButton} from "./language/DbConnectStatusBarButton";
Expand Down Expand Up @@ -752,7 +753,8 @@ export async function activate(
}
return connectionManager.cluster ? "cluster" : "none";
},
() => connectionManager.state === "CONNECTED"
() => connectionManager.state === "CONNECTED",
() => workspaceConfigs.pythonEnvironmentSetup
);
context.subscriptions.push(
bundleFileWatcher,
Expand Down Expand Up @@ -1072,33 +1074,31 @@ export async function activate(
// turn automated setup off for this project so an existing environment
// is used as-is. Workspace scope keeps it scoped and reversible; if no
// folder is open we fall back to Global so the write still lands.
telemetry.registerCommand(USE_MANUAL_SETUP_COMMAND_ID, async () => {
const hasFolder = (workspace.workspaceFolders?.length ?? 0) > 0;
const target = hasFolder
? ConfigurationTarget.Workspace
: ConfigurationTarget.Global;
try {
await workspaceConfigs.setPythonEnvironmentSetup(
"manual",
target
);
} catch (e) {
// Don't claim success if the write failed — the user would
// otherwise believe automated setup is off when it is not.
await window.showErrorMessage(
`Could not update databricks.python.environmentSetup: ${
e instanceof Error ? e.message : String(e)
}`
);
return;
telemetry.registerCommand(
USE_MANUAL_SETUP_COMMAND_ID,
async (arg?: {source?: PythonSetupOptOutSource}) => {
// The E_FETCH popup passes {source: "error_popup"}; a palette
// invocation passes nothing, so anything else reads as palette.
const source: PythonSetupOptOutSource =
arg?.source === "error_popup"
? "error_popup"
: "command_palette";
await optOutOfAutomatedPythonSetup(source, {
currentMode: () => workspaceConfigs.pythonEnvironmentSetup,
hasFolder: () =>
(workspace.workspaceFolders?.length ?? 0) > 0,
setManual: (target) =>
workspaceConfigs.setPythonEnvironmentSetup(
"manual",
target
),
recordOptOut: (report) =>
telemetry.recordManualSetupOptOut(report),
showError: (m) => window.showErrorMessage(m),
showInfo: (m) => window.showInformationMessage(m),
});
}
// Match the message to the scope actually written: "this project"
// for Workspace, "globally" for the no-folder Global fallback.
const scope = hasFolder ? "for this project" : "globally";
await window.showInformationMessage(
`Automated Python environment setup is now off ${scope} ("databricks.python.environmentSetup": "manual"). Your existing interpreter will be used as-is.`
);
}),
),
// E_UV_MISSING's "Install uv" button: run uv's official installer in a
// terminal so its output stays visible (same pattern as `az login`).
// A modal first makes the remote-install step an explicit, informed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,16 @@ describe(__filename, () => {
projectRoot: string;
compute?: "cluster" | "serverless" | "none";
connected?: boolean;
setupMode?: "auto" | "manual";
}
) {
return new PackageManagerTelemetry(
telemetry,
noInterpreter,
() => opts.projectRoot,
() => opts.compute ?? "none",
() => opts.connected ?? true
() => opts.connected ?? true,
() => opts.setupMode ?? "auto"
);
}

Expand All @@ -102,6 +104,42 @@ describe(__filename, () => {
expect(e.props["event.targetCompute"]).to.equal("cluster");
expect(e.props["event.setupTrigger"]).to.equal("explicit_command");
expect(e.props["event.interpreterSource"]).to.equal("unknown");
// primaryManager is uv, yet a real pip signal (requirements-dev.txt)
// makes the project not uv-suitable, so the flow is pip — setupMode
// tracks the effective flow, not the primary manager.
expect(e.props["event.setupMode"]).to.equal("pip");
});

it("reports setupMode=uv for a clean, auto uv project", async () => {
const {telemetry, events} = makeTelemetry("all");
const projectRoot = makeProject([["uv.lock", "version = 1\n"]]);
const pmt = makePmt(telemetry, {projectRoot}); // setupMode defaults to auto

await emit(pmt, "auto_open");

expect(events[0].props["event.setupMode"]).to.equal("uv");
});

it("reports setupMode=pip for an auto, non-uv (requirements) project", async () => {
const {telemetry, events} = makeTelemetry("all");
const projectRoot = makeProject([["requirements.txt", "requests\n"]]);
const pmt = makePmt(telemetry, {projectRoot}); // setupMode defaults to auto

await emit(pmt, "auto_open");

expect(events[0].props["event.primaryManager"]).to.equal("pip");
expect(events[0].props["event.setupMode"]).to.equal("pip");
});

it("reports setupMode=fallback-pip when the user opted out (manual)", async () => {
const {telemetry, events} = makeTelemetry("all");
// A clean uv project that would be "uv" on auto — manual still wins.
const projectRoot = makeProject([["uv.lock", "version = 1\n"]]);
const pmt = makePmt(telemetry, {projectRoot, setupMode: "manual"});

await emit(pmt, "auto_open");

expect(events[0].props["event.setupMode"]).to.equal("fallback-pip");
});

it("deduplicates per (trigger, projectRoot) within a session", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {MsPythonExtensionWrapper} from "./MsPythonExtensionWrapper";
import {ResolvedEnvironment} from "./MsPythonExtensionApi";
import {detectPackageManagers} from "./packageManagerDetection";
import {collectPackageManagerSignals} from "./packageManagerSignals";
import {resolveSetupMode} from "../python-setup/utils/pythonSetupGate";
import type {PythonEnvironmentSetupMode} from "../vscode-objs/WorkspaceConfigs";

export type {SetupTrigger};

Expand Down Expand Up @@ -34,7 +36,8 @@ export class PackageManagerTelemetry {
private readonly pythonExtension: MsPythonExtensionWrapper,
private readonly getProjectRoot: () => string | undefined,
private readonly getComputeType: () => ComputeType | "none",
private readonly isConnected: () => boolean
private readonly isConnected: () => boolean,
private readonly getSetupMode: () => PythonEnvironmentSetupMode
) {}

/**
Expand Down Expand Up @@ -80,6 +83,7 @@ export class PackageManagerTelemetry {
pythonVersion: this.getPythonMinorVersion(env),
targetCompute: this.getComputeType(),
trigger,
setupMode: resolveSetupMode(this.getSetupMode(), detection),
});
} catch (e) {
// Detection is measurement-only and must never disrupt setup.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -812,9 +812,11 @@ describe("makePythonSetupDeps showError", () => {
it("runs the VS Code command when a command-action button is picked", async () => {
const original = commands.executeCommand;
const executed: string[] = [];
const executedArgs: unknown[] = [];
(commands as unknown as {executeCommand: unknown}).executeCommand =
async (command: string) => {
async (command: string, ...args: unknown[]) => {
executed.push(command);
executedArgs.push(args[0]);
};
const originalOpen = env.openExternal;
const opened: string[] = [];
Expand Down Expand Up @@ -845,6 +847,9 @@ describe("makePythonSetupDeps showError", () => {
expect(executed).to.deep.equal([
"databricks.environment.installUv",
]);
// Command-actions are tagged as coming from the error popup, so a
// command can distinguish a popup click from a palette invocation.
expect(executedArgs).to.deep.equal([{source: "error_popup"}]);
expect(opened).to.have.length(0);
} finally {
(commands as unknown as {executeCommand: unknown}).executeCommand =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,13 @@ export function makePythonSetupDeps(
if (chosen.command) {
// A command-action (e.g. "Install uv", E_FETCH "Use manual
// setup") runs a registered VS Code command instead of
// opening a URL.
await commands.executeCommand(chosen.command);
// opening a URL. Tag the invocation as coming from the error
// popup so the command can tell it apart from a palette call
// (the manual-setup opt-out reads this for telemetry;
// commands that take no args simply ignore it).
await commands.executeCommand(chosen.command, {
source: "error_popup",
});
} else if (chosen.url) {
const opened = await openExternal(chosen.url);
if (!opened) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {expect} from "chai";
import {ConfigurationTarget} from "vscode";
import {PythonEnvironmentSetupMode} from "../../vscode-objs/WorkspaceConfigs";
import {
PythonSetupOptOutScope,
PythonSetupOptOutSource,
} from "../../telemetry/constants";
import {
ManualSetupOptOutDeps,
optOutOfAutomatedPythonSetup,
} from "./manualSetupOptOut";

/**
* Build the injected deps with recording spies. `currentMode` and `hasFolder`
* are configurable; `setManual` fails when `writeError` is set.
*/
function makeDeps(opts: {
currentMode?: PythonEnvironmentSetupMode;
hasFolder?: boolean;
writeError?: Error;
}) {
const recorded: {
scope: PythonSetupOptOutScope;
source: PythonSetupOptOutSource;
}[] = [];
const written: ConfigurationTarget[] = [];
const errors: string[] = [];
const infos: string[] = [];
const deps: ManualSetupOptOutDeps = {
currentMode: () => opts.currentMode ?? "auto",
hasFolder: () => opts.hasFolder ?? true,
setManual: async (target) => {
if (opts.writeError) {
throw opts.writeError;
}
written.push(target);
},
recordOptOut: (report) => recorded.push(report),
showError: async (m) => errors.push(m),
showInfo: async (m) => infos.push(m),
};
return {deps, recorded, written, errors, infos};
}

describe("optOutOfAutomatedPythonSetup", () => {
it("records the opt-out on a real auto->manual transition (workspace)", async () => {
const {deps, recorded, written, infos} = makeDeps({
currentMode: "auto",
hasFolder: true,
});

await optOutOfAutomatedPythonSetup("error_popup", deps);

expect(written).to.deep.equal([ConfigurationTarget.Workspace]);
expect(recorded).to.deep.equal([
{scope: "workspace", source: "error_popup"},
]);
expect(infos[0]).to.contain("for this project");
});

it("writes and confirms Global scope when no folder is open", async () => {
const {deps, recorded, written, infos} = makeDeps({
currentMode: "auto",
hasFolder: false,
});

await optOutOfAutomatedPythonSetup("command_palette", deps);

expect(written).to.deep.equal([ConfigurationTarget.Global]);
expect(recorded).to.deep.equal([
{scope: "global", source: "command_palette"},
]);
expect(infos[0]).to.contain("globally");
});

it("does NOT record when already manual (no transition), but still confirms", async () => {
const {deps, recorded, written, infos} = makeDeps({
currentMode: "manual",
hasFolder: true,
});

await optOutOfAutomatedPythonSetup("command_palette", deps);

// Idempotent write still happens, but no opt-out is counted.
expect(written).to.deep.equal([ConfigurationTarget.Workspace]);
expect(recorded).to.have.length(0);
expect(infos).to.have.length(1);
});

it("does NOT record and surfaces the error when the write fails", async () => {
const {deps, recorded, errors, infos} = makeDeps({
currentMode: "auto",
writeError: new Error("EACCES"),
});

await optOutOfAutomatedPythonSetup("error_popup", deps);

expect(recorded).to.have.length(0);
expect(infos).to.have.length(0);
expect(errors[0]).to.contain(
"Could not update databricks.python.environmentSetup"
);
expect(errors[0]).to.contain("EACCES");
});
});
Loading
Loading