Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions crates/socket-patch-cli/tests/in_process_python_envs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
74 changes: 71 additions & 3 deletions crates/socket-patch-core/src/crawlers/python_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1125,6 +1125,21 @@ fn pipenv_uses_virtual_env(var: &impl Fn(&str) -> Option<String>) -> 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<String>) -> 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,
Expand Down Expand Up @@ -1155,8 +1170,11 @@ fn pipenv_venv_in_project(cwd: &Path, var: &impl Fn(&str) -> Option<String>) ->
///
/// - No `./.venv` directory: Pipenv's own placement (a `.venv` file pointer
/// or `$WORKON_HOME/<name>-<hash>`, 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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/testing/pipenv-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<url>#sha256=<hex>", "hashes": ["sha256:<hex>"]}` 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/<uuid>/`; 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/<dir>-<hash>[-<python>]`; 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": "<url>#sha256=<hex>", "hashes": ["sha256:<hex>"]}` 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/<uuid>/`; 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/<dir>-<hash>[-<python>]`; 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.<name>.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. |
Expand Down
Loading