Repository navigation
Fix Pipenv ignoring pylock patch wiring (#912, #1122) - #1193
Conversation
Assisted-by: Claude Code:claude-opus-5-5
A pylock.toml (or pylock.<name>.toml) beside a Pipfile is read by Pipenv, not by a PEP 751 installer, and every pylock code path treated it as installer-neutral: - Vendored scans of a `use_pylock = true` project (Pipfile, Pipfile.lock and pylock.toml) wired only pylock.toml. Pipenv installs from Pipfile.lock when both exist, so every `pipenv sync` and `install --deploy` got the unpatched release after a "success" run (#1122). The router now wires Pipfile.lock and names the pylock in the pypi_multiple_lockfiles warning. - Hosted and vendored scans of a pylock-only Pipenv checkout wrote an `archive` entry that Pipenv's pylock reader drops, so `pipenv sync` installed the upstream release while the scan reported success, and vendored VEX attested not_affected (#912). Both modes now refuse with a `pipenv lock` pointer (redirect_pipenv_pylock_unsupported / pypi_pipenv_pylock_unsupported), and VEX discovery no longer trusts a Socket reference in such a pylock. The rule lives in one predicate, utils::python_lock::pipenv_reads_pylock, shared by the hosted rewriter, the vendored router and VEX discovery. Assisted-by: Claude Code:claude-opus-5-5
Adds a real-Pipenv 2026 e2e (part of the existing e2e_vex_build pipenv:: legs) for a `[pipenv] use_pylock = true` project: - with Pipfile.lock and pylock.toml, vendored mode wires Pipfile.lock and a fresh `pipenv install --deploy` gets the patched release (#1122); - the pylock-only checkout is refused in hosted and vendored mode and the pylock is left untouched (#912). Documents both cases in the Pipenv compatibility table. Assisted-by: Claude Code:claude-opus-5-5
|
BugBot review Generated by Claude Code |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Pylock refusal poisons sibling confirmation
- Modified pipenv_reads_pylock to check for uv.lock and poetry.lock presence, preventing pylock refusal when higher-priority tool locks govern the project.
Or push these changes by commenting:
@cursor push d597b38e73
Preview (d597b38e73)
diff --git a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs
--- a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs
+++ b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs
@@ -300,8 +300,7 @@
let content = crate::utils::fs::read_regular_to_string_sync(&cargo_toml_path).ok()?;
// Fallback: parse directory name as <name>-<version>
- package_name_version(&content)
- .or_else(|| CargoCrawler::parse_dir_name_version(dir_name))
+ package_name_version(&content).or_else(|| CargoCrawler::parse_dir_name_version(dir_name))
}
impl Default for CargoCrawler {
diff --git a/crates/socket-patch-core/src/formats/mod.rs b/crates/socket-patch-core/src/formats/mod.rs
--- a/crates/socket-patch-core/src/formats/mod.rs
+++ b/crates/socket-patch-core/src/formats/mod.rs
@@ -29,8 +29,8 @@
pub(crate) mod bun;
pub mod cargo;
pub mod composer;
+pub mod gem;
pub mod governing_locks;
-pub mod gem;
pub(crate) mod maven;
pub(crate) mod nuget;
pub mod pnpm;
diff --git a/crates/socket-patch-core/src/hosted/memory/mod.rs b/crates/socket-patch-core/src/hosted/memory/mod.rs
--- a/crates/socket-patch-core/src/hosted/memory/mod.rs
+++ b/crates/socket-patch-core/src/hosted/memory/mod.rs
@@ -66,9 +66,9 @@
classify, lookup_incomplete, mark_pinned, offers_from_results, Offers, RecordedIndex, Row,
Stage, ROLLOUT_DEFERRED,
};
+use crate::utils::purl_key::PurlKey;
use discover::Provider;
use stages::{Planned, RewriteRefused, Rewritten, StageOptions};
-use crate::utils::purl_key::PurlKey;
/// `"<crate version>+<git sha or 'unknown'>"`; the sha comes from the
/// `SOCKET_PATCH_GIT_SHA` build-time variable.
diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs
--- a/crates/socket-patch-core/src/patch/redirect/mod.rs
+++ b/crates/socket-patch-core/src/patch/redirect/mod.rs
@@ -79,9 +79,9 @@
use crate::formats::yarn::source::{classic_copy_source, CopySource};
use crate::formats::yarn::stanzas::{stanza_key, BerryStanzas};
#[cfg(test)]
+mod platform_wheel_tests;
+#[cfg(test)]
mod pnpm_equivalence_tests;
-#[cfg(test)]
-mod platform_wheel_tests;
mod poetry;
mod pypi_takeover;
pub use pypi_takeover::preflight_pypi_takeover;
@@ -567,7 +567,7 @@
bun_lockb_present,
&std::collections::BTreeSet::new(),
&std::collections::BTreeSet::new(),
- &yarnrc::OuterYarnMirror::default(),
+ &yarnrc::OuterYarnMirror::default(),
)
}
@@ -6803,8 +6803,10 @@
// One pass over the pom's repositories: `(id, url)` of each, which also
// answers the per-dep URL-refresh check below while the pom is still
// unchanged (a no-op rescan then never re-scans the pom per dep).
- let original_repos: Vec<(String, Option<String>)> =
- pom.as_deref().map(maven_repository_ids_and_urls).unwrap_or_default();
+ let original_repos: Vec<(String, Option<String>)> = pom
+ .as_deref()
+ .map(maven_repository_ids_and_urls)
+ .unwrap_or_default();
let hosted_repo_generations: std::collections::BTreeSet<String> = original_repos
.iter()
.filter_map(|(id, _)| generation::pin_name_uuid(id, false).map(str::to_string))
@@ -10876,7 +10878,10 @@
&[(YARNRC_REL, "yarn-offline-mirror: false\n")],
&[(YARNRC_REL, "yarn-offline-mirror:\n")],
&[(YARNRC_REL, "yarn-offline-mirror \"\"\n")],
- &[(YARNRC_REL, "yarn-offline-mirror-pruning true\n# yarn-offline-mirror ./m\n")],
+ &[(
+ YARNRC_REL,
+ "yarn-offline-mirror-pruning true\n# yarn-offline-mirror ./m\n",
+ )],
&[(npmrc::NPMRC_REL, "[scope]\nyarn-offline-mirror=./m\n")],
&[
(YARNRC_REL, "yarn-offline-mirror false\n"),
@@ -10892,7 +10897,10 @@
let mut r = RewriteResult::default();
rewrite_yarn_classic(&files, std::slice::from_ref(&ovr), &mut r);
assert!(r.warnings.is_empty(), "{rcs:?}: {:?}", r.warnings);
- assert!(r.files["yarn.lock"].contains("http://p.test/lp.tgz"), "{rcs:?}");
+ assert!(
+ r.files["yarn.lock"].contains("http://p.test/lp.tgz"),
+ "{rcs:?}"
+ );
assert!(r.refused_yarn_classic_uuids.is_empty(), "{rcs:?}");
}
}
@@ -10917,7 +10925,10 @@
rewrite_yarn_classic(&files, std::slice::from_ref(&other), &mut r);
assert!(r.refused_yarn_classic_uuids.is_empty());
assert_eq!(
- r.warnings.iter().map(|w| w.code.as_str()).collect::<Vec<_>>(),
+ r.warnings
+ .iter()
+ .map(|w| w.code.as_str())
+ .collect::<Vec<_>>(),
["redirect_yarn_classic_entry_not_found"]
);
}
diff --git a/crates/socket-patch-core/src/patch/redirect/poetry.rs b/crates/socket-patch-core/src/patch/redirect/poetry.rs
--- a/crates/socket-patch-core/src/patch/redirect/poetry.rs
+++ b/crates/socket-patch-core/src/patch/redirect/poetry.rs
@@ -89,7 +89,9 @@
new: Some(Value::String(new)),
});
}
- result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone());
+ result
+ .confirmed_python_lock_uuids
+ .insert(dep.patch_uuid.clone());
if !stale_warned {
if let Some(format) =
*writer_format.get_or_insert_with(|| pre_1_4_writer(&content))
@@ -124,14 +126,18 @@
}
// Already redirected to this artifact (idempotent re-scan).
LockStep::Unchanged => {
- result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone());
+ result
+ .confirmed_python_lock_uuids
+ .insert(dep.patch_uuid.clone());
}
LockStep::NotFound => result.warnings.push(RewriteWarning {
code: "redirect_poetry_entry_not_found".into(),
detail: format!("no {path} entry for {}@{}", dep.name, dep.version),
}),
LockStep::Refused(detail) => {
- result.refused_python_lock_uuids.insert(dep.patch_uuid.clone());
+ result
+ .refused_python_lock_uuids
+ .insert(dep.patch_uuid.clone());
result.warnings.push(RewriteWarning {
code: "redirect_poetry_lock_unsupported".into(),
detail: format!("{path}: {detail}"),
diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs
--- a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs
+++ b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs
@@ -489,6 +489,9 @@
let other = format!("log = {{ version = \"0.4.20\", registry = \"socket-patch-{C}\" }}\n");
let (outcome, after) = restore_b(&manifest(&other), &config).await;
assert_eq!(outcome.restored().count(), 1, "{:?}", outcome.pins);
- assert_eq!(after.as_deref().map(str::trim_start), Some(block(C).as_str()));
+ assert_eq!(
+ after.as_deref().map(str::trim_start),
+ Some(block(C).as_str())
+ );
}
}
diff --git a/crates/socket-patch-core/src/utils/group_commit.rs b/crates/socket-patch-core/src/utils/group_commit.rs
--- a/crates/socket-patch-core/src/utils/group_commit.rs
+++ b/crates/socket-patch-core/src/utils/group_commit.rs
@@ -1335,7 +1335,10 @@
(".socket/vendor/.gitattributes", true),
(".socket/vendor/gradle/g/a/maven-metadata.xml", true),
(".socket/vendor/gradle/g/a/1/a-1.jar", false),
- (".socket/vendor/gradle/g/a/1/socket-patch.vendor.json", false),
+ (
+ ".socket/vendor/gradle/g/a/1/socket-patch.vendor.json",
+ false,
+ ),
(".socket/vendor/npm/u/left-pad-1.3.0.tgz", false),
(".socket/manifest.json", false),
("packages/a/.socket/vendor/npm/u/a.tgz", false),
@@ -1437,10 +1440,16 @@
if keep {
group.rollback_to(savepoint);
group.commit().await.unwrap();
- assert!(unit.join("a.tgz").exists(), "a rolled-back removal is forgotten");
+ assert!(
+ unit.join("a.tgz").exists(),
+ "a rolled-back removal is forgotten"
+ );
} else {
drop(group);
- assert!(unit.join("a.tgz").exists(), "a dropped group deletes nothing");
+ assert!(
+ unit.join("a.tgz").exists(),
+ "a dropped group deletes nothing"
+ );
}
}
@@ -1449,8 +1458,14 @@
remove_tree_and_prune(&unit, &socket).await.unwrap();
group.commit().await.unwrap();
assert!(!unit.exists());
- assert!(!socket.join("vendor").exists(), "the emptied levels are pruned");
- assert!(socket.join("apply.lock").exists(), "`.socket/` itself stays");
+ assert!(
+ !socket.join("vendor").exists(),
+ "the emptied levels are pruned"
+ );
+ assert!(
+ socket.join("apply.lock").exists(),
+ "`.socket/` itself stays"
+ );
}
/// A journal the commit had to create `.socket/vendor/` for (a hosted
diff --git a/crates/socket-patch-core/src/utils/python_lock.rs b/crates/socket-patch-core/src/utils/python_lock.rs
--- a/crates/socket-patch-core/src/utils/python_lock.rs
+++ b/crates/socket-patch-core/src/utils/python_lock.rs
@@ -186,7 +186,8 @@
/// #912 / #1122: whether Pipenv installs from the PEP 751 lock `rel` (a
/// project-relative path). A `Pipfile` beside the lock makes Pipenv the
/// installer, and Pipenv reads a pylock only when that directory has no
-/// `Pipfile.lock`. Its reader (`PylockFile.convert_to_pipenv_lockfile`)
+/// `Pipfile.lock` and no higher-priority tool lock (`uv.lock` or
+/// `poetry.lock`). Its reader (`PylockFile.convert_to_pipenv_lockfile`)
/// keeps a package's version, marker and wheel / sdist hashes and nothing
/// else, so no hosted or vendored `archive` entry survives it: such a lock
/// can't carry a patch. `exists` answers for project-relative paths.
@@ -198,6 +199,8 @@
is_pep751_lock_name(name)
&& exists(&format!("{dir}Pipfile"))
&& !exists(&format!("{dir}Pipfile.lock"))
+ && !exists(&format!("{dir}uv.lock"))
+ && !exists(&format!("{dir}poetry.lock"))
}
pub fn python_lock_paths(root: &Path) -> std::io::Result<Vec<String>> {
diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs
--- a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs
+++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs
@@ -999,7 +999,10 @@
.filter(|l| !l.starts_with('#'))
.collect::<Vec<_>>()
.join("\n");
- assert!(!headerless.contains("lockfile v1"), "fixture drops the header");
+ assert!(
+ !headerless.contains("lockfile v1"),
+ "fixture drops the header"
+ );
write(tmp.path(), "yarn.lock", &headerless).await;
let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap();
assert_eq!(flavor, NpmLockFlavor::YarnClassic);
diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs
--- a/crates/socket-patch-core/src/vendor/mod.rs
+++ b/crates/socket-patch-core/src/vendor/mod.rs
@@ -129,8 +129,8 @@
};
// The hosted→vendored takeover refuses a berry project the backend would
// refuse BEFORE it reverts the hosted redirect.
+pub use npm_common::npm_tarball_gitignore_preflight;
pub use npm_lock::npm_lock_vendor_preflight;
-pub use npm_common::npm_tarball_gitignore_preflight;
pub use yarn_berry_lock::{yarn_berry_vendor_preflight, yarn_berry_vendor_target_preflight};
use std::collections::{HashMap, HashSet};
diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs
--- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs
+++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs
@@ -46,9 +46,7 @@
use crate::constants::SOCKET_DIR;
use crate::formats::yarn::berry_entry::{manifest_bin, render_pinned_entry, Pin};
use crate::formats::yarn::berry_gates::{self, BerryGate, Yarnrc, SUPPORTED_CACHE_KEY};
-use crate::formats::yarn::blocks::{
- berry_field, block_eol, replace_block, scan_blocks, LockBlock,
-};
+use crate::formats::yarn::blocks::{berry_field, block_eol, replace_block, scan_blocks, LockBlock};
use crate::formats::yarn::patterns::{pattern_real_name, split_berry_key_patterns, split_pattern};
use crate::manifest::schema::PatchRecord;
use crate::patch::apply::{normalize_file_path, PatchSources};
diff --git a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs
--- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs
+++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs
@@ -1512,11 +1512,7 @@
.filter(|&c| c != "vendor_prebuilt_downloaded")
.collect();
assert_eq!(codes, ["vendor_yarn_classic_non_registry_legacy_wiring"]);
- let detail = &warnings
- .iter()
- .find(|w| w.code == codes[0])
- .unwrap()
- .detail;
+ let detail = &warnings.iter().find(|w| w.code == codes[0]).unwrap().detail;
assert!(
detail.contains("host.test/fork")
&& detail.contains("vendor --revert")
diff --git a/crates/socket-patch-core/src/vex/product.rs b/crates/socket-patch-core/src/vex/product.rs
--- a/crates/socket-patch-core/src/vex/product.rs
+++ b/crates/socket-patch-core/src/vex/product.rs
@@ -1425,7 +1425,9 @@
async fn detect_git_remote_handles_non_existent_start_path() {
let dir = tempfile::tempdir().unwrap();
let nonexistent = dir.path().join("does/not/exist");
- assert!(detect_git_remote(&nonexistent, &mut Vec::new()).await.is_none());
+ assert!(detect_git_remote(&nonexistent, &mut Vec::new())
+ .await
+ .is_none());
}
/// B22: inside a submodule (`.git` is a `gitdir:` FILE), the product isYou can send follow-ups to the cloud agent here.
A Pipfile left beside a governing uv.lock, poetry.lock or pdm.lock does not make Pipenv the installer, but the hosted pylock rewriter and VEX discovery still treated the pylock as Pipenv's. The hosted refusal then marked the patch refused for every Python lock, so a successful uv.lock pin was reported as not redirected and dropped from VEX. pipenv_reads_pylock now applies the same tool-lock precedence the vendored router already used, so all three call sites agree. Assisted-by: Claude Code:claude-opus-5-5
|
BugBot review Generated by Claude Code |
Assisted-by: Claude Code:claude-opus-5-5
|
BugBot review Generated by Claude Code |
|
[agent] Generated by Claude Code |
|
[agent] A second check failed before any test ran: Generated by Claude Code |
bootstrap_tool checks for a healthy tool venv and then creates it, with no lock in between. The Pipenv e2e suite now has two tests, and libtest runs them in parallel. On a cold tools cache both miss the probe and race `uv venv` on the same pipenv-<version> directory, so one fails with "a virtual environment already exists". This reproduced in CI and 5/5 times locally from an empty tools root. A process-wide lock around the check-and-create fixes it; warm-cache runs only pay for a --version probe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PzAvnBX6HySvR6zXsFtdy7
|
bugbot run Generated by Claude Code |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 2cb9316. Configure here.
|
Ready for review (burn-down agent) at
Generated by Claude Code |
Final review briefWhat it does. When a
Risk: low. It only fires for a pylock beside a Look here
Verified
Changes I made: none. Open questions (not blocking): a project vendored before this fix in the #1122 layout ( Auto-merge (squash) is armed, so approving sends this straight to the merge queue. Generated by Claude Code |

LLM Description written by Claude Code:claude-opus-5-5
Fixes #912
Fixes #1122
Summary
A
pylock.toml(orpylock.<name>.toml) beside aPipfileis installedby Pipenv, not by a PEP 751 installer. Every pylock code path treated it as
installer-neutral, so both modes could report success while Pipenv installed
the unpatched release.
Shared root cause
Nothing asked whether a
Pipfilebeside the pylock makes Pipenv theinstaller:
use_pylock = trueproject wires only pylock.toml, but Pipenv installs from Pipfile.lock, sopipenv sync/install --deployinstall the unpatched release after a "success" run #1122 (vendored;Pipfile+Pipfile.lock+pylock.toml, theuse_pylock = truelayout): the router invendor/pypi.rs(step 2) sent anystandalone pylock holding the package to the python-lock flavor ahead of
Pipfile.lock. Pipenv installs fromPipfile.lockwhen both exist, so thewiring landed in a file Pipenv ignores.
archiveentry that Pipenv 2026.4+ ignores, sopipenv syncsilently installs the unpatched release from PyPI #912 (hosted and vendored;Pipfile+ pylock only, anypylock_name):the PEP 751 rewriters wrote an
archiveentry. Pipenv'sPylockFile.convert_to_pipenv_lockfilekeeps only version, marker andhashes, so
pipenv syncinstalled the upstream release. Vendoredvexthen attested
not_affected.Fix
The rule is one predicate,
utils::python_lock::pipenv_reads_pylock: a pylockbeside a
Pipfile, with noPipfile.lockand no governing uv/Poetry/PDM lock. Three call sites use it:Pipfile, the pylocks belong to Pipenv.Pipfile.lockpresent,Pipfile.lockis wired (pipenv flavor) andthe pylock is named in the
pypi_multiple_lockfileswarning.Pipfile.lock, the scan refuses withpypi_pipenv_pylock_unsupportedand apipenv lockremedy.redirect_pipenv_pylock_unsupported. Nothing is written, and the uuidlands in
refused_python_lock_uuids, so it is not counted as redirected.With
Pipfile.lockpresent both locks are still pinned, as before.(
DIAG_REF_INVALID), not discovered. Projects vendored before this fix nolonger get a false
not_affected.The Pipenv compatibility doc gets a row for both layouts. The npm, PyPI and
gem wrappers only dispatch to the binary, so they need no change.
Tests (red → green)
patch::redirect::pipenv_pylock_tests::pylock_read_by_pipenv_is_refused_without_pipfile_lock(pylock.toml + pylock.dev.toml)pylock.tomlvendor::pypi::tests::pipenv_pylock_without_pipfile_lock_refusesOk(PythonLocks)vex::discover::pypi_locks::tests::pylock_read_by_pipenv_is_not_trusted(hosted + vendored refs)vendor::pypi::tests::pipenv_pylock_beside_pipfile_lock_routes_to_pipenvPythonLocksvendor::pypi::tests::pipenv_use_pylock_project_vendors_into_pipfile_lock(vendor → Pipfile.lock wired, pylock byte-identical → revert)pylock_without_a_pipenv_consumer_or_beside_pipfile_lock_still_pinspylock_beside_a_governing_tool_lock_is_not_pipenvs(hosted),pylock_beside_a_governing_tool_lock_is_trusted(vex)uv.lockpin; vex diagnosed the refe2e_vex_build pipenv::pipenv_pylock_projects_wire_what_pipenv_installs: pylock-only refused in both modes with the pylock untouched; use_pylock vendored → freshpipenv install --deployinstalls the PATCHED sixCommands run locally:
cargo clippy --workspace --all-features -- -D warnings: clean.cargo test -p socket-patch-core --all-features --lib: 5841 pass. 4 fail, all permission-denial tests in modules this PR doesn't touch (copy_tree,vlt_heal,pypi_poetry/pypi_requirementswrite-failure). They can't fail as root in this sandbox; CI runs unprivileged.cargo test -p socket-patch-cli --all-features --libplus every pypiin_process_*/mode_migration_pypi/hosted_superseding_pypi/policy_pypi_namessuite: all pass exceptpipenv_hosted_to_vendored_names_the_unpatched_requirements. It needs the live PyPI JSON API, which this sandbox could not reach (error sending request for url (https://pypi.org/pypi/six/1.16.0/json)).SOCKET_PATCH_PIPENV_E2E_REQUIRED=1 SOCKET_PATCH_PIPENV_E2E_VERSIONS=2026.8.0 cargo test -p socket-patch-cli --all-features --test e2e_vex_build -- --ignored pipenv::pipenv_pylock: passes.cargo fmt: only this PR's hunks are formatted. Main currently has rustfmt drift under the pinned 1.93.1 toolchain, and CI runs no fmt check, so I left the unrelated files alone.Notes
vex_pipenv_pip_real::bootstrap_toolis now serialized. The new e2e is the Pipenv suite's second test, and on a cold tools cache the two raceduv venvon the same tool venv (failed in CI, reproduced 5/5 locally, 3/3 green after).CHANGELOG.md.Pipfile.lockand the pylock in vendored mode,the way hosted mode does. The pylock is unused by Pipenv while
Pipfile.lockexists, so this PR names it loudly instead.🤖 Generated with Claude Code
Note
Medium Risk
Changes PyPI patch routing, redirect, and VEX trust for Pipenv/pylock projects—incorrect detection could refuse valid pins or miss unsafe ones, but behavior is heavily test-covered.
Overview
Fixes false success when a PEP 751
pylock*.tomlsits next to aPipfile: Pipenv (not a PEP 751 installer) either ignores the pylock whenPipfile.lockexists (#1122) or reads the pylock but drops hosted/vendoredarchiveentries (#912).A shared helper
pipenv_reads_pylockdrives the behavior at three layers: hosted pylock redirect refuses withredirect_pipenv_pylock_unsupportedwhen only pylock + Pipfile exist; vendored PyPI routing wiresPipfile.lockwhen both locks are present (pylock named as extra lockfile) or errors withpypi_pipenv_pylock_unsupportedon pylock-only checkouts; VEX discovery treats Socket refs in those pylocks as invalid instead of attesting upstream installs. Governing uv/poetry/pdm locks still win over a stray Pipfile.Adds unit/integration tests, a Pipenv 2026
use_pylockignored e2e, doc row in pipenv compatibility, and a mutex on test tool bootstraps to avoid paralleluv venvraces.Reviewed by Cursor Bugbot for commit 2cb9316. Configure here.
Generated by Claude Code