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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-29 15:11:22 +08:00
1 джерело 3f9512056d
коміт c7d32f9e23
8 files changed
+195 -3

No files matched your search

+7 -1
Переглянути файл
@@ -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<DebugSessionId> {
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) {
+1
Переглянути файл
@@ -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;
+103
Переглянути файл
@@ -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<PathBuf> {
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<serde_json::Value> = 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::<Vec<_>>();
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<String, String> = 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::<Vec<_>>(), [root, vendor]);
}
}
}
+4 -1
Переглянути файл
@@ -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)))
}
+1
Переглянути файл
@@ -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;
+5
Переглянути файл
@@ -77,6 +77,8 @@ impl Entry {
pub struct StartOptions {
/// Output encoding label (`UTF-8`, `GBK`, `windows-1252`…); `None` = UTF-8.
pub encoding: Option<String>,
/// Project content and source roots added to Python launch environments.
pub python_paths: Vec<PathBuf>,
}
#[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)?,
+44
Переглянути файл
@@ -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<String, String>, 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::<Vec<_>>(), [root, other]);
}
}
+30 -1
Переглянути файл
@@ -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");
}
}