feat(interpreters): add an existing Python virtual environment as an interpreter (#2076)

Refs #1907

Add Python Interpreter → Existing environment: Browse picks the environment folder, which is resolved right away (pyvenv.cfg, bin/python or Scripts\python.exe) and added to the interpreter list as "Python X.Y (<env name>)", then remembered. Picking the folder rather than the executable keeps the environment's identity, since the native file picker resolves the venv's python symlink to the base interpreter. Works for `python -m venv` and uv environments; a broken environment shows an error instead of being added. Probing runs off the command thread.

Co-authored-by: Inf1mum <[email protected]>
Цей коміт міститься в:
Vadim LazukтаInf1mum authored and GitHub committed 2026-11-08 09:32:37 +08:00
1 джерело 9673ccc687
коміт 32a778d28d
13 files changed
+430 -25

No files matched your search

+20
Переглянути файл
@@ -823,6 +823,26 @@ export const commands = {
jdkDownload: (vendor: string, version: number, location: string | null, request: RequestId, onEvent: Channel<InstallEvent>) => typedError<Sdk, IdeError>(__TAURI_INVOKE("jdk_download", { vendor, version, location, request, onEvent })),
/** Installed interpreters / toolchains of `kind`; project-local ones first. */
sdkDetect: (kind: SdkKind) => typedError<Sdk[], IdeError>(__TAURI_INVOKE("sdk_detect", { kind })),
/** An existing Python executable or virtualenv folder as an SDK; `None` if Python cannot run. */
sdkPythonInterpreter: (path: string) => typedError<{
/**
* Executable (`python`, `node`, `pnpm`) or directory (Rust toolchain bin
* dir, GOROOT, TypeScript `lib` dir) that the setting stores.
*/
path: IpcPath,
/** Display name, e.g. `Python 3.12 (.venv)` or `stable-aarch64-apple-darwin`. */
name: string,
version: string | null,
/**
* Where it was found: `venv` | `system` | `pyenv` | `conda` | `uv` | `nvm` |
* `rustup` | `bundled` | `workspace` | `path` …
*/
source: string,
/** Environment / toolchain root (virtualenv dir, rustup toolchain dir, GOROOT). */
home: IpcPath | null,
/** Rust standard library sources, Go default GOPATH. */
extra: IpcPath | null,
} | null, IdeError>(__TAURI_INVOKE("sdk_python_interpreter", { path })),
/** The interpreter the project uses: the configured one, else its virtualenv. */
sdkPythonEffective: (near: string | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_python_effective", { near })),
/** The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`. */
+127 -1
Переглянути файл
@@ -5,6 +5,7 @@ import type { Sdk } from "@/bindings";
const mocks = vi.hoisted(() => ({
detect: vi.fn(),
createVenv: vi.fn(),
interpreter: vi.fn(),
choosePath: vi.fn(),
}));
vi.mock("@/platform/ipc", async (importOriginal) => {
@@ -16,6 +17,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
...real.commands,
sdkDetect: mocks.detect,
sdkPythonCreateVenv: mocks.createVenv,
sdkPythonInterpreter: mocks.interpreter,
cancelRequest: vi.fn(),
},
};
@@ -48,6 +50,7 @@ beforeEach(() => {
Object.assign(window, { __TAURI_INTERNALS__: { transformCallback: () => 1 } });
mocks.detect.mockResolvedValue(ok([VENV, SYSTEM]));
mocks.createVenv.mockResolvedValue(new Promise(() => {}));
mocks.interpreter.mockResolvedValue(ok(null));
useSdks.setState({ byKind: {}, loading: {}, errors: {} });
});
afterEach(() => {
@@ -119,6 +122,7 @@ describe("Add Python Interpreter", () => {
});
it("adds an existing interpreter without creating anything", async () => {
mocks.interpreter.mockResolvedValue(ok(VENV));
const onAdded = open();
await waitFor(() => expect(screen.getByRole("button", { name: "Base interpreter" })).toBeInTheDocument());
fireEvent.click(screen.getByRole("radio", { name: "Existing environment" }));
@@ -126,6 +130,128 @@ describe("Add Python Interpreter", () => {
fireEvent.click(await screen.findByRole("option", { name: /Python 3\.12 \(\.venv\)/ }));
fireEvent.click(screen.getByRole("button", { name: "OK" }));
expect(mocks.createVenv).not.toHaveBeenCalled();
expect(onAdded).toHaveBeenCalledWith("/project/.venv/bin/python");
await waitFor(() => expect(onAdded).toHaveBeenCalledWith("/project/.venv/bin/python"));
expect(mocks.interpreter).toHaveBeenCalledWith("/project/.venv/bin/python");
await waitFor(() =>
expect(useSdks.getState().byKind.python?.some((s) => s.path === "/project/.venv/bin/python")).toBe(true),
);
});
it("registers a virtualenv picked from outside detection", async () => {
const OUTSIDE: Sdk = {
path: "/opt/pyenv/erp19py3.12/bin/python",
name: "Python 3.12 (erp19py3.12)",
version: "3.12.1",
source: "venv",
home: "/opt/pyenv/erp19py3.12",
extra: null,
};
mocks.interpreter.mockResolvedValue(ok(OUTSIDE));
mocks.choosePath.mockResolvedValue("/opt/pyenv/erp19py3.12");
const onAdded = open();
await waitFor(() => expect(screen.getByRole("button", { name: "Base interpreter" })).toBeInTheDocument());
fireEvent.click(screen.getByRole("radio", { name: "Existing environment" }));
fireEvent.click(screen.getByRole("button", { name: "Browse…" }));
await waitFor(() => expect(screen.getByRole("button", { name: "OK" })).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "OK" }));
await waitFor(() => expect(onAdded).toHaveBeenCalledWith("/opt/pyenv/erp19py3.12/bin/python"));
expect(mocks.interpreter).toHaveBeenNthCalledWith(1, "/opt/pyenv/erp19py3.12");
expect(mocks.interpreter).toHaveBeenNthCalledWith(2, OUTSIDE.path);
await waitFor(() => expect(useSdks.getState().byKind.python?.some((s) => s.path === OUTSIDE.path)).toBe(true));
});
it.each([
["/project/.venv", "/project/.venv/bin/python"],
["C:\\project\\.venv", "C:\\project\\.venv\\Scripts\\python.exe"],
])("waits for validation and resolves the folder %s", async (folder, executable) => {
let finish!: (value: ReturnType<typeof ok<Sdk>>) => void;
mocks.interpreter.mockReturnValue(new Promise((resolve) => (finish = resolve)));
mocks.detect.mockResolvedValue(ok([SYSTEM]));
mocks.choosePath.mockResolvedValue(folder);
const onAdded = open();
fireEvent.click(screen.getByRole("radio", { name: "Existing environment" }));
fireEvent.click(screen.getByRole("button", { name: "Browse…" }));
await waitFor(() => expect(mocks.interpreter).toHaveBeenCalledWith(folder));
expect(onAdded).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "OK" })).toBeDisabled();
expect(mocks.choosePath).toHaveBeenCalledWith({
directory: true,
multiple: false,
title: "Select Python Environment Folder",
defaultPath: "/project",
});
expect(screen.getByRole("status", { name: "Checking interpreter" })).toBeInTheDocument();
await act(async () => finish(ok({ ...VENV, path: executable })));
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
const select = screen.getByLabelText("Interpreter:");
expect(select).toHaveTextContent(`Python 3.12 (.venv) — ${executable}`);
expect(onAdded).not.toHaveBeenCalled();
expect(useSdks.getState().byKind.python?.some((s) => s.path === executable && s.source === "venv")).toBe(false);
mocks.interpreter.mockResolvedValue(ok({ ...VENV, path: executable }));
fireEvent.click(screen.getByRole("button", { name: "OK" }));
await waitFor(() => expect(onAdded).toHaveBeenCalledWith(executable));
});
it.each(["/project/.venv/bin/python", "C:\\project\\.venv\\Scripts\\python.exe"])(
"preserves the detected interpreter path %s when validating",
async (path) => {
mocks.detect.mockResolvedValue(ok([{ ...VENV, path }]));
mocks.interpreter.mockResolvedValue(ok({ ...VENV, path }));
const onAdded = open();
fireEvent.click(screen.getByRole("radio", { name: "Existing environment" }));
fireEvent.click(screen.getByRole("button", { name: "Interpreter" }));
fireEvent.click(await screen.findByRole("option", { name: /Python 3\.12 \(\.venv\)/ }));
fireEvent.click(screen.getByRole("button", { name: "OK" }));
await waitFor(() => expect(onAdded).toHaveBeenCalledWith(path));
expect(mocks.interpreter).toHaveBeenCalledWith(path);
expect(mocks.choosePath).not.toHaveBeenCalled();
},
);
it("ignores a validation result after cancelling", async () => {
let finish!: (value: ReturnType<typeof ok<Sdk>>) => void;
mocks.interpreter.mockReturnValue(new Promise((resolve) => (finish = resolve)));
mocks.choosePath.mockResolvedValue(VENV.path);
const onAdded = open();
fireEvent.click(screen.getByRole("radio", { name: "Existing environment" }));
fireEvent.click(screen.getByRole("button", { name: "Browse…" }));
await waitFor(() => expect(mocks.interpreter).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await act(async () => finish(ok(VENV)));
expect(onAdded).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Interpreter" })).toHaveTextContent("Select an interpreter");
expect(screen.getByRole("button", { name: "OK" })).toBeDisabled();
});
it("keeps the newly remembered interpreter when a pending scan finishes", async () => {
let finish!: (value: ReturnType<typeof ok<Sdk[]>>) => void;
mocks.detect.mockReturnValue(new Promise((resolve) => (finish = resolve)));
mocks.interpreter.mockResolvedValue(ok(VENV));
mocks.choosePath.mockResolvedValue(VENV.path);
const onAdded = open();
fireEvent.click(screen.getByRole("radio", { name: "Existing environment" }));
fireEvent.click(screen.getByRole("button", { name: "Browse…" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Interpreter" })).toHaveTextContent(VENV.path));
fireEvent.click(screen.getByRole("button", { name: "OK" }));
await waitFor(() => expect(onAdded).toHaveBeenCalledWith(VENV.path));
await act(async () => finish(ok([SYSTEM])));
expect(useSdks.getState().byKind.python?.some((s) => s.path === VENV.path)).toBe(true);
expect(useSdks.getState().byKind.python?.some((s) => s.path === SYSTEM.path)).toBe(true);
});
it.each(["unusable", "failed"])("keeps an %s interpreter out of the project and shows an error", async (result) => {
mocks.interpreter.mockImplementation(() =>
result === "unusable" ? Promise.resolve(ok(null)) : Promise.reject(new Error("Cannot probe interpreter")),
);
mocks.choosePath.mockResolvedValue("/broken/bin/python");
const onAdded = open();
fireEvent.click(screen.getByRole("radio", { name: "Existing environment" }));
fireEvent.click(screen.getByRole("button", { name: "Browse…" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
result === "unusable" ? "Cannot run this Python interpreter" : "Cannot probe interpreter",
);
expect(onAdded).not.toHaveBeenCalled();
expect(useSdks.getState().byKind.python?.some((s) => s.path === "/broken/bin/python")).toBe(false);
});
});
+73 -15
Переглянути файл
@@ -5,7 +5,7 @@
import { isCancelled } from "@/platform/ipc";
import { useId, useMemo, useState } from "react";
import { Dialog, IconButton, Input, Select, Spinner, type SelectOption, T } from "@/ui";
import { choosePath, createVenv, rememberSdk, type VenvTool } from "./api";
import { choosePath, createVenv, pythonInterpreter, rememberSdk, type Sdk, type VenvTool } from "./api";
import { tildify, useDetected } from "./components";
import styles from "./Sdk.module.css";
import { tx } from "@/platform/i18n";
@@ -36,6 +36,7 @@ export function AddInterpreterDialog({
const [base, setBase] = useState<string | null>(null);
const [location, setLocation] = useState(`${root}/.venv`);
const [existing, setExisting] = useState<string | null>(null);
const [browsed, setBrowsed] = useState<Sdk[]>([]);
const [busy, setBusy] = useState<{ cancel: () => void } | null>(null);
const [log, setLog] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
@@ -48,17 +49,46 @@ export function AddInterpreterDialog({
detail: tildify(s.path),
});
const baseOptions = bases.map(toOption);
const existingOptions = useMemo(() => {
const opts = list.map(toOption);
if (existing && !opts.some((o) => o.value === existing))
opts.push({ value: existing, label: tildify(existing), detail: "custom" });
return opts;
}, [list, existing]);
const existingOptions = [...browsed, ...list.filter((s) => !browsed.some((b) => b.path === s.path))].map((s) => ({
value: s.path,
label: `${s.name} — ${tildify(s.path)}`,
}));
const checkExisting = (path: string, onResolved: (sdk: Sdk) => void) => {
setError(null);
let cancelled = false;
setBusy({
cancel: () => {
cancelled = true;
setBusy(null);
},
});
void pythonInterpreter(path)
.then((sdk) => {
if (cancelled) return;
if (!sdk) {
setError(tx("Cannot run this Python interpreter. Select a working Python executable or virtualenv folder."));
return;
}
onResolved(sdk);
})
.catch((e: unknown) => {
if (!cancelled) setError(String((e as Error)?.message ?? e));
})
.finally(() => {
if (!cancelled) setBusy(null);
});
};
const ok = () => {
if (busy) return;
setError(null);
if (mode === "existing") {
if (existing) onAdded(existing);
if (existing)
checkExisting(existing, (sdk) => {
rememberSdk("python", sdk);
onAdded(sdk.path);
});
return;
}
if (!baseValue || !location.trim()) return;
@@ -74,7 +104,7 @@ export function AddInterpreterDialog({
.finally(() => setBusy(null));
};
const canOk = mode === "new" ? !!baseValue && !!location.trim() && !busy : !!existing;
const canOk = !busy && (mode === "new" ? !!baseValue && !!location.trim() : !!existing?.trim());
const note = {
venv: tx("Runs “<base> -m venv <location>”; the new interpreter becomes the project interpreter."),
conda: tx("Runs “conda create -p <location>”; the new interpreter becomes the project interpreter."),
@@ -91,7 +121,11 @@ export function AddInterpreterDialog({
title="Add Python Interpreter"
width={680}
aria-label="Add Python Interpreter"
footerStart={busy && <Spinner size={14} label="Creating environment" />}
footerStart={
busy && (
<Spinner size={14} label={mode === "existing" ? tx("Checking interpreter") : tx("Creating environment")} />
)
}
buttons={[
{ label: "OK", role: "default", disabled: !canOk, onClick: ok },
{ label: "Cancel", role: "cancel" },
@@ -103,6 +137,7 @@ export function AddInterpreterDialog({
<li key={k.tool}>
<button
type="button"
disabled={!!busy}
className={`${styles.kind} ${kind === k.tool ? styles.kindOn : ""}`}
aria-current={kind === k.tool || undefined}
onClick={() => {
@@ -118,11 +153,23 @@ export function AddInterpreterDialog({
<div className={styles.form}>
<div className={styles.radios} role="radiogroup" aria-label={tx("Environment")}>
<label className={styles.radio}>
<input type="radio" name="env" checked={mode === "new"} onChange={() => setMode("new")} />{" "}
<input
type="radio"
name="env"
disabled={!!busy}
checked={mode === "new"}
onChange={() => setMode("new")}
/>{" "}
<T>New environment</T>
</label>
<label className={styles.radio}>
<input type="radio" name="env" checked={mode === "existing"} onChange={() => setMode("existing")} />{" "}
<input
type="radio"
name="env"
disabled={!!busy}
checked={mode === "existing"}
onChange={() => setMode("existing")}
/>{" "}
<T>Existing environment</T>
</label>
</div>
@@ -179,10 +226,12 @@ export function AddInterpreterDialog({
<label htmlFor={ids.existing}>
<T>Interpreter:</T>
</label>
<div className={styles.pathRow}>
<div className={`${styles.pathRow} ${styles.existingPathRow}`}>
<Select
id={ids.existing}
className={styles.pathInput}
value={existing}
disabled={!!busy}
options={existingOptions}
onChange={setExisting}
placeholder={loading ? "Detecting…" : "Select an interpreter"}
@@ -192,9 +241,18 @@ export function AddInterpreterDialog({
icon="folder-open"
size="small"
title="Browse…"
disabled={!!busy}
onClick={async () => {
const p = await choosePath({ directory: false, title: "Select Python Interpreter" });
if (p) setExisting(p);
const p = await choosePath({
directory: true,
title: "Select Python Environment Folder",
defaultPath: root,
});
if (p)
checkExisting(p, (sdk) => {
setBrowsed((current) => [sdk, ...current.filter((s) => s.path !== sdk.path)]);
setExisting(sdk.path);
});
}}
/>
</div>
+3
Переглянути файл
@@ -201,6 +201,9 @@
flex: 1;
min-width: 0;
}
.existingPathRow {
flex: none;
}
.pathInput {
flex: 1;
min-width: 0;
+13 -4
Переглянути файл
@@ -60,10 +60,14 @@ export function detectSdks(kind: SdkKind, force = false): Promise<Sdk[]> {
/** Adds an SDK (e.g. a just-created virtualenv) to the cached list. */
export function rememberSdk(kind: SdkKind, sdk: Sdk) {
useSdks.setState((s) => {
const list = s.byKind[kind] ?? [];
return { byKind: { ...s.byKind, [kind]: [sdk, ...list.filter((x) => x.path !== sdk.path)] } };
});
const remember = () =>
useSdks.setState((s) => {
const list = s.byKind[kind] ?? [];
return { byKind: { ...s.byKind, [kind]: [sdk, ...list.filter((x) => x.path !== sdk.path)] } };
});
remember();
// An already-running scan may not include this newly added environment.
void inflight.get(kind)?.then(remember);
}
export interface Streamed<T> {
@@ -93,6 +97,11 @@ export function createVenv(
return streamed((req, ch) => call(commands.sdkPythonCreateVenv(tool, base, location, req, ch)), onLine);
}
/** Resolves an interpreter picked by hand ("Existing environment") into an SDK like the detected ones. */
export function pythonInterpreter(path: string): Promise<Sdk | null> {
return isTauri() ? call(commands.sdkPythonInterpreter(path)) : Promise.resolve(null);
}
export function listPackages(python: string): Promise<PythonPackage[]> {
return call(commands.sdkPythonPackages(python, nextRequestId()));
}
+3
Переглянути файл
@@ -1,4 +1,7 @@
{
"Select Python Environment Folder": "Select Python Environment Folder",
"Checking interpreter": "Checking interpreter",
"Cannot run this Python interpreter. Select a working Python executable or virtualenv folder.": "Cannot run this Python interpreter. Select a working Python executable or virtualenv folder.",
"Native server declined rename": "Native server declined rename",
"document is too large or is not a regular file": "document is too large or is not a regular file",
"document is too large": "document is too large",
+3
Переглянути файл
@@ -1,4 +1,7 @@
{
"Select Python Environment Folder": "选择 Python 环境文件夹",
"Checking interpreter": "正在检查解释器",
"Cannot run this Python interpreter. Select a working Python executable or virtualenv folder.": "无法运行此 Python 解释器。请选择可用的 Python 可执行文件或虚拟环境文件夹。",
"Native server declined rename": "语言服务器拒绝重命名",
"document is too large or is not a regular file": "文档过大或不是普通文件",
"document is too large": "文档过大",
+2
Переглянути файл
@@ -20,6 +20,7 @@ export interface SelectOption<V extends string = string> {
}
export interface SelectProps<V extends string = string> {
id?: string;
value: V | null;
options: readonly SelectOption<V>[];
onChange: (value: V) => void;
@@ -177,6 +178,7 @@ export function Select<V extends string = string>({
<>
<button
ref={triggerRef}
id={aria.id}
type="button"
aria-haspopup="listbox"
aria-expanded={open}