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/<env>`) 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.
This commit is contained in:
arookieofc authored and GitHub committed 2026-11-07 05:32:45 +08:00
1 parent 0eca4366d0
commit d018771a8d
8 files changed
+270 -12

No files matched your search

+47 -2
View File
@@ -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/<env>` (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/<env>` holding a Python, relative
/// to the root, in name order.
fn pixi_env(root: &Path) -> Option<String> {
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()),
}
}
/// `<env>/python.exe` (Windows) or `<env>/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<String>) {
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<RunConfig>) {
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;
}
+2 -1
View File
@@ -64,7 +64,8 @@ pub fn ruff_configuration(root: &Path, configured: &str) -> IdeResult<Option<Pat
}
/// The project interpreter: the configured one, else a project virtualenv
/// (`.venv`, `venv`, …) in the project root, else the Python the project pins for mise / asdf.
/// (`.venv`, `venv`, …) or pixi environment (`.pixi/envs/<env>`) in the project
/// root, else the Python the project pins for mise / asdf.
pub fn python_interpreter(root: &Path, s: &Settings) -> Option<PathBuf> {
setting_path(root, &s.languages.python.interpreter)
.or_else(|| project_venv_python(root))
+65 -2
View File
@@ -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"];
/// `<root>/<venv>/bin/python` of the first project virtualenv.
/// `<root>/<venv>/bin/python` of the first project virtualenv or pixi environment.
pub(crate) fn project_venv_python(root: &Path) -> Option<PathBuf> {
PROJECT_VENVS
.iter()
@@ -81,6 +81,11 @@ pub(crate) fn project_venv_python(root: &Path) -> Option<PathBuf> {
.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<Sdk> {
found.push((dir.join(bin_dir()).join(exe("python")), "venv"));
}
}
// pixi environments (`.pixi/envs/<env>`), 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/<env>` of a pixi interpreter (`<env>/python`, `<env>/bin/python`).
fn pixi_env_home(python: &Path) -> Option<PathBuf> {
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 (`<env>` of `.pixi/envs/<env>`).
fn pixi_env_name(python: &Path) -> Option<String> {
pixi_env_home(python)?.file_name().map(|n| n.to_string_lossy().into_owned())
}
fn pyvenv_version(python: &Path) -> Option<String> {
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);
}
}
+10 -2
View File
@@ -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);
+83 -1
View File
@@ -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)", () => {
+31 -3
View File
@@ -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 {
+30
View File
@@ -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<string, RunConfig>();
/** 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<void> {
if (!isTauri()) return;
try {
cacheRunTemplates(await call(commands.runTemplates()));
} catch {
/* keep the previous cache */
}
}
+2 -1
View File
@@ -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<void> {
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.