diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index 03814ab23..621e0ce50 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -1069,7 +1069,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, pip --user, etc. +/// locations including Homebrew, conda, uv tools, pipx venvs, pip --user, +/// etc. pub async fn get_global_python_site_packages() -> Vec { let mut results = Vec::new(); let mut seen = HashSet::new(); @@ -1293,6 +1294,26 @@ pub async fn get_global_python_site_packages() -> Vec { } } + // pipx app venvs (`pipx install hatch`): one venv per app under + // `/venvs/`. Every candidate home that exists is + // scanned, not just the one pipx would pick today: an app installed + // under an older default is still a real install, and `seen` dedups + // overlaps (e.g. PIPX_HOME set to the default). + for pipx_home in pipx_home_candidates(&home_dir) { + let venvs = pipx_home.join("venvs"); + #[cfg(not(windows))] + let mut matches = + find_python_dirs(&venvs, &["*", "lib", "python3.*", "site-packages"]).await; + #[cfg(not(windows))] + matches + .extend(find_python_dirs(&venvs, &["*", "lib64", "python3.*", "site-packages"]).await); + #[cfg(windows)] + let matches = find_python_dirs(&venvs, &["*", "Lib", "site-packages"]).await; + for m in matches { + add_path(m, &mut seen, &mut results); + } + } + // 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\ @@ -1328,6 +1349,49 @@ pub async fn get_global_python_site_packages() -> Vec { results } +/// The directories pipx may use as its home, most specific first. +/// +/// pipx (>= 1.3, `pipx/paths.py`) uses `$PIPX_HOME` when set, otherwise +/// its legacy home `~/.local/pipx` if that exists, otherwise platformdirs' +/// user data dir: `$XDG_DATA_HOME/pipx` (default `~/.local/share/pipx`) on +/// Linux, `~/Library/Application Support/pipx` on macOS, and +/// `%USERPROFILE%\pipx` on Windows (with `%LOCALAPPDATA%\pipx\pipx` as its +/// platformdirs fallback). All of them are returned; callers skip the ones +/// that don't exist. +fn pipx_home_candidates(home_dir: &Path) -> Vec { + let mut homes = Vec::new(); + if let Some(pipx_home) = std::env::var_os("PIPX_HOME").filter(|v| !v.is_empty()) { + homes.push(PathBuf::from(pipx_home)); + } + homes.push(home_dir.join(".local").join("pipx")); + #[cfg(all(not(target_os = "macos"), not(windows)))] + { + // platformdirs ignores a relative XDG_DATA_HOME, per the XDG spec. + if let Some(xdg) = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + { + homes.push(xdg.join("pipx")); + } + homes.push(home_dir.join(".local").join("share").join("pipx")); + } + #[cfg(target_os = "macos")] + homes.push( + home_dir + .join("Library") + .join("Application Support") + .join("pipx"), + ); + #[cfg(windows)] + { + homes.push(home_dir.join("pipx")); + if let Ok(local) = std::env::var("LOCALAPPDATA") { + homes.push(PathBuf::from(local).join("pipx").join("pipx")); + } + } + homes +} + /// Returns true if `cwd` looks like a Python project root. /// /// Used by `PythonCrawler::get_site_packages_paths` to decide diff --git a/crates/socket-patch-core/tests/crawler_python_e2e.rs b/crates/socket-patch-core/tests/crawler_python_e2e.rs index 9e1addf9f..2ad70b423 100644 --- a/crates/socket-patch-core/tests/crawler_python_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_python_e2e.rs @@ -518,6 +518,191 @@ async fn get_global_python_site_packages_discovers_uv_python_install() { ); } +// ── pipx venv discovery ─────────────────────────────────────── + +/// Run `get_global_python_site_packages` with HOME, PIPX_HOME and +/// XDG_DATA_HOME rebound (`None` unsets), restoring all three after. +/// pipx resolves its home from these, so every pipx test has to pin +/// them or an ambient value on the host would decide the result. +async fn global_site_packages_with_env( + home: &Path, + pipx_home: Option<&Path>, + xdg_data_home: Option<&Path>, +) -> Vec { + let saved: Vec<(&str, Option)> = ["HOME", "PIPX_HOME", "XDG_DATA_HOME"] + .into_iter() + .map(|k| (k, std::env::var(k).ok())) + .collect(); + std::env::set_var("HOME", home); + for (key, value) in [("PIPX_HOME", pipx_home), ("XDG_DATA_HOME", xdg_data_home)] { + match value { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } + } + 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 a pipx app venv under `pipx_home`, in the +/// platform's venv layout (`lib/python3.X/site-packages` on Unix, +/// `Lib\site-packages` on Windows). +fn pipx_venv_site_packages(pipx_home: &Path, app: &str) -> std::path::PathBuf { + let venv = pipx_home.join("venvs").join(app); + if cfg!(windows) { + venv.join("Lib").join("site-packages") + } else { + venv.join("lib").join("python3.11").join("site-packages") + } +} + +/// `pipx install hatch` on Linux (pipx >= 1.3) puts the app venv at +/// `~/.local/share/pipx/venvs/hatch` (#415). Every app venv must surface, +/// not just the first. +#[cfg(all(not(target_os = "macos"), not(windows)))] +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_pipx_venvs_linux() { + let tmp = tempfile::tempdir().unwrap(); + let pipx_home = tmp.path().join(".local").join("share").join("pipx"); + let staged: Vec<_> = ["hatch", "black"] + .iter() + .map(|app| pipx_venv_site_packages(&pipx_home, app)) + .collect(); + for sp in &staged { + tokio::fs::create_dir_all(sp).await.unwrap(); + } + + let result = global_site_packages_with_env(tmp.path(), None, None).await; + for sp in &staged { + assert!( + result.iter().any(|p| p == sp), + "pipx venv {} must surface; got {result:?}", + sp.display() + ); + } +} + +/// pipx's Linux default follows `$XDG_DATA_HOME` (platformdirs' +/// `user_data_dir`), so a relocated data home moves the venvs too. +#[cfg(all(not(target_os = "macos"), not(windows)))] +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_pipx_venvs_under_xdg_data_home() { + let tmp = tempfile::tempdir().unwrap(); + let xdg = tmp.path().join("xdg-data"); + let sp = pipx_venv_site_packages(&xdg.join("pipx"), "hatch"); + tokio::fs::create_dir_all(&sp).await.unwrap(); + + let result = global_site_packages_with_env(tmp.path(), None, Some(&xdg)).await; + assert!( + result.iter().any(|p| p == &sp), + "pipx venv under XDG_DATA_HOME must surface; got {result:?}" + ); +} + +/// Native (C-extension) packages land in `lib64` on RHEL/Fedora/SUSE +/// venvs, the same split the other well-known scans already handle. +#[cfg(not(windows))] +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_pipx_venv_lib64() { + let tmp = tempfile::tempdir().unwrap(); + let pipx_home = tmp.path().join("pipx-home"); + let sp = pipx_home + .join("venvs") + .join("hatch") + .join("lib64") + .join("python3.11") + .join("site-packages"); + tokio::fs::create_dir_all(&sp).await.unwrap(); + + let result = global_site_packages_with_env(tmp.path(), Some(&pipx_home), None).await; + assert!( + result.iter().any(|p| p == &sp), + "pipx venv lib64 site-packages must surface; got {result:?}" + ); +} + +/// pipx's legacy home `~/.local/pipx` is still used when it exists +/// (pipx < 1.3 installs, and pipx's fallback on every OS), and is +/// what macOS runners use in the #415 probe. +#[cfg(not(windows))] +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_pipx_venvs_legacy_home() { + let tmp = tempfile::tempdir().unwrap(); + let sp = pipx_venv_site_packages(&tmp.path().join(".local").join("pipx"), "hatch"); + tokio::fs::create_dir_all(&sp).await.unwrap(); + + let result = global_site_packages_with_env(tmp.path(), None, None).await; + assert!( + result.iter().any(|p| p == &sp), + "legacy ~/.local/pipx venv must surface; got {result:?}" + ); +} + +/// platformdirs' macOS data dir is `~/Library/Application Support`, +/// which pipx 1.3–1.4 used as its default home. +#[cfg(target_os = "macos")] +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_pipx_venvs_macos_app_support() { + let tmp = tempfile::tempdir().unwrap(); + let pipx_home = tmp + .path() + .join("Library") + .join("Application Support") + .join("pipx"); + let sp = pipx_venv_site_packages(&pipx_home, "hatch"); + tokio::fs::create_dir_all(&sp).await.unwrap(); + + let result = global_site_packages_with_env(tmp.path(), None, None).await; + assert!( + result.iter().any(|p| p == &sp), + "macOS Application Support pipx venv must surface; got {result:?}" + ); +} + +/// pipx's Windows default home is `%USERPROFILE%\pipx`, with venvs in +/// the Windows layout `venvs\\Lib\site-packages`. +#[cfg(windows)] +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_pipx_venvs_windows() { + let tmp = tempfile::tempdir().unwrap(); + let sp = pipx_venv_site_packages(&tmp.path().join("pipx"), "hatch"); + tokio::fs::create_dir_all(&sp).await.unwrap(); + + let result = global_site_packages_with_env(tmp.path(), None, None).await; + assert!( + result.iter().any(|p| p == &sp), + "%USERPROFILE%\\pipx venv must surface; got {result:?}" + ); +} + +/// An explicit `PIPX_HOME` relocates every pipx venv, on every OS. +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_pipx_venvs_under_pipx_home() { + let tmp = tempfile::tempdir().unwrap(); + let pipx_home = tmp.path().join("custom pipx"); + let sp = pipx_venv_site_packages(&pipx_home, "hatch"); + tokio::fs::create_dir_all(&sp).await.unwrap(); + + let result = global_site_packages_with_env(tmp.path(), Some(&pipx_home), None).await; + assert!( + result.iter().any(|p| p == &sp), + "pipx venv under PIPX_HOME must surface; got {result:?}" + ); +} + // ── project-marker fallback in get_site_packages_paths ──────── /// A project with `pyproject.toml` but no `.venv` must fall through