Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion crates/socket-patch-core/src/crawlers/python_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1069,7 +1069,8 @@ fn run_site_query() -> Option<String> {
/// 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<PathBuf> {
let mut results = Vec::new();
let mut seen = HashSet::new();
Expand Down Expand Up @@ -1293,6 +1294,26 @@ pub async fn get_global_python_site_packages() -> Vec<PathBuf> {
}
}

// pipx app venvs (`pipx install hatch`): one venv per app under
// `<pipx home>/venvs/<app>`. 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\
Expand Down Expand Up @@ -1328,6 +1349,49 @@ pub async fn get_global_python_site_packages() -> Vec<PathBuf> {
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<PathBuf> {
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
Expand Down
185 changes: 185 additions & 0 deletions crates/socket-patch-core/tests/crawler_python_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::path::PathBuf> {
let saved: Vec<(&str, Option<String>)> = ["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\<app>\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
Expand Down
Loading