From ccb0347d1b2f344aef3a3168e71b9c50264dacdc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 07:29:03 +0000 Subject: [PATCH 01/12] Start fix for #502, #525, #528 Assisted-by: Claude Code:claude-opus-5-5 From 1883c61815d7c03042c7151265b6e5ee29a81422 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 07:48:30 +0000 Subject: [PATCH 02/12] Find the env uv and PDM install a project into socket-patch picked a Python project's environment from VIRTUAL_ENV, Pipenv, Poetry, ./.venv and ./venv. It never asked uv or PDM where they installed the project. With UV_PROJECT_ENVIRONMENT set, a PDM .pdm-python pointing at an out-of-tree venv, or a PEP 582 __pypackages__ layout, agent mode patched a stray .venv or the PATH Python. It also skipped the package as not installed, the hosted stale-install warning stayed silent, and vex attested installs that were still unpatched. Discovery now checks the env the project's manager records before the generic probes: PDM's saved interpreter (its venv, or __pypackages__//lib for a base interpreter or PDM 1.x), then UV_PROJECT_ENVIRONMENT for a project uv drives. Fixes #502, #525, #528. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/in_process_python_envs.rs | 111 ++++++ .../tests/in_process_redirect_pdm.rs | 110 ++++++ .../src/crawlers/python_crawler.rs | 345 ++++++++++++++++++ 3 files changed, 566 insertions(+) diff --git a/crates/socket-patch-cli/tests/in_process_python_envs.rs b/crates/socket-patch-cli/tests/in_process_python_envs.rs index dc9624f52..6ed8c23e8 100644 --- a/crates/socket-patch-cli/tests/in_process_python_envs.rs +++ b/crates/socket-patch-cli/tests/in_process_python_envs.rs @@ -109,6 +109,7 @@ fn assert_not_discovered(bodies: &[String], needle: &str) { /// `scan_run` directly. async fn scan_scrubbed(args: ScanArgs) -> i32 { std::env::remove_var("VIRTUAL_ENV"); + std::env::remove_var("UV_PROJECT_ENVIRONMENT"); scan_run(args).await } @@ -637,3 +638,113 @@ async fn pipenv_stray_venv_dirs_do_not_shadow_the_pipenv_venv() { assert_not_discovered(&bodies, "pkg:pypi/stray-decoy@6.6.6"); } } + +// --------------------------------------------------------------------------- +// Package-manager-recorded envs: PDM's saved interpreter / PEP 582, and uv's +// UV_PROJECT_ENVIRONMENT, ahead of a stray `./.venv` the manager never uses +// --------------------------------------------------------------------------- + +/// A project at `/app` with `pyproject` and an in-project `.venv` +/// holding `stray_decoy 6.6.6` that the project's manager does not use. +fn project_with_stray_venv(pyproject: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("app"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join("pyproject.toml"), pyproject).unwrap(); + let stray = venv_site_packages(&project.join(".venv"), "python3.12"); + std::fs::create_dir_all(&stray).unwrap(); + write_dist_info(&stray, "stray_decoy", "6.6.6"); + (tmp, project) +} + +/// A venv at `root` holding `pkg 1.0.0`; returns its interpreter path. +fn venv_with(root: &Path, pkg: &str) -> std::path::PathBuf { + let site = venv_site_packages(root, "python3.12"); + std::fs::create_dir_all(&site).unwrap(); + write_dist_info(&site, pkg, "1.0.0"); + std::fs::write(root.join("pyvenv.cfg"), "home = /usr/bin\n").unwrap(); + if cfg!(windows) { + root.join("Scripts").join("python.exe") + } else { + root.join("bin").join("python") + } +} + +async fn assert_scan_finds(project: &Path, wanted: &str) { + let server = MockServer::start().await; + mock_batch_empty(&server).await; + assert_eq!(scan_scrubbed(default_args(project, server.uri())).await, 0); + let bodies = batch_bodies(&server).await; + assert_discovered(&bodies, wanted); + assert_not_discovered(&bodies, "pkg:pypi/stray-decoy@6.6.6"); +} + +/// #502: PDM's `.pdm-python` names an out-of-tree venv +/// (`venv.in_project = false`, or `pdm use `); that is the env scanned. +#[tokio::test] +#[serial] +async fn pdm_saved_interpreter_venv_is_scanned_not_a_stray_dot_venv() { + let (tmp, project) = + project_with_stray_venv("[project]\nname = \"app\"\n[tool.pdm]\ndistribution = false\n"); + std::fs::write(project.join("pdm.lock"), "[metadata]\n").unwrap(); + let python = venv_with( + &tmp.path().join("pdm").join("venvs").join("app-AbCd-3.12"), + "pdm_pkg", + ); + std::fs::write(project.join(".pdm-python"), python.display().to_string()).unwrap(); + assert_scan_finds(&project, "pkg:pypi/pdm-pkg@1.0.0").await; +} + +/// #528: a PEP 582 PDM project installs into `__pypackages__//lib`. +#[tokio::test] +#[serial] +async fn pdm_pep582_pypackages_is_scanned_not_a_stray_dot_venv() { + let (tmp, project) = project_with_stray_venv("[project]\nname = \"app\"\n"); + std::fs::write(project.join("pdm.lock"), "[metadata]\n").unwrap(); + let lib = project.join("__pypackages__").join("3.11").join("lib"); + std::fs::create_dir_all(&lib).unwrap(); + write_dist_info(&lib, "pep582_pkg", "1.0.0"); + // The saved interpreter is a base Python, not a venv. + let base = tmp.path().join("usr").join("bin").join("python3.11"); + std::fs::write(project.join(".pdm-python"), base.display().to_string()).unwrap(); + assert_scan_finds(&project, "pkg:pypi/pep582-pkg@1.0.0").await; +} + +/// #525: uv syncs into `UV_PROJECT_ENVIRONMENT`, absolute or relative to the +/// project, and ignores an activated `VIRTUAL_ENV` for project commands. +#[tokio::test] +#[serial] +async fn uv_project_environment_is_scanned_not_a_stray_dot_venv() { + let other = tempfile::tempdir().unwrap(); + let decoy = other.path().join("tool-venv"); + let decoy_site = venv_site_packages(&decoy, "python3.12"); + std::fs::create_dir_all(&decoy_site).unwrap(); + write_dist_info(&decoy_site, "activated_decoy", "6.6.6"); + + for relative in [false, true] { + let (tmp, project) = project_with_stray_venv("[project]\nname = \"app\"\n"); + std::fs::write(project.join("uv.lock"), "version = 1\n").unwrap(); + let env = if relative { + project.join(".venv-ci") + } else { + tmp.path().join("opt").join("venv") + }; + venv_with(&env, "uv_pkg"); + let server = MockServer::start().await; + mock_batch_empty(&server).await; + std::env::set_var("VIRTUAL_ENV", &decoy); + if relative { + std::env::set_var("UV_PROJECT_ENVIRONMENT", ".venv-ci"); + } else { + std::env::set_var("UV_PROJECT_ENVIRONMENT", &env); + } + let code = scan_run(default_args(&project, server.uri())).await; + std::env::remove_var("VIRTUAL_ENV"); + std::env::remove_var("UV_PROJECT_ENVIRONMENT"); + assert_eq!(code, 0); + let bodies = batch_bodies(&server).await; + assert_discovered(&bodies, "pkg:pypi/uv-pkg@1.0.0"); + assert_not_discovered(&bodies, "pkg:pypi/stray-decoy@6.6.6"); + assert_not_discovered(&bodies, "pkg:pypi/activated-decoy@6.6.6"); + } +} diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs index 97f4a171d..4b380a52c 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs @@ -594,3 +594,113 @@ async fn assert_relock_roundtrip(lock: &str, relocked: &str) { "rollback restores the relocked lock byte for byte" ); } + +/// Spawn `scan --mode hosted --json` on `root` with a scrubbed environment +/// (no ambient `SOCKET_*`, `PDM_*` or `VIRTUAL_ENV`). +async fn scan_json( + root: &Path, + server: &MockServer, + extra: &[&str], +) -> (Option, serde_json::Value) { + let mut cmd = tokio::process::Command::new(binary()); + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") + || name.starts_with("PDM_") + || name == "VIRTUAL_ENV" + || name == "UV_PROJECT_ENVIRONMENT" + { + cmd.env_remove(key); + } + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1") + .args(["scan", "--mode", "hosted", "--yes", "--json", "--cwd"]) + .arg(root) + .args([ + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ]) + .args(extra); + let out = cmd.output().await.unwrap(); + let json = serde_json::from_slice(&out.stdout).unwrap_or_else(|error| { + panic!( + "{error}: stdout={} stderr={}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) + }); + (out.status.code(), json) +} + +fn stale_warning(json: &serde_json::Value) -> bool { + json["redirect"]["warnings"] + .as_array() + .is_some_and(|warnings| { + warnings + .iter() + .any(|warning| warning["code"] == "redirect_pypi_stale_install") + }) +} + +/// Lay `urllib3 1.26.18` with `bytes` as its `response.py` into `site`. +fn install_urllib3(site: &Path, bytes: &[u8]) { + std::fs::create_dir_all(site.join("urllib3-1.26.18.dist-info")).unwrap(); + std::fs::create_dir_all(site.join("urllib3")).unwrap(); + std::fs::write(site.join("urllib3").join("response.py"), bytes).unwrap(); +} + +/// #502 / #528: the env PDM installs into is the one its `.pdm-python` +/// records: an out-of-tree venv, or `__pypackages__//lib` when the +/// interpreter is a base Python (PEP 582). A warm, still-upstream copy there +/// must raise the stale-install warning and block the VEX attestation, as an +/// in-project `.venv` does; the empty stray `.venv` from `write_project` +/// must not stand in for it. Once PDM's env holds the patched bytes, the +/// warning is gone and the redirect attests. +#[tokio::test] +async fn pdm_recorded_env_is_probed_for_stale_hosted_installs() { + for pep582 in [false, true] { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("app"); + std::fs::create_dir_all(&root).unwrap(); + write_project(&root, LOCK); + let site = if pep582 { + let base = tmp.path().join("usr").join("bin").join("python3.11"); + std::fs::write(root.join(".pdm-python"), base.display().to_string()).unwrap(); + root.join("__pypackages__").join("3.11").join("lib") + } else { + let venv = tmp.path().join("pdm-venvs").join("app-AbCd-3.12"); + std::fs::create_dir_all(&venv).unwrap(); + std::fs::write(venv.join("pyvenv.cfg"), "home = /usr/bin\n").unwrap(); + let python = if cfg!(windows) { + venv.join("Scripts").join("python.exe") + } else { + venv.join("bin").join("python") + }; + std::fs::write(root.join(".pdm-python"), python.display().to_string()).unwrap(); + if cfg!(windows) { + venv.join("Lib").join("site-packages") + } else { + venv.join("lib").join("python3.12").join("site-packages") + } + }; + install_urllib3(&site, UPSTREAM); + + let vex = tmp.path().join("out.vex.json"); + let (code, json) = scan_json(&root, &server, &["--vex", vex.to_str().unwrap()]).await; + assert_eq!(code, Some(1), "pep582={pep582}: {json}"); + assert!(stale_warning(&json), "pep582={pep582}: {json}"); + assert!(!vex.exists(), "stale bytes cannot produce a VEX file"); + + install_urllib3(&site, PATCHED); + let (code, json) = scan_json(&root, &server, &["--vex", vex.to_str().unwrap()]).await; + assert_eq!(code, Some(0), "pep582={pep582}: {json}"); + assert!(!stale_warning(&json), "pep582={pep582}: {json}"); + assert_eq!(json["vex"]["statements"], 1, "pep582={pep582}: {json}"); + } +} diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index bc03ebb69..89079de02 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -318,6 +318,11 @@ async fn find_site_packages_under( /// Find local virtual environment `site-packages` directories. /// /// Checks (in order): +/// 0. The env the project's package manager records for it, which that +/// manager uses ahead of an activated venv or a stray `./.venv` (see +/// [`package_manager_recorded_site_packages`]): PDM's `.pdm-python` +/// interpreter (its venv, or `__pypackages__` for PEP 582) and uv's +/// `UV_PROJECT_ENVIRONMENT` /// 1. `VIRTUAL_ENV` environment variable (for a Pipenv project, only when /// Pipenv itself would use it) /// 2. For a Pipenv project, the venv(s) Pipenv resolves for it (see @@ -338,6 +343,13 @@ async fn find_local_venv_site_packages_with( var: &impl Fn(&str) -> Option, ) -> Vec { let mut results = Vec::new(); + + // 0. PDM and uv record where they install a project, and that record + // beats both an activated `VIRTUAL_ENV` and a `./.venv` they don't use. + if let Some(found) = package_manager_recorded_site_packages(cwd, var).await { + return found; + } + let pipenv = is_pipenv_project(cwd); // 1. Check VIRTUAL_ENV env var. Pipenv ignores it under `PIPENV_ACTIVE` @@ -388,6 +400,118 @@ async fn find_local_venv_site_packages_with( results } +/// The `site-packages` of the env the project's package manager records for +/// `cwd`, when that env exists. `None` means the manager records nothing +/// (or nothing installed yet), and the generic probes decide. +/// +/// - **PDM** installs into the interpreter saved in `.pdm-python` (PDM +/// 2.x; `[python] path` in `.pdm.toml` before that), ahead of an +/// activated venv. That interpreter's venv is the env (an out-of-tree +/// `venv.in_project = false` venv, or one picked with `pdm use`). An +/// interpreter that is not a venv means PEP 582: PDM installs into +/// `__pypackages__//lib`, which PDM 1.x also uses with no saved +/// interpreter at all. +/// - **uv** syncs a project into `UV_PROJECT_ENVIRONMENT` (absolute, or +/// relative to the project) instead of `./.venv`, and ignores an +/// activated `VIRTUAL_ENV` for project commands. +async fn package_manager_recorded_site_packages( + cwd: &Path, + var: &impl Fn(&str) -> Option, +) -> Option> { + if let Some(found) = pdm_project_site_packages(cwd).await { + return Some(found); + } + uv_project_environment_site_packages(cwd, var).await +} + +/// PDM's env for `cwd` (see [`package_manager_recorded_site_packages`]). +async fn pdm_project_site_packages(cwd: &Path) -> Option> { + let pep582 = || async { + let found = find_python_dirs(&cwd.join("__pypackages__"), &["*", "lib"]).await; + (!found.is_empty()).then_some(found) + }; + match pdm_saved_interpreter(cwd).await { + Some(python) => match venv_root_of_interpreter(&python) { + Some(root) => { + let found = find_site_packages_under(&root, "site-packages").await; + (!found.is_empty()).then_some(found) + } + None => pep582().await, + }, + None if is_pdm_project(cwd).await => pep582().await, + None => None, + } +} + +/// The interpreter PDM saved for `cwd`: `.pdm-python` (PDM 2.x), else +/// `[python] path` in the legacy `.pdm.toml`. A relative path is taken +/// against the project. +async fn pdm_saved_interpreter(cwd: &Path) -> Option { + let saved = match tokio::fs::read_to_string(cwd.join(".pdm-python")).await { + Ok(text) => text.trim().to_string(), + Err(_) => { + let text = tokio::fs::read_to_string(cwd.join(".pdm.toml")) + .await + .ok()?; + let doc = text.parse::().ok()?; + doc.get("python")?.get("path")?.as_str()?.trim().to_string() + } + }; + (!saved.is_empty()).then(|| cwd.join(saved)) +} + +/// Whether `cwd` is a PDM project: `pdm.lock`, `.pdm.toml`, or a +/// `[tool.pdm]` table in `pyproject.toml`. +async fn is_pdm_project(cwd: &Path) -> bool { + if cwd.join("pdm.lock").is_file() || cwd.join(".pdm.toml").is_file() { + return true; + } + let Ok(text) = tokio::fs::read_to_string(cwd.join("pyproject.toml")).await else { + return false; + }; + text.parse::() + .ok() + .and_then(|doc| doc.get("tool")?.get("pdm").map(|_| ())) + .is_some() +} + +/// The venv a Python interpreter path belongs to: `/bin/python…` or +/// `\Scripts\python.exe` with a `/pyvenv.cfg`. The path is not +/// resolved, since a venv's interpreter is a symlink to its base Python. +fn venv_root_of_interpreter(python: &Path) -> Option { + let root = python.parent()?.parent()?; + root.join("pyvenv.cfg") + .is_file() + .then(|| root.to_path_buf()) +} + +/// uv's `UV_PROJECT_ENVIRONMENT` for a uv project at `cwd` (see +/// [`package_manager_recorded_site_packages`]). Ambient in shells and +/// images, so it only counts for a project uv drives: one with `uv.lock`, +/// or a `pyproject.toml` that no other manager's lock or record claims. +async fn uv_project_environment_site_packages( + cwd: &Path, + var: &impl Fn(&str) -> Option, +) -> Option> { + let env = var("UV_PROJECT_ENVIRONMENT").filter(|v| !v.trim().is_empty())?; + let other_manager = [ + "poetry.lock", + "pdm.lock", + ".pdm-python", + "Pipfile", + "Pipfile.lock", + ] + .iter() + .any(|marker| cwd.join(marker).exists()); + let uv_project = + cwd.join("uv.lock").is_file() || (cwd.join("pyproject.toml").is_file() && !other_manager); + if !uv_project { + return None; + } + let found = find_site_packages_under(&cwd.join(env), "site-packages").await; + (!found.is_empty()).then_some(found) +} + /// Whether `cwd` is a Pipenv project: a `Pipfile` or a `Pipfile.lock`. fn is_pipenv_project(cwd: &Path) -> bool { cwd.join("Pipfile").is_file() || cwd.join("Pipfile.lock").is_file() @@ -2049,6 +2173,227 @@ mod tests { site } + /// A venv at `root` as the crawler sees it: `pyvenv.cfg`, an interpreter + /// path under `bin/` (`Scripts\\` on Windows), and its site-packages. + /// Returns `(interpreter, site_packages)`. + fn fake_venv_root(root: &Path) -> (PathBuf, PathBuf) { + let parent = root.parent().unwrap(); + let leaf = root.file_name().unwrap().to_str().unwrap(); + let site = fake_venv(parent, leaf); + std::fs::write(root.join("pyvenv.cfg"), "home = /usr/bin\n").unwrap(); + let python = if cfg!(windows) { + root.join("Scripts").join("python.exe") + } else { + root.join("bin").join("python") + }; + (python, site) + } + + fn env_of(pairs: &[(&str, String)]) -> impl Fn(&str) -> Option { + let pairs: Vec<(String, String)> = pairs + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(); + move |name: &str| { + pairs + .iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v.clone()) + } + } + + /// #502: PDM installs into the interpreter saved in `.pdm-python` (an + /// out-of-tree `venv.in_project = false` venv, or one bound with + /// `pdm use`), ahead of a stray `./.venv` and an activated venv. + #[tokio::test] + async fn pdm_saved_interpreter_venv_is_the_project_env() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("app"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write( + project.join("pyproject.toml"), + "[project]\nname = \"app\"\n[tool.pdm]\ndistribution = false\n", + ) + .unwrap(); + std::fs::write(project.join("pdm.lock"), "[metadata]\n").unwrap(); + let (python, pdm_site) = + fake_venv_root(&tmp.path().join("pdm-venvs").join("app-AbCd-3.12")); + std::fs::write( + project.join(".pdm-python"), + format!("{}\n", python.display()), + ) + .unwrap(); + let no_env = env_of(&[]); + + // Out-of-tree venv, nothing else around: found (was: skipped). + assert_eq!( + find_local_venv_site_packages_with(&project, &no_env).await, + vec![pdm_site.clone()] + ); + + // A stray `./.venv` PDM does not use is not patched. + let stray = fake_venv(&project, ".venv"); + assert_eq!( + find_local_venv_site_packages_with(&project, &no_env).await, + vec![pdm_site.clone()] + ); + + // PDM prefers its saved interpreter over an activated venv. + let other = tempfile::tempdir().unwrap(); + fake_venv(other.path(), "tool-venv"); + let activated = env_of(&[( + "VIRTUAL_ENV", + other + .path() + .join("tool-venv") + .to_string_lossy() + .into_owned(), + )]); + assert_eq!( + find_local_venv_site_packages_with(&project, &activated).await, + vec![pdm_site.clone()] + ); + + // Legacy PDM (`.pdm.toml` `[python] path`) records the same thing. + std::fs::remove_file(project.join(".pdm-python")).unwrap(); + let mut doc = toml_edit::DocumentMut::new(); + doc["python"]["path"] = toml_edit::value(python.to_string_lossy().into_owned()); + std::fs::write(project.join(".pdm.toml"), doc.to_string()).unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &no_env).await, + vec![pdm_site.clone()] + ); + std::fs::remove_file(project.join(".pdm.toml")).unwrap(); + + // Saved interpreter whose venv is gone: the generic probes decide. + std::fs::write( + project.join(".pdm-python"), + tmp.path() + .join("gone") + .join("bin") + .join("python") + .display() + .to_string(), + ) + .unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &no_env).await, + vec![stray] + ); + } + + /// #528: a PDM interpreter that is not a venv means PEP 582, and PDM + /// installs into `__pypackages__//lib` (PDM 1.x does so with no + /// saved interpreter at all). The PATH Python is never the env. + #[tokio::test] + async fn pdm_pep582_pypackages_is_the_project_env() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("demo"); + let lib = project.join("__pypackages__").join("3.11").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(); + // A base interpreter: no pyvenv.cfg next to it. + let base = tmp.path().join("usr").join("bin").join("python3.11"); + std::fs::create_dir_all(base.parent().unwrap()).unwrap(); + std::fs::write(project.join(".pdm-python"), base.display().to_string()).unwrap(); + let no_env = env_of(&[]); + assert_eq!( + find_local_venv_site_packages_with(&project, &no_env).await, + vec![lib.clone()] + ); + + // PDM 1.x: no saved interpreter, still a PDM project. + std::fs::remove_file(project.join(".pdm-python")).unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &no_env).await, + vec![lib.clone()] + ); + + // `__pypackages__` outside a PDM project is not PDM's. + std::fs::remove_file(project.join("pdm.lock")).unwrap(); + assert!(find_local_venv_site_packages_with(&project, &no_env) + .await + .is_empty()); + } + + /// #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. + #[tokio::test] + async fn uv_project_environment_is_the_project_env() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("app"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write( + project.join("pyproject.toml"), + "[project]\nname = \"app\"\n", + ) + .unwrap(); + std::fs::write(project.join("uv.lock"), "version = 1\n").unwrap(); + let abs_site = fake_venv(&tmp.path().join("opt"), "venv"); + let abs = tmp.path().join("opt").join("venv"); + let rel_site = fake_venv(&project, ".venv-ci"); + let stray = fake_venv(&project, ".venv"); + let other = tempfile::tempdir().unwrap(); + fake_venv(other.path(), "tool-venv"); + let activated = other + .path() + .join("tool-venv") + .to_string_lossy() + .into_owned(); + + let abs_env = env_of(&[("UV_PROJECT_ENVIRONMENT", abs.to_string_lossy().into_owned())]); + assert_eq!( + find_local_venv_site_packages_with(&project, &abs_env).await, + vec![abs_site.clone()] + ); + let rel_env = env_of(&[ + ("UV_PROJECT_ENVIRONMENT", ".venv-ci".to_string()), + ("VIRTUAL_ENV", activated.clone()), + ]); + assert_eq!( + find_local_venv_site_packages_with(&project, &rel_env).await, + vec![rel_site.clone()] + ); + + // Lock-less uv project (just pyproject.toml) counts too. + std::fs::remove_file(project.join("uv.lock")).unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &abs_env).await, + vec![abs_site] + ); + + // ...but not a project another manager drives, nor a non-project. + std::fs::write(project.join("poetry.lock"), "").unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &abs_env).await, + vec![stray.clone()] + ); + std::fs::remove_file(project.join("poetry.lock")).unwrap(); + std::fs::remove_file(project.join("pyproject.toml")).unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &abs_env).await, + vec![stray.clone()] + ); + + // An env that does not exist yet leaves the generic probes in charge. + std::fs::write( + project.join("pyproject.toml"), + "[project]\nname = \"app\"\n", + ) + .unwrap(); + let missing = env_of(&[("UV_PROJECT_ENVIRONMENT", "not-synced".to_string())]); + assert_eq!( + find_local_venv_site_packages_with(&project, &missing).await, + vec![stray] + ); + } + /// The end-to-end shape: a Pipenv project with NO in-project venv and /// Pipenv's default out-of-tree placement under WORKON_HOME is found by /// name+hash (with and without the `-` suffix), while a From ce6aee00e414f048c0afac6838824f64073696fd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 07:48:30 +0000 Subject: [PATCH 03/12] Document uv and PDM project env discovery Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- docs/testing/pdm-compatibility.md | 13 ++++++++----- docs/testing/uv-compatibility.md | 7 +++++++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 72bffac7a..5f0d834c7 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -477,7 +477,7 @@ the model is **not uniform** today: `workspaces`). One repo-root invocation discovers every member. A member that is itself a workspace root is recursed into (bounded depth). - **cwd-only (single project):** gem, pypi, composer. The crawler inspects only the project - rooted at `--cwd` (pypi looks at `$VIRTUAL_ENV`, `/.venv` / `venv`, then a Poetry project's out-of-tree virtualenv(s) under Poetry's `virtualenvs.path`; composer at the vendor tree); it does **not** + rooted at `--cwd` (pypi first takes the env the project's manager records: PDM's `.pdm-python` interpreter, meaning its venv or, for a base interpreter, PEP 582 `__pypackages__//lib`, and uv's `UV_PROJECT_ENVIRONMENT`. Otherwise it looks at `$VIRTUAL_ENV`, `/.venv` / `venv`, then a Poetry project's out-of-tree virtualenv(s) under Poetry's `virtualenvs.path`; composer at the vendor tree); it does **not** descend into sibling subprojects. A monorepo with several independent lockfiles in subdirectories (`backend/Gemfile.lock` + `frontend/Gemfile.lock`, multiple `.venv`, multiple `go.mod` / `composer.json`) is handled by invoking the tool **once per subproject** (`--cwd` each), as a diff --git a/docs/testing/pdm-compatibility.md b/docs/testing/pdm-compatibility.md index 4541634b3..f32cbbe2d 100644 --- a/docs/testing/pdm-compatibility.md +++ b/docs/testing/pdm-compatibility.md @@ -69,11 +69,14 @@ Measured details: - **Hosted mode verifies the lock's file hash** on install for every supported release (tamper the hash and `pdm sync` fails closed). Vendored mode's protection is the committed wheel bytes, verified by the same hash. -- **`__pypackages__` (PEP 582) projects are not covered.** PDM 0.x/1.x default - to `__pypackages__`, and 2.x does so under `python.use_venv = false`; the - installed-set crawler probes virtualenvs (`VIRTUAL_ENV`, `./.venv`), so agent - and vendored mode need a virtualenv install. Run PDM with `python.use_venv` - on, or use hosted mode. +- **The installed env is the one PDM records.** The crawler follows the + interpreter in `.pdm-python` (`[python] path` in `.pdm.toml` on older PDM), + ahead of an activated venv or a stray `./.venv`. That covers an out-of-tree + venv (`venv.in_project = false`) and one bound with `pdm use `. When + the interpreter is a base Python, or a PDM 0.x/1.x project saved none, the + env is `__pypackages__//lib` (PEP 582; PDM 2.x under + `python.use_venv = false`). Agent mode patches it there, and the hosted + stale-install warning and `vex` check it. - **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), diff --git a/docs/testing/uv-compatibility.md b/docs/testing/uv-compatibility.md index 16f1a67ba..42d248742 100644 --- a/docs/testing/uv-compatibility.md +++ b/docs/testing/uv-compatibility.md @@ -32,6 +32,13 @@ frozen, locked, and ordinary installation outcomes separately where supported. ## Limits +- The installed env is the one uv syncs into. With `UV_PROJECT_ENVIRONMENT` set + (absolute, or relative to the project), the crawler uses that env in place of + `./.venv` or an activated `VIRTUAL_ENV`, as `uv sync` / `uv run` do. This + holds for a project with `uv.lock`, or a `pyproject.toml` that no other + manager's lock claims. Agent mode patches that env, and the hosted + stale-install warning and `vex` check it. Run the scan with the same + `UV_PROJECT_ENVIRONMENT` that `uv sync` used. - uv 0.0 has no native `uv.lock`; its compatibility lane is compiled requirements. `uv pip sync` rejects the bare local wheel paths emitted by vendored requirements through uv 0.1.23 (`Unexpected '.', expected '-c', '-e', '-r' From 06cba3fe78839351259f9e9fd4e8e9f42d0e6c3b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 07:54:20 +0000 Subject: [PATCH 04/12] Honor PDM_IGNORE_SAVED_PYTHON in env discovery PDM disregards the saved .pdm-python interpreter when PDM_IGNORE_SAVED_PYTHON is set, so discovery does too and falls back to the generic probes. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/python_crawler.rs | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index 89079de02..a8e7a647d 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -418,19 +418,29 @@ async fn package_manager_recorded_site_packages( cwd: &Path, var: &impl Fn(&str) -> Option, ) -> Option> { - if let Some(found) = pdm_project_site_packages(cwd).await { + if let Some(found) = pdm_project_site_packages(cwd, var).await { return Some(found); } uv_project_environment_site_packages(cwd, var).await } /// PDM's env for `cwd` (see [`package_manager_recorded_site_packages`]). -async fn pdm_project_site_packages(cwd: &Path) -> Option> { +/// `PDM_IGNORE_SAVED_PYTHON` makes PDM disregard the saved interpreter. +async fn pdm_project_site_packages( + cwd: &Path, + var: &impl Fn(&str) -> Option, +) -> Option> { let pep582 = || async { let found = find_python_dirs(&cwd.join("__pypackages__"), &["*", "lib"]).await; (!found.is_empty()).then_some(found) }; - match pdm_saved_interpreter(cwd).await { + let ignore_saved = var("PDM_IGNORE_SAVED_PYTHON").is_some_and(|v| !v.is_empty()); + let saved = if ignore_saved { + None + } else { + pdm_saved_interpreter(cwd).await + }; + match saved { Some(python) => match venv_root_of_interpreter(&python) { Some(root) => { let found = find_site_packages_under(&root, "site-packages").await; @@ -2254,6 +2264,13 @@ mod tests { vec![pdm_site.clone()] ); + // `PDM_IGNORE_SAVED_PYTHON` makes PDM disregard `.pdm-python`. + let ignored = env_of(&[("PDM_IGNORE_SAVED_PYTHON", "1".to_string())]); + assert_eq!( + find_local_venv_site_packages_with(&project, &ignored).await, + vec![stray.clone()] + ); + // Legacy PDM (`.pdm.toml` `[python] path`) records the same thing. std::fs::remove_file(project.join(".pdm-python")).unwrap(); let mut doc = toml_edit::DocumentMut::new(); From ab7aeb64737acdc84baa322267cd5e96a44ac5e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 08:08:00 +0000 Subject: [PATCH 05/12] Don't block on a FIFO pyproject in PDM probes The new PDM env probe opened pyproject.toml, .pdm-python and .pdm.toml with a plain read, so a FIFO in their place wedged scan forever (caught by hosted_scan_returns_with_fifo_candidate). Read them with read_regular_to_string, as the Poetry probe already does. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/crawlers/python_crawler.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index a8e7a647d..e910b3911 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -457,10 +457,10 @@ async fn pdm_project_site_packages( /// `[python] path` in the legacy `.pdm.toml`. A relative path is taken /// against the project. async fn pdm_saved_interpreter(cwd: &Path) -> Option { - let saved = match tokio::fs::read_to_string(cwd.join(".pdm-python")).await { + let saved = match read_regular_to_string(&cwd.join(".pdm-python")).await { Ok(text) => text.trim().to_string(), Err(_) => { - let text = tokio::fs::read_to_string(cwd.join(".pdm.toml")) + let text = read_regular_to_string(&cwd.join(".pdm.toml")) .await .ok()?; let doc = text.parse::().ok()?; @@ -476,7 +476,7 @@ async fn is_pdm_project(cwd: &Path) -> bool { if cwd.join("pdm.lock").is_file() || cwd.join(".pdm.toml").is_file() { return true; } - let Ok(text) = tokio::fs::read_to_string(cwd.join("pyproject.toml")).await else { + let Ok(text) = read_regular_to_string(&cwd.join("pyproject.toml")).await else { return false; }; text.parse::() From 60dfb81867f5de8f88132f20a829a4c18989dc16 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 08:32:51 +0000 Subject: [PATCH 06/12] Honor PDM_PYTHON; let uv and Poetry locks win PDM_PYTHON outranks .pdm-python, so discovery now follows it too. A project with uv.lock or poetry.lock is installed by uv or Poetry (they drive hosted installs ahead of pdm.lock), so a leftover PDM record or __pypackages__ there no longer decides the env. Found by Bugbot review. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/python_crawler.rs | 57 ++++++++++++++++--- docs/testing/pdm-compatibility.md | 5 +- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index e910b3911..095ea9131 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -425,7 +425,8 @@ async fn package_manager_recorded_site_packages( } /// PDM's env for `cwd` (see [`package_manager_recorded_site_packages`]). -/// `PDM_IGNORE_SAVED_PYTHON` makes PDM disregard the saved interpreter. +/// `PDM_PYTHON` outranks the saved interpreter, and +/// `PDM_IGNORE_SAVED_PYTHON` makes PDM disregard the saved one. async fn pdm_project_site_packages( cwd: &Path, var: &impl Fn(&str) -> Option, @@ -434,13 +435,27 @@ async fn pdm_project_site_packages( let found = find_python_dirs(&cwd.join("__pypackages__"), &["*", "lib"]).await; (!found.is_empty()).then_some(found) }; + // `uv.lock` and `poetry.lock` drive installs ahead of `pdm.lock` (the + // hosted rewriters' precedence), so a leftover PDM record next to one + // is not where the project is installed. + if cwd.join("uv.lock").is_file() || cwd.join("poetry.lock").is_file() { + return None; + } + let pdm_project = cwd.join(".pdm-python").is_file() || is_pdm_project(cwd).await; + if !pdm_project { + return None; + } + // `PDM_PYTHON` outranks the saved interpreter. + let overridden = var("PDM_PYTHON") + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()); let ignore_saved = var("PDM_IGNORE_SAVED_PYTHON").is_some_and(|v| !v.is_empty()); - let saved = if ignore_saved { - None - } else { - pdm_saved_interpreter(cwd).await + let interpreter = match overridden { + Some(python) => Some(cwd.join(python)), + None if ignore_saved => None, + None => pdm_saved_interpreter(cwd).await, }; - match saved { + match interpreter { Some(python) => match venv_root_of_interpreter(&python) { Some(root) => { let found = find_site_packages_under(&root, "site-packages").await; @@ -448,8 +463,7 @@ async fn pdm_project_site_packages( } None => pep582().await, }, - None if is_pdm_project(cwd).await => pep582().await, - None => None, + None => pep582().await, } } @@ -2271,6 +2285,26 @@ mod tests { vec![stray.clone()] ); + // `PDM_PYTHON` outranks `.pdm-python`. + let (ci_python, ci_site) = fake_venv_root(&tmp.path().join("ci-venv")); + let pinned = env_of(&[("PDM_PYTHON", ci_python.to_string_lossy().into_owned())]); + assert_eq!( + find_local_venv_site_packages_with(&project, &pinned).await, + vec![ci_site] + ); + + // Next to `uv.lock` or `poetry.lock` (which drive installs ahead of + // `pdm.lock`) a leftover PDM record is not the project's env. + for lock in ["uv.lock", "poetry.lock"] { + std::fs::write(project.join(lock), "").unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &no_env).await, + vec![stray.clone()], + "{lock}" + ); + std::fs::remove_file(project.join(lock)).unwrap(); + } + // Legacy PDM (`.pdm.toml` `[python] path`) records the same thing. std::fs::remove_file(project.join(".pdm-python")).unwrap(); let mut doc = toml_edit::DocumentMut::new(); @@ -2331,6 +2365,13 @@ mod tests { vec![lib.clone()] ); + // A uv project with a leftover PDM lock is uv's, not PEP 582. + std::fs::write(project.join("uv.lock"), "version = 1\n").unwrap(); + assert!(find_local_venv_site_packages_with(&project, &no_env) + .await + .is_empty()); + std::fs::remove_file(project.join("uv.lock")).unwrap(); + // `__pypackages__` outside a PDM project is not PDM's. std::fs::remove_file(project.join("pdm.lock")).unwrap(); assert!(find_local_venv_site_packages_with(&project, &no_env) diff --git a/docs/testing/pdm-compatibility.md b/docs/testing/pdm-compatibility.md index f32cbbe2d..3f2c0f252 100644 --- a/docs/testing/pdm-compatibility.md +++ b/docs/testing/pdm-compatibility.md @@ -70,8 +70,9 @@ Measured details: release (tamper the hash and `pdm sync` fails closed). Vendored mode's protection is the committed wheel bytes, verified by the same hash. - **The installed env is the one PDM records.** The crawler follows the - interpreter in `.pdm-python` (`[python] path` in `.pdm.toml` on older PDM), - ahead of an activated venv or a stray `./.venv`. That covers an out-of-tree + interpreter in `PDM_PYTHON`, else `.pdm-python` (`[python] path` in + `.pdm.toml` on older PDM), ahead of an activated venv or a stray `./.venv`. + A project with `uv.lock` or `poetry.lock` is not treated as PDM's. That covers an out-of-tree venv (`venv.in_project = false`) and one bound with `pdm use `. When the interpreter is a base Python, or a PDM 0.x/1.x project saved none, the env is `__pypackages__//lib` (PEP 582; PDM 2.x under From 5ecac794d24527c40ec14ba2876c9d46ab9e948e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:25:14 +0000 Subject: [PATCH 07/12] Respect Poetry and PDM venv order in env probes A Poetry project without poetry.lock ([tool.poetry] or poetry.toml) is Poetry's, so an ambient UV_PROJECT_ENVIRONMENT no longer takes it over. With no saved PDM interpreter, PDM uses an activated or in-project venv before PEP 582, so __pypackages__ is now the last resort instead of beating VIRTUAL_ENV and ./.venv. Found in review. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/python_crawler.rs | 119 ++++++++++++++---- docs/testing/pdm-compatibility.md | 4 +- 2 files changed, 96 insertions(+), 27 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index 095ea9131..dabf376c9 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -397,6 +397,13 @@ async fn find_local_venv_site_packages_with( results.extend(matches); } + // 5. A PDM project with no recorded interpreter and no venv for PDM to + // pick (an activated one, `./.venv`) is a PEP 582 project (PDM 1.x's + // default): its packages live in `__pypackages__//lib`. + if results.is_empty() && pdm_drives_project(cwd).await { + results = pdm_pep582_dirs(cwd).await; + } + results } @@ -424,25 +431,16 @@ async fn package_manager_recorded_site_packages( uv_project_environment_site_packages(cwd, var).await } -/// PDM's env for `cwd` (see [`package_manager_recorded_site_packages`]). -/// `PDM_PYTHON` outranks the saved interpreter, and -/// `PDM_IGNORE_SAVED_PYTHON` makes PDM disregard the saved one. +/// The env of PDM's interpreter for `cwd` (see +/// [`package_manager_recorded_site_packages`]): `PDM_PYTHON`, else the saved +/// one unless `PDM_IGNORE_SAVED_PYTHON`. With neither, PDM picks an active +/// or project venv first, so the generic probes decide (PEP 582 is their +/// last resort, see [`find_local_venv_site_packages_with`]). async fn pdm_project_site_packages( cwd: &Path, var: &impl Fn(&str) -> Option, ) -> Option> { - let pep582 = || async { - let found = find_python_dirs(&cwd.join("__pypackages__"), &["*", "lib"]).await; - (!found.is_empty()).then_some(found) - }; - // `uv.lock` and `poetry.lock` drive installs ahead of `pdm.lock` (the - // hosted rewriters' precedence), so a leftover PDM record next to one - // is not where the project is installed. - if cwd.join("uv.lock").is_file() || cwd.join("poetry.lock").is_file() { - return None; - } - let pdm_project = cwd.join(".pdm-python").is_file() || is_pdm_project(cwd).await; - if !pdm_project { + if !pdm_drives_project(cwd).await { return None; } // `PDM_PYTHON` outranks the saved interpreter. @@ -455,16 +453,27 @@ async fn pdm_project_site_packages( None if ignore_saved => None, None => pdm_saved_interpreter(cwd).await, }; - match interpreter { - Some(python) => match venv_root_of_interpreter(&python) { - Some(root) => { - let found = find_site_packages_under(&root, "site-packages").await; - (!found.is_empty()).then_some(found) - } - None => pep582().await, - }, - None => pep582().await, + let found = match venv_root_of_interpreter(&interpreter?) { + Some(root) => find_site_packages_under(&root, "site-packages").await, + None => pdm_pep582_dirs(cwd).await, + }; + (!found.is_empty()).then_some(found) +} + +/// Whether PDM installs the project at `cwd`: a PDM project (see +/// [`is_pdm_project`], or a `.pdm-python`) with no `uv.lock` or +/// `poetry.lock`, which drive installs ahead of `pdm.lock` (the hosted +/// rewriters' precedence). +async fn pdm_drives_project(cwd: &Path) -> bool { + if cwd.join("uv.lock").is_file() || cwd.join("poetry.lock").is_file() { + return false; } + cwd.join(".pdm-python").is_file() || is_pdm_project(cwd).await +} + +/// PEP 582 package dirs: `__pypackages__//lib`. +async fn pdm_pep582_dirs(cwd: &Path) -> Vec { + find_python_dirs(&cwd.join("__pypackages__"), &["*", "lib"]).await } /// The interpreter PDM saved for `cwd`: `.pdm-python` (PDM 2.x), else @@ -518,8 +527,9 @@ async fn uv_project_environment_site_packages( var: &impl Fn(&str) -> Option, ) -> Option> { let env = var("UV_PROJECT_ENVIRONMENT").filter(|v| !v.trim().is_empty())?; - let other_manager = [ + let other_lock = [ "poetry.lock", + "poetry.toml", "pdm.lock", ".pdm-python", "Pipfile", @@ -527,6 +537,11 @@ async fn uv_project_environment_site_packages( ] .iter() .any(|marker| cwd.join(marker).exists()); + // A lockless Poetry project is still Poetry's (`[tool.poetry]`). + let other_manager = other_lock + || read_regular_to_string(&cwd.join("pyproject.toml")) + .await + .is_ok_and(|text| text.contains("[tool.poetry")); let uv_project = cwd.join("uv.lock").is_file() || (cwd.join("pyproject.toml").is_file() && !other_manager); if !uv_project { @@ -2365,6 +2380,37 @@ mod tests { vec![lib.clone()] ); + // With no saved interpreter (or one PDM ignores), PDM picks an + // activated venv or `./.venv` before PEP 582. + let other = tempfile::tempdir().unwrap(); + let active_site = fake_venv(other.path(), "active"); + let active = env_of(&[( + "VIRTUAL_ENV", + other.path().join("active").to_string_lossy().into_owned(), + )]); + assert_eq!( + find_local_venv_site_packages_with(&project, &active).await, + vec![active_site] + ); + let dot_venv = fake_venv(&project, ".venv"); + assert_eq!( + find_local_venv_site_packages_with(&project, &no_env).await, + vec![dot_venv] + ); + std::fs::write(project.join(".pdm-python"), base.display().to_string()).unwrap(); + let ignored = env_of(&[("PDM_IGNORE_SAVED_PYTHON", "1".to_string())]); + assert_eq!( + find_local_venv_site_packages_with(&project, &ignored).await, + vec![fake_venv(&project, ".venv")] + ); + // ...while a saved base interpreter still means PEP 582. + assert_eq!( + find_local_venv_site_packages_with(&project, &no_env).await, + vec![lib.clone()] + ); + std::fs::remove_file(project.join(".pdm-python")).unwrap(); + std::fs::remove_dir_all(project.join(".venv")).unwrap(); + // A uv project with a leftover PDM lock is uv's, not PEP 582. std::fs::write(project.join("uv.lock"), "version = 1\n").unwrap(); assert!(find_local_venv_site_packages_with(&project, &no_env) @@ -2426,6 +2472,29 @@ mod tests { vec![abs_site] ); + // A lockless Poetry project (`[tool.poetry]`, or `poetry.toml`) is + // Poetry's: an ambient UV_PROJECT_ENVIRONMENT does not take it over. + std::fs::write( + project.join("pyproject.toml"), + "[tool.poetry]\nname = \"app\"\n", + ) + .unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &abs_env).await, + vec![stray.clone()] + ); + std::fs::write( + project.join("pyproject.toml"), + "[project]\nname = \"app\"\n", + ) + .unwrap(); + std::fs::write(project.join("poetry.toml"), "").unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &abs_env).await, + vec![stray.clone()] + ); + std::fs::remove_file(project.join("poetry.toml")).unwrap(); + // ...but not a project another manager drives, nor a non-project. std::fs::write(project.join("poetry.lock"), "").unwrap(); assert_eq!( diff --git a/docs/testing/pdm-compatibility.md b/docs/testing/pdm-compatibility.md index 3f2c0f252..c265b5599 100644 --- a/docs/testing/pdm-compatibility.md +++ b/docs/testing/pdm-compatibility.md @@ -74,8 +74,8 @@ Measured details: `.pdm.toml` on older PDM), ahead of an activated venv or a stray `./.venv`. A project with `uv.lock` or `poetry.lock` is not treated as PDM's. That covers an out-of-tree venv (`venv.in_project = false`) and one bound with `pdm use `. When - the interpreter is a base Python, or a PDM 0.x/1.x project saved none, the - env is `__pypackages__//lib` (PEP 582; PDM 2.x under + the interpreter is a base Python, or a PDM 0.x/1.x project saved none and + has no activated or in-project venv, the env is `__pypackages__//lib` (PEP 582; PDM 2.x under `python.use_venv = false`). Agent mode patches it there, and the hosted stale-install warning and `vex` check it. - **A non-default lock filename (`pdm lock -L custom.lock`) is invisible** to the From 2a22d4eec0e2491d8ccc853ea6783418f2e731cf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:36:04 +0000 Subject: [PATCH 08/12] Keep lockless PDM projects out of the uv probe A PDM project with no lock and no saved interpreter ([tool.pdm] or .pdm.toml) is still PDM's, so an ambient UV_PROJECT_ENVIRONMENT no longer claims it before the PEP 582 fallback; uv.lock still wins. A [tool.pdm.build] table alone (the pdm-backend build backend) no longer marks a project as PDM-managed. Found in review. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/python_crawler.rs | 64 +++++++++++++++++-- 1 file changed, 60 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 dabf376c9..ed0072e9b 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -494,7 +494,9 @@ async fn pdm_saved_interpreter(cwd: &Path) -> Option { } /// Whether `cwd` is a PDM project: `pdm.lock`, `.pdm.toml`, or a -/// `[tool.pdm]` table in `pyproject.toml`. +/// `[tool.pdm]` table in `pyproject.toml` with settings beyond `build` (a +/// `[tool.pdm.build]` table alone only configures the pdm-backend build +/// backend, which projects driven by other managers use too). async fn is_pdm_project(cwd: &Path) -> bool { if cwd.join("pdm.lock").is_file() || cwd.join(".pdm.toml").is_file() { return true; @@ -504,7 +506,10 @@ async fn is_pdm_project(cwd: &Path) -> bool { }; text.parse::() .ok() - .and_then(|doc| doc.get("tool")?.get("pdm").map(|_| ())) + .and_then(|doc| { + let pdm = doc.get("tool")?.get("pdm")?.as_table_like()?; + pdm.iter().any(|(key, _)| key != "build").then_some(()) + }) .is_some() } @@ -537,8 +542,9 @@ async fn uv_project_environment_site_packages( ] .iter() .any(|marker| cwd.join(marker).exists()); - // A lockless Poetry project is still Poetry's (`[tool.poetry]`). + // A lockless Poetry (`[tool.poetry]`) or PDM project is still theirs. let other_manager = other_lock + || is_pdm_project(cwd).await || read_regular_to_string(&cwd.join("pyproject.toml")) .await .is_ok_and(|text| text.contains("[tool.poetry")); @@ -2411,6 +2417,30 @@ mod tests { std::fs::remove_file(project.join(".pdm-python")).unwrap(); std::fs::remove_dir_all(project.join(".venv")).unwrap(); + // A lockless PDM project's `__pypackages__` is not handed to an + // ambient UV_PROJECT_ENVIRONMENT. + std::fs::remove_file(project.join("pdm.lock")).unwrap(); + std::fs::write( + project.join("pyproject.toml"), + "[project]\nname = \"demo\"\n[tool.pdm]\ndistribution = false\n", + ) + .unwrap(); + fake_venv(&tmp.path().join("uv-env"), "venv"); + let uv_env = env_of(&[( + "UV_PROJECT_ENVIRONMENT", + tmp.path().join("uv-env").join("venv").to_string_lossy().into_owned(), + )]); + assert_eq!( + find_local_venv_site_packages_with(&project, &uv_env).await, + vec![lib.clone()] + ); + std::fs::write( + project.join("pyproject.toml"), + "[project]\nname = \"demo\"\n", + ) + .unwrap(); + std::fs::write(project.join("pdm.lock"), "[metadata]\n").unwrap(); + // A uv project with a leftover PDM lock is uv's, not PEP 582. std::fs::write(project.join("uv.lock"), "version = 1\n").unwrap(); assert!(find_local_venv_site_packages_with(&project, &no_env) @@ -2469,7 +2499,7 @@ mod tests { std::fs::remove_file(project.join("uv.lock")).unwrap(); assert_eq!( find_local_venv_site_packages_with(&project, &abs_env).await, - vec![abs_site] + vec![abs_site.clone()] ); // A lockless Poetry project (`[tool.poetry]`, or `poetry.toml`) is @@ -2495,6 +2525,32 @@ mod tests { ); std::fs::remove_file(project.join("poetry.toml")).unwrap(); + // A lockless PDM project is PDM's, while a `[tool.pdm.build]` table + // alone (the pdm-backend build backend) does not make one. + std::fs::write( + project.join("pyproject.toml"), + "[project]\nname = \"app\"\n[tool.pdm]\ndistribution = false\n", + ) + .unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &abs_env).await, + vec![stray.clone()] + ); + std::fs::write( + project.join("pyproject.toml"), + "[project]\nname = \"app\"\n[tool.pdm.build]\nincludes = [\"app\"]\n", + ) + .unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &abs_env).await, + vec![abs_site.clone()] + ); + std::fs::write( + project.join("pyproject.toml"), + "[project]\nname = \"app\"\n", + ) + .unwrap(); + // ...but not a project another manager drives, nor a non-project. std::fs::write(project.join("poetry.lock"), "").unwrap(); assert_eq!( From c1710a974bbe90b0acc1c0b94c14579362f03351 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:38:38 +0000 Subject: [PATCH 09/12] Honor PDM_IGNORE_ACTIVE_VENV in env discovery PDM skips an activated venv when PDM_IGNORE_ACTIVE_VENV is set (CI, tox), so for a PDM project the VIRTUAL_ENV probe now does too, and the project venv or __pypackages__ decides. Found by Bugbot. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/python_crawler.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index ed0072e9b..541469444 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -355,8 +355,11 @@ async fn find_local_venv_site_packages_with( // 1. Check VIRTUAL_ENV env var. Pipenv ignores it under `PIPENV_ACTIVE` // (a `pipenv shell` started in another project) and // `PIPENV_IGNORE_VIRTUALENVS`, so for a Pipenv project the activated venv - // then belongs to something else and must not be patched. - if !pipenv || pipenv_uses_virtual_env(var) { + // then belongs to something else and must not be patched. PDM likewise + // skips an activated venv under `PDM_IGNORE_ACTIVE_VENV`. + let pdm_ignores_active = var("PDM_IGNORE_ACTIVE_VENV").is_some_and(|v| !v.is_empty()) + && pdm_drives_project(cwd).await; + if (!pipenv || pipenv_uses_virtual_env(var)) && !pdm_ignores_active { if let Some(virtual_env) = var("VIRTUAL_ENV") { let venv_path = PathBuf::from(&virtual_env); let matches = find_site_packages_under(&venv_path, "site-packages").await; @@ -2398,6 +2401,18 @@ mod tests { find_local_venv_site_packages_with(&project, &active).await, vec![active_site] ); + // ...unless PDM_IGNORE_ACTIVE_VENV tells PDM to skip it. + let opted_out = env_of(&[ + ( + "VIRTUAL_ENV", + other.path().join("active").to_string_lossy().into_owned(), + ), + ("PDM_IGNORE_ACTIVE_VENV", "1".to_string()), + ]); + assert_eq!( + find_local_venv_site_packages_with(&project, &opted_out).await, + vec![lib.clone()] + ); let dot_venv = fake_venv(&project, ".venv"); assert_eq!( find_local_venv_site_packages_with(&project, &no_env).await, From aabaf5b59cca24ca7d107fe437b69baf81aa5b0e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:00:41 +0000 Subject: [PATCH 10/12] Parse PDM boolean env settings like PDM does PDM treats 0, false and no (any case) as false for PDM_IGNORE_ACTIVE_VENV and PDM_IGNORE_SAVED_PYTHON. Discovery treated any non-empty value as true, so PDM_IGNORE_ACTIVE_VENV=0 skipped the activated venv PDM uses. Found in review. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/python_crawler.rs | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index 4ee532c7b..f472bb163 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -365,7 +365,7 @@ async fn find_local_venv_site_packages_with( // it once `poetry env use` recorded an env for the project (see // [`poetry_active_prefix`]). PDM likewise skips an activated venv under // `PDM_IGNORE_ACTIVE_VENV`. - let pdm_ignores_active = var("PDM_IGNORE_ACTIVE_VENV").is_some_and(|v| !v.is_empty()) + let pdm_ignores_active = pdm_env_flag(var, "PDM_IGNORE_ACTIVE_VENV") && pdm_drives_project(cwd).await; let active_prefix = match &poetry { Some(project) => poetry_active_prefix(project, var), @@ -462,7 +462,7 @@ async fn pdm_project_site_packages( let overridden = var("PDM_PYTHON") .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()); - let ignore_saved = var("PDM_IGNORE_SAVED_PYTHON").is_some_and(|v| !v.is_empty()); + let ignore_saved = pdm_env_flag(var, "PDM_IGNORE_SAVED_PYTHON"); let interpreter = match overridden { Some(python) => Some(cwd.join(python)), None if ignore_saved => None, @@ -475,6 +475,14 @@ async fn pdm_project_site_packages( (!found.is_empty()).then_some(found) } +/// A boolean PDM environment setting, parsed like PDM's `ensure_boolean`: +/// set and non-empty, and not `false` / `no` / `0` (any case). +fn pdm_env_flag(var: &impl Fn(&str) -> Option, name: &str) -> bool { + var(name).is_some_and(|v| { + !v.is_empty() && !matches!(v.to_ascii_lowercase().as_str(), "false" | "no" | "0") + }) +} + /// Whether PDM installs the project at `cwd`: a PDM project (see /// [`is_pdm_project`], or a `.pdm-python`) with no `uv.lock` or /// `poetry.lock`, which drive installs ahead of `pdm.lock` (the hosted @@ -2553,6 +2561,15 @@ mod tests { find_local_venv_site_packages_with(&project, &ignored).await, vec![stray.clone()] ); + // ...a boolean PDM parses: false values keep `.pdm-python`. + for falsy in ["0", "false", "NO"] { + let kept = env_of(&[("PDM_IGNORE_SAVED_PYTHON", falsy.to_string())]); + assert_eq!( + find_local_venv_site_packages_with(&project, &kept).await, + vec![pdm_site.clone()], + "PDM_IGNORE_SAVED_PYTHON={falsy:?}" + ); + } // `PDM_PYTHON` outranks `.pdm-python`. let (ci_python, ci_site) = fake_venv_root(&tmp.path().join("ci-venv")); @@ -2644,7 +2661,7 @@ mod tests { )]); assert_eq!( find_local_venv_site_packages_with(&project, &active).await, - vec![active_site] + vec![active_site.clone()] ); // ...unless PDM_IGNORE_ACTIVE_VENV tells PDM to skip it. let opted_out = env_of(&[ @@ -2658,6 +2675,21 @@ mod tests { find_local_venv_site_packages_with(&project, &opted_out).await, vec![lib.clone()] ); + // PDM parses the flag as a boolean: false values keep the venv. + for falsy in ["0", "false", "No", ""] { + let kept = env_of(&[ + ( + "VIRTUAL_ENV", + other.path().join("active").to_string_lossy().into_owned(), + ), + ("PDM_IGNORE_ACTIVE_VENV", falsy.to_string()), + ]); + assert_eq!( + find_local_venv_site_packages_with(&project, &kept).await, + vec![active_site.clone()], + "PDM_IGNORE_ACTIVE_VENV={falsy:?}" + ); + } let dot_venv = fake_venv(&project, ".venv"); assert_eq!( find_local_venv_site_packages_with(&project, &no_env).await, From 8c9d28d9ea839a80f24a2e3ec8507cddc72d76e2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 2 Oct 2026 15:15:44 +0000 Subject: [PATCH 11/12] Fix PDM environment detection when PDM_PYTHON points to base interpreter When PDM_PYTHON is set to a base/system Python (not a venv), the code previously fell back to PEP 582 mode (__pypackages__), which is incorrect. PDM resolves the interpreter and install environment separately: - If the interpreter is already a venv/conda env, PDM installs there - Otherwise, with default python.use_venv=true, PDM uses the project venv - PEP 582 is only used when no interpreter is saved and use_venv=false The fix restructures the logic to: 1. Check PEP 582 only when there's NO interpreter at all 2. Use a venv if the interpreter is detected as one 3. Return None (for generic discovery) if the interpreter is a base Python This allows generic probes to find the actual project venv instead of incorrectly returning stale PEP 582 dirs or missing out-of-tree venvs. --- .../src/crawlers/python_crawler.rs | 35 +++++++++++-------- 1 file changed, 20 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 f472bb163..acff25343 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -365,8 +365,8 @@ async fn find_local_venv_site_packages_with( // it once `poetry env use` recorded an env for the project (see // [`poetry_active_prefix`]). PDM likewise skips an activated venv under // `PDM_IGNORE_ACTIVE_VENV`. - let pdm_ignores_active = pdm_env_flag(var, "PDM_IGNORE_ACTIVE_VENV") - && pdm_drives_project(cwd).await; + let pdm_ignores_active = + pdm_env_flag(var, "PDM_IGNORE_ACTIVE_VENV") && pdm_drives_project(cwd).await; let active_prefix = match &poetry { Some(project) => poetry_active_prefix(project, var), None if pdm_ignores_active => None, @@ -426,13 +426,13 @@ async fn find_local_venv_site_packages_with( /// `cwd`, when that env exists. `None` means the manager records nothing /// (or nothing installed yet), and the generic probes decide. /// -/// - **PDM** installs into the interpreter saved in `.pdm-python` (PDM -/// 2.x; `[python] path` in `.pdm.toml` before that), ahead of an -/// activated venv. That interpreter's venv is the env (an out-of-tree -/// `venv.in_project = false` venv, or one picked with `pdm use`). An -/// interpreter that is not a venv means PEP 582: PDM installs into -/// `__pypackages__//lib`, which PDM 1.x also uses with no saved -/// interpreter at all. +/// - **PDM** picks the interpreter from `PDM_PYTHON` or `.pdm-python` (PDM +/// 2.x; `[python] path` in `.pdm.toml` before that). If that interpreter +/// is already a venv (including conda), PDM installs there. Otherwise, +/// with the default `python.use_venv = true`, PDM still creates or uses +/// the project venv (`.venv` or an out-of-tree location). PEP 582 +/// (`__pypackages__//lib`) is only used when no interpreter is +/// saved and `use_venv = false`. /// - **uv** syncs a project into `UV_PROJECT_ENVIRONMENT` (absolute, or /// relative to the project) instead of `./.venv`, and ignores an /// activated `VIRTUAL_ENV` for project commands. @@ -468,9 +468,12 @@ async fn pdm_project_site_packages( None if ignore_saved => None, None => pdm_saved_interpreter(cwd).await, }; - let found = match venv_root_of_interpreter(&interpreter?) { - Some(root) => find_site_packages_under(&root, "site-packages").await, + let found = match interpreter { None => pdm_pep582_dirs(cwd).await, + Some(ref path) => match venv_root_of_interpreter(path) { + Some(root) => find_site_packages_under(&root, "site-packages").await, + None => return None, + }, }; (!found.is_empty()).then_some(found) } @@ -506,9 +509,7 @@ async fn pdm_saved_interpreter(cwd: &Path) -> Option { let saved = match read_regular_to_string(&cwd.join(".pdm-python")).await { Ok(text) => text.trim().to_string(), Err(_) => { - let text = read_regular_to_string(&cwd.join(".pdm.toml")) - .await - .ok()?; + let text = read_regular_to_string(&cwd.join(".pdm.toml")).await.ok()?; let doc = text.parse::().ok()?; doc.get("python")?.get("path")?.as_str()?.trim().to_string() } @@ -2720,7 +2721,11 @@ mod tests { fake_venv(&tmp.path().join("uv-env"), "venv"); let uv_env = env_of(&[( "UV_PROJECT_ENVIRONMENT", - tmp.path().join("uv-env").join("venv").to_string_lossy().into_owned(), + tmp.path() + .join("uv-env") + .join("venv") + .to_string_lossy() + .into_owned(), )]); assert_eq!( find_local_venv_site_packages_with(&project, &uv_env).await, From ca62351bca2e9e57cabb29b9cc11e16ad88db68e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:16:45 +0000 Subject: [PATCH 12/12] Treat base PDM interpreters by PDM's use_venv A base interpreter in PDM_PYTHON (CI's system Python) no longer overrides the saved venv: the first recorded interpreter that is an environment wins, and conda envs (conda-meta/) count as environments. A base interpreter means PEP 582 only when python.use_venv is off (PDM_USE_VENV, or pdm.toml / .pdm.toml; off by default only on PDM 1.x). Otherwise the venv probes decide. Found by Bugbot. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/in_process_python_envs.rs | 4 +- .../tests/in_process_redirect_pdm.rs | 1 + .../src/crawlers/python_crawler.rs | 133 +++++++++++++++--- docs/testing/pdm-compatibility.md | 16 ++- 4 files changed, 127 insertions(+), 27 deletions(-) diff --git a/crates/socket-patch-cli/tests/in_process_python_envs.rs b/crates/socket-patch-cli/tests/in_process_python_envs.rs index 6ed8c23e8..f9deaa42d 100644 --- a/crates/socket-patch-cli/tests/in_process_python_envs.rs +++ b/crates/socket-patch-cli/tests/in_process_python_envs.rs @@ -704,9 +704,11 @@ async fn pdm_pep582_pypackages_is_scanned_not_a_stray_dot_venv() { let lib = project.join("__pypackages__").join("3.11").join("lib"); std::fs::create_dir_all(&lib).unwrap(); write_dist_info(&lib, "pep582_pkg", "1.0.0"); - // The saved interpreter is a base Python, not a venv. + // The saved interpreter is a base Python, not a venv, and the project + // turned PDM's venvs off (`pdm config -l python.use_venv false`). let base = tmp.path().join("usr").join("bin").join("python3.11"); std::fs::write(project.join(".pdm-python"), base.display().to_string()).unwrap(); + std::fs::write(project.join("pdm.toml"), "[python]\nuse_venv = false\n").unwrap(); assert_scan_finds(&project, "pkg:pypi/pep582-pkg@1.0.0").await; } diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs index 4b380a52c..c7adfe131 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs @@ -672,6 +672,7 @@ async fn pdm_recorded_env_is_probed_for_stale_hosted_installs() { let site = if pep582 { let base = tmp.path().join("usr").join("bin").join("python3.11"); std::fs::write(root.join(".pdm-python"), base.display().to_string()).unwrap(); + std::fs::write(root.join("pdm.toml"), "[python]\nuse_venv = false\n").unwrap(); root.join("__pypackages__").join("3.11").join("lib") } else { let venv = tmp.path().join("pdm-venvs").join("app-AbCd-3.12"); diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index f472bb163..dfdee4f52 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -458,23 +458,57 @@ async fn pdm_project_site_packages( if !pdm_drives_project(cwd).await { return None; } - // `PDM_PYTHON` outranks the saved interpreter. + // `PDM_PYTHON` outranks the saved interpreter. The first of them that + // is an environment (a venv, or a conda env PDM reuses) is where PDM + // installs; a base interpreter (CI often points `PDM_PYTHON` at the + // system Python) only picks the Python a venv is made from. let overridden = var("PDM_PYTHON") .map(|v| v.trim().to_string()) - .filter(|v| !v.is_empty()); - let ignore_saved = pdm_env_flag(var, "PDM_IGNORE_SAVED_PYTHON"); - let interpreter = match overridden { - Some(python) => Some(cwd.join(python)), - None if ignore_saved => None, - None => pdm_saved_interpreter(cwd).await, - }; - let found = match venv_root_of_interpreter(&interpreter?) { - Some(root) => find_site_packages_under(&root, "site-packages").await, - None => pdm_pep582_dirs(cwd).await, + .filter(|v| !v.is_empty()) + .map(|python| cwd.join(python)); + let saved = if pdm_env_flag(var, "PDM_IGNORE_SAVED_PYTHON") { + None + } else { + pdm_saved_interpreter(cwd).await }; + let interpreters: Vec = overridden.into_iter().chain(saved).collect(); + if let Some(root) = interpreters.iter().find_map(|i| env_root_of_interpreter(i)) { + let found = find_site_packages_under(&root, "site-packages").await; + return (!found.is_empty()).then_some(found); + } + // A base interpreter means PEP 582 only with `python.use_venv` off; + // with it on (PDM 2.x's default) PDM uses a venv the generic probes + // find, with `__pypackages__` as their last resort. + if interpreters.is_empty() || pdm_uses_venv(cwd, var).await { + return None; + } + let found = pdm_pep582_dirs(cwd).await; (!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. +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 { + continue; + }; + let setting = text + .parse::() + .ok() + .and_then(|doc| doc.get("python")?.get("use_venv")?.as_bool()); + if let Some(on) = setting { + return on; + } + } + !cwd.join(".pdm.toml").is_file() || cwd.join(".pdm-python").is_file() +} + /// A boolean PDM environment setting, parsed like PDM's `ensure_boolean`: /// set and non-empty, and not `false` / `no` / `0` (any case). fn pdm_env_flag(var: &impl Fn(&str) -> Option, name: &str) -> bool { @@ -536,14 +570,22 @@ async fn is_pdm_project(cwd: &Path) -> bool { .is_some() } -/// The venv a Python interpreter path belongs to: `/bin/python…` or -/// `\Scripts\python.exe` with a `/pyvenv.cfg`. The path is not -/// resolved, since a venv's interpreter is a symlink to its base Python. -fn venv_root_of_interpreter(python: &Path) -> Option { - let root = python.parent()?.parent()?; - root.join("pyvenv.cfg") - .is_file() - .then(|| root.to_path_buf()) +/// The environment a Python interpreter path belongs to: a venv +/// (`/bin/python…` or `\Scripts\python.exe` with a +/// `/pyvenv.cfg`), or a conda env (`conda-meta/` at ``, whose +/// interpreter is `/bin/python…` or `\python.exe`). The path is +/// not resolved, since a venv's interpreter is a symlink to its base Python. +fn env_root_of_interpreter(python: &Path) -> Option { + let parent = python.parent()?; + let grandparent = parent.parent(); + if let Some(root) = grandparent.filter(|root| root.join("pyvenv.cfg").is_file()) { + return Some(root.to_path_buf()); + } + grandparent + .into_iter() + .chain(std::iter::once(parent)) + .find(|root| root.join("conda-meta").is_dir()) + .map(Path::to_path_buf) } /// uv's `UV_PROJECT_ENVIRONMENT` for a uv project at `cwd` (see @@ -2571,6 +2613,30 @@ mod tests { ); } + // A base `PDM_PYTHON` (CI's system Python) does not displace the + // saved venv PDM installs into. + let system = tmp.path().join("usr").join("bin").join("python3"); + let base_override = env_of(&[("PDM_PYTHON", system.to_string_lossy().into_owned())]); + assert_eq!( + find_local_venv_site_packages_with(&project, &base_override).await, + vec![pdm_site.clone()] + ); + + // A conda env PDM reuses (`conda-meta/`, no pyvenv.cfg) is an env. + let conda = tmp.path().join("conda").join("envs").join("app"); + let conda_site = fake_venv(conda.parent().unwrap(), "app"); + std::fs::create_dir_all(conda.join("conda-meta")).unwrap(); + let conda_python = if cfg!(windows) { + conda.join("python.exe") + } else { + conda.join("bin").join("python") + }; + let conda_env = env_of(&[("PDM_PYTHON", conda_python.to_string_lossy().into_owned())]); + assert_eq!( + find_local_venv_site_packages_with(&project, &conda_env).await, + vec![conda_site] + ); + // `PDM_PYTHON` outranks `.pdm-python`. let (ci_python, ci_site) = fake_venv_root(&tmp.path().join("ci-venv")); let pinned = env_of(&[("PDM_PYTHON", ci_python.to_string_lossy().into_owned())]); @@ -2634,15 +2700,42 @@ mod tests { ) .unwrap(); std::fs::write(project.join("pdm.lock"), "[metadata]\n").unwrap(); - // A base interpreter: no pyvenv.cfg next to it. + // A base interpreter (no pyvenv.cfg next to it) with + // `pdm config -l python.use_venv false`, as in #528. let base = tmp.path().join("usr").join("bin").join("python3.11"); std::fs::create_dir_all(base.parent().unwrap()).unwrap(); std::fs::write(project.join(".pdm-python"), base.display().to_string()).unwrap(); + std::fs::write(project.join("pdm.toml"), "[python]\nuse_venv = false\n").unwrap(); let no_env = env_of(&[]); assert_eq!( find_local_venv_site_packages_with(&project, &no_env).await, vec![lib.clone()] ); + // ...even beside a `./.venv` PDM does not use. + let unused = fake_venv(&project, ".venv"); + assert_eq!( + find_local_venv_site_packages_with(&project, &no_env).await, + vec![lib.clone()] + ); + // With `use_venv` on (PDM 2.x's default, or PDM_USE_VENV), a base + // interpreter only seeds the venv: `./.venv` is the env. + std::fs::remove_file(project.join("pdm.toml")).unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &no_env).await, + vec![unused.clone()] + ); + let env_off = env_of(&[("PDM_USE_VENV", "0".to_string())]); + assert_eq!( + find_local_venv_site_packages_with(&project, &env_off).await, + vec![lib.clone()] + ); + std::fs::write(project.join("pdm.toml"), "[python]\nuse_venv = false\n").unwrap(); + let env_on = env_of(&[("PDM_USE_VENV", "1".to_string())]); + assert_eq!( + find_local_venv_site_packages_with(&project, &env_on).await, + vec![unused] + ); + std::fs::remove_dir_all(project.join(".venv")).unwrap(); // PDM 1.x: no saved interpreter, still a PDM project. std::fs::remove_file(project.join(".pdm-python")).unwrap(); diff --git a/docs/testing/pdm-compatibility.md b/docs/testing/pdm-compatibility.md index c265b5599..99f57e19c 100644 --- a/docs/testing/pdm-compatibility.md +++ b/docs/testing/pdm-compatibility.md @@ -72,12 +72,16 @@ Measured details: - **The installed env is the one PDM records.** The crawler follows the interpreter in `PDM_PYTHON`, else `.pdm-python` (`[python] path` in `.pdm.toml` on older PDM), ahead of an activated venv or a stray `./.venv`. - A project with `uv.lock` or `poetry.lock` is not treated as PDM's. That covers an out-of-tree - venv (`venv.in_project = false`) and one bound with `pdm use `. When - the interpreter is a base Python, or a PDM 0.x/1.x project saved none and - has no activated or in-project venv, the env is `__pypackages__//lib` (PEP 582; PDM 2.x under - `python.use_venv = false`). Agent mode patches it there, and the hosted - stale-install warning and `vex` check it. + The first of them that is an environment wins: a venv (an out-of-tree + `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 + 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. - **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),