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
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {expect} from "chai";
import path from "node:path";
import {
PythonSetupEnvironmentSetup,
PythonSetupSetupDeps,
Expand All @@ -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,
Expand Down Expand Up @@ -123,6 +125,7 @@ function makeDeps(
status: "ok",
compute: {kind: "serverless", version: "5"},
}),
realPath: async (p) => p,
adoptInterpreter: async () => {},
openProjectFile: async () => {},
restoreProjectFile: async () => {},
Expand Down Expand Up @@ -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([
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1320,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(
Expand Down Expand Up @@ -1873,7 +2000,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);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -141,13 +142,19 @@ export interface PythonSetupSetupDeps {
compute: SetupCompute
) => Promise<SetupPreset | undefined>;

/**
* 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<string>;

/**
* 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<void>;
adoptInterpreter: (venvDir: string, projectRoot: string) => Promise<void>;

/**
* Open the project's pyproject.toml in an editor — the "Open pyproject.toml"
Expand Down Expand Up @@ -219,7 +226,8 @@ export interface PythonSetupSetupDeps {
*/
showSuccess: (
result: PythonSetupResult,
flags: SetupPresetFlags
flags: SetupPresetFlags,
venvDir: string
) => Promise<void>;

/**
Expand Down Expand Up @@ -605,11 +613,37 @@ 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);
} 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.
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
Expand Down Expand Up @@ -699,7 +733,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<string> {
if (path.isAbsolute(venvPath)) {
return venvPath;
}
const climbs = venvPath.split(/[\\/]/)[0] === "..";
return path.resolve(
climbs ? await this.deps.realPath(cwd) : cwd,
venvPath
);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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-"));
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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(
Expand All @@ -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);
Expand Down
Loading
Loading