From bf68aec52897031a8adfee1c2a8e2f5589edd0a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 12:37:37 +0000 Subject: [PATCH 1/3] Start fix for #842 Assisted-by: Claude Code:claude-opus-5-5 From 5aab282ab8cdcc817e943b69109dafa4c838668e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 12:48:13 +0000 Subject: [PATCH 2/3] Find old Pipenv venv with Pipfile in-project key A Pipfile with `[pipenv] venv_in_project = true` and no ./.venv made discovery conclude that Pipenv had no venv yet. Only Pipenv 2026.2+ reads that key: 2018.11 through 2026.1 keep the venv in WORKON_HOME. On those versions agent scan skipped an installed package as "not installed", leaving the venv unpatched with exit 0, and hosted scan lost its stale-install warning. The "no venv yet" answer now comes only from PIPENV_VENV_IN_PROJECT, which every Pipenv reads. With the Pipfile key alone, the WORKON_HOME venv is still found. Fixes #842 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/in_process_python_envs.rs | 25 +++++++ .../src/crawlers/python_crawler.rs | 66 +++++++++++++++++-- docs/testing/pipenv-compatibility.md | 2 +- 3 files changed, 86 insertions(+), 7 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 9e9f7601f..b6219b8b3 100644 --- a/crates/socket-patch-cli/tests/in_process_python_envs.rs +++ b/crates/socket-patch-cli/tests/in_process_python_envs.rs @@ -662,6 +662,31 @@ async fn pipenv_explicit_not_in_project_still_scans_dot_venv() { } } +/// #842: only Pipenv 2026.2+ reads the Pipfile's `[pipenv] venv_in_project +/// = true`; older Pipenv keeps the venv in WORKON_HOME. With no `./.venv`, +/// scan must still find that venv instead of skipping the installed +/// package as "not installed". +#[tokio::test] +#[serial] +async fn pipenv_pipfile_in_project_without_dot_venv_scans_workon_venv() { + let (_tmp, project, workon) = pipenv_project(); + std::fs::write( + project.join("Pipfile"), + "[packages]\npipenv-pkg = \"*\"\n\n[pipenv]\nvenv_in_project = true\n", + ) + .unwrap(); + let server = MockServer::start().await; + mock_batch_empty(&server).await; + let env: Vec<(&str, &Path)> = vec![ + ("WORKON_HOME", &workon), + ("PIPENV_CUSTOM_VENV_NAME", Path::new("proj-env")), + ]; + let code = scan_with_pipenv_env(default_args(&project, server.uri()), &env).await; + assert_eq!(code, 0); + let bodies = batch_bodies(&server).await; + assert_discovered(&bodies, "pkg:pypi/pipenv-pkg@1.0.0"); +} + /// #546: Pipenv loads the project's `.env` before it picks the venv, so a /// `PIPENV_CUSTOM_VENV_NAME` or `WORKON_HOME` there decides which venv is /// scanned (and `PIPENV_DONT_LOAD_ENV` turns that off). diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index 02558cb2e..34752473a 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -1125,16 +1125,25 @@ fn pipenv_uses_virtual_env(var: &impl Fn(&str) -> Option) -> bool { var("PIPENV_ACTIVE").is_none() && !ignore } +/// The in-project choice `PIPENV_VENV_IN_PROJECT` (or +/// `PIPENV_NO_VENV_IN_PROJECT`) makes, which every Pipenv reads. A +/// non-boolean, non-empty variable counts as "yes". +fn pipenv_env_venv_in_project(var: &impl Fn(&str) -> Option) -> Option { + match pipenv_env_setting(var, "VENV_IN_PROJECT") { + Some(Ok(flag)) => Some(flag), + Some(Err(text)) if !text.is_empty() => Some(true), + _ => None, + } +} + /// An explicit in-project choice for the Pipenv project at `cwd`, if any: /// `PIPENV_VENV_IN_PROJECT` (or `PIPENV_NO_VENV_IN_PROJECT`) first, then /// Pipenv 2026.2+'s Pipfile `[pipenv] venv_in_project`. A non-boolean, /// non-empty variable counts as "yes", as `setting or ` did /// through Pipenv 2026.1. fn pipenv_venv_in_project(cwd: &Path, var: &impl Fn(&str) -> Option) -> Option { - match pipenv_env_setting(var, "VENV_IN_PROJECT") { - Some(Ok(flag)) => return Some(flag), - Some(Err(text)) if !text.is_empty() => return Some(true), - _ => {} + if let Some(flag) = pipenv_env_venv_in_project(var) { + return Some(flag); } // Non-blocking, regular-files-only read: a FIFO `Pipfile` (a lock alone // marks the project) must not wedge discovery. @@ -1156,7 +1165,10 @@ fn pipenv_venv_in_project(cwd: &Path, var: &impl Fn(&str) -> Option) -> /// - No `./.venv` directory: Pipenv's own placement (a `.venv` file pointer /// or `$WORKON_HOME/-`, see /// [`find_pipenv_virtualenv_site_packages`]). An explicit "in project" -/// with no `./.venv` means Pipenv has no venv yet, so nothing. +/// from the environment with no `./.venv` means Pipenv has no venv yet, +/// so nothing. From the Pipfile key alone it means that only on Pipenv +/// 2026.2+; older ones ignore the key, so Pipenv's own placement is +/// still returned (#842). /// - A `./.venv` directory and an explicit "in project": `./.venv` only. /// - A `./.venv` directory and an explicit "not in project": Pipenv /// 2023.11.14+ ignores `./.venv` and uses WORKON_HOME, but 2018.11 @@ -1624,7 +1636,13 @@ async fn pipenv_settings_site_packages( let in_project = pipenv_venv_in_project(cwd, var); let dot_venv = cwd.join(".venv"); if !dot_venv.is_dir() { - if in_project == Some(true) && !dot_venv.exists() { + // Only the environment variable is read by every Pipenv: the + // Pipfile key alone leaves 2018.11 through 2026.1 on WORKON_HOME + // (#842). + if in_project == Some(true) + && !dot_venv.exists() + && pipenv_env_venv_in_project(var) == Some(true) + { return Vec::new(); } return find_pipenv_virtualenv_site_packages_with(cwd, var).await; @@ -5565,6 +5583,42 @@ mod tests { } } + /// #842: only Pipenv 2026.2+ reads the Pipfile's `[pipenv] + /// venv_in_project = true`. With no `./.venv`, 2018.11 through 2026.1 + /// keep the venv in WORKON_HOME, so that venv is still found. The + /// environment variable is read by every Pipenv: with it, no `./.venv` + /// still means Pipenv has no venv yet. + #[tokio::test] + async fn pipenv_pipfile_in_project_without_dot_venv_finds_workon_venv() { + let (_tmp, project, site, var) = pipenv_project_with_workon_venv(&[]); + std::fs::write( + project.join("Pipfile"), + "[packages]\nsix = \"==1.16.0\"\n\n[pipenv]\nvenv_in_project = true\n", + ) + .unwrap(); + assert_eq!( + find_local_venv_site_packages_with(&project, &var).await, + vec![site], + "Pipenv <= 2026.1 ignores the Pipfile key and uses WORKON_HOME" + ); + + for env in [ + ("PIPENV_VENV_IN_PROJECT", "1"), + ("PIPENV_NO_VENV_IN_PROJECT", "0"), + ] { + let (_tmp, project, _site, var) = + pipenv_project_with_workon_venv(&[(env.0, env.1.to_string())]); + assert!( + find_local_venv_site_packages_with(&project, &var) + .await + .is_empty(), + "{}={:?}: every Pipenv reads it, so no ./.venv means no venv", + env.0, + env.1 + ); + } + } + /// #334: with nothing explicit, Pipenv up to 2026.1 uses an existing /// `./.venv` directory, while 2026.2+ prefers a WORKON_HOME venv that /// already exists. The crawler cannot tell the versions apart without diff --git a/docs/testing/pipenv-compatibility.md b/docs/testing/pipenv-compatibility.md index 08e466e87..7f81a3cb9 100644 --- a/docs/testing/pipenv-compatibility.md +++ b/docs/testing/pipenv-compatibility.md @@ -17,7 +17,7 @@ requirements.txt lanes of the same ecosystem. | Input | Hosted | Vendored | Agent | |-------|--------|----------|-------| -| `Pipfile.lock`, `pipfile-spec: 6` (Pipenv 7 and later) | Every category (`default`, `develop`, Pipenv 2022+ named categories) that pins the patched release becomes `{"file" \| "path": "#sha256=", "hashes": ["sha256:"]}` with `markers`/`extras`/`index` kept as Pipenv wrote them and `version` dropped. `path` for Pipenv 7–11, `file` from 2018. `_meta` (the Pipfile content hash) and the Pipfile are untouched. | Every matching category refers to the committed wheel under `.socket/vendor/pypi//`; wheels with extras use `path` (Pipenv 2022's file-URL bug). An exported `requirements.txt` (`pipenv requirements`, or one reached through an in-root `-r` include) that pins the release exactly is wired to the same wheel, as hosted mode rewrites both files (#612). Requires Pipenv 2018 or later (`pypi_pipenv_installer_unsupported`). | Independent of the lock: patches the installed distribution in the venv Pipenv resolves for the project — `VIRTUAL_ENV` unless `PIPENV_ACTIVE` / `PIPENV_IGNORE_VIRTUALENVS` is set, in-project `.venv` subject to `PIPENV_VENV_IN_PROJECT` and the Pipfile's `[pipenv] venv_in_project`, or Pipenv's default `$WORKON_HOME/-[-]`; never `venv/` (discovered without running Pipenv). Settings come from the project's `.env` (or `PIPENV_DOTENV_LOCATION`, unless `PIPENV_DONT_LOAD_ENV`) and the process environment using the concrete current, process-cached, 2018 shell, and 2020 shell profiles described below. With a `.venv` directory and an existing WORKON_HOME venv, each profile retains its native in-project choice: Pipenv 2026.2+ prefers the WORKON_HOME venv when nothing is set, 2023.11.14+ uses it when the project is explicitly not in-project, and older releases use `.venv` either way. | +| `Pipfile.lock`, `pipfile-spec: 6` (Pipenv 7 and later) | Every category (`default`, `develop`, Pipenv 2022+ named categories) that pins the patched release becomes `{"file" \| "path": "#sha256=", "hashes": ["sha256:"]}` with `markers`/`extras`/`index` kept as Pipenv wrote them and `version` dropped. `path` for Pipenv 7–11, `file` from 2018. `_meta` (the Pipfile content hash) and the Pipfile are untouched. | Every matching category refers to the committed wheel under `.socket/vendor/pypi//`; wheels with extras use `path` (Pipenv 2022's file-URL bug). An exported `requirements.txt` (`pipenv requirements`, or one reached through an in-root `-r` include) that pins the release exactly is wired to the same wheel, as hosted mode rewrites both files (#612). Requires Pipenv 2018 or later (`pypi_pipenv_installer_unsupported`). | Independent of the lock: patches the installed distribution in the venv Pipenv resolves for the project — `VIRTUAL_ENV` unless `PIPENV_ACTIVE` / `PIPENV_IGNORE_VIRTUALENVS` is set, in-project `.venv` subject to `PIPENV_VENV_IN_PROJECT` and the Pipfile's `[pipenv] venv_in_project`, or Pipenv's default `$WORKON_HOME/-[-]`; never `venv/` (discovered without running Pipenv). Settings come from the project's `.env` (or `PIPENV_DOTENV_LOCATION`, unless `PIPENV_DONT_LOAD_ENV`) and the process environment using the concrete current, process-cached, 2018 shell, and 2020 shell profiles described below. With a `.venv` directory and an existing WORKON_HOME venv, each profile retains its native in-project choice: Pipenv 2026.2+ prefers the WORKON_HOME venv when nothing is set, 2023.11.14+ uses it when the project is explicitly not in-project, and older releases use `.venv` either way. With no `./.venv`, only `PIPENV_VENV_IN_PROJECT` means "no venv yet" for every release; the Pipfile's `venv_in_project = true` alone still finds the WORKON_HOME venv, which Pipenv up to 2026.1 keeps using because only 2026.2+ reads that key. | | `Pipfile.lock`, `pipfile-spec` < 6 (Pipenv 0–6) | Refused (`redirect_pipenv_skipped`), lock untouched. | Refused (`pypi_pipenv_spec_unsupported`). | Works. | | PEP 751 `pylock.toml` / `pylock..toml` beside the Pipfile (Pipenv 2026, `[pipenv] use_pylock = true`) | With a `Pipfile.lock`, Pipenv installs from it: both locks are redirected. With no `Pipfile.lock`, Pipenv installs from the pylock but keeps only each entry's version and hashes (never an `archive`), so the pin is refused (`redirect_pipenv_pylock_unsupported`) and nothing is written; run `pipenv lock`. | With a `Pipfile.lock`, it is the file wired, and the pylock (which PEP 751 installers such as `uv pip install -r pylock.toml` read) is wired with it to the same wheel (#1368); a symlinked pylock is named in `pypi_multiple_lockfiles` instead. With no `Pipfile.lock`, refused (`pypi_pipenv_pylock_unsupported`), and `vex` does not trust a Socket reference in that pylock. | Works. | | Lock-only checkout (nothing installed) | Discovered from the lock and redirected. | Discovered from the lock; the patched wheel or source distribution is downloaded and verified from the service without a local install. | Nothing to patch (no installed distribution); the lock's pins are listed as lockfile-only packages. | From 41362706a10dae7521aa683d2b06eacc998cb126 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 13:22:27 +0000 Subject: [PATCH 3/3] Keep old Pipenv's WORKON venv for NO_ variable Review follow-up. Only a truthy PIPENV_VENV_IN_PROJECT means every Pipenv uses ./.venv: older releases ignore PIPENV_NO_VENV_IN_PROJECT just as they ignore the Pipfile key, so with no ./.venv the WORKON_HOME venv is now found for that variable too. The test also covers each variable combined with the Pipfile key. Refs #842 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/python_crawler.rs | 88 +++++++++++-------- 1 file changed, 51 insertions(+), 37 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index 34752473a..fa6083566 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -1125,15 +1125,19 @@ fn pipenv_uses_virtual_env(var: &impl Fn(&str) -> Option) -> bool { var("PIPENV_ACTIVE").is_none() && !ignore } -/// The in-project choice `PIPENV_VENV_IN_PROJECT` (or -/// `PIPENV_NO_VENV_IN_PROJECT`) makes, which every Pipenv reads. A -/// non-boolean, non-empty variable counts as "yes". -fn pipenv_env_venv_in_project(var: &impl Fn(&str) -> Option) -> Option { - match pipenv_env_setting(var, "VENV_IN_PROJECT") { - Some(Ok(flag)) => Some(flag), - Some(Err(text)) if !text.is_empty() => Some(true), - _ => None, - } +/// Whether every Pipenv puts the project's venv at `./.venv`: a +/// `PIPENV_VENV_IN_PROJECT` that is neither empty nor a "no" word. 2018.11 +/// through 2020 read it as `bool(value)` and later releases parse it, so +/// only such a value means the same to all of them; only newer releases +/// read `PIPENV_NO_VENV_IN_PROJECT`, and only 2026.2+ the Pipfile key. +fn pipenv_in_project_for_every_version(var: &impl Fn(&str) -> Option) -> bool { + var("PIPENV_VENV_IN_PROJECT").is_some_and(|value| { + !value.is_empty() + && !matches!( + value.to_ascii_lowercase().as_str(), + "0" | "false" | "no" | "off" + ) + }) } /// An explicit in-project choice for the Pipenv project at `cwd`, if any: @@ -1142,8 +1146,10 @@ fn pipenv_env_venv_in_project(var: &impl Fn(&str) -> Option) -> Option` did /// through Pipenv 2026.1. fn pipenv_venv_in_project(cwd: &Path, var: &impl Fn(&str) -> Option) -> Option { - if let Some(flag) = pipenv_env_venv_in_project(var) { - return Some(flag); + match pipenv_env_setting(var, "VENV_IN_PROJECT") { + Some(Ok(flag)) => return Some(flag), + Some(Err(text)) if !text.is_empty() => return Some(true), + _ => {} } // Non-blocking, regular-files-only read: a FIFO `Pipfile` (a lock alone // marks the project) must not wedge discovery. @@ -1164,11 +1170,11 @@ fn pipenv_venv_in_project(cwd: &Path, var: &impl Fn(&str) -> Option) -> /// /// - No `./.venv` directory: Pipenv's own placement (a `.venv` file pointer /// or `$WORKON_HOME/-`, see -/// [`find_pipenv_virtualenv_site_packages`]). An explicit "in project" -/// from the environment with no `./.venv` means Pipenv has no venv yet, -/// so nothing. From the Pipfile key alone it means that only on Pipenv -/// 2026.2+; older ones ignore the key, so Pipenv's own placement is -/// still returned (#842). +/// [`find_pipenv_virtualenv_site_packages`]). A truthy +/// `PIPENV_VENV_IN_PROJECT` with no `./.venv` means Pipenv has no venv +/// yet, so nothing. Any other "in project" (`PIPENV_NO_VENV_IN_PROJECT`, +/// the Pipfile key) is ignored by older releases, so Pipenv's own +/// placement is still returned (#842). /// - A `./.venv` directory and an explicit "in project": `./.venv` only. /// - A `./.venv` directory and an explicit "not in project": Pipenv /// 2023.11.14+ ignores `./.venv` and uses WORKON_HOME, but 2018.11 @@ -1636,13 +1642,10 @@ async fn pipenv_settings_site_packages( let in_project = pipenv_venv_in_project(cwd, var); let dot_venv = cwd.join(".venv"); if !dot_venv.is_dir() { - // Only the environment variable is read by every Pipenv: the + // "No venv yet" only when every Pipenv agrees on `./.venv`: the // Pipfile key alone leaves 2018.11 through 2026.1 on WORKON_HOME // (#842). - if in_project == Some(true) - && !dot_venv.exists() - && pipenv_env_venv_in_project(var) == Some(true) - { + if !dot_venv.exists() && pipenv_in_project_for_every_version(var) { return Vec::new(); } return find_pipenv_virtualenv_site_packages_with(cwd, var).await; @@ -5585,9 +5588,10 @@ mod tests { /// #842: only Pipenv 2026.2+ reads the Pipfile's `[pipenv] /// venv_in_project = true`. With no `./.venv`, 2018.11 through 2026.1 - /// keep the venv in WORKON_HOME, so that venv is still found. The - /// environment variable is read by every Pipenv: with it, no `./.venv` - /// still means Pipenv has no venv yet. + /// keep the venv in WORKON_HOME, so that venv is still found. Every + /// Pipenv reads a truthy `PIPENV_VENV_IN_PROJECT`: with it, no + /// `./.venv` still means Pipenv has no venv yet, key or not. Only newer + /// releases read `PIPENV_NO_VENV_IN_PROJECT`. #[tokio::test] async fn pipenv_pipfile_in_project_without_dot_venv_finds_workon_venv() { let (_tmp, project, site, var) = pipenv_project_with_workon_venv(&[]); @@ -5602,20 +5606,30 @@ mod tests { "Pipenv <= 2026.1 ignores the Pipfile key and uses WORKON_HOME" ); - for env in [ - ("PIPENV_VENV_IN_PROJECT", "1"), - ("PIPENV_NO_VENV_IN_PROJECT", "0"), + for (env, workon_used) in [ + (("PIPENV_VENV_IN_PROJECT", "1"), false), + (("PIPENV_VENV_IN_PROJECT", "yes"), false), + (("PIPENV_NO_VENV_IN_PROJECT", "0"), true), ] { - let (_tmp, project, _site, var) = - pipenv_project_with_workon_venv(&[(env.0, env.1.to_string())]); - assert!( - find_local_venv_site_packages_with(&project, &var) - .await - .is_empty(), - "{}={:?}: every Pipenv reads it, so no ./.venv means no venv", - env.0, - env.1 - ); + for pipfile_key in [false, true] { + let (_tmp, project, site, var) = + pipenv_project_with_workon_venv(&[(env.0, env.1.to_string())]); + if pipfile_key { + std::fs::write( + project.join("Pipfile"), + "[packages]\nsix = \"==1.16.0\"\n\n[pipenv]\nvenv_in_project = true\n", + ) + .unwrap(); + } + let expected = if workon_used { vec![site] } else { Vec::new() }; + assert_eq!( + find_local_venv_site_packages_with(&project, &var).await, + expected, + "{}={:?}, Pipfile key: {pipfile_key}", + env.0, + env.1 + ); + } } }