From c7d32f9e23ef9e57a6f97a342096274a16d34bf1 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:11:22 +0800 Subject: [PATCH] fix(python): include project roots in run and debug imports Python file launches only exposed the script directory, so imports of sibling project packages failed. Add content, source and test roots to the effective PYTHONPATH after environment resolution, preserving existing paths. Apply the shared behavior to Python/test process launches and debugpy launches while leaving attach and remote adapter paths unchanged. Refs #216 --- crates/ide-host/src/ops/debug.rs | 8 ++- crates/ide-host/src/ops/mod.rs | 1 + crates/ide-host/src/ops/python.rs | 103 ++++++++++++++++++++++++++++++ crates/ide-host/src/ops/run.rs | 5 +- crates/ide-run/src/lib.rs | 1 + crates/ide-run/src/process.rs | 5 ++ crates/ide-run/src/python_path.rs | 44 +++++++++++++ crates/ide-run/tests/process.rs | 31 ++++++++- 8 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 crates/ide-host/src/ops/python.rs create mode 100644 crates/ide-run/src/python_path.rs diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index 63bf3c632..a115a4128 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -6,7 +6,13 @@ use std::path::PathBuf; /// Starts a session owned by this window: its events (and those of child sessions) go here only. pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult { - let config = match_java_debug_encoding(config, cfg!(windows)); + let mut config = match_java_debug_encoding(config, cfg!(windows)); + if config.adapter == "debugpy" + && config.request == "launch" + && let Ok(project) = host.project(window) + { + super::python::prepare_debug(&mut config, &super::python::paths(&project))?; + } let label = window; let owner = SessionOwner::new(host.owner_window(label), host.window_sink(label)); let settings = match host.project(label) { diff --git a/crates/ide-host/src/ops/mod.rs b/crates/ide-host/src/ops/mod.rs index 6f39bf88e..f1de3ea33 100644 --- a/crates/ide-host/src/ops/mod.rs +++ b/crates/ide-host/src/ops/mod.rs @@ -16,6 +16,7 @@ pub mod java; pub mod lsp; pub mod nav; pub mod project; +mod python; pub mod refactor; pub mod run; pub mod sdk; diff --git a/crates/ide-host/src/ops/python.rs b/crates/ide-host/src/ops/python.rs new file mode 100644 index 000000000..e0cfbb464 --- /dev/null +++ b/crates/ide-host/src/ops/python.rs @@ -0,0 +1,103 @@ +use crate::project::Project; +use ide_core::IdeResult; +use ide_dap::LaunchConfig; +use ide_workspace::roots::RootKind; +use std::collections::BTreeMap; +use std::path::PathBuf; + +pub(super) fn paths(project: &Project) -> Vec { + let mut paths = vec![project.root.clone()]; + paths.extend(project.content_roots()); + for root in project.roots().roots { + if matches!(root.kind, RootKind::Source | RootKind::Test) { + let path = project.root.join(root.path); + if !paths.contains(&path) { + paths.push(path); + } + } + } + paths +} + +pub(super) fn prepare_debug(config: &mut LaunchConfig, paths: &[PathBuf]) -> IdeResult<()> { + // Adapter-specific env takes precedence over the common launch fields in DAP. + let mut extra: Option = config.extra_json.as_deref().and_then(|s| serde_json::from_str(s).ok()); + // A custom adapter may run on another host; local paths are not portable. + if extra.as_ref().is_some_and(|e| e.get("debugServer").is_some()) { + return Ok(()); + } + if let Some(env) = extra.as_mut().and_then(|e| e.get_mut("env")) { + if env.is_null() { + *env = serde_json::json!({}); + } + if !env.is_object() { + return Ok(()); + } + let mut vars = BTreeMap::new(); + if let Some(value) = env.get("PYTHONPATH").and_then(|v| v.as_str()) { + vars.insert("PYTHONPATH".into(), value.to_owned()); + } + ide_run::python_path::extend(&mut vars, paths, env.get("PYTHONPATH").is_none())?; + env["PYTHONPATH"] = serde_json::json!(vars["PYTHONPATH"]); + config.extra_json = extra.map(|e| e.to_string()); + } else { + ide_run::python_path::extend(&mut config.env, paths, true)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_preserves_remote_paths_and_handles_null_env() { + let root = std::env::temp_dir().join("project"); + let mut config = LaunchConfig { + name: "Python".into(), + adapter: "debugpy".into(), + request: "launch".into(), + program: None, + args: vec![], + cwd: None, + env: BTreeMap::new(), + extra_json: Some(serde_json::json!({"env": null, "debugServer": "remote:5678"}).to_string()), + }; + let before = config.extra_json.clone(); + prepare_debug(&mut config, std::slice::from_ref(&root)).unwrap(); + assert_eq!(config.extra_json, before); + assert!(config.env.is_empty()); + config.extra_json = Some(serde_json::json!({"env": null}).to_string()); + prepare_debug(&mut config, std::slice::from_ref(&root)).unwrap(); + let value: serde_json::Value = serde_json::from_str(config.extra_json.as_deref().unwrap()).unwrap(); + let paths = std::env::split_paths(value["env"]["PYTHONPATH"].as_str().unwrap()).collect::>(); + assert_eq!(paths[0], root); + } + + #[test] + fn debug_uses_the_effective_environment_field() { + for extra in [false, true] { + let root = std::env::temp_dir().join("project"); + let vendor = root.join("vendor"); + let vars = BTreeMap::from([("PYTHONPATH".into(), vendor.to_string_lossy().into_owned())]); + let mut config = LaunchConfig { + name: "Python".into(), + adapter: "debugpy".into(), + request: "launch".into(), + program: Some("app/main.py".into()), + args: vec![], + cwd: None, + env: vars.clone(), + extra_json: extra.then(|| serde_json::json!({"env": vars}).to_string()), + }; + prepare_debug(&mut config, std::slice::from_ref(&root)).unwrap(); + let vars: BTreeMap = if extra { + let value: serde_json::Value = serde_json::from_str(config.extra_json.as_deref().unwrap()).unwrap(); + serde_json::from_value(value["env"].clone()).unwrap() + } else { + config.env + }; + assert_eq!(std::env::split_paths(&vars["PYTHONPATH"]).collect::>(), [root, vendor]); + } + } +} diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs index 36e60775d..d5442f805 100644 --- a/crates/ide-host/src/ops/run.rs +++ b/crates/ide-host/src/ops/run.rs @@ -43,7 +43,10 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND)); } let encoding = settings.console.encoding; - let options = StartOptions { encoding: Some(encoding).filter(|e| !e.trim().is_empty()) }; + let options = StartOptions { + encoding: Some(encoding).filter(|e| !e.trim().is_empty()), + python_paths: super::python::paths(&project), + }; host.processes.start(&project.root, &config, &options, project.sink.clone(), Box::new(move |c| on_output(c))) } diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index 7d448b170..6f24ae218 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -7,6 +7,7 @@ pub mod gradle_jvm; pub mod java_cmdline; pub mod junit; mod process; +pub mod python_path; mod utf8; pub mod which; pub mod wrap; diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index 1cf066ddc..97c97c3b9 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -77,6 +77,8 @@ impl Entry { pub struct StartOptions { /// Output encoding label (`UTF-8`, `GBK`, `windows-1252`…); `None` = UTF-8. pub encoding: Option, + /// Project content and source roots added to Python launch environments. + pub python_paths: Vec, } #[derive(Default)] @@ -311,6 +313,9 @@ impl ProcessManager { return Err(IdeError::invalid("input cannot be redirected from a file when emulating a terminal")); } let mut env = launch_env(root, config)?; + if matches!(config.kind.as_str(), "python" | "pytest") { + crate::python_path::extend(&mut env.vars, &options.python_paths, !env.clear)?; + } let on_output: OutputFn = Arc::from(on_output); let on_output = match extras.output_file.as_deref() { Some(file) => tee_output(&resolve_cwd(root, Some(Path::new(file))), on_output)?, diff --git a/crates/ide-run/src/python_path.rs b/crates/ide-run/src/python_path.rs new file mode 100644 index 000000000..3a2906f75 --- /dev/null +++ b/crates/ide-run/src/python_path.rs @@ -0,0 +1,44 @@ +//! Python imports see the same content/source roots as the IDE. +use ide_core::{IdeError, IdeResult}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +pub fn extend(env: &mut BTreeMap, roots: &[PathBuf], inherit: bool) -> IdeResult<()> { + if roots.is_empty() { + return Ok(()); + } + let inherited = if inherit { crate::system_env().remove("PYTHONPATH") } else { None }; + let existing = env.get("PYTHONPATH").or(inherited.as_ref()); + let mut paths = Vec::new(); + for root in roots { + if !paths.contains(root) { + paths.push(root.clone()); + } + } + if let Some(existing) = existing { + for path in std::env::split_paths(existing) { + if !paths.contains(&path) { + paths.push(path); + } + } + } + let joined = + std::env::join_paths(paths).map_err(|e| IdeError::invalid(format!("invalid Python import path: {e}")))?; + env.insert("PYTHONPATH".into(), joined.to_string_lossy().into_owned()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_configured_paths_without_duplicates_or_system_leaks() { + let root = std::env::temp_dir().join("python project"); + let other = root.join("vendor"); + let value = std::env::join_paths([&other, &root]).unwrap().to_string_lossy().into_owned(); + let mut env = BTreeMap::from([("PYTHONPATH".into(), value)]); + extend(&mut env, std::slice::from_ref(&root), false).unwrap(); + assert_eq!(std::env::split_paths(&env["PYTHONPATH"]).collect::>(), [root, other]); + } +} diff --git a/crates/ide-run/tests/process.rs b/crates/ide-run/tests/process.rs index c93323668..de49fcbde 100644 --- a/crates/ide-run/tests/process.rs +++ b/crates/ide-run/tests/process.rs @@ -428,7 +428,7 @@ fn build_tools_get_the_console_encoding_for_every_jvm() { std::fs::set_permissions(&mvn, std::fs::Permissions::from_mode(0o755)).unwrap(); let chunks: Chunks = Arc::default(); let sink = chunks.clone(); - let options = StartOptions { encoding: Some("GBK".into()) }; + let options = StartOptions { encoding: Some("GBK".into()), ..Default::default() }; let config = cfg("t:mvn", mvn.to_str().unwrap(), &["spring-boot:run"]); let info = h.pm.start(h.root.path(), &config, &options, h.sink.clone(), Box::new(move |c| sink.lock().push(c))).unwrap(); @@ -439,3 +439,32 @@ fn build_tools_get_the_console_encoding_for_every_jvm() { ); assert_eq!(text(&chunks, stderr), "Consume Time: 1 ms\n"); } + +#[test] +fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() { + for (kind, source_dir) in [("python", ""), ("pytest", "src")] { + let h = harness(); + let root = h.root.path(); + let source = root.join(source_dir); + for dir in [root.join("app"), source.join("helpers"), root.join("vendor")] { + std::fs::create_dir_all(dir).unwrap(); + } + std::fs::write(source.join("helpers/__init__.py"), "def hello(): return 'import OK'\n").unwrap(); + std::fs::write(root.join("vendor/custom.py"), "VALUE = 'vendor OK'\n").unwrap(); + std::fs::write( + root.join("app/main.py"), + "from helpers import hello\nfrom custom import VALUE\nprint(hello(), VALUE)\n", + ) + .unwrap(); + std::fs::write(root.join(".env"), format!("PYTHONPATH={}\n", root.join("vendor").display())).unwrap(); + let mut config = cfg("python-imports", "python3", &["app/main.py"]); + config.kind = kind.into(); + config.extras.env_files = vec![".env".into()]; + let options = StartOptions { python_paths: vec![root.to_owned(), source], ..Default::default() }; + let chunks: Chunks = Arc::default(); + let sink = chunks.clone(); + let info = h.pm.start(root, &config, &options, h.sink.clone(), Box::new(move |c| sink.lock().push(c))).unwrap(); + assert_eq!(wait_exit(&h, info.id, Duration::from_secs(10)), Some(0), "{}", text(&chunks, stderr)); + assert_eq!(text(&chunks, stdout), "import OK vendor OK\n"); + } +}