Skip to content

Commit 54bc27f

Browse files
Fix Pipenv project falling back to system Python (#504, #947) (#950)
* Start fix for #504, #947 Assisted-by: Claude Code:claude-opus-5-5 * Test Pipenv crawl never reads the system Python A Pipenv project with no Pipenv venv yet must not have the OS Python's site-packages crawled as if they were the project's: agent mode patched them in place (#504), and vendored mode tried to vendor system-only packages into Pipfile.lock and exited 1 (#947). Replace the test that pinned the global fallback for a Pipfile marker with one asserting the opposite, and add CLI scans for agent, hosted and vendored modes. Assisted-by: Claude Code:claude-opus-5-5 * Stop Pipenv projects falling back to system Python When a Pipenv project had no Pipenv venv (a fresh checkout before pipenv install, or a project that only has a plain venv/), scan read the OS Python's site-packages instead. Agent mode then patched the system Python in place and VEX attested the project as fixed (#504); vendored mode tried to vendor system-only packages and failed with a misleading 'run pipenv lock' error (#947). A Pipenv project's env is only ever the one Pipenv resolves, so an empty result there is final. Lock-only packages still come from Pipfile.lock. Fixes #504 Fixes #947 Assisted-by: Claude Code:claude-opus-5-5 * Route Gradle digests through utils::digest main has failed socket-patch-core's lib tests since Gradle support (#646) and the digest helpers (#865) both landed. The guard test production_digests_go_through_the_helpers flags three files #646 added that still hash inline: crawlers/gradle_cache.rs, patch/jvm_jar.rs and patch/sidecars/maven.rs. That breaks test, test-release and coverage on every open PR. Each inline sha1/sha256 call now goes through sha1_hex_of or sha256_hex_of, which compute the same lowercase hex. Behaviour is unchanged. Ported from #878 so CI on this PR runs against a green base; it no-ops once #878 lands on main. Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d1b70a1 commit 54bc27f

3 files changed

Lines changed: 179 additions & 49 deletions

File tree

‎crates/socket-patch-cli/tests/in_process_python_envs.rs‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,106 @@ async fn pipenv_dotenv_settings_pick_the_scanned_venv() {
712712
assert_not_discovered(&batch_bodies(&server).await, "pkg:pypi/pipenv-pkg@1.0.0");
713713
}
714714

715+
/// A Pipenv project at `<tmp>/proj` (Pipfile + Pipfile.lock locking
716+
/// `urllib3 1.26.18`) with no Pipenv venv yet, under a stubbed HOME whose
717+
/// conda root holds `system_decoy 6.6.6` (a package the global crawler
718+
/// would find in the OS Python). Returns `(tmp, project, home)`.
719+
fn pipenv_project_without_venv() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
720+
let tmp = tempfile::tempdir().unwrap();
721+
let project = tmp.path().join("proj");
722+
std::fs::create_dir_all(&project).unwrap();
723+
std::fs::write(
724+
project.join("Pipfile"),
725+
include_str!("../../socket-patch-core/tests/fixtures/pipenv/2026.8.0/Pipfile"),
726+
)
727+
.unwrap();
728+
std::fs::write(
729+
project.join("Pipfile.lock"),
730+
include_str!("../../socket-patch-core/tests/fixtures/pipenv/2026.8.0/Pipfile.lock"),
731+
)
732+
.unwrap();
733+
let home = tmp.path().join("home");
734+
let system = home
735+
.join("anaconda3")
736+
.join("lib")
737+
.join("python3.11")
738+
.join("site-packages");
739+
std::fs::create_dir_all(&system).unwrap();
740+
write_dist_info(&system, "system_decoy", "6.6.6");
741+
std::fs::create_dir_all(tmp.path().join("wh")).unwrap();
742+
(tmp, project, home)
743+
}
744+
745+
/// Run `scan` from a Pipenv project with an empty WORKON_HOME and HOME
746+
/// stubbed to `home`, and return `(exit code, batch bodies)`.
747+
async fn scan_pipenv_without_venv(
748+
project: &Path,
749+
home: &Path,
750+
mode: Option<socket_patch_cli::commands::scan::ScanMode>,
751+
) -> (i32, Vec<String>) {
752+
let server = MockServer::start().await;
753+
mock_batch_empty(&server).await;
754+
let prev_home = std::env::var_os("HOME");
755+
let prev_profile = std::env::var_os("USERPROFILE");
756+
std::env::set_var("HOME", home);
757+
std::env::set_var("USERPROFILE", home);
758+
let workon = project.parent().unwrap().join("wh");
759+
let mut args = default_args(project, server.uri());
760+
args.mode = mode;
761+
let code = scan_with_pipenv_env(args, &[("WORKON_HOME", &workon)]).await;
762+
match prev_home {
763+
Some(v) => std::env::set_var("HOME", v),
764+
None => std::env::remove_var("HOME"),
765+
}
766+
match prev_profile {
767+
Some(v) => std::env::set_var("USERPROFILE", v),
768+
None => std::env::remove_var("USERPROFILE"),
769+
}
770+
(code, batch_bodies(&server).await)
771+
}
772+
773+
/// #504: a Pipenv project with no Pipenv venv has nothing installed for
774+
/// it. A project-scoped scan must not fall back to the OS Python's
775+
/// site-packages (which agent mode would then patch in place), whether or
776+
/// not a `venv/` Pipenv never uses sits in the project.
777+
#[tokio::test]
778+
#[serial]
779+
async fn pipenv_without_a_venv_never_scans_the_system_python() {
780+
for with_stray_venv in [false, true] {
781+
let (_tmp, project, home) = pipenv_project_without_venv();
782+
if with_stray_venv {
783+
let stray = venv_site_packages(&project.join("venv"), "python3.12");
784+
std::fs::create_dir_all(&stray).unwrap();
785+
write_dist_info(&stray, "stray_decoy", "6.6.6");
786+
}
787+
let (code, bodies) = scan_pipenv_without_venv(
788+
&project,
789+
&home,
790+
Some(socket_patch_cli::commands::scan::ScanMode::Agent),
791+
)
792+
.await;
793+
assert_eq!(code, 0, "stray venv/: {with_stray_venv}");
794+
assert_not_discovered(&bodies, "pkg:pypi/system-decoy@6.6.6");
795+
assert_not_discovered(&bodies, "pkg:pypi/stray-decoy@6.6.6");
796+
}
797+
}
798+
799+
/// #947: a vendored (or hosted) scan of a fresh Pipenv checkout takes its
800+
/// candidates from Pipfile.lock alone; a package that exists only in the
801+
/// OS Python is not the project's and must never reach the patch query.
802+
#[tokio::test]
803+
#[serial]
804+
async fn pipenv_fresh_checkout_candidates_come_from_the_lock_only() {
805+
use socket_patch_cli::commands::scan::ScanMode;
806+
for mode in [ScanMode::Vendored, ScanMode::Hosted] {
807+
let (_tmp, project, home) = pipenv_project_without_venv();
808+
let (code, bodies) = scan_pipenv_without_venv(&project, &home, Some(mode)).await;
809+
assert_eq!(code, 0, "{mode:?}");
810+
assert_discovered(&bodies, "pkg:pypi/urllib3@1.26.18");
811+
assert_not_discovered(&bodies, "pkg:pypi/system-decoy@6.6.6");
812+
}
813+
}
814+
715815
// ---------------------------------------------------------------------------
716816
// Package-manager-recorded envs: PDM's saved interpreter / PEP 582, and uv's
717817
// UV_PROJECT_ENVIRONMENT, ahead of a stray `./.venv` the manager never uses

‎crates/socket-patch-core/src/crawlers/python_crawler.rs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3022,7 +3022,8 @@ impl PythonCrawler {
30223022
/// `.venv`, and `venv` directories, then Poetry's and Pipenv's
30233023
/// out-of-tree virtualenvs.
30243024
/// 2. If no venv was found AND the cwd looks like a Python
3025-
/// project (see `is_python_project`), fall through
3025+
/// project (see `is_python_project`) that is not a Pipenv
3026+
/// project (whose env is only ever Pipenv's own), fall through
30263027
/// to `get_global_python_site_packages`. This mirrors the
30273028
/// cargo / ruby / go pattern where a project marker
30283029
/// indicates "scan this ecosystem globally for this project".
@@ -3044,6 +3045,13 @@ impl PythonCrawler {
30443045
if !venv_paths.is_empty() {
30453046
return Ok(venv_paths);
30463047
}
3048+
// A Pipenv project's env is only ever the one Pipenv resolves for it
3049+
// (see `pipenv_project_site_packages`). With none yet, nothing is
3050+
// installed for the project, and its lock-only packages come from
3051+
// `Pipfile.lock`; the OS Python is never its env (#504, #947).
3052+
if is_pipenv_project(&options.cwd) {
3053+
return Ok(Vec::new());
3054+
}
30473055
if is_python_project(&options.cwd).await {
30483056
return Ok(get_global_python_site_packages().await);
30493057
}

‎crates/socket-patch-core/tests/crawler_python_e2e.rs‎

Lines changed: 70 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,59 +1188,81 @@ async fn get_site_packages_paths_falls_back_via_uv_lock_marker() {
11881188
let _ = (result, staged);
11891189
}
11901190

1191-
/// A pipenv-managed project ships `Pipfile`/`Pipfile.lock` and commonly has
1192-
/// NO pyproject.toml / setup.py / requirements.txt — the marker list must
1193-
/// include it or a fresh clone (pipenv keeps its venvs out-of-tree under
1194-
/// `~/.local/share/virtualenvs`) returns zero packages via the no-marker
1195-
/// early-out. The vendor layer already treats `Pipfile.lock` as a
1196-
/// first-class pypi flavor; discovery must agree.
1191+
/// #504 / #947: a Pipenv project's env is the one Pipenv resolves for it
1192+
/// (#388). With no Pipenv venv yet nothing is installed for the project, and
1193+
/// its lock-only packages come from `Pipfile.lock`, so a project-scoped crawl
1194+
/// must return nothing rather than fall back to the global interpreters
1195+
/// (which agent mode would patch in place, and vendored mode would try to
1196+
/// vendor). Holds for a `Pipfile`, a lone `Pipfile.lock`, and a `venv/`
1197+
/// Pipenv never uses.
11971198
#[tokio::test]
11981199
#[serial]
1199-
async fn get_site_packages_paths_falls_back_via_pipfile_marker() {
1200-
let project = tempfile::tempdir().unwrap();
1201-
let home = tempfile::tempdir().unwrap();
1202-
tokio::fs::write(
1203-
project.path().join("Pipfile"),
1204-
b"[packages]\nrequests = \"*\"\n",
1205-
)
1206-
.await
1207-
.unwrap();
1200+
async fn get_site_packages_paths_pipenv_without_venv_never_falls_back_to_global() {
1201+
for (marker, body, stray_venv) in [
1202+
("Pipfile", "[packages]\nsix = \"*\"\n", false),
1203+
(
1204+
"Pipfile.lock",
1205+
"{\"default\": {}, \"develop\": {}}\n",
1206+
false,
1207+
),
1208+
("Pipfile", "[packages]\nsix = \"*\"\n", true),
1209+
] {
1210+
let project = tempfile::tempdir().unwrap();
1211+
let home = tempfile::tempdir().unwrap();
1212+
let workon = tempfile::tempdir().unwrap();
1213+
tokio::fs::write(project.path().join(marker), body)
1214+
.await
1215+
.unwrap();
1216+
if stray_venv {
1217+
let stray = project
1218+
.path()
1219+
.join("venv")
1220+
.join("lib")
1221+
.join("python3.11")
1222+
.join("site-packages");
1223+
tokio::fs::create_dir_all(&stray).await.unwrap();
1224+
}
12081225

1209-
// Stage an anaconda3 layout under the stubbed HOME — scanned by global
1210-
// discovery on every platform, so this test needs no per-OS forks.
1211-
let staged = home
1212-
.path()
1213-
.join("anaconda3")
1214-
.join("lib")
1215-
.join("python3.11")
1216-
.join("site-packages");
1217-
tokio::fs::create_dir_all(&staged).await.unwrap();
1226+
// Stage an anaconda3 layout under the stubbed HOME: global discovery
1227+
// scans it on every platform, so seeing it means the fallback ran.
1228+
let staged = home
1229+
.path()
1230+
.join("anaconda3")
1231+
.join("lib")
1232+
.join("python3.11")
1233+
.join("site-packages");
1234+
tokio::fs::create_dir_all(&staged).await.unwrap();
1235+
1236+
let prev_virtual_env = std::env::var("VIRTUAL_ENV").ok();
1237+
std::env::remove_var("VIRTUAL_ENV");
1238+
let prev_workon = std::env::var("WORKON_HOME").ok();
1239+
std::env::set_var("WORKON_HOME", workon.path());
1240+
let prev_home = std::env::var("HOME").ok();
1241+
std::env::set_var("HOME", home.path());
1242+
let crawler = PythonCrawler;
1243+
let opts = CrawlerOptions {
1244+
cwd: project.path().to_path_buf(),
1245+
global: false,
1246+
global_prefix: None,
1247+
};
1248+
let result = crawler.get_site_packages_paths(&opts).await.unwrap();
1249+
if let Some(v) = prev_home {
1250+
std::env::set_var("HOME", v);
1251+
}
1252+
match prev_workon {
1253+
Some(v) => std::env::set_var("WORKON_HOME", v),
1254+
None => std::env::remove_var("WORKON_HOME"),
1255+
}
1256+
if let Some(v) = prev_virtual_env {
1257+
std::env::set_var("VIRTUAL_ENV", v);
1258+
}
12181259

1219-
let prev_virtual_env = std::env::var("VIRTUAL_ENV").ok();
1220-
std::env::remove_var("VIRTUAL_ENV");
1221-
let prev_home = std::env::var("HOME").ok();
1222-
std::env::set_var("HOME", home.path());
1223-
let crawler = PythonCrawler;
1224-
let opts = CrawlerOptions {
1225-
cwd: project.path().to_path_buf(),
1226-
global: false,
1227-
global_prefix: None,
1228-
};
1229-
let result = crawler.get_site_packages_paths(&opts).await.unwrap();
1230-
if let Some(v) = prev_home {
1231-
std::env::set_var("HOME", v);
1232-
}
1233-
if let Some(v) = prev_virtual_env {
1234-
std::env::set_var("VIRTUAL_ENV", v);
1260+
assert!(
1261+
result.is_empty(),
1262+
"{marker} (stray venv/: {stray_venv}) must not fall back to the \
1263+
global site-packages; got {result:?}"
1264+
);
12351265
}
1236-
1237-
#[cfg(not(windows))]
1238-
assert!(
1239-
result.iter().any(|p| p == &staged),
1240-
"Pipfile marker must trigger global fallback; got {result:?}"
1241-
);
1242-
#[cfg(windows)]
1243-
let _ = (result, staged);
12441266
}
12451267

12461268
/// Without any Python-project marker AND without a venv, local-mode

0 commit comments

Comments
 (0)