diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index bc03ebb69..7c64b5a8f 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -1321,8 +1321,8 @@ fn run_site_query() -> Option { /// Get global/system Python `site-packages` directories. /// /// Queries `python3` for site-packages paths, then checks well-known system -/// locations including Homebrew, conda, uv tools, pipx venvs, pip --user, -/// etc. +/// locations including Homebrew, conda, uv tools and interpreters, pipx +/// venvs, PDM's global project and interpreters, pip --user, etc. pub async fn get_global_python_site_packages() -> Vec { let mut results = Vec::new(); let mut seen = HashSet::new(); @@ -1498,48 +1498,10 @@ pub async fn get_global_python_site_packages() -> Vec { let miniconda = home_dir.join("miniconda3"); scan_well_known(&miniconda, "site-packages", &mut seen, &mut results).await; - // uv tools — platform-specific install root. - #[cfg(target_os = "macos")] - { - // Legacy/secondary location only: uv follows XDG conventions on - // macOS (`uv tool dir` → ~/.local/share/uv/tools, covered by the - // not(windows) scan below), but older layouts used the platform - // data dir, so keep scanning it too. - let uv_base = home_dir - .join("Library") - .join("Application Support") - .join("uv") - .join("tools"); - let uv_matches = - find_python_dirs(&uv_base, &["*", "lib", "python3.*", "site-packages"]).await; - for m in uv_matches { - add_path(m, &mut seen, &mut results); - } - } - #[cfg(windows)] - { - // %LOCALAPPDATA%\uv\tools - if let Ok(local) = std::env::var("LOCALAPPDATA") { - let uv_base = PathBuf::from(local).join("uv").join("tools"); - let uv_matches = find_python_dirs(&uv_base, &["*", "Lib", "site-packages"]).await; - for m in uv_matches { - add_path(m, &mut seen, &mut results); - } - } - } - #[cfg(not(windows))] - { - // uv uses XDG paths on BOTH Linux and macOS (`uv tool dir` → - // ~/.local/share/uv/tools; verified against a real uv install — - // macOS does NOT get an Application Support tool dir). - let uv_base = home_dir - .join(".local") - .join("share") - .join("uv") - .join("tools"); - let uv_matches = - find_python_dirs(&uv_base, &["*", "lib", "python3.*", "site-packages"]).await; - for m in uv_matches { + // uv tool envs (`uv tool install`): one venv per tool under every + // root uv may use (see `uv_dir_candidates`). + for tools in uv_dir_candidates(&home_dir, "UV_TOOL_DIR", "tools") { + for m in find_child_env_site_packages(&tools).await { add_path(m, &mut seen, &mut results); } } @@ -1564,38 +1526,244 @@ pub async fn get_global_python_site_packages() -> Vec { } } - // uv-managed Python interpreters (`uv python install 3.X`) live at: - // Linux/macOS: ~/.local/share/uv/python/cpython-3.X.*/lib/python3.X/site-packages/ - // Windows: %LOCALAPPDATA%\uv\python\cpython-3.X.*\Lib\site-packages\ - // The typical flow is `uv venv` + `uv pip install`, where the venv layout - // is already covered by `find_local_venv_site_packages`. But power users + // uv-managed Python interpreters (`uv python install 3.X`), one per + // child of uv's python dir (`cpython-3.X.*-`). The typical + // flow is `uv venv` + `uv pip install`, where the venv layout is + // already covered by `find_local_venv_site_packages`. But power users // can install packages directly into the managed interpreter (e.g. via - // `/bin/pip install ...`), and globally-discovered crawls - // should surface those. + // `uv pip install --system --python `), and globally + // discovered crawls should surface those. + for python in uv_dir_candidates(&home_dir, "UV_PYTHON_INSTALL_DIR", "python") { + for m in find_child_env_site_packages(&python).await { + add_path(m, &mut seen, &mut results); + } + } + + // PDM's global project (`pdm add -g`) and PDM-managed interpreters + // (`pdm python install`). + for m in pdm_global_site_packages(&home_dir).await { + add_path(m, &mut seen, &mut results); + } + + results +} + +/// `site-packages` of every environment directly under `parent`: +/// `//lib{,64}/python3.X/site-packages` on Unix and +/// `\\Lib\site-packages` on Windows. +async fn find_child_env_site_packages(parent: &Path) -> Vec { #[cfg(not(windows))] { - let uv_python = home_dir - .join(".local") - .join("share") + let mut matches = + find_python_dirs(parent, &["*", "lib", "python3.*", "site-packages"]).await; + matches + .extend(find_python_dirs(parent, &["*", "lib64", "python3.*", "site-packages"]).await); + matches + } + #[cfg(windows)] + { + find_python_dirs(parent, &["*", "Lib", "site-packages"]).await + } +} + +/// `site-packages` of the one environment (venv or interpreter) whose +/// prefix is `prefix`, in the same layouts as +/// [`find_child_env_site_packages`]. +async fn find_env_site_packages(prefix: &Path) -> Vec { + #[cfg(not(windows))] + { + let mut matches = find_python_dirs(prefix, &["lib", "python3.*", "site-packages"]).await; + matches.extend(find_python_dirs(prefix, &["lib64", "python3.*", "site-packages"]).await); + matches + } + #[cfg(windows)] + { + find_python_dirs(prefix, &["Lib", "site-packages"]).await + } +} + +/// `$var` as a directory when it is set to an absolute path. platformdirs +/// and uv both ignore a relative `XDG_*` value, per the XDG spec. +#[cfg(not(windows))] +fn absolute_env_dir(var: &str) -> Option { + std::env::var_os(var) + .map(PathBuf::from) + .filter(|p| p.is_absolute()) +} + +/// The directories uv may keep `bucket` (`tools` or `python`) in, most +/// specific first. +/// +/// uv (`StateStore::from_settings`) uses `$override_var` (`UV_TOOL_DIR` +/// or `UV_PYTHON_INSTALL_DIR`) when set, made absolute against the cwd. +/// Otherwise it uses `/uv/`, where the data dir is +/// `$XDG_DATA_HOME` (absolute only) or `~/.local/share` on Linux and +/// macOS, and `%APPDATA%` on Windows (`uv tool dir` prints +/// `%APPDATA%\uv\tools` there). The legacy roots that older layouts used +/// are returned too: `~/Library/Application Support/uv` on macOS, and +/// `%LOCALAPPDATA%\uv`, which earlier socket-patch releases scanned, on +/// Windows. Callers skip the ones that don't exist. +#[cfg_attr(windows, allow(unused_variables))] +fn uv_dir_candidates(home_dir: &Path, override_var: &str, bucket: &str) -> Vec { + let mut dirs = Vec::new(); + if let Some(dir) = std::env::var_os(override_var).filter(|v| !v.is_empty()) { + let dir = PathBuf::from(dir); + dirs.push(std::path::absolute(&dir).unwrap_or(dir)); + } + #[cfg(not(windows))] + { + if let Some(xdg) = absolute_env_dir("XDG_DATA_HOME") { + dirs.push(xdg.join("uv").join(bucket)); + } + dirs.push( + home_dir + .join(".local") + .join("share") + .join("uv") + .join(bucket), + ); + } + #[cfg(target_os = "macos")] + dirs.push( + home_dir + .join("Library") + .join("Application Support") .join("uv") - .join("python"); - let uv_matches = - find_python_dirs(&uv_python, &["*", "lib", "python3.*", "site-packages"]).await; - for m in uv_matches { - add_path(m, &mut seen, &mut results); + .join(bucket), + ); + #[cfg(windows)] + for var in ["APPDATA", "LOCALAPPDATA"] { + if let Some(base) = std::env::var_os(var).filter(|v| !v.is_empty()) { + dirs.push(PathBuf::from(base).join("uv").join(bucket)); } } + dirs +} + +/// The directories platformdirs may resolve for PDM's per-user config +/// (`xdg_var` = `XDG_CONFIG_HOME`, `unix_default` = `.config`) or data +/// (`XDG_DATA_HOME`, `.local/share`) dir, most specific first: +/// `$xdg_var/pdm` (absolute only; platformdirs 4.4+ also honors it on +/// macOS), `~//pdm` on Linux, +/// `~/Library/Application Support/pdm` on macOS, and +/// `%LOCALAPPDATA%\pdm\pdm` on Windows. +#[cfg_attr(windows, allow(unused_variables))] +fn pdm_dir_candidates(home_dir: &Path, xdg_var: &str, unix_default: &Path) -> Vec { + let mut dirs = Vec::new(); + #[cfg(not(windows))] + if let Some(xdg) = absolute_env_dir(xdg_var) { + dirs.push(xdg.join("pdm")); + } + #[cfg(all(not(target_os = "macos"), not(windows)))] + dirs.push(home_dir.join(unix_default).join("pdm")); + #[cfg(target_os = "macos")] + dirs.push( + home_dir + .join("Library") + .join("Application Support") + .join("pdm"), + ); #[cfg(windows)] - { - if let Ok(local) = std::env::var("LOCALAPPDATA") { - let uv_python = PathBuf::from(local).join("uv").join("python"); - let uv_matches = find_python_dirs(&uv_python, &["*", "Lib", "site-packages"]).await; - for m in uv_matches { - add_path(m, &mut seen, &mut results); + if let Some(local) = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()) { + dirs.push(PathBuf::from(local).join("pdm").join("pdm")); + } + dirs +} + +/// `site-packages` of PDM's global installs: +/// +/// - The global project's environment (`pdm add -g`). The project lives +/// at the `global_project.path` setting, by default +/// `/pdm/global-project`. Its environment is the +/// in-project `.venv`, an out-of-tree venv under `venv.location` +/// (default `/pdm/venvs`) named +/// `--`, or whatever interpreter +/// `pdm use -g` recorded in its `.pdm-python`. +/// - PDM-managed interpreters (`pdm python install`), one per child of +/// `python.install_root` (default `/pdm/python`). +/// +/// The settings come from PDM's global config file, `$PDM_CONFIG_FILE` +/// or `/pdm/config.toml`. Every candidate is collected, +/// and the ones that don't exist yield nothing. +async fn pdm_global_site_packages(home_dir: &Path) -> Vec { + let config_dirs = pdm_dir_candidates(home_dir, "XDG_CONFIG_HOME", Path::new(".config")); + let data_dirs = pdm_dir_candidates( + home_dir, + "XDG_DATA_HOME", + &Path::new(".local").join("share"), + ); + + let mut config_files: Vec = std::env::var_os("PDM_CONFIG_FILE") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .into_iter() + .collect(); + config_files.extend(config_dirs.iter().map(|d| d.join("config.toml"))); + + let mut projects = Vec::new(); + let mut install_roots = Vec::new(); + let mut venv_roots = Vec::new(); + // PDM expands `~` with Python's `expanduser`, which reads USERPROFILE + // on Windows and ignores HOME there (Python 3.8+), so a Git Bash HOME + // must not decide where a relocated setting points. + let var = |name: &str| { + if cfg!(windows) && name == "HOME" { + return None; + } + std::env::var(name).ok() + }; + for file in &config_files { + let Ok(text) = read_regular_to_string(file).await else { + continue; + }; + let Ok(doc) = text.parse::() else { + continue; + }; + let setting = |table: &str, key: &str| { + doc.get(table) + .and_then(|t| t.get(key)) + .and_then(|v| v.as_str()) + .filter(|v| !v.is_empty()) + .map(|v| expand_home(v, &var)) + }; + projects.extend(setting("global_project", "path")); + install_roots.extend(setting("python", "install_root")); + venv_roots.extend(setting("venv", "location")); + } + projects.extend(config_dirs.iter().map(|d| d.join("global-project"))); + install_roots.extend(data_dirs.iter().map(|d| d.join("python"))); + venv_roots.extend(data_dirs.iter().map(|d| d.join("venvs"))); + + let mut results = Vec::new(); + for project in &projects { + results.extend(find_env_site_packages(&project.join(".venv")).await); + if let Some(name) = project.file_name().and_then(|n| n.to_str()) { + let prefix = format!("{name}-"); + for root in &venv_roots { + for entry in crate::utils::fs::list_dir_entries(root).await { + if entry.file_name().to_string_lossy().starts_with(&prefix) { + results.extend(find_env_site_packages(&root.join(entry.file_name())).await); + } + } + } + } + // `.pdm-python` names the interpreter: `/bin/python3` in a + // venv or Unix install, `\Scripts\python.exe` in a Windows + // venv, `\python.exe` in a Windows install. + if let Ok(text) = read_regular_to_string(&project.join(".pdm-python")).await { + let interpreter = PathBuf::from(text.trim()); + let prefixes = interpreter.is_absolute().then_some(&interpreter); + for prefix in prefixes + .into_iter() + .flat_map(|i| i.ancestors().skip(1).take(2)) + { + results.extend(find_env_site_packages(prefix).await); } } } - + for root in &install_roots { + results.extend(find_child_env_site_packages(root).await); + } results } diff --git a/crates/socket-patch-core/tests/crawler_python_e2e.rs b/crates/socket-patch-core/tests/crawler_python_e2e.rs index 2ad70b423..b534ac9e7 100644 --- a/crates/socket-patch-core/tests/crawler_python_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_python_e2e.rs @@ -703,6 +703,345 @@ async fn get_global_python_site_packages_discovers_pipx_venvs_under_pipx_home() ); } +// ── uv and PDM global dirs (#449, #451) ─────────────────────── + +/// Env vars that relocate a Python tool's global dirs. Each +/// `global_site_packages_with_vars` call unsets the ones it isn't given, +/// so an ambient value on the host can't decide the result. +const TOOL_DIR_VARS: &[&str] = &[ + "PIPX_HOME", + "XDG_DATA_HOME", + "XDG_CONFIG_HOME", + "UV_TOOL_DIR", + "UV_PYTHON_INSTALL_DIR", + "PDM_CONFIG_FILE", +]; + +/// Run `get_global_python_site_packages` with HOME and `vars` bound and +/// every other [`TOOL_DIR_VARS`] entry unset, restoring all of them after. +async fn global_site_packages_with_vars( + home: &Path, + vars: &[(&str, &Path)], +) -> Vec { + let mut keys: Vec<&str> = vec!["HOME"]; + keys.extend(TOOL_DIR_VARS); + keys.extend( + vars.iter() + .map(|(k, _)| *k) + .filter(|k| !TOOL_DIR_VARS.contains(k)), + ); + let saved: Vec<(&str, Option)> = + keys.iter().map(|k| (*k, std::env::var(k).ok())).collect(); + std::env::set_var("HOME", home); + for key in TOOL_DIR_VARS { + std::env::remove_var(key); + } + for (key, value) in vars { + std::env::set_var(key, value); + } + let result = get_global_python_site_packages().await; + for (key, value) in saved { + match value { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } + } + result +} + +/// The site-packages of the environment at `prefix` in the platform's +/// layout (`lib/python3.12/site-packages` on Unix, `Lib\site-packages` +/// on Windows), created on disk. +async fn stage_env(prefix: &Path) -> std::path::PathBuf { + let sp = if cfg!(windows) { + prefix.join("Lib").join("site-packages") + } else { + prefix.join("lib").join("python3.12").join("site-packages") + }; + tokio::fs::create_dir_all(&sp).await.unwrap(); + sp +} + +fn assert_surfaces(result: &[std::path::PathBuf], sp: &Path, what: &str) { + assert!( + result.iter().any(|p| p == sp), + "{what} ({}) must surface; got {result:?}", + sp.display() + ); +} + +/// `UV_TOOL_DIR` relocates every `uv tool install` env, on every OS +/// (#449). +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_uv_tools_under_uv_tool_dir() { + let tmp = tempfile::tempdir().unwrap(); + let tools = tmp.path().join("custom-tools"); + let sp = stage_env(&tools.join("pycowsay")).await; + + let result = global_site_packages_with_vars(tmp.path(), &[("UV_TOOL_DIR", &tools)]).await; + assert_surfaces(&result, &sp, "uv tool env under UV_TOOL_DIR"); +} + +/// uv's data dir follows an absolute `$XDG_DATA_HOME` on Linux and macOS +/// (`uv tool dir` → `$XDG_DATA_HOME/uv/tools`) (#449). +#[cfg(not(windows))] +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_uv_tools_under_xdg_data_home() { + let tmp = tempfile::tempdir().unwrap(); + let xdg = tmp.path().join("xdg"); + let sp = stage_env(&xdg.join("uv").join("tools").join("pycowsay")).await; + + let result = global_site_packages_with_vars(tmp.path(), &[("XDG_DATA_HOME", &xdg)]).await; + assert_surfaces(&result, &sp, "uv tool env under XDG_DATA_HOME"); +} + +/// On Windows uv keeps tool envs under `%APPDATA%\uv\tools` (the roaming +/// profile), not `%LOCALAPPDATA%` (#449). +#[cfg(windows)] +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_uv_tools_under_appdata() { + let tmp = tempfile::tempdir().unwrap(); + let appdata = tmp.path().join("Roaming"); + let sp = stage_env(&appdata.join("uv").join("tools").join("pycowsay")).await; + + let result = global_site_packages_with_vars(tmp.path(), &[("APPDATA", &appdata)]).await; + assert_surfaces(&result, &sp, "uv tool env under %APPDATA%"); +} + +/// `UV_PYTHON_INSTALL_DIR` relocates every `uv python install` +/// interpreter, on every OS (#449). +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_uv_python_under_install_dir() { + let tmp = tempfile::tempdir().unwrap(); + let pyinst = tmp.path().join("pyinst"); + let sp = stage_env(&pyinst.join("cpython-3.12.11-linux-x86_64-gnu")).await; + + let result = + global_site_packages_with_vars(tmp.path(), &[("UV_PYTHON_INSTALL_DIR", &pyinst)]).await; + assert_surfaces(&result, &sp, "uv python under UV_PYTHON_INSTALL_DIR"); +} + +/// uv-managed interpreters follow `$XDG_DATA_HOME` like tool envs (#449). +#[cfg(not(windows))] +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_uv_python_under_xdg_data_home() { + let tmp = tempfile::tempdir().unwrap(); + let xdg = tmp.path().join("xdg"); + let sp = stage_env(&xdg.join("uv").join("python").join("cpython-3.12.11")).await; + + let result = global_site_packages_with_vars(tmp.path(), &[("XDG_DATA_HOME", &xdg)]).await; + assert_surfaces(&result, &sp, "uv python under XDG_DATA_HOME"); +} + +/// On Windows uv-managed interpreters live under `%APPDATA%\uv\python` +/// (#449). +#[cfg(windows)] +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_uv_python_under_appdata() { + let tmp = tempfile::tempdir().unwrap(); + let appdata = tmp.path().join("Roaming"); + let sp = stage_env(&appdata.join("uv").join("python").join("cpython-3.12.11")).await; + + let result = global_site_packages_with_vars(tmp.path(), &[("APPDATA", &appdata)]).await; + assert_surfaces(&result, &sp, "uv python under %APPDATA%"); +} + +/// PDM's per-user config dir (platformdirs `user_config_dir("pdm")`) +/// under `home`, with the env var Windows needs to find it. +fn pdm_config_dir(home: &Path) -> (std::path::PathBuf, Vec<(&'static str, std::path::PathBuf)>) { + if cfg!(windows) { + let local = home.join("Local"); + (local.join("pdm").join("pdm"), vec![("LOCALAPPDATA", local)]) + } else if cfg!(target_os = "macos") { + ( + home.join("Library").join("Application Support").join("pdm"), + vec![], + ) + } else { + (home.join(".config").join("pdm"), vec![]) + } +} + +/// PDM's per-user data dir (platformdirs `user_data_dir("pdm")`). +fn pdm_data_dir(home: &Path) -> (std::path::PathBuf, Vec<(&'static str, std::path::PathBuf)>) { + if cfg!(windows) || cfg!(target_os = "macos") { + pdm_config_dir(home) + } else { + (home.join(".local").join("share").join("pdm"), vec![]) + } +} + +async fn global_site_packages_with_owned_vars( + home: &Path, + vars: &[(&'static str, std::path::PathBuf)], +) -> Vec { + let borrowed: Vec<(&str, &Path)> = vars.iter().map(|(k, v)| (*k, v.as_path())).collect(); + global_site_packages_with_vars(home, &borrowed).await +} + +/// `pdm use -g ` creates the global project's `.venv` and every +/// later `pdm add -g` installs there (#451). +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_pdm_global_project_venv() { + let tmp = tempfile::tempdir().unwrap(); + let (config, vars) = pdm_config_dir(tmp.path()); + let sp = stage_env(&config.join("global-project").join(".venv")).await; + + let result = global_site_packages_with_owned_vars(tmp.path(), &vars).await; + assert_surfaces(&result, &sp, "PDM global project .venv"); +} + +/// platformdirs moves PDM's config dir, and with it the global project, +/// under an absolute `$XDG_CONFIG_HOME` (#451). +#[cfg(not(windows))] +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_pdm_global_project_under_xdg_config_home() { + let tmp = tempfile::tempdir().unwrap(); + let xdg = tmp.path().join("xdg-config"); + let sp = stage_env(&xdg.join("pdm").join("global-project").join(".venv")).await; + + let result = global_site_packages_with_vars(tmp.path(), &[("XDG_CONFIG_HOME", &xdg)]).await; + assert_surfaces(&result, &sp, "PDM global project under XDG_CONFIG_HOME"); +} + +/// `pdm python install 3.12` puts CPython under +/// `/pdm/python/cpython@3.12.N`, which PDM 2.12's +/// `pdm add -g` installs straight into (#451). +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_pdm_managed_interpreters() { + let tmp = tempfile::tempdir().unwrap(); + let (data, vars) = pdm_data_dir(tmp.path()); + let sp = stage_env(&data.join("python").join("cpython@3.12.14")).await; + + let result = global_site_packages_with_owned_vars(tmp.path(), &vars).await; + assert_surfaces(&result, &sp, "PDM-managed interpreter"); +} + +/// `global_project.path` and `python.install_root` in PDM's global +/// config (`$PDM_CONFIG_FILE` here) relocate both, with `~` expanded the +/// way PDM's `expanduser` does: from USERPROFILE on Windows, where a Git +/// Bash HOME is ignored, and from HOME elsewhere (#451). +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_follows_pdm_config_overrides() { + let tmp = tempfile::tempdir().unwrap(); + let home = if cfg!(windows) { + tmp.path().join("msys-home") + } else { + tmp.path().to_path_buf() + }; + let config_file = tmp.path().join("pdm-config.toml"); + tokio::fs::write( + &config_file, + "[global_project]\npath = \"~/gp\"\n\n[python]\ninstall_root = \"~/pyroot\"\n", + ) + .await + .unwrap(); + let project_sp = stage_env(&tmp.path().join("gp").join(".venv")).await; + let python_sp = stage_env(&tmp.path().join("pyroot").join("cpython@3.12.14")).await; + + let mut vars: Vec<(&str, &Path)> = vec![("PDM_CONFIG_FILE", &config_file)]; + if cfg!(windows) { + vars.push(("USERPROFILE", tmp.path())); + } + let result = global_site_packages_with_vars(&home, &vars).await; + assert_surfaces( + &result, + &project_sp, + "PDM global project at global_project.path", + ); + assert_surfaces( + &result, + &python_sp, + "PDM interpreter under python.install_root", + ); +} + +/// The settings are also read from PDM's default global config file, +/// `/pdm/config.toml` (#451). +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_reads_pdm_default_config_file() { + let tmp = tempfile::tempdir().unwrap(); + let (config, vars) = pdm_config_dir(tmp.path()); + tokio::fs::create_dir_all(&config).await.unwrap(); + let gp = tmp.path().join("elsewhere").join("global"); + tokio::fs::write( + config.join("config.toml"), + format!("[global_project]\npath = '{}'\n", gp.display()), + ) + .await + .unwrap(); + let sp = stage_env(&gp.join(".venv")).await; + + let result = global_site_packages_with_owned_vars(tmp.path(), &vars).await; + assert_surfaces(&result, &sp, "PDM global project from config.toml"); +} + +/// `pdm use -g ` records the interpreter in the global +/// project's `.pdm-python`; with no venv, `pdm add -g` installs into +/// that interpreter's own site-packages (#451). +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_follows_pdm_global_project_interpreter() { + let tmp = tempfile::tempdir().unwrap(); + let (config, vars) = pdm_config_dir(tmp.path()); + let project = config.join("global-project"); + tokio::fs::create_dir_all(&project).await.unwrap(); + let prefix = tmp.path().join("some-python"); + let sp = stage_env(&prefix).await; + let interpreter = if cfg!(windows) { + prefix.join("python.exe") + } else { + prefix.join("bin").join("python3") + }; + tokio::fs::write( + project.join(".pdm-python"), + format!("{}\n", interpreter.display()), + ) + .await + .unwrap(); + + let result = global_site_packages_with_owned_vars(tmp.path(), &vars).await; + assert_surfaces(&result, &sp, "interpreter named by the global .pdm-python"); +} + +/// With `venv.in_project = false` the global project's venv is created +/// under `venv.location` as `global-project--`. Other +/// projects' venvs in the same dir are not global installs (#451). +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_pdm_global_project_out_of_tree_venv() { + let tmp = tempfile::tempdir().unwrap(); + let (config, vars) = pdm_config_dir(tmp.path()); + tokio::fs::create_dir_all(&config).await.unwrap(); + let venvs = tmp.path().join("pdm-venvs"); + tokio::fs::write( + config.join("config.toml"), + format!("[venv]\nlocation = '{}'\n", venvs.display()), + ) + .await + .unwrap(); + let sp = stage_env(&venvs.join("global-project-Vt4hK2Zp-3.12")).await; + let other = stage_env(&venvs.join("webapp-Q9xLm3Rd-3.12")).await; + + let result = global_site_packages_with_owned_vars(tmp.path(), &vars).await; + assert_surfaces(&result, &sp, "PDM global project venv under venv.location"); + assert!( + !result.iter().any(|p| p == &other), + "another project's PDM venv is not a global install; got {result:?}" + ); +} + // ── project-marker fallback in get_site_packages_paths ──────── /// A project with `pyproject.toml` but no `.venv` must fall through