From a4eda70abcf37b900aa22005ae66fdc86b3021c8 Mon Sep 17 00:00:00 2001 From: "@rugpanov" Date: Tue, 13 Oct 2026 22:13:16 +0200 Subject: [PATCH 1/3] fix(python-setup): select the shared uv workspace venv for a workspace member *Why*: * In a monorepo with one uv workspace and one bundle per member, setup-local provisions the shared venv at the workspace root and reports `venvPath` relative to the member folder, for example `../../.venv` (#2157). * The extension passed `venvPath` to the Python extension without resolving it against the project folder, and it read the venv name from a path relative to the extension host's working folder. *What*: * Resolve `venvPath` to an absolute folder before adopting it and reading its name. A value that climbs counts from the symlink-resolved project folder on Unix, as the CLI does; `.venv` stays under the folder as opened; an absolute value stays as is. * Parse `uvWorkspaceRoot` and `uvWorkspaceBackupPath`. The success summary names the shared venv and the root pyproject.toml for a member, with Windows separators on Windows. * Count `W_UV_WORKSPACE_CONSTRAINTS_REPLACED` in the setup-result warning histogram. Co-authored-by: Isaac --- .../PythonSetupEnvironmentSetup.test.ts | 93 ++++++++++++++++++- .../PythonSetupEnvironmentSetup.ts | 40 +++++++- .../controllers/pythonSetupDeps.test.ts | 66 ++++++++++++- .../controllers/pythonSetupDeps.ts | 15 +-- .../models/PythonSetupResult.test.ts | 14 ++- .../python-setup/models/PythonSetupResult.ts | 12 +++ .../models/fixtures/setupLocalResults.ts | 16 +++- .../python-setup/utils/setupSummary.test.ts | 55 +++++++++++ .../src/python-setup/utils/setupSummary.ts | 64 +++++++++---- .../telemetry/pythonSetupExtensions.test.ts | 9 +- .../src/telemetry/pythonSetupExtensions.ts | 1 + 11 files changed, 346 insertions(+), 39 deletions(-) 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 97a5fe263..13638e49c 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts @@ -1,4 +1,5 @@ import {expect} from "chai"; +import path from "node:path"; import { PythonSetupEnvironmentSetup, PythonSetupSetupDeps, @@ -11,6 +12,7 @@ import { import {PythonSetupResult} from "../models/PythonSetupResult"; import { SUCCESS_REAL_RUN, + SUCCESS_REAL_RUN_UV_WORKSPACE_MEMBER, SUCCESS_REAL_RUN_WITH_WARNINGS, ERROR_NO_TARGET, ERROR_USAGE, @@ -123,6 +125,7 @@ function makeDeps( status: "ok", compute: {kind: "serverless", version: "5"}, }), + realPath: async (p) => p, adoptInterpreter: async () => {}, openProjectFile: async () => {}, restoreProjectFile: async () => {}, @@ -187,7 +190,7 @@ describe("PythonSetupEnvironmentSetup.setup", () => { expect(cli.options[0].cwd).to.equal("/proj"); expect(cli.options[0].onLog).to.be.a("function"); expect(adopted).to.deep.equal([ - {venvPath: SUCCESS_REAL_RUN.venvPath, root: "/proj"}, + {venvPath: path.resolve("/proj", ".venv"), root: "/proj"}, ]); expect(saved).to.deep.equal([ { @@ -223,10 +226,94 @@ describe("PythonSetupEnvironmentSetup.setup", () => { // Adoption targets /projA (the run's cwd), never the switched-to /projB: // otherwise B's interpreter setting would point at A's venv. expect(adopted).to.deep.equal([ - {venvPath: SUCCESS_REAL_RUN.venvPath, root: "/projA"}, + {venvPath: path.resolve("/projA", ".venv"), root: "/projA"}, ]); }); + it("adopts the shared venv at the root for a uv workspace member", async () => { + const member = "/mono/bundles/sales"; + const shared = path.resolve(member, "../../.venv"); + const adopted: Array<{venvPath: string; root: string}> = []; + const venvDirs: string[] = []; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + cli: makeCli({resolve: SUCCESS_REAL_RUN_UV_WORKSPACE_MEMBER}), + projectRoot: () => member, + adoptInterpreter: async (venvPath, root) => { + adopted.push({venvPath, root}); + }, + showSuccess: async (_r, _flags, venvDir) => { + venvDirs.push(venvDir); + }, + }) + ); + + await setup.setup(); + + expect(setup.ready).to.equal(true); + expect(adopted).to.deep.equal([{venvPath: shared, root: member}]); + expect(venvDirs).to.deep.equal([shared]); + }); + + it("counts a climbing venvPath from the real project folder", async () => { + // The CLI counts "../../.venv" from the symlink-resolved project folder, + // so the controller must not join it to the folder by text. + const adopted: string[] = []; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + cli: makeCli({resolve: SUCCESS_REAL_RUN_UV_WORKSPACE_MEMBER}), + projectRoot: () => "/link/sales", + realPath: async () => "/mono/deep/bundles/sales", + adoptInterpreter: async (venvDir) => { + adopted.push(venvDir); + }, + }) + ); + + await setup.setup(); + + expect(adopted).to.deep.equal([ + path.resolve("/mono/deep/bundles/sales", "../../.venv"), + ]); + }); + + it("keeps .venv under the project folder as opened", async () => { + // The same folder either way, but the opened form keeps the interpreter + // inside the workspace folder for the Python extension. + const adopted: string[] = []; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + projectRoot: () => "/link/proj", + realPath: async () => "/real/proj", + adoptInterpreter: async (venvDir) => { + adopted.push(venvDir); + }, + }) + ); + + await setup.setup(); + + expect(adopted).to.deep.equal([path.resolve("/link/proj", ".venv")]); + }); + + it("keeps an absolute venvPath as it is", async () => { + const abs = path.resolve("/envs/shared"); + const adopted: string[] = []; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + cli: makeCli({resolve: {...SUCCESS_REAL_RUN, venvPath: abs}}), + realPath: async () => "/real/proj", + adoptInterpreter: async (venvDir) => { + adopted.push(venvDir); + }, + }) + ); + + await setup.setup(); + + expect(adopted).to.deep.equal([abs]); + }); + it("reports ready only for the project it was set up for, not another active project", async () => { // Set up /projA, then model the active project switching to /projB // (never provisioned). readiness must track the run's project, not leak @@ -1873,7 +1960,7 @@ describe("PythonSetupEnvironmentSetup constraint-conflict recovery", () => { expect(cli.calls[1].skipConstraints).to.equal(true); expect(cli.calls[1].skipDbconnect).to.equal(undefined); // The retry succeeded, so the interpreter is adopted and the project is ready. - expect(adopted).to.deep.equal([SUCCESS_REAL_RUN.venvPath]); + expect(adopted).to.deep.equal([path.resolve("/proj", ".venv")]); expect(setup.ready).to.equal(true); }); diff --git a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts index 5634444d1..ce97a62f8 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import {Disposable, Event, EventEmitter} from "vscode"; import {logging} from "@databricks/sdk-experimental"; import {Loggers} from "../../logger"; @@ -141,13 +142,19 @@ export interface PythonSetupSetupDeps { compute: SetupCompute ) => Promise; + /** + * The folder with symlinks resolved, on Unix; the folder as given on + * Windows. The CLI counts a relative venvPath from this form. + */ + realPath: (folder: string) => Promise; + /** * Point the MS Python extension at the provisioned venv interpreter for * `projectRoot`. The root is passed in (not re-read) so adoption always * targets the project the run provisioned, even if the user switched the - * active project during the (multi-second) CLI run. + * active project during the (multi-second) CLI run. `venvDir` is absolute. */ - adoptInterpreter: (venvPath: string, projectRoot: string) => Promise; + adoptInterpreter: (venvDir: string, projectRoot: string) => Promise; /** * Open the project's pyproject.toml in an editor — the "Open pyproject.toml" @@ -219,7 +226,8 @@ export interface PythonSetupSetupDeps { */ showSuccess: ( result: PythonSetupResult, - flags: SetupPresetFlags + flags: SetupPresetFlags, + venvDir: string ) => Promise; /** @@ -605,11 +613,13 @@ export class PythonSetupEnvironmentSetup implements Disposable { // Adoption is the point of the flow: without it the venv exists on disk // but is unusable from the editor, so a failure here is a setup failure — // surface it and stay not-ready rather than rejecting with no message. + let venvDir: string; try { + venvDir = await this.resolveVenvDir(cwd, result.venvPath); // Adopt for the cwd captured at the top of the run, not the live // active project: a mid-run project switch must not point another // project's interpreter setting at this run's venv. - await this.deps.adoptInterpreter(result.venvPath, cwd); + await this.deps.adoptInterpreter(venvDir, cwd); } catch (e) { // The CLI succeeded, so there is no CLI error to report — but the // flow failed. `adopt` is the extension's own phase, appended to the @@ -699,7 +709,27 @@ export class PythonSetupEnvironmentSetup implements Disposable { warnings: result.warnings, }); - this.present(this.deps.showSuccess(result, flags)); + this.present(this.deps.showSuccess(result, flags, venvDir)); + } + + /** + * The absolute folder for the CLI's `venvPath`. A value that climbs + * (`../../.venv` for a uv workspace member) counts from the real project + * folder, as the CLI does; another relative value stays under the folder as + * opened. + */ + private async resolveVenvDir( + cwd: string, + venvPath: string + ): Promise { + if (path.isAbsolute(venvPath)) { + return venvPath; + } + const climbs = venvPath.split(/[\\/]/)[0] === ".."; + return path.resolve( + climbs ? await this.deps.realPath(cwd) : cwd, + venvPath + ); } /** 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 d373880d5..a53f4469f 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,14 @@ import {expect} from "chai"; -import {mkdtemp, readdir, readFile, rm, writeFile} from "fs/promises"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + symlink, + writeFile, +} from "fs/promises"; import {tmpdir} from "os"; import path from "path"; import {commands, env, QuickPick, QuickPickItem, Uri, window} from "vscode"; @@ -1033,6 +1042,33 @@ describe("makePythonSetupDeps openProjectFile", () => { }); }); +describe("makePythonSetupDeps realPath", () => { + it("resolves symlinks in the project folder on Unix", async function () { + if (process.platform === "win32") { + // Windows keeps the path as given, and symlinks need privileges. + this.skip(); + } + const root = await realpath( + await mkdtemp(path.join(tmpdir(), "vpex-ws-")) + ); + try { + await mkdir(path.join(root, "deep", "bundles", "a"), { + recursive: true, + }); + await symlink( + path.join(root, "deep", "bundles"), + path.join(root, "link") + ); + const deps = makePythonSetupDeps(makeWiring()); + expect(await deps.realPath(path.join(root, "link", "a"))).to.equal( + path.join(root, "deep", "bundles", "a") + ); + } finally { + await rm(root, {recursive: true, force: true}); + } + }); +}); + describe("makePythonSetupDeps restoreProjectFile", () => { it("copies the CLI's backup over the project's pyproject.toml", async () => { const dir = await mkdtemp(path.join(tmpdir(), "vpex-restore-")); @@ -1192,7 +1228,7 @@ describe("makePythonSetupDeps showSuccess", () => { }) ); - await deps.showSuccess(SUCCESS_DEFAULT, {}); + await deps.showSuccess(SUCCESS_DEFAULT, {}, "/proj/.venv"); expect(appended.join("")).to.have.length.greaterThan(0); // The automatic reveal fires regardless of what the user clicks. @@ -1205,7 +1241,7 @@ describe("makePythonSetupDeps showSuccess", () => { it("raises an info toast (never a warning) when the run had warnings", async () => { const deps = makePythonSetupDeps(makeWiring()); - await deps.showSuccess(SUCCESS_WITH_WARNINGS, {}); + await deps.showSuccess(SUCCESS_WITH_WARNINGS, {}, "/proj/.venv"); // A successful run is informational even when it carried warnings; // the warning count is in the message and the details behind it. @@ -1215,6 +1251,28 @@ describe("makePythonSetupDeps showSuccess", () => { expect(warnShownWith).to.have.length(0); }); + it("reads the project name from the venv folder it is given", async () => { + const appended: string[] = []; + const venvDir = await mkdtemp(path.join(tmpdir(), "venv-")); + try { + await writeFile( + path.join(venvDir, "pyvenv.cfg"), + "home = /usr/bin\nprompt = sales\n" + ); + const deps = makePythonSetupDeps( + makeWiring({ + log: {append: (c) => appended.push(c), show: () => {}}, + }) + ); + + await deps.showSuccess(SUCCESS_DEFAULT, {}, venvDir); + } finally { + await rm(venvDir, {recursive: true, force: true}); + } + + expect(appended.join("")).to.contain(".venv (sales)"); + }); + it("reveals the channel again when View Details is picked", async () => { let shown = 0; const deps = makePythonSetupDeps( @@ -1229,7 +1287,7 @@ describe("makePythonSetupDeps showSuccess", () => { ); reply = "View Details"; - await deps.showSuccess(SUCCESS_DEFAULT, {}); + await deps.showSuccess(SUCCESS_DEFAULT, {}, "/proj/.venv"); // Once on the automatic reveal, again when the button is picked. expect(shown).to.equal(2); diff --git a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts index cd88cc9ba..4fac7e515 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts @@ -1,6 +1,6 @@ import {randomBytes} from "crypto"; import {existsSync} from "fs"; -import {copyFile, rename, rm} from "fs/promises"; +import {copyFile, realpath, rename, rm} from "fs/promises"; import path from "path"; import {commands, ProgressLocation, Uri, window} from "vscode"; import {PackageManagerDetection} from "../../language/packageManagerDetection"; @@ -272,9 +272,12 @@ export function makePythonSetupDeps( wiring.createQuickPick ); }, - adoptInterpreter: async (venvPath: string, projectRoot: string) => { + // Windows keeps the path as given. + realPath: async (folder: string) => + process.platform === "win32" ? folder : realpath(folder), + adoptInterpreter: async (venvDir: string, projectRoot: string) => { await wiring.setActiveInterpreter( - venvInterpreterPath(venvPath), + venvInterpreterPath(venvDir), Uri.file(projectRoot) ); }, @@ -434,16 +437,14 @@ export function makePythonSetupDeps( ); } }, - showSuccess: async (result, flags) => { + showSuccess: async (result, flags, venvDir) => { // Write the full breakdown to the log channel: in --output json // mode the CLI streams little or nothing to stderr on success, so // the channel would otherwise be empty. This is where the details // the one-line message omits (versions, compute, backup, how to run // notebooks, full warnings) live. Enrich the venv lines with the // project name uv recorded in pyvenv.cfg when it can be read. - const projectName = result.venvPath - ? await readVenvProjectName(result.venvPath) - : undefined; + const projectName = await readVenvProjectName(venvDir); wiring.log.append(formatSetupLog(result, flags, projectName)); // Reveal the output channel automatically so those details are in // front of the user, then still raise the notification (with its diff --git a/packages/databricks-vscode/src/python-setup/models/PythonSetupResult.test.ts b/packages/databricks-vscode/src/python-setup/models/PythonSetupResult.test.ts index 9bedc077b..40a8e39c8 100644 --- a/packages/databricks-vscode/src/python-setup/models/PythonSetupResult.test.ts +++ b/packages/databricks-vscode/src/python-setup/models/PythonSetupResult.test.ts @@ -8,6 +8,7 @@ import { SUCCESS_DEFAULT, SUCCESS_CONSTRAINTS_ONLY, SUCCESS_REAL_RUN, + SUCCESS_REAL_RUN_UV_WORKSPACE_MEMBER, SUCCESS_WITH_WARNINGS, ERROR_NO_TARGET, ERROR_USAGE, @@ -46,10 +47,21 @@ describe("parsePythonSetupResult", () => { const r = parsePythonSetupResult(asStdout(SUCCESS_REAL_RUN)); expect(r.ok).to.equal(true); expect(r.dryRun).to.equal(false); - expect(r.venvPath).to.equal("/home/user/project/.venv"); + expect(r.venvPath).to.equal(".venv"); expect(r.backupPath).to.equal("/home/user/project/pyproject.toml.bak"); }); + it("parses the uv workspace fields of a workspace member run", () => { + const r = parsePythonSetupResult( + asStdout(SUCCESS_REAL_RUN_UV_WORKSPACE_MEMBER) + ); + expect(r.venvPath).to.equal("../../.venv"); + expect(r.uvWorkspaceRoot).to.equal("/home/user/monorepo"); + expect(r.uvWorkspaceBackupPath).to.equal( + "/home/user/monorepo/pyproject.toml.bak" + ); + }); + it("parses an E_NO_TARGET failure and exposes the error object", () => { const r = parsePythonSetupResult(asStdout(ERROR_NO_TARGET)); expect(r.ok).to.equal(false); diff --git a/packages/databricks-vscode/src/python-setup/models/PythonSetupResult.ts b/packages/databricks-vscode/src/python-setup/models/PythonSetupResult.ts index 3f7766f5a..f9d7782aa 100644 --- a/packages/databricks-vscode/src/python-setup/models/PythonSetupResult.ts +++ b/packages/databricks-vscode/src/python-setup/models/PythonSetupResult.ts @@ -120,8 +120,20 @@ export interface PythonSetupResult { resolved?: PythonSetupResolvedInfo; greenfield: boolean; plan?: PythonSetupPlan; + /** + * The venv folder in slash form: relative to the project folder (`.venv`, + * or `../../.venv` for a uv workspace member), or absolute when + * `UV_PROJECT_ENVIRONMENT` is absolute. + */ venvPath?: string; backupPath?: string; + /** + * Set only for a uv workspace member: the absolute workspace root, whose + * pyproject.toml holds the constraint pins. + */ + uvWorkspaceRoot?: string; + /** The backup of the root pyproject.toml, when the run changed that file. */ + uvWorkspaceBackupPath?: string; phases: PythonSetupPhaseEntry[]; warnings: PythonSetupWarning[]; error: PythonSetupError | null; diff --git a/packages/databricks-vscode/src/python-setup/models/fixtures/setupLocalResults.ts b/packages/databricks-vscode/src/python-setup/models/fixtures/setupLocalResults.ts index 7979b1eb7..7a63d25f8 100644 --- a/packages/databricks-vscode/src/python-setup/models/fixtures/setupLocalResults.ts +++ b/packages/databricks-vscode/src/python-setup/models/fixtures/setupLocalResults.ts @@ -20,6 +20,8 @@ * provision — every acceptance case uses `--dry-run`). It follows * libs/localenv/result.go: `dryRun: false`, no `plan`, and the `venvPath` / * `backupPath` fields the extension's adopt-interpreter step consumes. + * SUCCESS_REAL_RUN_UV_WORKSPACE_MEMBER follows the same file for a member of a + * uv workspace: the shared venv and the constraint pins live at the root. */ import {PythonSetupResult} from "../PythonSetupResult"; @@ -249,7 +251,7 @@ export const SUCCESS_REAL_RUN: PythonSetupResult = { artifactSource: "network", }, greenfield: false, - venvPath: "/home/user/project/.venv", + venvPath: ".venv", backupPath: "/home/user/project/pyproject.toml.bak", phases: [ {phase: "preflight", status: "ok"}, @@ -264,6 +266,18 @@ export const SUCCESS_REAL_RUN: PythonSetupResult = { durationMs: 0, }; +/** + * Synthesized real run in `bundles/sales`, a member of the uv workspace at + * `/home/user/monorepo`. `venvPath` stays relative to the member folder. + */ +export const SUCCESS_REAL_RUN_UV_WORKSPACE_MEMBER: PythonSetupResult = { + ...SUCCESS_REAL_RUN, + venvPath: "../../.venv", + backupPath: "/home/user/monorepo/bundles/sales/pyproject.toml.bak", + uvWorkspaceRoot: "/home/user/monorepo", + uvWorkspaceBackupPath: "/home/user/monorepo/pyproject.toml.bak", +}; + /** * Synthesized real run over an existing project that merges cleanly except for a * couple of pin conflicts: {@link SUCCESS_REAL_RUN} plus the merge warnings a diff --git a/packages/databricks-vscode/src/python-setup/utils/setupSummary.test.ts b/packages/databricks-vscode/src/python-setup/utils/setupSummary.test.ts index ab60a9e80..771de148d 100644 --- a/packages/databricks-vscode/src/python-setup/utils/setupSummary.test.ts +++ b/packages/databricks-vscode/src/python-setup/utils/setupSummary.test.ts @@ -6,6 +6,7 @@ import { SUCCESS_DEFAULT, SUCCESS_CONSTRAINTS_ONLY, SUCCESS_REAL_RUN, + SUCCESS_REAL_RUN_UV_WORKSPACE_MEMBER, } from "../models/fixtures/setupLocalResults"; // The three presets the picker resolves to, as the skip flags formatSetupLog @@ -62,6 +63,20 @@ describe("formatSetupNotification", () => { }); }); +describe("formatSetupNotification for a uv workspace member", () => { + it("names the shared venv, which another member may have created", () => { + expect( + formatSetupNotification( + SUCCESS_REAL_RUN_UV_WORKSPACE_MEMBER, + "linux" + ) + ).to.equal( + "Python environment ready — the shared uv workspace venv " + + "(../../.venv) is selected for your Databricks project." + ); + }); +}); + describe("formatSetupLog", () => { it("is non-empty and self-delimited with leading/trailing newlines", () => { const log = formatSetupLog(SUCCESS_DEFAULT, FULL); @@ -175,6 +190,46 @@ describe("formatSetupLog", () => { ); }); + it("names the shared venv and the root pyproject.toml for a uv workspace member", () => { + const log = formatSetupLog( + SUCCESS_REAL_RUN_UV_WORKSPACE_MEMBER, + FULL, + undefined, + "linux" + ); + expect(log).to.contain( + " • Added matching Databricks constraints to the uv workspace root pyproject.toml" + ); + expect(log).to.contain( + " • Synced the shared uv workspace virtual environment ../../.venv with uv sync" + ); + expect(log).to.contain( + " • Selected ../../.venv as the workspace interpreter" + ); + expect(log).to.contain( + " • Backed up your previous uv workspace root pyproject.toml (pyproject.toml.bak)" + ); + expect(log).to.contain("is selected (`../../.venv/bin/python`)."); + }); + + it("shows the member venv folder with Windows separators on Windows", () => { + const log = formatSetupLog( + SUCCESS_REAL_RUN_UV_WORKSPACE_MEMBER, + FULL, + undefined, + "win32" + ); + expect(log).to.contain( + " • Selected ..\\..\\.venv as the workspace interpreter" + ); + expect( + formatSetupNotification( + SUCCESS_REAL_RUN_UV_WORKSPACE_MEMBER, + "win32" + ) + ).to.contain("(..\\..\\.venv)"); + }); + it("omits the backup line when nothing was backed up", () => { const noBackup: PythonSetupResult = { ...SUCCESS_REAL_RUN, diff --git a/packages/databricks-vscode/src/python-setup/utils/setupSummary.ts b/packages/databricks-vscode/src/python-setup/utils/setupSummary.ts index 05e99ccfa..7d46b7a17 100644 --- a/packages/databricks-vscode/src/python-setup/utils/setupSummary.ts +++ b/packages/databricks-vscode/src/python-setup/utils/setupSummary.ts @@ -15,8 +15,15 @@ import {venvInterpreterPath} from "./venvInterpreterPath"; * message names the count but the outcome stays informational; the warnings * themselves are listed in the details. */ -export function formatSetupNotification(result: PythonSetupResult): string { - const tail = ".venv created and selected for your Databricks project."; +export function formatSetupNotification( + result: PythonSetupResult, + platform: NodeJS.Platform = process.platform +): string { + const venv = venvFolder(result, platform); + // Another member may have created the shared venv already. + const tail = result.uvWorkspaceRoot + ? `the shared uv workspace venv (${venv}) is selected for your Databricks project.` + : `${venv} created and selected for your Databricks project.`; const fallback = result.pythonResolution === "installed_fallback" ? "Python download failed; used a compatible installed Python " + @@ -50,12 +57,12 @@ export function formatSetupNotification(result: PythonSetupResult): string { * * `projectName` is the human-readable name uv associated with the venv (from * pyproject's `[project].name`, else the project folder name), surfaced by the - * caller from `.venv/pyvenv.cfg`. It is shown in parentheses beside the venv - * folder when known; when omitted the bare `.venv` folder name stands alone. + * caller from the venv's `pyvenv.cfg`. It is shown in parentheses beside the + * venv folder when known; when omitted the folder name stands alone. * - * `platform` selects the OS-specific interpreter path shown in the notebook - * hint (`.venv/bin/python` vs `.venv\Scripts\python.exe`); it defaults to the - * host and is injectable so the output is deterministic in tests. + * `platform` selects the OS-specific venv folder and interpreter path shown + * (`.venv/bin/python` vs `.venv\Scripts\python.exe`); it defaults to the host + * and is injectable so the output is deterministic in tests. * * Returned with a leading and trailing newline so it reads as its own block * beneath any CLI output already streamed to the channel. @@ -68,10 +75,15 @@ export function formatSetupLog( ): string { const withDbconnect = !flags.skipDbconnect; const constraintsApplied = !flags.skipConstraints; - // The venv folder is always `.venv`; when the caller resolved the project - // name, show it alongside so the line reads like the label VS Code puts on - // the interpreter (e.g. ".venv (my-project)"). - const venvLabel = projectName ? `.venv (${projectName})` : ".venv"; + // When the caller resolved the project name, show it alongside so the line + // reads like the label VS Code puts on the interpreter (e.g. + // ".venv (my-project)"). + const venv = venvFolder(result, platform); + const venvLabel = projectName ? `${venv} (${projectName})` : venv; + // A uv workspace member keeps its pins and their backup in the root file. + const pinsFile = result.uvWorkspaceRoot + ? "the uv workspace root pyproject.toml" + : "pyproject.toml"; const lines: string[] = [ withDbconnect ? "Python environment ready for Databricks Connect." @@ -90,14 +102,21 @@ export function formatSetupLog( lines.push("", "What was done:"); if (constraintsApplied) { - lines.push( - " • Added matching Databricks constraints to pyproject.toml" - ); + lines.push(` • Added matching Databricks constraints to ${pinsFile}`); } lines.push( - ` • Built a new virtual environment with uv sync called ${venvLabel}` + result.uvWorkspaceRoot + ? ` • Synced the shared uv workspace virtual environment ${venvLabel} with uv sync` + : ` • Built a new virtual environment with uv sync called ${venvLabel}` ); lines.push(` • Selected ${venvLabel} as the workspace interpreter`); + if (result.uvWorkspaceBackupPath) { + lines.push( + ` • Backed up your previous uv workspace root pyproject.toml (${path.basename( + result.uvWorkspaceBackupPath + )})` + ); + } if (result.backupPath) { lines.push( ` • Backed up your previous pyproject.toml (${path.basename( @@ -109,7 +128,7 @@ export function formatSetupLog( // Name the environment to look for in the picker when we have it; the // interpreter path is the version-proof anchor either way. Render it // OS-aware (`.venv/bin/python` vs `.venv\Scripts\python.exe`). - const interpreter = venvInterpreterPath(".venv", platform); + const interpreter = venvInterpreterPath(venv, platform); const selectedHint = projectName ? `is selected: ${projectName} (\`${interpreter}\`).` : `is selected (\`${interpreter}\`).`; @@ -148,3 +167,16 @@ function computeLabel( } return compute.source || undefined; } + +/** + * The venv folder as the CLI reports it, relative to the project (`../../.venv` + * for a uv workspace member), with the separators of `platform`. A dry run + * reports none; `.venv` stands in. + */ +function venvFolder( + result: PythonSetupResult, + platform: NodeJS.Platform +): string { + const venv = result.venvPath || ".venv"; + return platform === "win32" ? venv.replace(/\//g, "\\") : venv; +} diff --git a/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.test.ts b/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.test.ts index 526c378b0..a9f10f3bb 100644 --- a/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.test.ts +++ b/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.test.ts @@ -313,14 +313,18 @@ describe(__filename, () => { {code: "W_DBCONNECT_CONSOLIDATED", message: "irrelevant"}, {code: "W_USER_CONSTRAINT_CONFLICT", message: "irrelevant"}, {code: "W_USER_CONSTRAINT_CONFLICT", message: "irrelevant"}, + { + code: "W_UV_WORKSPACE_CONSTRAINTS_REPLACED", + message: "irrelevant", + }, ], }); // The total is a numeric metric, not a property. - expect(events[1].metrics["event.warningsCount"]).to.equal(6); + expect(events[1].metrics["event.warningsCount"]).to.equal(7); expect(events[1].props).to.not.have.property("event.warningsCount"); // The per-code counts are a JSON-stringified property (objects never - // become metrics), covering all five known codes with the repeated one + // become metrics), covering all known codes with the repeated one // counted twice. expect( JSON.parse(events[1].props["event.warningCodeCounts"]) @@ -330,6 +334,7 @@ describe(__filename, () => { W_DBCONNECT_PIN_DUPLICATED: 1, W_DBCONNECT_CONSOLIDATED: 1, W_USER_CONSTRAINT_CONFLICT: 2, + W_UV_WORKSPACE_CONSTRAINTS_REPLACED: 1, }); }); diff --git a/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.ts b/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.ts index 609f304d8..b5a80e3a4 100644 --- a/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.ts +++ b/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.ts @@ -178,6 +178,7 @@ const KNOWN_WARNING_CODES: ReadonlySet = new Set([ "W_DBCONNECT_PIN_DUPLICATED", "W_DBCONNECT_CONSOLIDATED", "W_USER_CONSTRAINT_CONFLICT", + "W_UV_WORKSPACE_CONSTRAINTS_REPLACED", ]); /** Bucket for a warning code outside {@link KNOWN_WARNING_CODES}. */ From 12d9c366e0d23f3ae85095fd1a9c7c8071553769 Mon Sep 17 00:00:00 2001 From: "@rugpanov" Date: Tue, 13 Oct 2026 23:35:33 +0200 Subject: [PATCH 2/3] docs(python-setup): describe W_UV_WORKSPACE_CONSTRAINTS_REPLACED in the warning list Co-authored-by: Isaac --- .../databricks-vscode/src/telemetry/pythonSetupExtensions.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.ts b/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.ts index b5a80e3a4..f62995ced 100644 --- a/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.ts +++ b/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.ts @@ -166,6 +166,8 @@ function categoricalEnvKey(envKey: string | undefined): string | undefined { * another dependency group) was removed so a single managed pin survives. * - `W_USER_CONSTRAINT_CONFLICT` — a user dependency is provably disjoint from an * env constraint. + * - `W_UV_WORKSPACE_CONSTRAINTS_REPLACED` — for a uv workspace member, the pins + * in the root pyproject.toml, which every member shares, are replaced. * * Held as a set so an unknown code (schema drift, or a code added CLI-side before * this list is updated) collapses to {@link UNRECOGNISED_WARNING_CODE} rather than From d377b620b44a9ef56d91d1de89ad1725a6fc0d5c Mon Sep 17 00:00:00 2001 From: "@rugpanov" Date: Wed, 14 Oct 2026 15:05:48 +0200 Subject: [PATCH 3/3] fix(python-setup): offer no report when the project folder is gone before adoption A realpath failure on the project folder (renamed or deleted during the CLI run) is a filesystem state, not an extension defect. Show a plain error without "Report this problem" and record reportOffered: false. Co-authored-by: Isaac --- .../PythonSetupEnvironmentSetup.test.ts | 40 +++++++++++++++++++ .../PythonSetupEnvironmentSetup.ts | 24 +++++++++++ 2 files changed, 64 insertions(+) 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 13638e49c..3f083ffd4 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts @@ -1407,6 +1407,46 @@ describe("PythonSetupEnvironmentSetup telemetry", () => { ]); }); + it("offers no report when the project folder is gone before adoption", async () => { + // A folder renamed or deleted during the CLI run is not an extension + // defect: no "Report this problem", and adoption never runs. + const telemetry = makeTelemetryRecorder(); + const errors: Array<{message: string; actions?: unknown[]}> = []; + let adopted = false; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + ...telemetry, + cli: makeCli({resolve: SUCCESS_REAL_RUN_UV_WORKSPACE_MEMBER}), + realPath: async () => { + throw new Error("ENOENT: no such file or directory"); + }, + adoptInterpreter: async () => { + adopted = true; + }, + showError: async (message, _detail, actions) => { + errors.push({message, actions}); + }, + }) + ); + + await setup.setup(); + + expect(adopted).to.equal(false); + expect(setup.ready).to.equal(false); + expect(errors).to.have.length(1); + expect(errors[0].message).to.contain("/proj"); + expect(errors[0].actions ?? []).to.deep.equal([]); + expect(telemetry.results).to.deep.equal([ + { + outcome: "failed", + failurePhase: "adopt", + envKey: SUCCESS_REAL_RUN_UV_WORKSPACE_MEMBER.compute!.envKey, + reportOffered: false, + warnings: SUCCESS_REAL_RUN_UV_WORKSPACE_MEMBER.warnings, + }, + ]); + }); + it("reports cancelled when the user aborts the run", async () => { const telemetry = makeTelemetryRecorder(); 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 ce97a62f8..bb7d73412 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts @@ -616,6 +616,30 @@ export class PythonSetupEnvironmentSetup implements Disposable { let venvDir: string; try { venvDir = await this.resolveVenvDir(cwd, result.venvPath); + } catch (e) { + // The project folder moved or was deleted during the run: a + // filesystem state, not an extension defect, so offer no report. + const message = e instanceof Error ? e.message : String(e); + reportResult({ + outcome: "failed", + ...(result.pythonResolution !== undefined + ? {pythonSetupFlow: result.pythonResolution} + : {}), + failurePhase: "adopt", + envKey: result.compute.envKey, + reportOffered: false, + warnings: result.warnings, + }); + this.present( + this.deps.showError( + `Cannot find the project folder ${cwd}. Open the project again and run the setup again.`, + message, + [] + ) + ); + return; + } + try { // Adopt for the cwd captured at the top of the run, not the live // active project: a mid-run project switch must not point another // project's interpreter setting at this run's venv.