From 597c03487992c9a545b69cf66021a81b29ddce68 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 02:25:59 +0000 Subject: [PATCH 1/3] Start fix for #449, #451 Assisted-by: Claude Code:claude-opus-5-5 From 1a82a4920f36b1f73dca2d4c5b6a8894652eb777 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 02:35:03 +0000 Subject: [PATCH 2/3] Find uv and PDM global installs in -g scans scan -g, get -g, apply -g, rollback -g and vex -g looked for uv tool envs and uv-managed Pythons only at fixed paths, and never looked for PDM's global installs at all. On Windows every uv tool env was missed (uv uses %APPDATA%, not %LOCALAPPDATA%). On every OS, UV_TOOL_DIR, UV_PYTHON_INSTALL_DIR and XDG_DATA_HOME were ignored. `pdm add -g` packages in the global project venv, and packages in PDM-managed interpreters, were silently left out. Global discovery now follows each tool's own path rules: uv's override env vars and state dir, and PDM's platformdirs config/data dirs plus the global_project.path, python.install_root and venv.location settings and the global project's .pdm-python. Fixes #449 Fixes #451 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/python_crawler.rs | 292 ++++++++++++---- .../tests/crawler_python_e2e.rs | 330 ++++++++++++++++++ 2 files changed, 556 insertions(+), 66 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index bc03ebb69..671ffbfae 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,236 @@ 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 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))] { - let uv_python = home_dir - .join(".local") - .join("share") + 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(); + let var = |name: &str| 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..fe6aadab0 100644 --- a/crates/socket-patch-core/tests/crawler_python_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_python_e2e.rs @@ -703,6 +703,336 @@ 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 (#451). +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_follows_pdm_config_overrides() { + let tmp = tempfile::tempdir().unwrap(); + 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 result = + global_site_packages_with_vars(tmp.path(), &[("PDM_CONFIG_FILE", &config_file)]).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 From e5ab4b5d47d51f534464b43bda48bd303b12ba27 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 03:12:07 +0000 Subject: [PATCH 3/3] Expand PDM's ~ from USERPROFILE on Windows PDM expands a `~` in global_project.path or python.install_root with Python's expanduser, which uses USERPROFILE on Windows and ignores HOME. Under Git Bash, HOME is an MSYS path, so the relocated global project or interpreter root was looked for in the wrong place. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/python_crawler.rs | 10 +++++++++- .../socket-patch-core/tests/crawler_python_e2e.rs | 15 ++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index 671ffbfae..7c64b5a8f 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -1703,7 +1703,15 @@ async fn pdm_global_site_packages(home_dir: &Path) -> Vec { let mut projects = Vec::new(); let mut install_roots = Vec::new(); let mut venv_roots = Vec::new(); - let var = |name: &str| std::env::var(name).ok(); + // 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; diff --git a/crates/socket-patch-core/tests/crawler_python_e2e.rs b/crates/socket-patch-core/tests/crawler_python_e2e.rs index fe6aadab0..b534ac9e7 100644 --- a/crates/socket-patch-core/tests/crawler_python_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_python_e2e.rs @@ -928,11 +928,17 @@ async fn get_global_python_site_packages_discovers_pdm_managed_interpreters() { /// `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 (#451). +/// 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, @@ -943,8 +949,11 @@ async fn get_global_python_site_packages_follows_pdm_config_overrides() { 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 result = - global_site_packages_with_vars(tmp.path(), &[("PDM_CONFIG_FILE", &config_file)]).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,