From d018771a8d63c2f6dd35a1243cbb734c95ad69fa Mon Sep 17 00:00:00 2001 From: arookieofc Date: Sat, 7 Nov 2026 05:32:45 +0800 Subject: [PATCH] fix(run): Windows system python, pytest templates in gutter configs, pixi env detection (#1886) * fix(run): use system python per OS and pytest templates, detect pixi envs Gutter/context pytest configs on Windows used `python3`, which is not a command there; fall back to `python` on Windows. Gutter-created pytest configs now also inherit "Edit configuration templates... > pytest" values via a template cache populated with the run configurations. Pixi environments (`.pixi/envs/`) are detected as the project's Python: as run-configuration env (`PyEnv::Pixi`, tools via `pixi run` or the env's own executables), as the project interpreter for scripts/tests and the debugger, and as selectable SDKs, so the interpreter picked in the IDE feeds completion. Fixes l0ng-ai/openidea#1885 * fix(run): drop the pytest template's own target in gutter configs testConfigFor always appends the gutter file, so a target stored on the template would run twice (template target plus the file). The gutter file is the target. Also corrects pixi_env's doc comment to the implemented name order. --- crates/ide-run/src/detect.rs | 49 ++++++++++++++- crates/ide-sdk/src/apply.rs | 3 +- crates/ide-sdk/src/discover.rs | 67 +++++++++++++++++++- src/features/run/EditConfigsDialog.tsx | 12 +++- src/features/run/configs.test.ts | 84 +++++++++++++++++++++++++- src/features/run/context.ts | 34 ++++++++++- src/features/run/runTemplates.ts | 30 +++++++++ src/features/run/store.ts | 3 +- 8 files changed, 270 insertions(+), 12 deletions(-) create mode 100644 src/features/run/runTemplates.ts diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 6a2d8976c..101351ddd 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -835,6 +835,9 @@ enum PyEnv { Runner(&'static str), /// A project-local virtualenv directory (relative to root). Venv(String), + /// A pixi environment directory `.pixi/envs/` (relative to root); tools + /// also run as `pixi run …`, the interpreter directly for the debugger. + Pixi(String), System, } @@ -854,9 +857,25 @@ impl PyEnv { return Self::Venv(venv.to_owned()); } } + if let Some(env) = Self::pixi_env(root) { + return Self::Pixi(env); + } Self::System } + /// The first pixi environment `.pixi/envs/` holding a Python, relative + /// to the root, in name order. + fn pixi_env(root: &Path) -> Option { + let envs = root.join(".pixi/envs"); + let mut dirs: Vec<_> = + std::fs::read_dir(&envs).into_iter().flatten().flatten().map(|e| e.path()).filter(|d| d.is_dir()).collect(); + dirs.sort_by_key(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()); + dirs.iter() + .find(|d| d.join(Self::python_exe()).is_file() || d.join("bin").join(Self::python_exe()).is_file()) + .and_then(|d| d.strip_prefix(root).ok()) + .map(|rel| rel.to_string_lossy().replace('\\', "/")) + } + fn bin_dir() -> &'static str { if cfg!(windows) { "Scripts" } else { "bin" } } @@ -880,6 +899,18 @@ impl PyEnv { } } Self::System => (tool.to_owned(), Vec::new()), + // A pixi env's executables sit in `bin` (Unix) or `Scripts` (Windows) + // like a venv's; else `pixi run` finds the tool. + Self::Pixi(v) => { + let exe = if cfg!(windows) { format!("{tool}.exe") } else { tool.to_owned() }; + for bin in ["Scripts", "bin"] { + let rel = format!("{v}/{bin}/{exe}"); + if root.join(&rel).is_file() { + return (rel, Vec::new()); + } + } + ("pixi".to_owned(), vec!["run".into(), tool.into()]) + } } } @@ -894,9 +925,19 @@ impl PyEnv { match self { Self::Runner(r) => ((*r).to_owned(), vec!["run".into(), "python".into()]), Self::Venv(v) => (format!("{v}/{}/{}", Self::bin_dir(), Self::python_exe()), Vec::new()), + Self::Pixi(v) => (Self::pixi_python(v), Vec::new()), Self::System => ((if cfg!(windows) { "python" } else { "python3" }).to_owned(), Vec::new()), } } + + /// `/python.exe` (Windows) or `/bin/python` of a pixi environment. + fn pixi_python(env: &str) -> String { + if cfg!(windows) { + format!("{env}/{}", Self::python_exe()) + } else { + format!("{env}/bin/{}", Self::python_exe()) + } + } } /// The interpreter an imported debugger configuration runs with, as `(command, leading args)`: @@ -909,6 +950,9 @@ pub(crate) fn project_python(root: &Path) -> (String, Vec) { return (rel, Vec::new()); } } + if let Some(env) = PyEnv::pixi_env(root) { + return (PyEnv::pixi_python(&env), Vec::new()); + } PyEnv::detect(root).python() } @@ -943,8 +987,9 @@ fn uses_pytest(root: &Path) -> bool { fn detect_python(root: &Path, out: &mut Vec) { let markers = ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "pytest.ini", "tox.ini", "manage.py"]; let env = PyEnv::detect(root); - // A project virtualenv marks a Python project too (scripts + tests without packaging files). - let venv = matches!(env, PyEnv::Venv(_)); + // A project virtualenv or pixi environment marks a Python project too + // (scripts + tests without packaging files). + let venv = matches!(env, PyEnv::Venv(_) | PyEnv::Pixi(_)); if !venv && !markers.iter().any(|m| root.join(m).is_file()) { return; } diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index a87ffcf5d..7d098d949 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -64,7 +64,8 @@ pub fn ruff_configuration(root: &Path, configured: &str) -> IdeResult`) in the project +/// root, else the Python the project pins for mise / asdf. pub fn python_interpreter(root: &Path, s: &Settings) -> Option { setting_path(root, &s.languages.python.interpreter) .or_else(|| project_venv_python(root)) diff --git a/crates/ide-sdk/src/discover.rs b/crates/ide-sdk/src/discover.rs index 0cb75532b..0d7e44b66 100644 --- a/crates/ide-sdk/src/discover.rs +++ b/crates/ide-sdk/src/discover.rs @@ -73,7 +73,7 @@ fn dedupe_by_real_path(paths: Vec<(PathBuf, &'static str)>) -> Vec<(PathBuf, &'s /// Project-local virtualenv directories checked by auto-detection, in order. pub(crate) const PROJECT_VENVS: [&str; 4] = [".venv", "venv", "env", ".env"]; -/// `//bin/python` of the first project virtualenv. +/// `//bin/python` of the first project virtualenv or pixi environment. pub(crate) fn project_venv_python(root: &Path) -> Option { PROJECT_VENVS .iter() @@ -81,6 +81,11 @@ pub(crate) fn project_venv_python(root: &Path) -> Option { .filter(|v| v.join("pyvenv.cfg").is_file()) .map(|v| v.join(bin_dir()).join(exe("python"))) .find(|p| p.is_file()) + .or_else(|| { + subdirs(&root.join(".pixi/envs")).iter().find_map(|env| { + [env.join(exe("python")), env.join("bin").join(exe("python"))].into_iter().find(|p| p.is_file()) + }) + }) } /// `/usr/bin/python3` on macOS is a stub that opens an installer dialog when @@ -110,6 +115,14 @@ fn python(root: Option<&Path>) -> Vec { found.push((dir.join(bin_dir()).join(exe("python")), "venv")); } } + // pixi environments (`.pixi/envs/`), which are no virtualenvs. + for env in subdirs(&root.join(".pixi/envs")) { + let python = + [env.join(exe("python")), env.join("bin").join(exe("python"))].into_iter().find(|p| p.is_file()); + if let Some(python) = python { + found.push((python, "pixi")); + } + } } if let Ok(venv) = std::env::var("VIRTUAL_ENV") { found.push((Path::new(&venv).join(bin_dir()).join(exe("python")), "venv")); @@ -215,6 +228,10 @@ pub(crate) fn python_sdk(path: &Path, source: &str, root: Option<&Path>) -> Opti .map(|n| n.to_string_lossy().into_owned()); format!("Python {short} (conda: {})", env.unwrap_or_default()) } + (None, "pixi") => { + let env = pixi_env_name(path).unwrap_or_default(); + format!("Python {short} (pixi: {env})") + } (None, "pyenv") => format!("Python {short} (pyenv)"), (None, tool @ ("uv" | "mise" | "asdf")) => format!("Python {short} ({tool})"), _ => format!("Python {short}"), @@ -224,11 +241,26 @@ pub(crate) fn python_sdk(path: &Path, source: &str, root: Option<&Path>) -> Opti name: label, version: Some(version), source: source.to_owned(), - home: venv.or_else(|| path.parent().and_then(Path::parent).map(Path::to_path_buf)).map(Into::into), + home: venv + .or_else(|| (source == "pixi").then(|| pixi_env_home(path)).flatten()) + .or_else(|| path.parent().and_then(Path::parent).map(Path::to_path_buf)) + .map(Into::into), extra: None, }) } +/// `.pixi/envs/` of a pixi interpreter (`/python`, `/bin/python`). +fn pixi_env_home(python: &Path) -> Option { + let dir = python.parent()?; + let env = if dir.file_name().is_some_and(|n| n == "bin") { dir.parent()? } else { dir }; + (env.parent()?.file_name()? == "envs" && env.parent()?.parent()?.file_name()? == ".pixi").then(|| env.to_path_buf()) +} + +/// The name of a pixi interpreter's environment (`` of `.pixi/envs/`). +fn pixi_env_name(python: &Path) -> Option { + pixi_env_home(python)?.file_name().map(|n| n.to_string_lossy().into_owned()) +} + fn pyvenv_version(python: &Path) -> Option { let cfg = std::fs::read_to_string(venv_of(python)?.join("pyvenv.cfg")).ok()?; cfg.lines().find_map(|l| { @@ -781,3 +813,34 @@ mod maven_path_tests { assert_eq!(home, dunce::canonicalize(real.path()).unwrap()); } } + +#[cfg(test)] +mod pixi_tests { + use super::*; + + #[test] + fn a_pixi_env_is_a_project_python_and_an_sdk_home() { + let root = tempfile::tempdir().unwrap(); + let env = root.path().join(".pixi/envs/default"); + let bin = if cfg!(windows) { env.clone() } else { env.join("bin") }; + std::fs::create_dir_all(&bin).unwrap(); + let python = bin.join(exe("python")); + std::fs::write(&python, "").unwrap(); + + assert_eq!(project_venv_python(root.path()), Some(python.clone())); + assert_eq!(pixi_env_home(&python), Some(env.clone())); + assert_eq!(pixi_env_name(&python).as_deref(), Some("default")); + + // A second env sorts after; the first with a Python wins. + let other = root.path().join(".pixi/envs/aaa-empty"); + std::fs::create_dir_all(&other).unwrap(); + assert_eq!(project_venv_python(root.path()), Some(python)); + } + + #[test] + fn non_pixi_paths_have_no_pixi_home() { + assert_eq!(pixi_env_home(Path::new("/usr/bin/python3")), None); + assert_eq!(pixi_env_home(Path::new("/p/.venv/bin/python")), None); + assert_eq!(pixi_env_home(Path::new("/p/envs/x/bin/python")), None); + } +} diff --git a/src/features/run/EditConfigsDialog.tsx b/src/features/run/EditConfigsDialog.tsx index 461c84b96..79c2736c0 100644 --- a/src/features/run/EditConfigsDialog.tsx +++ b/src/features/run/EditConfigsDialog.tsx @@ -29,6 +29,7 @@ import { importConfigs, importLabel } from "./importConfigs"; import { targetName } from "./remote"; import { saveEditedConfigs, selectConfig, useRun } from "./store"; import { blankTemplate, COMPOUND, missingParams, newConfigOfType, templateId, typeFor } from "./types"; +import { cacheRunTemplates } from "./runTemplates"; import styles from "./Run.module.css"; import { tx } from "@/platform/i18n"; @@ -142,7 +143,11 @@ function EditConfigsDialog() { if (!isTauri()) return; let live = true; call(commands.runTemplates()) - .then((t) => live && setTemplates(t)) + .then((t) => { + if (!live) return; + setTemplates(t); + cacheRunTemplates(t); + }) .catch(() => live && setTemplates([])); return () => { live = false; @@ -305,7 +310,10 @@ function EditConfigsDialog() { setSaving(true); try { if (dirty) await saveEditedConfigs(draft, removedDetectedIds); - if (templatesDirty && templates) await call(commands.runSaveTemplates(templates)); + if (templatesDirty && templates) { + await call(commands.runSaveTemplates(templates)); + cacheRunTemplates(templates); + } setRemovedDetectedIds([]); setDirty(false); setTemplatesDirty(false); diff --git a/src/features/run/configs.test.ts b/src/features/run/configs.test.ts index 91b143b56..64a0abb10 100644 --- a/src/features/run/configs.test.ts +++ b/src/features/run/configs.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import type { RunConfig } from "@/platform/ipc"; +import { usePlatform } from "@/platform/platformInfo"; import { commandLine, duplicateConfig, @@ -14,6 +15,8 @@ import { validateConfig, } from "./configs"; import { contextConfig, fileRole } from "./context"; +import { cacheRunTemplates } from "./runTemplates"; +import { registerBuiltinTypes } from "./types"; import { envFromRows } from "./EnvDialog"; import { configRemoved, defaultSelection, rerunConfig, useRun, type RunSession } from "./store"; @@ -380,6 +383,85 @@ describe("context configuration", () => { expect(contextConfig("tools/gen.py", plain)).toMatchObject({ command: "python3", args: ["tools/gen.py"] }); expect(contextConfig("tools/gen.py", [])).toMatchObject({ command: "python3", args: ["tools/gen.py"] }); }); + + describe("system python per OS (#1885)", () => { + const os = usePlatform.getState().os; + afterEach(() => { + usePlatform.setState({ os }); + cacheRunTemplates([]); + }); + + it("falls back to `python` on Windows, `python3` elsewhere", () => { + usePlatform.setState({ os: "windows" }); + // `python3` is not a command on Windows. + expect(contextConfig("tools/gen.py", [])).toMatchObject({ command: "python", args: ["tools/gen.py"] }); + expect(contextConfig("worker/test_worker.py", [])).toMatchObject({ + command: "python", + args: ["-m", "pytest", "worker/test_worker.py"], + }); + usePlatform.setState({ os: "linux" }); + expect(contextConfig("tools/gen.py", [])).toMatchObject({ command: "python3", args: ["tools/gen.py"] }); + expect(contextConfig("worker/test_worker.py", [])).toMatchObject({ + command: "python3", + args: ["-m", "pytest", "worker/test_worker.py"], + }); + }); + + it("seeds gutter pytest configs from the pytest template", () => { + const dispose = registerBuiltinTypes(); + try { + cacheRunTemplates([ + cfg({ id: "template:pytest", kind: "pytest", params: { command: "pixi", runnerArgs: "run pytest" } }), + ]); + expect(contextConfig("worker/test_worker.py", [])).toMatchObject({ + command: "pixi", + args: ["run", "pytest", "worker/test_worker.py"], + isTest: true, + }); + // Template options come along; the file stays the target. + cacheRunTemplates([ + cfg({ + id: "template:pytest", + kind: "pytest", + params: { command: "uv", runnerArgs: "run pytest", options: "-x" }, + }), + ]); + expect(contextConfig("worker/test_worker.py", [])!.args).toEqual([ + "run", + "pytest", + "-x", + "worker/test_worker.py", + ]); + // A target stored on the template itself is dropped: the gutter file is + // the target, or the run would execute both. + cacheRunTemplates([ + cfg({ + id: "template:pytest", + kind: "pytest", + params: { command: "pixi", runnerArgs: "run pytest", target: "tests", options: "-x" }, + }), + ]); + expect(contextConfig("worker/test_worker.py", [])!.args).toEqual([ + "run", + "pytest", + "-x", + "worker/test_worker.py", + ]); + // No template: the detected interpreter / system python as before. + cacheRunTemplates([]); + const venv = [ + cfg({ id: "python:module:app", kind: "python", command: ".venv/bin/python", args: ["-m", "app"] }), + ]; + expect(contextConfig("worker/test_worker.py", venv)).toMatchObject({ + command: ".venv/bin/python", + args: ["-m", "pytest", "worker/test_worker.py"], + }); + } finally { + dispose(); + cacheRunTemplates([]); + } + }); + }); }); describe("rerunning a session after its configuration changed (#1497)", () => { diff --git a/src/features/run/context.ts b/src/features/run/context.ts index be404e8a3..4dd3cdb11 100644 --- a/src/features/run/context.ts +++ b/src/features/run/context.ts @@ -9,6 +9,8 @@ import { phpunitArgs } from "./testing/phpunit"; import { goTestParts } from "./goArguments"; import { goBuildType } from "./goTypes"; import { isWindows } from "./jvmTypes"; +import { typeFor } from "./types"; +import { runTemplate } from "./runTemplates"; export type FileRole = "test" | "main" | "script" | null; @@ -135,9 +137,16 @@ export function projectPython(configs: RunConfig[], relPath?: string): { command return null; } +/** `python` on Windows (where `python3` is not a command), `python3` elsewhere. */ +function systemPython(): string { + return isWindows() ? "python" : "python3"; +} + /** * PyCharm runs pytest for a `test_*.py` file even when the project declares no * test setup: `python -m pytest`, with the project's interpreter when detected. + * The "Edit configuration templates…" › pytest values win over both the + * interpreter and the runner arguments, like a template a new config inherits. */ function implicitPytest(relPath: string, configs: RunConfig[]): RunConfig | null { if (!relPath.endsWith(".py")) return null; @@ -146,11 +155,11 @@ function implicitPytest(relPath: string, configs: RunConfig[]): RunConfig | null relPath, ); const py = projectPython(owners); - return { + const base: RunConfig = { id: "pytest:implicit", name: "pytest", kind: "pytest", - command: py?.command ?? "python3", + command: py?.command ?? systemPython(), args: [...(py?.prefix ?? []), "-m", "pytest"], cwd: owners[0]?.cwd ?? null, module: owners[0]?.module, @@ -159,6 +168,25 @@ function implicitPytest(relPath: string, configs: RunConfig[]): RunConfig | null isTest: true, debugAdapter: "debugpy", }; + return applyPytestTemplate(base); +} + +/** + * "Edit configuration templates…" › pytest applied to a gutter/context pytest + * configuration: its Command and Runner arguments replace the detected + * interpreter (`pixi` + `run pytest`, a venv's `pytest`, …) while the file the + * context adds stays the target — a target stored on the template itself is + * dropped, or the run would execute both it and the file. Without a template + * the config is unchanged. + */ +function applyPytestTemplate(config: RunConfig): RunConfig { + const type = typeFor("pytest"); + const template = runTemplate("pytest"); + const params = type && template ? { ...type.defaults, ...template.params, target: "" } : null; + if (!type || !params) return config; + const line = type.commandLine(params); + if (!line) return config; + return { ...config, command: line.command, args: line.args }; } /** @@ -347,7 +375,7 @@ function scriptConfigFor( }; if (relPath.endsWith(".py")) { // Reuse the project's interpreter (venv / uv / poetry) when detected. - const py = projectPython(configs) ?? { command: "python3", prefix: [] }; + const py = projectPython(configs) ?? { command: systemPython(), prefix: [] }; const module = pythonModuleRun(relPath, content, configs); if (module) return { diff --git a/src/features/run/runTemplates.ts b/src/features/run/runTemplates.ts new file mode 100644 index 000000000..074facbf7 --- /dev/null +++ b/src/features/run/runTemplates.ts @@ -0,0 +1,30 @@ +/** + * A synchronous cache of the "Edit configuration templates…" values, so the + * run-context producers (gutter icons, ⌃⇧R) — which build configurations + * synchronously — can apply them. `loadConfigs` refreshes it from disk; the + * Edit Configurations dialog writes through it on save. + */ +import { call, commands, isTauri, type RunConfig } from "@/platform/ipc"; + +const cache = new Map(); + +/** The stored template of `kind` ("Edit configuration templates…" › kind), if any. */ +export function runTemplate(kind: string): RunConfig | undefined { + return cache.get(kind); +} + +/** Caches the stored templates (called with `runTemplates()`'s result). */ +export function cacheRunTemplates(templates: readonly RunConfig[]): void { + cache.clear(); + for (const t of templates) cache.set(t.kind, t); +} + +/** Refreshes the cache from disk; a no-op off the desktop. */ +export async function loadRunTemplates(): Promise { + if (!isTauri()) return; + try { + cacheRunTemplates(await call(commands.runTemplates())); + } catch { + /* keep the previous cache */ + } +} diff --git a/src/features/run/store.ts b/src/features/run/store.ts index e2f754305..8e5162405 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -52,6 +52,7 @@ import { compoundChildren } from "./configs"; import { remoteLabel, startWithRemedies } from "./remote"; import { TerminalOutput } from "./terminalOutput"; import { typeFor } from "./types"; +import { loadRunTemplates } from "./runTemplates"; import { recordRun } from "./services"; import { startedPort } from "./serverPort"; import { tomcatBrowserUrl } from "./tomcat"; @@ -197,7 +198,7 @@ export async function loadConfigs(): Promise { return; } try { - const saved = await call(commands.runConfigs()); + const [saved] = await Promise.all([call(commands.runConfigs()), loadRunTemplates()]); if (useProject.getState().project?.root !== root) return; // Temporary configs survive reloads (and restarts) until saved or evicted; a saved one wins. // They are kept without what settings derived: today's toolchains apply to them, as to the others.