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..fa6083566 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -1125,6 +1125,21 @@ fn pipenv_uses_virtual_env(var: &impl Fn(&str) -> Option) -> bool { var("PIPENV_ACTIVE").is_none() && !ignore } +/// 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: /// `PIPENV_VENV_IN_PROJECT` (or `PIPENV_NO_VENV_IN_PROJECT`) first, then /// Pipenv 2026.2+'s Pipfile `[pipenv] venv_in_project`. A non-boolean, @@ -1155,8 +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" -/// with no `./.venv` means Pipenv has no venv yet, so nothing. +/// [`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 @@ -1624,7 +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() { - if in_project == Some(true) && !dot_venv.exists() { + // "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 !dot_venv.exists() && pipenv_in_project_for_every_version(var) { return Vec::new(); } return find_pipenv_virtualenv_site_packages_with(cwd, var).await; @@ -5565,6 +5586,53 @@ 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. 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(&[]); + 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, workon_used) in [ + (("PIPENV_VENV_IN_PROJECT", "1"), false), + (("PIPENV_VENV_IN_PROJECT", "yes"), false), + (("PIPENV_NO_VENV_IN_PROJECT", "0"), true), + ] { + 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 + ); + } + } + } + /// #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. |