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"); + } +}