From 66ecf4c967785b1bef9ee5529c46987793c3c315 Mon Sep 17 00:00:00 2001 From: arookieofc Date: Sat, 7 Nov 2026 07:21:28 +0800 Subject: [PATCH] feat(sdk): add Conda to the Add Python Interpreter env tools (#1889) Extends the VenvTool set with a "Conda Environment" option: the dialog runs 'conda create -p -y', pinning 'python=' from the chosen base interpreter when it reports a version. An existing conda-meta directory is refused before conda runs, and a missing conda binary reports an install hint instead of a spawn failure. Refs l0ng-ai/openidea#1724 --- crates/ide-sdk/src/python.rs | 39 +++++++++++++++++++ crates/ide-sdk/src/tests.rs | 32 +++++++++++++++ src/bindings.ts | 4 +- .../__tests__/addInterpreter.test.tsx | 29 ++++++++------ .../languages/AddInterpreterDialog.tsx | 2 + src/features/settings/languages/api.ts | 2 +- src/platform/locales/text/zh.json | 2 + 7 files changed, 96 insertions(+), 14 deletions(-) diff --git a/crates/ide-sdk/src/python.rs b/crates/ide-sdk/src/python.rs index 35c33beee..ed12cf32b 100644 --- a/crates/ide-sdk/src/python.rs +++ b/crates/ide-sdk/src/python.rs @@ -26,6 +26,8 @@ pub enum VenvTool { Uv, /// `poetry env use ` in the project, then `poetry env info --path`. Poetry, + /// `conda create -p -y` (conda must be on PATH). + Conda, } /// Creates a virtualenv at `location` with `tool` and returns its interpreter. @@ -83,6 +85,7 @@ pub(crate) fn create_venv_with( .ok_or_else(|| IdeError::internal(format!("{} was not created", python.display()))) } VenvTool::Poetry => create_poetry_env(base, location, &which, cancel, on_line), + VenvTool::Conda => create_conda_env(base, location, &which, cancel, on_line), } } @@ -118,6 +121,42 @@ fn create_poetry_env( .ok_or_else(|| IdeError::internal(format!("{} was not created", python.display()))) } +/// `conda create -p -y` builds an environment at the requested prefix. +/// `base` pins the Python version (`python=`) when it reports one, so the +/// created env matches the interpreter the user picked rather than conda's default. +fn create_conda_env( + base: &Path, + location: &Path, + which: &dyn Fn(&str) -> Option, + cancel: &CancellationToken, + on_line: &dyn Fn(String), +) -> IdeResult { + if location.join("conda-meta").is_dir() { + return Err(IdeError::new( + ide_core::error::ErrorKind::AlreadyExists, + format!("{} already contains a conda environment", location.display()), + )); + } + let conda = which("conda") + .ok_or_else(|| IdeError::unavailable("conda is not installed (install it from https://docs.conda.io)"))?; + if let Some(parent) = location.parent() { + std::fs::create_dir_all(parent)?; + } + let mut args = vec!["create".to_owned(), "-p".to_owned(), location.to_string_lossy().into_owned(), "-y".to_owned()]; + if let Some(version) = query_in(base, &["--version"], None, &[], Duration::from_secs(15)) + .and_then(|v| v.strip_prefix("Python ").map(str::to_owned)) + { + let short = version.split('.').take(2).collect::>().join("."); + if !short.is_empty() { + args.push(format!("python={short}")); + } + } + stream(&conda, &args, None, &[], cancel, on_line)?; + let python = location.join(bin_dir()).join(exe("python")); + crate::discover::python_sdk(&python, "conda", location.parent().and_then(Path::parent)) + .ok_or_else(|| IdeError::internal(format!("{} was not created", python.display()))) +} + /// Arguments of `base -m venv location`. `-I` (isolated mode) keeps the working /// directory off `sys.path`: a project's `logging.py` or `typing.py` there would /// otherwise shadow the standard library modules venv imports. (`-P` does just diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index ceb43473b..1442b61fc 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -749,6 +749,16 @@ fn env_tools_are_reported_missing_before_they_run() { ) .unwrap_err(); assert!(poetry.message.contains("Poetry is not installed"), "{}", poetry.message); + let conda = crate::python::create_venv_with( + crate::python::VenvTool::Conda, + &base, + &location, + none, + &Default::default(), + &|_| {}, + ) + .unwrap_err(); + assert!(conda.message.contains("conda is not installed"), "{}", conda.message); // An existing environment is refused before any tool runs. std::fs::create_dir_all(&location).unwrap(); std::fs::write(location.join("pyvenv.cfg"), "home = /python").unwrap(); @@ -764,6 +774,28 @@ fn env_tools_are_reported_missing_before_they_run() { assert!(again.message.contains("already contains a virtual environment"), "{}", again.message); } +#[test] +fn an_existing_conda_environment_is_not_recreated() { + // `conda-meta/` marks a conda prefix: refuse before `conda create` would fail obscurely. + let dir = tempfile::tempdir().unwrap(); + let location = dir.path().join("envs").join("ds"); + let base = dir.path().join("python"); + std::fs::create_dir_all(location.join("conda-meta")).unwrap(); + fn none(_: &str) -> Option { + None + } + let err = crate::python::create_venv_with( + crate::python::VenvTool::Conda, + &base, + &location, + none, + &Default::default(), + &|_| {}, + ) + .unwrap_err(); + assert!(err.message.contains("already contains a conda environment"), "{}", err.message); +} + #[test] fn neutral_dir_is_fresh_and_empty() { let a = crate::python::neutral_dir().unwrap(); diff --git a/src/bindings.ts b/src/bindings.ts index 0ba380462..07b6dabb0 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -7785,7 +7785,9 @@ export type VenvTool = /** `uv venv --python ` (uv must be on PATH). */ "uv" | /** `poetry env use ` in the project, then `poetry env info --path`. */ -"poetry"; +"poetry" | +/** `conda create -p -y` (conda must be on PATH). */ +"conda"; /** What a view or custom editor shows. */ export type View = { diff --git a/src/features/settings/__tests__/addInterpreter.test.tsx b/src/features/settings/__tests__/addInterpreter.test.tsx index b7198e382..07832ab4b 100644 --- a/src/features/settings/__tests__/addInterpreter.test.tsx +++ b/src/features/settings/__tests__/addInterpreter.test.tsx @@ -64,7 +64,7 @@ describe("Add Python Interpreter", () => { it("lists the environment types PyCharm offers", async () => { open(); await waitFor(() => expect(mocks.detect).toHaveBeenCalled()); - for (const label of ["Virtualenv Environment", "uv Environment", "Poetry Environment"]) { + for (const label of ["Virtualenv Environment", "Conda Environment", "uv Environment", "Poetry Environment"]) { expect(screen.getByRole("button", { name: label })).toBeInTheDocument(); } }); @@ -86,21 +86,26 @@ describe("Add Python Interpreter", () => { await waitFor(() => expect(onAdded).toHaveBeenCalledWith("/project/.venv/bin/python")); }); - it("passes uv as the tool and Poetry hides the location field", async () => { + it("passes each tool and Poetry hides the location field", async () => { let finish: (v: unknown) => void = () => {}; mocks.createVenv.mockImplementation(() => new Promise((r) => (finish = r))); open(); await waitFor(() => expect(screen.getByRole("button", { name: "Base interpreter" })).toBeInTheDocument()); - fireEvent.click(screen.getByRole("button", { name: "uv Environment" })); - fireEvent.click(screen.getByRole("button", { name: "OK" })); - expect(mocks.createVenv).toHaveBeenCalledWith( - "uv", - "/usr/bin/python3", - "/project/.venv", - expect.anything(), - expect.anything(), - ); - await act(async () => finish(ok(VENV))); + for (const [label, tool] of [ + ["uv Environment", "uv"], + ["Conda Environment", "conda"], + ] as const) { + fireEvent.click(screen.getByRole("button", { name: label })); + fireEvent.click(screen.getByRole("button", { name: "OK" })); + expect(mocks.createVenv).toHaveBeenLastCalledWith( + tool, + "/usr/bin/python3", + "/project/.venv", + expect.anything(), + expect.anything(), + ); + await act(async () => finish(ok(VENV))); + } fireEvent.click(screen.getByRole("button", { name: "Poetry Environment" })); await waitFor(() => expect(screen.getByLabelText("Location:")).toBeDisabled()); fireEvent.click(screen.getByRole("button", { name: "OK" })); diff --git a/src/features/settings/languages/AddInterpreterDialog.tsx b/src/features/settings/languages/AddInterpreterDialog.tsx index 3d50a2a00..ea85a3119 100644 --- a/src/features/settings/languages/AddInterpreterDialog.tsx +++ b/src/features/settings/languages/AddInterpreterDialog.tsx @@ -15,6 +15,7 @@ type Mode = "new" | "existing"; /** Environment types of the left column, in PyCharm's order. */ const KINDS: { tool: VenvTool; label: string; missing: string }[] = [ { tool: "venv", label: "Virtualenv Environment", missing: "" }, + { tool: "conda", label: "Conda Environment", missing: "conda is not installed" }, { tool: "uv", label: "uv Environment", missing: "uv is not installed" }, { tool: "poetry", label: "Poetry Environment", missing: "Poetry is not installed" }, ]; @@ -76,6 +77,7 @@ export function AddInterpreterDialog({ const canOk = mode === "new" ? !!baseValue && !!location.trim() && !busy : !!existing; const note = { venv: tx("Runs “ -m venv ”; the new interpreter becomes the project interpreter."), + conda: tx("Runs “conda create -p ”; the new interpreter becomes the project interpreter."), uv: tx("Runs “uv venv --python ”; the new interpreter becomes the project interpreter."), poetry: tx( "Runs “poetry env use ” in the project; poetry keeps the environment under its own cache directory.", diff --git a/src/features/settings/languages/api.ts b/src/features/settings/languages/api.ts index 00ed41afb..77f179c0c 100644 --- a/src/features/settings/languages/api.ts +++ b/src/features/settings/languages/api.ts @@ -82,7 +82,7 @@ function streamed( } /** Tool that creates a new Python environment (`VenvTool` in `ide-sdk`). */ -export type VenvTool = "venv" | "uv" | "poetry"; +export type VenvTool = "venv" | "uv" | "poetry" | "conda"; export function createVenv( tool: VenvTool, diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index d4b498ebf..afef76eb7 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -575,6 +575,7 @@ "Conflicts:": "冲突:", "Connect": "连接", "Connected": "已连接", + "Conda Environment": "Conda 环境", "Connecting to Docker…": "正在连接 Docker…", "Connecting to GitHub…": "正在连接 GitHub…", "Console": "控制台", @@ -4682,6 +4683,7 @@ "{count} modified action": "已修改 {count} 个操作", "{count} modified actions": "已修改 {count} 个操作", "Runs “ -m venv ”; the new interpreter becomes the project interpreter.": "运行“ -m venv ”;新的解释器将成为项目解释器。", + "Runs “conda create -p ”; the new interpreter becomes the project interpreter.": "运行“conda create -p ”;新的解释器将成为项目解释器。", "Runs “poetry env use ” in the project; poetry keeps the environment under its own cache directory.": "在项目中运行“poetry env use ”;Poetry 会将环境保存在其自己的缓存目录下。", "Runs “uv venv --python ”; the new interpreter becomes the project interpreter.": "运行“uv venv --python ”;新的解释器将成为项目解释器。", "{vendor} {version} is unpacked into a folder of its own in {location}, where JDK detection finds it.": "{vendor} {version} 将解压到 {location} 下它自己的文件夹中,JDK 检测会在那里找到它。",