mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
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]>
This commit is contained in:
1 parent
9673ccc687
commit
32a778d28d
13 files changed
+430
-25
No files matched your search
@@ -45,6 +45,15 @@ pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<V
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// An existing Python executable or virtualenv folder as an SDK; `None` if Python cannot run.
|
||||
pub async fn sdk_python_interpreter(host: &Host, window: &str, path: PathBuf) -> IdeResult<Option<Sdk>> {
|
||||
host.require_trust(window)?;
|
||||
let root = host.project(window).ok().map(|p| p.root.clone());
|
||||
tokio::task::spawn_blocking(move || Ok(ide_sdk::python_interpreter_sdk(&path, root.as_deref())))
|
||||
.await
|
||||
.map_err(join_err)?
|
||||
}
|
||||
|
||||
/// The interpreter the project uses: the configured one, else its virtualenv.
|
||||
pub fn sdk_python_effective(host: &Host, window: &str, near: Option<PathBuf>) -> IdeResult<Option<ide_core::IpcPath>> {
|
||||
host.require_trust(window)?;
|
||||
@@ -288,6 +297,10 @@ pub async fn sdk_python_pip(
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
|
||||
d.add(
|
||||
"sdk_python_interpreter",
|
||||
|c| async move { sdk_python_interpreter(&c.host, &c.window, c.arg("path")?).await },
|
||||
);
|
||||
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window, c.arg("near")?) });
|
||||
d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) });
|
||||
d.add("sdk_php_xdebug", |c| async move { sdk_php_xdebug(&c.host, &c.window, c.arg("php")?, c.arg("cwd")?).await });
|
||||
@@ -334,3 +347,69 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
.await
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::HostConfig;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn fake_venv(root: &Path, name: &str) -> PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let venv = root.join(name);
|
||||
std::fs::create_dir_all(venv.join("bin")).unwrap();
|
||||
std::fs::write(venv.join("pyvenv.cfg"), "home = /usr/bin\nversion = 3.12.1\n").unwrap();
|
||||
let python = venv.join("bin/python");
|
||||
std::fs::write(&python, "#!/bin/sh\necho 3.12.1\n").unwrap();
|
||||
std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
python
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_hand_picked_venv_is_resolved_like_a_detected_interpreter() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(|_| {})),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
|
||||
let sdk = sdk_python_interpreter(&host, "main", fake_venv(&root, ".venv")).await.unwrap().unwrap();
|
||||
assert_eq!((sdk.name.as_str(), sdk.source.as_str()), ("Python 3.12 (project)", "venv"));
|
||||
let python = fake_venv(&base, "erp19py3.12");
|
||||
let sdk = sdk_python_interpreter(&host, "main", python.parent().unwrap().parent().unwrap().into())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!((sdk.name.as_str(), sdk.source.as_str()), ("Python 3.12 (erp19py3.12)", "venv"));
|
||||
assert_eq!(&*sdk.path, python.as_path());
|
||||
host.project("main")
|
||||
.unwrap()
|
||||
.settings
|
||||
.set(
|
||||
ide_workspace::SettingsScope::Project,
|
||||
"languages.python.interpreter",
|
||||
serde_json::to_value(&sdk.path).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
host.close_project("main");
|
||||
host.open_project("main", &root).unwrap();
|
||||
let detected = sdk_detect(&host, "main", SdkKind::Python).await.unwrap();
|
||||
assert!(detected.iter().any(|s| s.path == sdk.path && s.name == sdk.name));
|
||||
assert_eq!(sdk_python_effective(&host, "main", None).unwrap(), Some(sdk.path));
|
||||
assert!(sdk_python_interpreter(&host, "main", root.join("README.md")).await.unwrap().is_none());
|
||||
host.close_project("main");
|
||||
}
|
||||
}
|
||||
@@ -197,16 +197,36 @@ fn python(root: Option<&Path>) -> Vec<Sdk> {
|
||||
par_map(&candidates, |(path, source)| python_sdk(path, source, root))
|
||||
}
|
||||
|
||||
/// Interpreter `path` (one the project is set to, wherever it lives) as an SDK, named
|
||||
/// like the detected ones.
|
||||
/// A runnable Python executable or virtualenv folder, named like detected SDKs.
|
||||
pub fn python_interpreter_sdk(path: &Path, root: Option<&Path>) -> Option<Sdk> {
|
||||
python_sdk(path, if venv_of(path).is_some() { "venv" } else { "system" }, root)
|
||||
let path = if path.is_dir() && path.join("pyvenv.cfg").is_file() {
|
||||
path.join(bin_dir()).join(exe("python"))
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
if !path.is_file() || !usable(&path) {
|
||||
return None;
|
||||
}
|
||||
// Run Python code: --version and pyvenv.cfg can still work after the base
|
||||
// installation (including its standard library) has been removed.
|
||||
let version =
|
||||
query(&path, &["-I", "-c", "import sys; print('.'.join(map(str, sys.version_info[:3])))"], VERSION_TIMEOUT)?;
|
||||
if version.split('.').count() != 3
|
||||
|| !version.split('.').all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(python_sdk_with_version(&path, if venv_of(&path).is_some() { "venv" } else { "system" }, root, version))
|
||||
}
|
||||
|
||||
pub(crate) fn python_sdk(path: &Path, source: &str, root: Option<&Path>) -> Option<Sdk> {
|
||||
let version = query(path, &["--version"], VERSION_TIMEOUT)
|
||||
.and_then(|v| v.strip_prefix("Python ").map(str::to_owned))
|
||||
.or_else(|| pyvenv_version(path))?;
|
||||
Some(python_sdk_with_version(path, source, root, version))
|
||||
}
|
||||
|
||||
fn python_sdk_with_version(path: &Path, source: &str, root: Option<&Path>, version: String) -> Sdk {
|
||||
let short = version.split('.').take(2).collect::<Vec<_>>().join(".");
|
||||
let venv = venv_of(path);
|
||||
let label = match (&venv, source) {
|
||||
@@ -236,7 +256,7 @@ pub(crate) fn python_sdk(path: &Path, source: &str, root: Option<&Path>) -> Opti
|
||||
(None, tool @ ("uv" | "mise" | "asdf")) => format!("Python {short} ({tool})"),
|
||||
_ => format!("Python {short}"),
|
||||
};
|
||||
Some(Sdk {
|
||||
Sdk {
|
||||
path: path.into(),
|
||||
name: label,
|
||||
version: Some(version),
|
||||
@@ -246,7 +266,7 @@ pub(crate) fn python_sdk(path: &Path, source: &str, root: Option<&Path>) -> Opti
|
||||
.or_else(|| path.parent().and_then(Path::parent).map(Path::to_path_buf))
|
||||
.map(Into::into),
|
||||
extra: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `.pixi/envs/<env>` of a pixi interpreter (`<env>/python`, `<env>/bin/python`).
|
||||
|
||||
@@ -78,22 +78,92 @@ fn project_venv_is_the_default_interpreter() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn an_interpreter_outside_detection_is_named_like_a_detected_one() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("shop");
|
||||
let member = fake_venv(&root.join("backend"), ".venv");
|
||||
working_python(&member);
|
||||
let sdk = crate::python_interpreter_sdk(&member, Some(&root)).unwrap();
|
||||
assert_eq!(
|
||||
(sdk.name.as_str(), sdk.version.as_deref(), sdk.source.as_str()),
|
||||
("Python 3.12 (backend)", Some("3.12.1"), "venv")
|
||||
);
|
||||
let own = fake_venv(&root, ".venv");
|
||||
working_python(&own);
|
||||
assert_eq!(crate::python_interpreter_sdk(&own, Some(&root)).unwrap().name, "Python 3.12 (shop)");
|
||||
// Outside the project the folder name says which one it is.
|
||||
let elsewhere = fake_venv(&tmp.path().join("other"), ".venv");
|
||||
working_python(&elsewhere);
|
||||
assert_eq!(crate::python_interpreter_sdk(&elsewhere, Some(&root)).unwrap().name, "Python 3.12 (.venv)");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn working_python(path: &Path) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::write(path, "#!/bin/sh\n[ \"$1\" = '-I' ] && [ \"$2\" = '-c' ] || exit 1\necho 3.12.1\n").unwrap();
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn picked_venv_folders_and_executables_are_validated_without_resolving_symlinks() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = tmp.path().join("base-python");
|
||||
working_python(&base);
|
||||
for (name, cfg) in [("venv", "version = 3.12.1\n"), (".venv", "uv = 0.9.0\nversion_info = 3.12.1\n")] {
|
||||
let python = fake_venv(tmp.path(), name);
|
||||
let home = python.parent().unwrap().parent().unwrap();
|
||||
std::fs::write(home.join("pyvenv.cfg"), cfg).unwrap();
|
||||
std::fs::remove_file(&python).unwrap();
|
||||
std::os::unix::fs::symlink(&base, &python).unwrap();
|
||||
for picked in [home, python.as_path()] {
|
||||
let sdk = crate::python_interpreter_sdk(picked, None).unwrap();
|
||||
assert_eq!(&*sdk.path, python.as_path());
|
||||
assert_eq!(sdk.source, "venv");
|
||||
assert_eq!(sdk.version.as_deref(), Some("3.12.1"));
|
||||
}
|
||||
}
|
||||
assert_eq!(crate::python_interpreter_sdk(&base, None).unwrap().source, "system");
|
||||
std::fs::remove_file(&base).unwrap();
|
||||
assert!(crate::python_interpreter_sdk(&tmp.path().join(".venv"), None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_existing_virtualenv_resolves_from_its_folder_or_platform_executable() {
|
||||
let Some(base) = crate::exec::which("python3").or_else(|| crate::exec::which("python")) else { return };
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().join("existing");
|
||||
let status =
|
||||
std::process::Command::new(base).args(["-I", "-m", "venv", "--without-pip"]).arg(&home).status().unwrap();
|
||||
assert!(status.success());
|
||||
let python = home.join(bin_dir()).join(exe("python"));
|
||||
for picked in [&home, &python] {
|
||||
let sdk = crate::python_interpreter_sdk(picked, None).unwrap();
|
||||
assert_eq!(&*sdk.path, python.as_path());
|
||||
assert_eq!(&**sdk.home.as_ref().unwrap(), home.as_path());
|
||||
assert_eq!(sdk.source, "venv");
|
||||
assert!(sdk.name.ends_with("(existing)"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn picked_interpreters_must_run_python_code_even_with_valid_venv_metadata() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let python = fake_venv(tmp.path(), "broken");
|
||||
// A broken runtime can still answer --version.
|
||||
std::fs::write(&python, "#!/bin/sh\n[ \"$1\" = '--version' ] && echo 'Python 3.12.1' && exit 0\nexit 1\n").unwrap();
|
||||
std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(crate::python_interpreter_sdk(&python, None).is_none());
|
||||
std::fs::write(&python, "#!/bin/sh\necho not-python\n").unwrap();
|
||||
assert!(crate::python_interpreter_sdk(&python, None).is_none());
|
||||
std::fs::remove_file(&python).unwrap();
|
||||
assert!(crate::python_interpreter_sdk(&python, None).is_none());
|
||||
assert!(crate::python_interpreter_sdk(tmp.path(), None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pyright_gets_python_path_and_venv_env() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -411,6 +411,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
tools::jdk_download_location,
|
||||
tools::jdk_download,
|
||||
sdk::sdk_detect,
|
||||
sdk::sdk_python_interpreter,
|
||||
sdk::sdk_python_effective,
|
||||
sdk::sdk_php_effective,
|
||||
sdk::sdk_php_xdebug,
|
||||
|
||||
@@ -96,6 +96,17 @@ pub async fn sdk_locate_tools(
|
||||
ide_host::ops::sdk::sdk_locate_tools(&state.host, window.label(), names, near).await
|
||||
}
|
||||
|
||||
/// An existing Python executable or virtualenv folder as an SDK; `None` if Python cannot run.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn sdk_python_interpreter(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
path: PathBuf,
|
||||
) -> IdeResult<Option<Sdk>> {
|
||||
ide_host::ops::sdk::sdk_python_interpreter(&state.host, window.label(), path).await
|
||||
}
|
||||
|
||||
/// Creates a Python environment with `tool` (`venv` / `uv` / `poetry`); output streams through `on_output`.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -201,6 +201,9 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.existingPathRow {
|
||||
flex: none;
|
||||
}
|
||||
.pathInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "文档过大",
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user