From 3bc321eeed212e6569f9e1fcb9de80a07e244ed2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 21:22:36 +0000 Subject: [PATCH 1/5] Start fix for #609, #566 Assisted-by: Claude Code:claude-opus-5-5 From a0f3f091523de0eebaba6939550f0e3e256d01ab Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 21:36:06 +0000 Subject: [PATCH 2/5] Read PDM settings through PDM's config layers Agent mode decided whether a PDM project uses PEP 582 (__pypackages__) from the project's pdm.toml alone, and only when use_venv was a TOML bool. PDM 2.27+ writes `use_venv = "false"`, and plain `pdm config python.use_venv false` writes the user config, so socket-patch patched an activated or stray venv instead of the copy `pdm run` imports (#609). Global mode likewise skipped PDM's site config (/etc/xdg/pdm/config.toml and the macOS/Windows equivalents), so a global project relocated there was never crawled and `get -g` could patch a different copy (#566). Settings now resolve in PDM's order: environment, project config, user config ($PDM_CONFIG_FILE or the per-user file), then the site config, with string values parsed like PDM's ensure_boolean. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/python_crawler.rs | 378 ++++++++++++++++-- docs/testing/pdm-compatibility.md | 12 +- 2 files changed, 361 insertions(+), 29 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index dfdee4f52..01120b242 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -486,27 +486,71 @@ async fn pdm_project_site_packages( (!found.is_empty()).then_some(found) } -/// PDM's `python.use_venv` for `cwd`: `PDM_USE_VENV`, else `[python] -/// use_venv` in the project's `pdm.toml` (PDM 2.x) or legacy `.pdm.toml`. -/// Unset, it is on, except for a legacy PDM 1.x project (a `.pdm.toml` and -/// no `.pdm-python`), where it defaulted to off. +/// PDM's `python.use_venv` for `cwd`, resolved through PDM's config +/// layers (see [`pdm_project_setting`]): `PDM_USE_VENV`, then `[python] +/// use_venv` in the project, user and site configs. Unset, it is on, +/// except for a legacy PDM 1.x project (a `.pdm.toml` and no +/// `.pdm-python`), where it defaulted to off. async fn pdm_uses_venv(cwd: &Path, var: &impl Fn(&str) -> Option) -> bool { if var("PDM_USE_VENV").is_some() { return pdm_env_flag(var, "PDM_USE_VENV"); } - for config in ["pdm.toml", ".pdm.toml"] { - let Ok(text) = read_regular_to_string(&cwd.join(config)).await else { + if let Some(on) = pdm_project_setting(cwd, var, "python", "use_venv") + .await + .as_ref() + .and_then(pdm_config_bool) + { + return on; + } + !cwd.join(".pdm.toml").is_file() || cwd.join(".pdm-python").is_file() +} + +/// The first `[table] key` set in PDM's file-backed config for the project +/// at `cwd`, highest precedence first: the project's `pdm.toml` (PDM 2.x) +/// or legacy `.pdm.toml`, then the user config ([`pdm_user_config_files`]), +/// then the site config ([`pdm_site_config_dirs`]), the defaults layer PDM +/// puts under the user config. Environment overrides are the caller's. +async fn pdm_project_setting( + cwd: &Path, + var: &impl Fn(&str) -> Option, + table: &str, + key: &str, +) -> Option { + let home = var("HOME") + .or_else(|| var("USERPROFILE")) + .map(PathBuf::from); + let files = [cwd.join("pdm.toml"), cwd.join(".pdm.toml")] + .into_iter() + .chain(pdm_user_config_files(home.as_deref(), var)) + .chain( + pdm_site_config_dirs(var) + .into_iter() + .map(|d| d.join("config.toml")), + ); + for file in files { + let Ok(text) = read_regular_to_string(&file).await else { continue; }; let setting = text .parse::() .ok() - .and_then(|doc| doc.get("python")?.get("use_venv")?.as_bool()); - if let Some(on) = setting { - return on; + .and_then(|doc| doc.get(table)?.get(key).cloned()); + if setting.is_some() { + return setting; } } - !cwd.join(".pdm.toml").is_file() || cwd.join(".pdm-python").is_file() + None +} + +/// A boolean PDM config value as PDM reads it: a TOML bool, or a string +/// parsed like PDM's `ensure_boolean` (PDM 2.27+ writes `pdm config` +/// values as strings, e.g. `use_venv = "false"`). +fn pdm_config_bool(item: &toml_edit::Item) -> Option { + item.as_bool().or_else(|| { + item.as_str().map(|v| { + !v.is_empty() && !matches!(v.to_ascii_lowercase().as_str(), "false" | "no" | "0") + }) + }) } /// A boolean PDM environment setting, parsed like PDM's `ensure_boolean`: @@ -1947,28 +1991,80 @@ fn uv_dir_candidates(home_dir: &Path, override_var: &str, bucket: &str) -> Vec

Vec { +fn pdm_dir_candidates( + home_dir: Option<&Path>, + var: &impl Fn(&str) -> Option, + xdg_var: &str, + unix_default: &Path, +) -> Vec { let mut dirs = Vec::new(); #[cfg(not(windows))] - if let Some(xdg) = absolute_env_dir(xdg_var) { + if let Some(xdg) = var(xdg_var).map(PathBuf::from).filter(|p| p.is_absolute()) { dirs.push(xdg.join("pdm")); } #[cfg(all(not(target_os = "macos"), not(windows)))] - dirs.push(home_dir.join(unix_default).join("pdm")); + dirs.extend(home_dir.map(|h| h.join(unix_default).join("pdm"))); #[cfg(target_os = "macos")] - dirs.push( - home_dir - .join("Library") - .join("Application Support") - .join("pdm"), - ); + dirs.extend(home_dir.map(|h| h.join("Library").join("Application Support").join("pdm"))); #[cfg(windows)] - if let Some(local) = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()) { + if let Some(local) = var("LOCALAPPDATA").filter(|v| !v.is_empty()) { dirs.push(PathBuf::from(local).join("pdm").join("pdm")); } dirs } +/// PDM's user config file, as PDM picks it: `$PDM_CONFIG_FILE` when set, +/// otherwise `config.toml` in the per-user config dir +/// ([`pdm_dir_candidates`]). +fn pdm_user_config_files( + home_dir: Option<&Path>, + var: &impl Fn(&str) -> Option, +) -> Vec { + if let Some(custom) = var("PDM_CONFIG_FILE").filter(|v| !v.is_empty()) { + return vec![PathBuf::from(custom)]; + } + pdm_dir_candidates(home_dir, var, "XDG_CONFIG_HOME", Path::new(".config")) + .into_iter() + .map(|d| d.join("config.toml")) + .collect() +} + +/// PDM's site config dir (platformdirs' `site_config_path("pdm")`), whose +/// `config.toml` PDM reads as the defaults under the user config: the +/// first `$XDG_CONFIG_DIRS` entry (default `/etc/xdg`) + `/pdm` on Linux, +/// `/Library/Application Support/pdm` on macOS, and +/// `%PROGRAMDATA%\pdm\pdm` on Windows. +#[cfg_attr(target_os = "macos", allow(unused_variables))] +fn pdm_site_config_dirs(var: &impl Fn(&str) -> Option) -> Vec { + #[cfg(all(not(target_os = "macos"), not(windows)))] + { + let dirs = var("XDG_CONFIG_DIRS").filter(|v| !v.trim().is_empty()); + let first = dirs + .as_deref() + .unwrap_or("/etc/xdg") + .split(':') + .next() + .unwrap_or_default() + .trim_end_matches('/') + .to_string(); + (!first.is_empty()) + .then(|| PathBuf::from(first).join("pdm")) + .into_iter() + .collect() + } + #[cfg(target_os = "macos")] + { + vec![PathBuf::from("/Library/Application Support/pdm")] + } + #[cfg(windows)] + { + let base = var("PROGRAMDATA") + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| r"C:\ProgramData".to_string()); + vec![PathBuf::from(base).join("pdm").join("pdm")] + } +} + /// `site-packages` of PDM's global installs: /// /// - The global project's environment (`pdm add -g`). The project lives @@ -1982,22 +2078,38 @@ fn pdm_dir_candidates(home_dir: &Path, xdg_var: &str, unix_default: &Path) -> Ve /// `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. +/// or `/pdm/config.toml`, and from the site config +/// ([`pdm_site_config_dirs`]) PDM layers under it. 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")); + pdm_global_site_packages_with(home_dir, &|name: &str| std::env::var(name).ok()).await +} + +/// [`pdm_global_site_packages`] with the environment read through `env`. +async fn pdm_global_site_packages_with( + home_dir: &Path, + env: &impl Fn(&str) -> Option, +) -> Vec { + let config_dirs = + pdm_dir_candidates(Some(home_dir), env, "XDG_CONFIG_HOME", Path::new(".config")); let data_dirs = pdm_dir_candidates( - home_dir, + Some(home_dir), + env, "XDG_DATA_HOME", &Path::new(".local").join("share"), ); - let mut config_files: Vec = std::env::var_os("PDM_CONFIG_FILE") + let mut config_files: Vec = env("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"))); + config_files.extend( + pdm_site_config_dirs(env) + .into_iter() + .map(|d| d.join("config.toml")), + ); let mut projects = Vec::new(); let mut install_roots = Vec::new(); @@ -2009,7 +2121,7 @@ async fn pdm_global_site_packages(home_dir: &Path) -> Vec { if cfg!(windows) && name == "HOME" { return None; } - std::env::var(name).ok() + env(name) }; for file in &config_files { let Ok(text) = read_regular_to_string(file).await else { @@ -2840,6 +2952,220 @@ mod tests { .is_empty()); } + /// A PEP 582 PDM project (a saved base interpreter) beside a stray + /// `./.venv` PDM ignores, for the `python.use_venv` layer tests. + /// Returns `(project, __pypackages__ lib, stray .venv site)`. + fn pdm_pep582_fixture(root: &Path) -> (PathBuf, PathBuf, PathBuf) { + let project = root.join("demo"); + let lib = project.join("__pypackages__").join("3.12").join("lib"); + std::fs::create_dir_all(&lib).unwrap(); + std::fs::write( + project.join("pyproject.toml"), + "[project]\nname = \"demo\"\n", + ) + .unwrap(); + std::fs::write(project.join("pdm.lock"), "[metadata]\n").unwrap(); + let base = root.join("usr").join("bin").join("python3.12"); + std::fs::create_dir_all(base.parent().unwrap()).unwrap(); + std::fs::write(project.join(".pdm-python"), base.display().to_string()).unwrap(); + let stray = fake_venv(&project, ".venv"); + (project, lib, stray) + } + + /// #609: PDM 2.27+ writes `pdm config -l python.use_venv false` as the + /// string `"false"`, which PDM parses like `ensure_boolean`. + #[tokio::test] + async fn pdm_use_venv_string_values_parse_like_pdm() { + let tmp = tempfile::tempdir().unwrap(); + let (project, lib, stray) = pdm_pep582_fixture(tmp.path()); + let no_env = env_of(&[]); + for off in ["\"false\"", "\"False\"", "\"no\"", "\"0\"", "\"\"", "false"] { + std::fs::write( + project.join("pdm.toml"), + format!("[python]\nuse_venv = {off}\n"), + ) + .unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &no_env).await, + vec![lib.clone()], + "use_venv = {off}" + ); + } + for on in ["\"true\"", "\"1\"", "true"] { + std::fs::write( + project.join("pdm.toml"), + format!("[python]\nuse_venv = {on}\n"), + ) + .unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &no_env).await, + vec![stray.clone()], + "use_venv = {on}" + ); + } + } + + /// #609: `pdm config python.use_venv false` (no `-l`) writes the user + /// config, which PDM reads under the project's `pdm.toml`; so does a + /// `$PDM_CONFIG_FILE`. + #[tokio::test] + async fn pdm_use_venv_is_read_from_the_user_config() { + let tmp = tempfile::tempdir().unwrap(); + let (project, lib, stray) = pdm_pep582_fixture(tmp.path()); + let config_home = tmp.path().join("config-home"); + let local_app_data = tmp.path().join("local-app-data"); + let user_config = if cfg!(windows) { + local_app_data.join("pdm").join("pdm").join("config.toml") + } else { + config_home.join("pdm").join("config.toml") + }; + std::fs::create_dir_all(user_config.parent().unwrap()).unwrap(); + std::fs::write(&user_config, "[python]\nuse_venv = \"false\"\n").unwrap(); + let user_env = env_of(&[ + ( + "XDG_CONFIG_HOME", + config_home.to_string_lossy().into_owned(), + ), + ( + "LOCALAPPDATA", + local_app_data.to_string_lossy().into_owned(), + ), + ]); + assert_eq!( + find_local_venv_site_packages_with(&project, &user_env).await, + vec![lib.clone()] + ); + // The project config outranks the user config... + std::fs::write(project.join("pdm.toml"), "[python]\nuse_venv = true\n").unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &user_env).await, + vec![stray.clone()] + ); + std::fs::remove_file(project.join("pdm.toml")).unwrap(); + // ...and PDM_USE_VENV outranks both. + let forced_on = env_of(&[ + ( + "XDG_CONFIG_HOME", + config_home.to_string_lossy().into_owned(), + ), + ( + "LOCALAPPDATA", + local_app_data.to_string_lossy().into_owned(), + ), + ("PDM_USE_VENV", "1".to_string()), + ]); + assert_eq!( + find_local_venv_site_packages_with(&project, &forced_on).await, + vec![stray.clone()] + ); + // `$PDM_CONFIG_FILE` replaces the user config file. + let custom = tmp.path().join("custom.toml"); + std::fs::write(&custom, "[python]\nuse_venv = false\n").unwrap(); + let custom_env = env_of(&[("PDM_CONFIG_FILE", custom.to_string_lossy().into_owned())]); + assert_eq!( + find_local_venv_site_packages_with(&project, &custom_env).await, + vec![lib.clone()] + ); + let custom_over_user = env_of(&[ + ( + "XDG_CONFIG_HOME", + config_home.to_string_lossy().into_owned(), + ), + ( + "LOCALAPPDATA", + local_app_data.to_string_lossy().into_owned(), + ), + ( + "PDM_CONFIG_FILE", + tmp.path() + .join("missing.toml") + .to_string_lossy() + .into_owned(), + ), + ]); + assert_eq!( + find_local_venv_site_packages_with(&project, &custom_over_user).await, + vec![stray] + ); + } + + /// The platformdirs site config dir a test can relocate: the first + /// `$XDG_CONFIG_DIRS` entry on Linux, `%PROGRAMDATA%\pdm\pdm` on + /// Windows. (macOS's `/Library/Application Support/pdm` is fixed.) + #[cfg(not(target_os = "macos"))] + fn pdm_test_site_config(root: &Path) -> (PathBuf, Vec<(&'static str, String)>) { + let xdg = root.join("etc").join("xdg"); + let program_data = root.join("program-data"); + let file = if cfg!(windows) { + program_data.join("pdm").join("pdm").join("config.toml") + } else { + xdg.join("pdm").join("config.toml") + }; + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + let other = root.join("etc").join("other-xdg"); + let sep = if cfg!(windows) { ";" } else { ":" }; + let env = vec![ + ( + "XDG_CONFIG_DIRS", + format!("{}{sep}{}", xdg.display(), other.display()), + ), + ("PROGRAMDATA", program_data.to_string_lossy().into_owned()), + ]; + (file, env) + } + + /// #609: the site config is PDM's lowest config layer, so a + /// machine-wide `python.use_venv = false` means PEP 582 too. + #[cfg(not(target_os = "macos"))] + #[tokio::test] + async fn pdm_use_venv_is_read_from_the_site_config() { + let tmp = tempfile::tempdir().unwrap(); + let (project, lib, stray) = pdm_pep582_fixture(tmp.path()); + let (site, pairs) = pdm_test_site_config(tmp.path()); + std::fs::write(&site, "[python]\nuse_venv = false\n").unwrap(); + let env = env_of(&pairs); + assert_eq!( + find_local_venv_site_packages_with(&project, &env).await, + vec![lib] + ); + // The user config outranks it. + let custom = tmp.path().join("user.toml"); + std::fs::write(&custom, "[python]\nuse_venv = true\n").unwrap(); + let mut pairs = pairs; + pairs.push(("PDM_CONFIG_FILE", custom.to_string_lossy().into_owned())); + assert_eq!( + find_local_venv_site_packages_with(&project, &env_of(&pairs)).await, + vec![stray] + ); + } + + /// #566: `-g` reads PDM's global-project, venv and interpreter + /// locations from the site config as well as the user config. + #[cfg(not(target_os = "macos"))] + #[tokio::test] + async fn pdm_global_settings_are_read_from_the_site_config() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + let (site, pairs) = pdm_test_site_config(tmp.path()); + let global = tmp.path().join("opt").join("global"); + let project_site = fake_venv(&global, ".venv"); + let installs = tmp.path().join("opt").join("pythons"); + let managed_site = fake_venv(&installs, "cpython@3.12.4"); + std::fs::write( + &site, + format!( + "[global_project]\npath = {:?}\n[python]\ninstall_root = {:?}\n", + global.to_string_lossy(), + installs.to_string_lossy() + ), + ) + .unwrap(); + let found = pdm_global_site_packages_with(&home, &env_of(&pairs)).await; + assert!(found.contains(&project_site), "{found:?}"); + assert!(found.contains(&managed_site), "{found:?}"); + } + /// #525: uv syncs a project into `UV_PROJECT_ENVIRONMENT` (absolute, or /// relative to the project) instead of `./.venv`, ignoring an /// activated venv; the variable means nothing outside a uv project. diff --git a/docs/testing/pdm-compatibility.md b/docs/testing/pdm-compatibility.md index 99f57e19c..c3254a192 100644 --- a/docs/testing/pdm-compatibility.md +++ b/docs/testing/pdm-compatibility.md @@ -76,12 +76,18 @@ Measured details: `venv.in_project = false` venv, or one bound with `pdm use `) or a conda env. A project with `uv.lock` or `poetry.lock` is not treated as PDM's. A base interpreter means PEP 582 (`__pypackages__//lib`) only - when `python.use_venv` is off (`PDM_USE_VENV`, or `[python] use_venv` in - `pdm.toml` / `.pdm.toml`; off by default only for PDM 1.x). Otherwise the + when `python.use_venv` is off (`PDM_USE_VENV`, else `[python] use_venv` in + the first of the project's `pdm.toml` / `.pdm.toml`, the user config + (`$PDM_CONFIG_FILE` or `/pdm/config.toml`) and the site + config (`/etc/xdg/pdm/config.toml` or the first `$XDG_CONFIG_DIRS` entry, + `/Library/Application Support/pdm`, `%PROGRAMDATA%\pdm\pdm`), as a bool or + the string PDM 2.27+ writes; off by default only for PDM 1.x). Otherwise the activated or in-project venv is used, with `__pypackages__` as the last resort. `PDM_IGNORE_SAVED_PYTHON` and `PDM_IGNORE_ACTIVE_VENV` are honored, parsed as PDM parses booleans. Agent mode patches the env found, - and the hosted stale-install warning and `vex` check it. + and the hosted stale-install warning and `vex` check it. Global mode (`-g`) + reads `global_project.path`, `venv.location` and `python.install_root` from + the user and site configs alike. - **A non-default lock filename (`pdm lock -L custom.lock`) is invisible** to the scan, which only reads `pdm.lock`. A package locked at two versions (a marker fork) is refused (`pypi_pdm_lock_forked_package` / a version-mismatch refusal), From 4521c098e73b2c1d1c7662a683775b1d9733fa17 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:24:59 +0000 Subject: [PATCH 3/5] Read one PDM user config, skip empty XDG dirs PDM reads a single user config file: the most specific per-user config dir platformdirs picks. A use_venv missing from $XDG_CONFIG_HOME/pdm/config.toml no longer falls through to ~/.config/pdm, which PDM never reads in that setup. A leading empty or relative $XDG_CONFIG_DIRS entry (e.g. from "$XDG_CONFIG_DIRS:/other" with the variable unset) no longer drops the site config layer. The first absolute entry is used, falling back to /etc/xdg. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/python_crawler.rs | 87 ++++++++++++++++--- docs/testing/pdm-compatibility.md | 4 +- 2 files changed, 76 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index 01120b242..e43a46213 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -2014,8 +2014,10 @@ fn pdm_dir_candidates( } /// PDM's user config file, as PDM picks it: `$PDM_CONFIG_FILE` when set, -/// otherwise `config.toml` in the per-user config dir -/// ([`pdm_dir_candidates`]). +/// otherwise `config.toml` in the per-user config dir. platformdirs +/// resolves that to one directory, the most specific of +/// [`pdm_dir_candidates`], so only that file is a layer: a setting missing +/// from it must not fall through to another candidate. fn pdm_user_config_files( home_dir: Option<&Path>, var: &impl Fn(&str) -> Option, @@ -2025,6 +2027,7 @@ fn pdm_user_config_files( } pdm_dir_candidates(home_dir, var, "XDG_CONFIG_HOME", Path::new(".config")) .into_iter() + .take(1) .map(|d| d.join("config.toml")) .collect() } @@ -2038,19 +2041,16 @@ fn pdm_user_config_files( fn pdm_site_config_dirs(var: &impl Fn(&str) -> Option) -> Vec { #[cfg(all(not(target_os = "macos"), not(windows)))] { - let dirs = var("XDG_CONFIG_DIRS").filter(|v| !v.trim().is_empty()); + // The first absolute entry: an empty or relative one (a leading + // `:` from `$XDG_CONFIG_DIRS:/other` with the variable unset) is + // not a config dir, per the XDG spec. + let dirs = var("XDG_CONFIG_DIRS").unwrap_or_default(); let first = dirs - .as_deref() - .unwrap_or("/etc/xdg") .split(':') - .next() - .unwrap_or_default() - .trim_end_matches('/') - .to_string(); - (!first.is_empty()) - .then(|| PathBuf::from(first).join("pdm")) - .into_iter() - .collect() + .map(|d| d.trim_end_matches('/')) + .find(|d| Path::new(d).is_absolute()) + .unwrap_or("/etc/xdg"); + vec![PathBuf::from(first).join("pdm")] } #[cfg(target_os = "macos")] { @@ -3089,6 +3089,67 @@ mod tests { ); } + /// platformdirs resolves PDM's user config dir to ONE directory, so a + /// `use_venv` in a less specific candidate (`~/.config/pdm` while + /// `$XDG_CONFIG_HOME` is set) is not a layer PDM reads. + #[cfg(not(windows))] + #[tokio::test] + async fn pdm_user_config_is_one_file_not_every_candidate() { + let tmp = tempfile::tempdir().unwrap(); + let (project, lib, stray) = pdm_pep582_fixture(tmp.path()); + let home = tmp.path().join("home"); + let shadowed = if cfg!(target_os = "macos") { + home.join("Library").join("Application Support").join("pdm") + } else { + home.join(".config").join("pdm") + }; + std::fs::create_dir_all(&shadowed).unwrap(); + std::fs::write(shadowed.join("config.toml"), "[python]\nuse_venv = false\n").unwrap(); + let home_only = env_of(&[("HOME", home.to_string_lossy().into_owned())]); + assert_eq!( + find_local_venv_site_packages_with(&project, &home_only).await, + vec![lib] + ); + let config_home = tmp.path().join("config-home"); + std::fs::create_dir_all(config_home.join("pdm")).unwrap(); + std::fs::write(config_home.join("pdm").join("config.toml"), "[venv]\n").unwrap(); + let relocated = env_of(&[ + ("HOME", home.to_string_lossy().into_owned()), + ( + "XDG_CONFIG_HOME", + config_home.to_string_lossy().into_owned(), + ), + ]); + assert_eq!( + find_local_venv_site_packages_with(&project, &relocated).await, + vec![stray] + ); + } + + /// The site config dir is the first ABSOLUTE `$XDG_CONFIG_DIRS` entry: + /// a leading empty segment (`:/etc/xdg`) does not drop the site layer. + #[cfg(all(not(target_os = "macos"), not(windows)))] + #[test] + fn pdm_site_config_dir_skips_empty_and_relative_xdg_entries() { + let dirs = |value: &str| { + let value = value.to_string(); + pdm_site_config_dirs(&move |name: &str| { + (name == "XDG_CONFIG_DIRS").then(|| value.clone()) + }) + }; + assert_eq!(dirs(":/etc/site"), vec![PathBuf::from("/etc/site/pdm")]); + assert_eq!( + dirs("relative:/etc/site/:/etc/other"), + vec![PathBuf::from("/etc/site/pdm")] + ); + assert_eq!(dirs(""), vec![PathBuf::from("/etc/xdg/pdm")]); + assert_eq!(dirs(":"), vec![PathBuf::from("/etc/xdg/pdm")]); + assert_eq!( + pdm_site_config_dirs(&|_: &str| None), + vec![PathBuf::from("/etc/xdg/pdm")] + ); + } + /// The platformdirs site config dir a test can relocate: the first /// `$XDG_CONFIG_DIRS` entry on Linux, `%PROGRAMDATA%\pdm\pdm` on /// Windows. (macOS's `/Library/Application Support/pdm` is fixed.) diff --git a/docs/testing/pdm-compatibility.md b/docs/testing/pdm-compatibility.md index c3254a192..4779feb21 100644 --- a/docs/testing/pdm-compatibility.md +++ b/docs/testing/pdm-compatibility.md @@ -79,8 +79,8 @@ Measured details: when `python.use_venv` is off (`PDM_USE_VENV`, else `[python] use_venv` in the first of the project's `pdm.toml` / `.pdm.toml`, the user config (`$PDM_CONFIG_FILE` or `/pdm/config.toml`) and the site - config (`/etc/xdg/pdm/config.toml` or the first `$XDG_CONFIG_DIRS` entry, - `/Library/Application Support/pdm`, `%PROGRAMDATA%\pdm\pdm`), as a bool or + config (`/etc/xdg/pdm/config.toml` or the first absolute `$XDG_CONFIG_DIRS` + entry, `/Library/Application Support/pdm`, `%PROGRAMDATA%\pdm\pdm`), as a bool or the string PDM 2.27+ writes; off by default only for PDM 1.x). Otherwise the activated or in-project venv is used, with `__pypackages__` as the last resort. `PDM_IGNORE_SAVED_PYTHON` and `PDM_IGNORE_ACTIVE_VENV` are From a2d6b087ba0b63c503a4c6e19791f12d98c3cb02 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 2 Oct 2026 20:31:31 -0400 Subject: [PATCH 4/5] Match PDM project precedence and macOS site config Honor XDG_CONFIG_DIRS for macOS site defaults and preserve native root/whitespace handling. Overlay legacy project settings before modern pdm.toml, while retaining environment priority and missing-key fallback. Enable site-layer tests on macOS and cover both legacy/string directions. Validation: 68 Python crawler tests passed; native PDM 2.29.2 controls with platformdirs 4.9.6 and 4.12.2. The legacy regression also passes on main and fails with the original PR lookup. --- .../src/crawlers/python_crawler.rs | 109 ++++++++++++++---- docs/testing/pdm-compatibility.md | 7 +- 2 files changed, 90 insertions(+), 26 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index e43a46213..56b4cad1b 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -506,8 +506,9 @@ async fn pdm_uses_venv(cwd: &Path, var: &impl Fn(&str) -> Option) -> boo } /// The first `[table] key` set in PDM's file-backed config for the project -/// at `cwd`, highest precedence first: the project's `pdm.toml` (PDM 2.x) -/// or legacy `.pdm.toml`, then the user config ([`pdm_user_config_files`]), +/// at `cwd`, highest precedence first: legacy `.pdm.toml` settings overlay +/// the project's `pdm.toml` (as in PDM's `project_config`), then the user +/// config ([`pdm_user_config_files`]), /// then the site config ([`pdm_site_config_dirs`]), the defaults layer PDM /// puts under the user config. Environment overrides are the caller's. async fn pdm_project_setting( @@ -519,7 +520,7 @@ async fn pdm_project_setting( let home = var("HOME") .or_else(|| var("USERPROFILE")) .map(PathBuf::from); - let files = [cwd.join("pdm.toml"), cwd.join(".pdm.toml")] + let files = [cwd.join(".pdm.toml"), cwd.join("pdm.toml")] .into_iter() .chain(pdm_user_config_files(home.as_deref(), var)) .chain( @@ -2034,28 +2035,28 @@ fn pdm_user_config_files( /// PDM's site config dir (platformdirs' `site_config_path("pdm")`), whose /// `config.toml` PDM reads as the defaults under the user config: the -/// first `$XDG_CONFIG_DIRS` entry (default `/etc/xdg`) + `/pdm` on Linux, -/// `/Library/Application Support/pdm` on macOS, and +/// first absolute `$XDG_CONFIG_DIRS` entry + `/pdm` on Linux and macOS, +/// defaulting to `/etc/xdg/pdm` or `/Library/Application Support/pdm`, and /// `%PROGRAMDATA%\pdm\pdm` on Windows. -#[cfg_attr(target_os = "macos", allow(unused_variables))] fn pdm_site_config_dirs(var: &impl Fn(&str) -> Option) -> Vec { - #[cfg(all(not(target_os = "macos"), not(windows)))] + #[cfg(not(windows))] { // The first absolute entry: an empty or relative one (a leading // `:` from `$XDG_CONFIG_DIRS:/other` with the variable unset) is // not a config dir, per the XDG spec. let dirs = var("XDG_CONFIG_DIRS").unwrap_or_default(); + let default = if cfg!(target_os = "macos") { + "/Library/Application Support" + } else { + "/etc/xdg" + }; let first = dirs .split(':') - .map(|d| d.trim_end_matches('/')) + .map(str::trim) .find(|d| Path::new(d).is_absolute()) - .unwrap_or("/etc/xdg"); + .unwrap_or(default); vec![PathBuf::from(first).join("pdm")] } - #[cfg(target_os = "macos")] - { - vec![PathBuf::from("/Library/Application Support/pdm")] - } #[cfg(windows)] { let base = var("PROGRAMDATA") @@ -3005,6 +3006,63 @@ mod tests { } } + /// PDM overlays legacy project keys onto pdm.toml. A modern string must + /// not shadow a legacy bool once both encodings are understood. + #[tokio::test] + async fn pdm_legacy_config_overrides_modern_string_values() { + let tmp = tempfile::tempdir().unwrap(); + let (project, lib, stray) = pdm_pep582_fixture(tmp.path()); + for (modern, legacy) in [(true, false), (false, true)] { + std::fs::write( + project.join("pdm.toml"), + format!("[python]\nuse_venv = \"{modern}\"\n"), + ) + .unwrap(); + std::fs::write( + project.join(".pdm.toml"), + format!("[python]\nuse_venv = {legacy}\n"), + ) + .unwrap(); + let expected = if legacy { &stray } else { &lib }; + assert_eq!( + find_local_venv_site_packages_with(&project, &env_of(&[])).await, + vec![expected.clone()], + "modern string {modern}, legacy bool {legacy}" + ); + // Environment overrides still outrank both project files. + let env = env_of(&[("PDM_USE_VENV", modern.to_string())]); + let expected = if modern { &stray } else { &lib }; + assert_eq!( + find_local_venv_site_packages_with(&project, &env).await, + vec![expected.clone()], + "environment {modern}, legacy bool {legacy}" + ); + } + } + + #[tokio::test] + async fn pdm_legacy_config_missing_key_uses_modern_setting() { + let tmp = tempfile::tempdir().unwrap(); + let (project, lib, stray) = pdm_pep582_fixture(tmp.path()); + std::fs::write( + project.join(".pdm.toml"), + "[python]\npath = \"unused-base-python\"\n", + ) + .unwrap(); + for modern in [false, true] { + std::fs::write( + project.join("pdm.toml"), + format!("[python]\nuse_venv = \"{modern}\"\n"), + ) + .unwrap(); + let expected = if modern { &stray } else { &lib }; + assert_eq!( + find_local_venv_site_packages_with(&project, &env_of(&[])).await, + vec![expected.clone()] + ); + } + } + /// #609: `pdm config python.use_venv false` (no `-l`) writes the user /// config, which PDM reads under the project's `pdm.toml`; so does a /// `$PDM_CONFIG_FILE`. @@ -3128,7 +3186,7 @@ mod tests { /// The site config dir is the first ABSOLUTE `$XDG_CONFIG_DIRS` entry: /// a leading empty segment (`:/etc/xdg`) does not drop the site layer. - #[cfg(all(not(target_os = "macos"), not(windows)))] + #[cfg(not(windows))] #[test] fn pdm_site_config_dir_skips_empty_and_relative_xdg_entries() { let dirs = |value: &str| { @@ -3142,18 +3200,25 @@ mod tests { dirs("relative:/etc/site/:/etc/other"), vec![PathBuf::from("/etc/site/pdm")] ); - assert_eq!(dirs(""), vec![PathBuf::from("/etc/xdg/pdm")]); - assert_eq!(dirs(":"), vec![PathBuf::from("/etc/xdg/pdm")]); + assert_eq!(dirs("/"), vec![PathBuf::from("/pdm")]); assert_eq!( - pdm_site_config_dirs(&|_: &str| None), - vec![PathBuf::from("/etc/xdg/pdm")] + dirs(" /etc/site/ :/etc/other"), + vec![PathBuf::from("/etc/site/pdm")] ); + let default = if cfg!(target_os = "macos") { + PathBuf::from("/Library/Application Support/pdm") + } else { + PathBuf::from("/etc/xdg/pdm") + }; + for value in ["", ":", "relative"] { + assert_eq!(dirs(value), vec![default.clone()]); + } + assert_eq!(pdm_site_config_dirs(&|_: &str| None), vec![default]); } /// The platformdirs site config dir a test can relocate: the first - /// `$XDG_CONFIG_DIRS` entry on Linux, `%PROGRAMDATA%\pdm\pdm` on - /// Windows. (macOS's `/Library/Application Support/pdm` is fixed.) - #[cfg(not(target_os = "macos"))] + /// `$XDG_CONFIG_DIRS` entry on Linux/macOS, `%PROGRAMDATA%\pdm\pdm` + /// on Windows. fn pdm_test_site_config(root: &Path) -> (PathBuf, Vec<(&'static str, String)>) { let xdg = root.join("etc").join("xdg"); let program_data = root.join("program-data"); @@ -3177,7 +3242,6 @@ mod tests { /// #609: the site config is PDM's lowest config layer, so a /// machine-wide `python.use_venv = false` means PEP 582 too. - #[cfg(not(target_os = "macos"))] #[tokio::test] async fn pdm_use_venv_is_read_from_the_site_config() { let tmp = tempfile::tempdir().unwrap(); @@ -3202,7 +3266,6 @@ mod tests { /// #566: `-g` reads PDM's global-project, venv and interpreter /// locations from the site config as well as the user config. - #[cfg(not(target_os = "macos"))] #[tokio::test] async fn pdm_global_settings_are_read_from_the_site_config() { let tmp = tempfile::tempdir().unwrap(); diff --git a/docs/testing/pdm-compatibility.md b/docs/testing/pdm-compatibility.md index 4779feb21..8416c3abb 100644 --- a/docs/testing/pdm-compatibility.md +++ b/docs/testing/pdm-compatibility.md @@ -77,10 +77,11 @@ Measured details: conda env. A project with `uv.lock` or `poetry.lock` is not treated as PDM's. A base interpreter means PEP 582 (`__pypackages__//lib`) only when `python.use_venv` is off (`PDM_USE_VENV`, else `[python] use_venv` in - the first of the project's `pdm.toml` / `.pdm.toml`, the user config + the project's legacy `.pdm.toml` over `pdm.toml`, then the user config (`$PDM_CONFIG_FILE` or `/pdm/config.toml`) and the site - config (`/etc/xdg/pdm/config.toml` or the first absolute `$XDG_CONFIG_DIRS` - entry, `/Library/Application Support/pdm`, `%PROGRAMDATA%\pdm\pdm`), as a bool or + config (`pdm/config.toml` under the first absolute `$XDG_CONFIG_DIRS` + entry on Linux/macOS, defaulting to `/etc/xdg` or `/Library/Application Support`, + or `%PROGRAMDATA%\pdm\pdm\config.toml` on Windows), as a bool or the string PDM 2.27+ writes; off by default only for PDM 1.x). Otherwise the activated or in-project venv is used, with `__pypackages__` as the last resort. `PDM_IGNORE_SAVED_PYTHON` and `PDM_IGNORE_ACTIVE_VENV` are From 3226a8f2a8c851be496cb2efc3f59af8d5198949 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 2 Oct 2026 20:59:43 -0400 Subject: [PATCH 5/5] Resolve implicit PDM site config with its macOS runtime --- .../src/crawlers/python_crawler.rs | 174 ++++++-- .../src/crawlers/python_crawler/pdm_site.rs | 381 ++++++++++++++++++ docs/testing/pdm-compatibility.md | 16 +- 3 files changed, 538 insertions(+), 33 deletions(-) create mode 100644 crates/socket-patch-core/src/crawlers/python_crawler/pdm_site.rs diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index 56b4cad1b..c232a38bb 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -11,6 +11,9 @@ use crate::utils::process::{CommandRunner, SystemCommandRunner}; #[cfg(test)] mod oracle; +#[cfg(target_os = "macos")] +mod pdm_site; + // --------------------------------------------------------------------------- // Python command discovery // --------------------------------------------------------------------------- @@ -522,12 +525,28 @@ async fn pdm_project_setting( .map(PathBuf::from); let files = [cwd.join(".pdm.toml"), cwd.join("pdm.toml")] .into_iter() - .chain(pdm_user_config_files(home.as_deref(), var)) - .chain( - pdm_site_config_dirs(var) - .into_iter() - .map(|d| d.join("config.toml")), - ); + .chain(pdm_user_config_files(home.as_deref(), var)); + if let Some(setting) = pdm_setting_from_files(files, table, key).await { + return Some(setting); + } + // On macOS the implicit site layer needs PDM's runtime; do not probe + // it when a higher-precedence file already supplies the setting. + pdm_setting_from_files( + pdm_site_config_dirs(var) + .await + .into_iter() + .map(|dir| dir.join("config.toml")), + table, + key, + ) + .await +} + +async fn pdm_setting_from_files( + files: impl IntoIterator, + table: &str, + key: &str, +) -> Option { for file in files { let Ok(text) = read_regular_to_string(&file).await else { continue; @@ -2036,26 +2055,34 @@ fn pdm_user_config_files( /// PDM's site config dir (platformdirs' `site_config_path("pdm")`), whose /// `config.toml` PDM reads as the defaults under the user config: the /// first absolute `$XDG_CONFIG_DIRS` entry + `/pdm` on Linux and macOS, -/// defaulting to `/etc/xdg/pdm` or `/Library/Application Support/pdm`, and -/// `%PROGRAMDATA%\pdm\pdm` on Windows. -fn pdm_site_config_dirs(var: &impl Fn(&str) -> Option) -> Vec { +/// defaulting to `/etc/xdg/pdm` on Linux, and `%PROGRAMDATA%\pdm\pdm` on +/// Windows. macOS's implicit default depends on PDM's Python runtime; +/// an unrecognized launcher or failed probe leaves that layer unknown. +async fn pdm_site_config_dirs(var: &impl Fn(&str) -> Option) -> Vec { #[cfg(not(windows))] { // The first absolute entry: an empty or relative one (a leading // `:` from `$XDG_CONFIG_DIRS:/other` with the variable unset) is // not a config dir, per the XDG spec. let dirs = var("XDG_CONFIG_DIRS").unwrap_or_default(); - let default = if cfg!(target_os = "macos") { - "/Library/Application Support" - } else { - "/etc/xdg" - }; - let first = dirs + if let Some(first) = dirs .split(':') .map(str::trim) .find(|d| Path::new(d).is_absolute()) - .unwrap_or(default); - vec![PathBuf::from(first).join("pdm")] + { + return vec![PathBuf::from(first).join("pdm")]; + } + #[cfg(target_os = "macos")] + { + pdm_site::implicit_site_config_dir(var) + .await + .into_iter() + .collect() + } + #[cfg(not(target_os = "macos"))] + { + vec![PathBuf::from("/etc/xdg/pdm")] + } } #[cfg(windows)] { @@ -2108,6 +2135,7 @@ async fn pdm_global_site_packages_with( config_files.extend(config_dirs.iter().map(|d| d.join("config.toml"))); config_files.extend( pdm_site_config_dirs(env) + .await .into_iter() .map(|d| d.join("config.toml")), ); @@ -3187,33 +3215,115 @@ mod tests { /// The site config dir is the first ABSOLUTE `$XDG_CONFIG_DIRS` entry: /// a leading empty segment (`:/etc/xdg`) does not drop the site layer. #[cfg(not(windows))] - #[test] - fn pdm_site_config_dir_skips_empty_and_relative_xdg_entries() { - let dirs = |value: &str| { - let value = value.to_string(); - pdm_site_config_dirs(&move |name: &str| { - (name == "XDG_CONFIG_DIRS").then(|| value.clone()) + #[tokio::test] + async fn pdm_site_config_dir_skips_empty_and_relative_xdg_entries() { + async fn dirs(value: &str) -> Vec { + pdm_site_config_dirs(&|name: &str| { + (name == "XDG_CONFIG_DIRS").then(|| value.to_string()) }) - }; - assert_eq!(dirs(":/etc/site"), vec![PathBuf::from("/etc/site/pdm")]); + .await + } assert_eq!( - dirs("relative:/etc/site/:/etc/other"), + dirs(":/etc/site").await, vec![PathBuf::from("/etc/site/pdm")] ); - assert_eq!(dirs("/"), vec![PathBuf::from("/pdm")]); assert_eq!( - dirs(" /etc/site/ :/etc/other"), + dirs("relative:/etc/site/:/etc/other").await, + vec![PathBuf::from("/etc/site/pdm")] + ); + assert_eq!(dirs("/").await, vec![PathBuf::from("/pdm")]); + assert_eq!( + dirs(" /etc/site/ :/etc/other").await, vec![PathBuf::from("/etc/site/pdm")] ); let default = if cfg!(target_os = "macos") { - PathBuf::from("/Library/Application Support/pdm") + // No PATH means no recognized PDM runtime, not a guessed path. + vec![] } else { - PathBuf::from("/etc/xdg/pdm") + vec![PathBuf::from("/etc/xdg/pdm")] }; for value in ["", ":", "relative"] { - assert_eq!(dirs(value), vec![default.clone()]); + assert_eq!(dirs(value).await, default); } - assert_eq!(pdm_site_config_dirs(&|_: &str| None), vec![default]); + assert_eq!(pdm_site_config_dirs(&|_: &str| None).await, default); + } + + /// The implicit macOS site path belongs to PDM's runtime, not the + /// crawler's platform: Homebrew Python uses a different site layer. + #[cfg(target_os = "macos")] + #[tokio::test] + async fn pdm_implicit_site_config_follows_launcher_runtime() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempfile::tempdir().unwrap(); + let python = tmp.path().join("python3.13"); + std::fs::write( + &python, + "#!/bin/sh\nprintf '\"/opt/homebrew/share/pdm\"\\n'\n", + ) + .unwrap(); + std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap(); + let pdm = tmp.path().join("pdm"); + std::fs::write( + &pdm, + format!( + "#!{}\nraise RuntimeError('do not run PDM')\n", + python.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&pdm, std::fs::Permissions::from_mode(0o755)).unwrap(); + let env = env_of(&[("PATH", tmp.path().to_string_lossy().into_owned())]); + assert_eq!( + pdm_site_config_dirs(&env).await, + vec![PathBuf::from("/opt/homebrew/share/pdm")] + ); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn pdm_explicit_site_project_and_user_settings_do_not_probe_the_runtime() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempfile::tempdir().unwrap(); + let marker = tmp.path().join("probe-ran"); + let python = tmp.path().join("python"); + std::fs::write( + &python, + format!("#!/bin/sh\nprintf ran > '{}'\nexit 1\n", marker.display()), + ) + .unwrap(); + std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap(); + let pdm = tmp.path().join("pdm"); + std::fs::write(&pdm, format!("#!{}\n", python.display())).unwrap(); + std::fs::set_permissions(&pdm, std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = tmp.path().to_string_lossy().into_owned(); + let explicit = env_of(&[ + ("PATH", path.clone()), + ("XDG_CONFIG_DIRS", "/explicit".into()), + ]); + assert_eq!( + pdm_site_config_dirs(&explicit).await, + vec![PathBuf::from("/explicit/pdm")] + ); + + let project = tmp.path().join("project"); + std::fs::create_dir(&project).unwrap(); + let env = env_of(&[("PATH", path.clone())]); + for name in [".pdm.toml", "pdm.toml"] { + let file = project.join(name); + std::fs::write(&file, "[python]\nuse_venv = false\n").unwrap(); + assert!(!pdm_uses_venv(&project, &env).await); + std::fs::remove_file(file).unwrap(); + } + let user = tmp.path().join("config.toml"); + std::fs::write(&user, "[python]\nuse_venv = false\n").unwrap(); + let env = env_of(&[ + ("PATH", path), + ("PDM_CONFIG_FILE", user.to_string_lossy().into_owned()), + ]); + assert!(!pdm_uses_venv(&project, &env).await); + assert!(!marker.exists(), "a lower-priority probe ran unnecessarily"); } /// The platformdirs site config dir a test can relocate: the first diff --git a/crates/socket-patch-core/src/crawlers/python_crawler/pdm_site.rs b/crates/socket-patch-core/src/crawlers/python_crawler/pdm_site.rs new file mode 100644 index 000000000..8896e3aef --- /dev/null +++ b/crates/socket-patch-core/src/crawlers/python_crawler/pdm_site.rs @@ -0,0 +1,381 @@ +//! Resolve the implicit macOS site config with PDM's own Python runtime. +//! +//! platformdirs distinguishes Homebrew from other Python installations via +//! `sys.base_prefix`. Neither the crawler's platform nor which directories +//! exist tells us which one PDM uses. Unknown launchers yield no site layer; +//! we never run PDM itself, which would load project and installed plugins. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +use tokio::io::AsyncReadExt; + +use crate::utils::fs::open_regular_file; +use crate::utils::process::{command_for, resolve_tool_with}; + +const HEADER_LIMIT: u64 = 8192; +const OUTPUT_LIMIT: u64 = 4096; +const PROBE_TIMEOUT: Duration = Duration::from_secs(5); +const PROBE: &str = + "import json, platformdirs; print(json.dumps(str(platformdirs.site_config_path('pdm'))))"; + +/// `None` is unknown, not a guess at `/Library` or a Homebrew prefix. +/// Only a PDM launcher on an absolute PATH entry identifies the runtime. +pub(super) async fn implicit_site_config_dir( + var: &impl Fn(&str) -> Option, +) -> Option { + let launcher = resolve_tool_with("pdm", &|name| var(name).map(Into::into))?; + // The regular-file opener follows normal pipx/uv launcher symlinks, + // but refuses a FIFO/device. Read only a bounded script header. + let (file, _) = open_regular_file(&launcher).await.ok()?; + let mut header = Vec::new(); + file.take(HEADER_LIMIT) + .read_to_end(&mut header) + .await + .ok()?; + let interpreter = launcher_interpreter(std::str::from_utf8(&header).ok()?)?; + probe_runtime_site_dir(&interpreter, var, PROBE_TIMEOUT).await +} + +/// Recognize ordinary Python console scripts and the exact shell trampoline +/// pip/distlib and uv write for long or space-containing interpreter paths. +/// This is not a shell parser: env shims, arguments and arbitrary wrappers +/// are unknown. In particular, an absolute non-Python shebang is not enough. +fn launcher_interpreter(header: &str) -> Option { + let mut lines = header.lines(); + let shebang = lines.next()?.strip_prefix("#!")?.trim(); + let interpreter = if shebang == "/bin/sh" { + let command = lines + .next()? + .strip_prefix("'''exec' ")? + .strip_suffix(" \"$0\" \"$@\"")?; + if lines.next()? != "' '''" { + return None; + } + if let Some(single) = command.strip_prefix('\'') { + let path = single.strip_suffix('\'')?; + (!path.contains('\'')).then_some(path)? + } else if let Some(double) = command.strip_prefix('"') { + let path = double.strip_suffix('"')?; + // No expansions or shell escapes: pass the literal path as one + // argv entry rather than evaluating a shell command. + (!path.contains(['"', '$', '`', '\\'])).then_some(path)? + } else { + (!command.contains(|c: char| c.is_whitespace() || "'\"\\$`;|&<>()*?[]{}".contains(c))) + .then_some(command)? + } + } else { + // Kernel shebangs cannot encode a space-containing executable path; + // installers use the trampoline above instead. + (!shebang.contains(char::is_whitespace)).then_some(shebang)? + }; + if interpreter.chars().any(char::is_control) { + return None; + } + let path = Path::new(interpreter); + let name = path.file_name()?.to_str()?; + let version = name + .strip_prefix("python") + .or_else(|| name.strip_prefix("pypy"))?; + if version == "t" { + return None; + } + let version = version.strip_suffix('t').unwrap_or(version); + if !version.is_empty() + && !version + .split('.') + .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())) + { + return None; + } + path.is_absolute().then(|| path.to_path_buf()) +} + +async fn probe_runtime_site_dir( + interpreter: &Path, + var: &impl Fn(&str) -> Option, + timeout: Duration, +) -> Option { + let cwd = tempfile::Builder::new() + .prefix("socket-pdm-site-") + .tempdir() + .ok()?; + let child = runtime_probe_command(interpreter, var, cwd.path()) + .spawn() + .ok()?; + read_runtime_site_dir(child, timeout).await +} + +fn runtime_probe_command( + interpreter: &Path, + var: &impl Fn(&str) -> Option, + cwd: &Path, +) -> tokio::process::Command { + // Preserve the interpreter's lexical venv path. Canonicalizing bin/python + // could launch its base Python and lose PDM's installed platformdirs. + let mut command = tokio::process::Command::from(command_for(interpreter)); + command + // Unlike -I, -E keeps normal user-site pip installs available. + // Do not use -S: Homebrew's normal site initialization affects the + // sys.base_prefix that platformdirs uses for its default site path. + .args(["-E", "-B", "-c", PROBE]) + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + // -E alone is insufficient: site.py/Homebrew's sitecustomize read + // PYTHONUSERBASE/PYTHONHOME directly, and macOS startup can honor + // PYTHONEXECUTABLE despite it. Copy only non-Python ambient variables + // so neither imports nor interpreter identity can be redirected. + command + .env_clear() + .envs(std::env::vars_os().filter(|(name, _)| { + !name.as_encoded_bytes().starts_with(b"PYTHON") + && !name.as_encoded_bytes().starts_with(b"_PYTHON") + && name != "__PYVENV_LAUNCHER__" + })); + // Keep the probe and the caller's injected environment in agreement; + // in particular, do not inherit an unrelated XDG override in tests. + for name in ["PATH", "HOME", "XDG_CONFIG_DIRS"] { + if let Some(value) = var(name) { + command.env(name, value); + } else { + command.env_remove(name); + } + } + command +} + +async fn read_runtime_site_dir( + mut child: tokio::process::Child, + timeout: Duration, +) -> Option { + let stdout = child.stdout.take()?; + tokio::time::timeout(timeout, async { + let mut output = Vec::new(); + stdout + .take(OUTPUT_LIMIT + 1) + .read_to_end(&mut output) + .await + .ok()?; + if output.len() > OUTPUT_LIMIT as usize || !child.wait().await.ok()?.success() { + return None; + } + // Exactly one JSON string, not a guessed line in mixed diagnostics. + let path = PathBuf::from(serde_json::from_slice::(&output).ok()?); + path.is_absolute().then_some(path) + }) + .await + .ok() + .flatten() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::{symlink, PermissionsExt}; + + fn executable(path: &Path, contents: &str) { + std::fs::write(path, contents).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + #[test] + fn recognizes_python_launchers_without_resolving_interpreter_symlinks() { + for name in ["python", "python3", "python3.13", "python3.13t", "pypy3"] { + let interpreter = format!("/a/venv/bin/{name}"); + assert_eq!( + launcher_interpreter(&format!("#!{interpreter}\nfrom pdm.core import main\n")), + Some(PathBuf::from(&interpreter)) + ); + } + for command in [ + "'/a path/bin/python'", + "\"/a path/bin/python\"", + "/a/bin/python", + ] { + let expected = command.trim_matches(['\'', '"']); + assert_eq!( + launcher_interpreter(&format!( + "#!/bin/sh\n'''exec' {command} \"$0\" \"$@\"\n' '''\n" + )), + Some(PathBuf::from(expected)) + ); + } + } + + #[test] + fn refuses_env_shell_arguments_and_unrecognized_trampolines() { + for header in [ + "#!/usr/bin/env python3\n", + "#!/bin/sh\necho injected\n", + "#!/bin/zsh\n", + "#!/a/bin/python -S\n", + "#!relative/python\n", + "#!/a path/bin/python\n", + "#!/a/bin/python-config\n", + "#!/a/bin/pythont\n", + "#!/bin/sh\n'''exec' /a/python; /a/evil \"$0\" \"$@\"\n' '''\n", + "#!/bin/sh\n'''exec' \"/a/$HOME/python\" \"$0\" \"$@\"\n' '''\n", + "#!/bin/sh\n'''exec' '/a/python' \"$0\" \"$@\"; evil\n' '''\n", + "#!/bin/sh\n'''exec' '/a/python' \"$0\" \"$@\"\nwrong\n", + ] { + assert_eq!(launcher_interpreter(header), None, "{header}"); + } + } + + #[tokio::test] + async fn absolute_path_launcher_symlink_selects_runtime_without_executing_pdm() { + let tmp = tempfile::tempdir().unwrap(); + let python = tmp.path().join("python"); + executable(&python, "#!/bin/sh\nprintf '\"/runtime/site/pdm\"\\n'\n"); + let launcher = tmp.path().join("real-pdm"); + executable( + &launcher, + &format!( + "#!{}\nraise RuntimeError('PDM must not run')\n", + python.display() + ), + ); + symlink(&launcher, tmp.path().join("pdm")).unwrap(); + let env = |name: &str| (name == "PATH").then(|| format!(".:{}", tmp.path().display())); + assert_eq!( + implicit_site_config_dir(&env).await, + Some(PathBuf::from("/runtime/site/pdm")) + ); + assert_eq!( + implicit_site_config_dir(&|_: &str| Some(".".into())).await, + None + ); + executable(&launcher, "#!/bin/sh\nexit 99\n"); + assert_eq!(implicit_site_config_dir(&env).await, None); + } + + #[tokio::test] + async fn probe_bounds_and_validates_output_and_process_status() { + let tmp = tempfile::tempdir().unwrap(); + let python = tmp.path().join("python"); + for script in [ + "printf 'not json\\n'", + "printf '\"relative/pdm\"\\n'", + "printf '\"/one\"\\n\"/two\"\\n'", + "printf '\"/valid\"\\n'; exit 1", + // A valid absolute JSON path that would be accepted if the + // output limit were accidentally removed. + "printf '\"/%4096s\"\\n' ''", + ] { + executable(&python, &format!("#!/bin/sh\n{script}\n")); + assert_eq!( + probe_runtime_site_dir(&python, &|_: &str| None, PROBE_TIMEOUT).await, + None, + "{script}" + ); + } + } + + #[tokio::test] + async fn probe_uses_private_cwd_null_stdin_exact_args_and_injected_env() { + let tmp = tempfile::tempdir().unwrap(); + let python = tmp.path().join("python"); + let recorded = tmp.path().join("cwd"); + executable(&python, &format!( + "#!/bin/sh\n[ \"$1 $2 $3\" = '-E -B -c' ] || exit 1\n[ \"${{XDG_CONFIG_DIRS-unset}}\" = unset ] || exit 2\nread input && exit 3\nprintf '%s' \"$PWD\" > '{}'\nprintf '\"/site/pdm\"\\n'\n", + recorded.display() + )); + assert_eq!( + probe_runtime_site_dir(&python, &|_: &str| None, PROBE_TIMEOUT).await, + Some(PathBuf::from("/site/pdm")) + ); + let cwd = PathBuf::from(std::fs::read_to_string(recorded).unwrap()); + assert!(cwd + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("socket-pdm-site-")); + assert!( + !cwd.exists(), + "the private probe directory is removed on return" + ); + } + + #[tokio::test] + async fn probe_removes_python_overrides_without_changing_the_parent_environment() { + const CHILD: &str = "SOCKET_PATCH_PDM_SITE_ENV_TEST_CHILD"; + let overrides = [ + "PYTHONPATH", + "PYTHONHOME", + "PYTHONUSERBASE", + "PYTHONEXECUTABLE", + "PYTHON_FUTURE_OVERRIDE", + "_PYTHON_SYSCONFIGDATA_PATH", + "__PYVENV_LAUNCHER__", + ]; + if std::env::var_os(CHILD).is_none() { + // Set overrides only in a separate test process. Other tests and + // the caller must never observe a temporary global environment. + let test_name = concat!( + "crawlers::python_crawler::pdm_site::tests::", + "probe_removes_python_overrides_without_changing_the_parent_environment" + ); + let mut child = tokio::process::Command::new(std::env::current_exe().unwrap()); + child + .args(["--exact", test_name, "--nocapture"]) + .env(CHILD, "1") + .envs(overrides.map(|name| (name, "/untrusted/project"))) + .stdin(Stdio::null()) + .kill_on_drop(true); + let result = tokio::time::timeout(Duration::from_secs(15), child.output()) + .await + .expect("environment control timed out") + .unwrap(); + assert!( + result.status.success(), + "{}{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let python = tmp.path().join("python"); + executable(&python, "#!/bin/sh\n[ -z \"${PYTHONPATH+x}${PYTHONHOME+x}${PYTHONUSERBASE+x}${PYTHONEXECUTABLE+x}${PYTHON_FUTURE_OVERRIDE+x}${_PYTHON_SYSCONFIGDATA_PATH+x}${__PYVENV_LAUNCHER__+x}\" ] || exit 1\nprintf '\"/safe/site/pdm\"\\n'\n"); + assert_eq!( + probe_runtime_site_dir(&python, &|_: &str| None, PROBE_TIMEOUT).await, + Some(PathBuf::from("/safe/site/pdm")) + ); + for name in overrides { + assert_eq!(std::env::var(name).unwrap(), "/untrusted/project"); + } + } + + #[tokio::test] + async fn probe_timeout_kills_its_child() { + let tmp = tempfile::tempdir().unwrap(); + let python = tmp.path().join("python"); + executable(&python, "#!/bin/sh\nexec /bin/sleep 30\n"); + let child = runtime_probe_command(&python, &|_: &str| None, tmp.path()) + .spawn() + .unwrap(); + // Capture the actual OS child identity before awaiting its timeout; + // do not assume a newly spawned shell has written a PID file yet. + let pid = child.id().unwrap() as i32; + let timeout = Duration::from_millis(100); + let started = std::time::Instant::now(); + assert_eq!(read_runtime_site_dir(child, timeout).await, None); + assert!( + started.elapsed() >= timeout, + "the child failed before the deadline" + ); + tokio::time::timeout(PROBE_TIMEOUT, async { + // Signal zero only checks existence; kill_on_drop must terminate + // and Tokio must reap this exact child, without a shell grandchild. + while unsafe { libc::kill(pid, 0) } == 0 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("timed-out probe was left running"); + } +} diff --git a/docs/testing/pdm-compatibility.md b/docs/testing/pdm-compatibility.md index 8416c3abb..383fec433 100644 --- a/docs/testing/pdm-compatibility.md +++ b/docs/testing/pdm-compatibility.md @@ -80,7 +80,8 @@ Measured details: the project's legacy `.pdm.toml` over `pdm.toml`, then the user config (`$PDM_CONFIG_FILE` or `/pdm/config.toml`) and the site config (`pdm/config.toml` under the first absolute `$XDG_CONFIG_DIRS` - entry on Linux/macOS, defaulting to `/etc/xdg` or `/Library/Application Support`, + entry on Linux/macOS, defaulting to `/etc/xdg` on Linux or the site + directory reported by PDM's own Python runtime on macOS, or `%PROGRAMDATA%\pdm\pdm\config.toml` on Windows), as a bool or the string PDM 2.27+ writes; off by default only for PDM 1.x). Otherwise the activated or in-project venv is used, with `__pypackages__` as the last @@ -89,6 +90,19 @@ Measured details: and the hosted stale-install warning and `vex` check it. Global mode (`-g`) reads `global_project.path`, `venv.location` and `python.install_root` from the user and site configs alike. +- **Implicit macOS site settings require a recognized PDM launcher.** The + crawler reads an absolute-PATH Python console launcher (including the + standard pip/uv shell trampoline for paths with spaces) and asks that + interpreter's `platformdirs` for the site path. Homebrew Python can use + `/share/pdm`; other runtimes commonly use + `/Library/Application Support/pdm`. The probe imports `platformdirs` + directly without invoking PDM or its plugin loader. + Unknown wrappers, missing `platformdirs`, or a failed five-second probe + leave the implicit site layer undiscovered. Explicit `XDG_CONFIG_DIRS` + works without a launcher or probe; project and user settings take priority. + The probe ignores Python import and launcher environment overrides; + normal HOME-based user-site installs work, but an installation available + only through a custom `PYTHONUSERBASE` is not discovered this way. - **A non-default lock filename (`pdm lock -L custom.lock`) is invisible** to the scan, which only reads `pdm.lock`. A package locked at two versions (a marker fork) is refused (`pypi_pdm_lock_forked_package` / a version-mismatch refusal),