From 20d0726da3319b5db58daa168620cb72b80e533a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:22:00 +0000 Subject: [PATCH 1/5] Start fix for #1368 (Pipenv pylock lane) Assisted-by: Claude Code:claude-opus-5-5 From 2bf13900888e54873bdb4d3f4c590a4ad0a2b303 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:41:46 +0000 Subject: [PATCH 2/5] Vendor a Pipenv pylock.toml with Pipfile.lock With `[pipenv] use_pylock = true`, `pipenv lock` writes a pylock.toml beside Pipfile.lock, and PEP 751 installers (`uv pip install -r pylock.toml`, pip) install from it. Vendored mode wired only Pipfile.lock and named the pylock as an unpatched install source, so `vendor --check` and `vex` reported contested wiring, and re-running vendor changed nothing, so the suggested remedy looped. Vendoring a Pipenv project now also points the pylock's entry for the package at the same committed wheel. The ledger entry records it, revert restores it, a superseding patch re-wires it, and a re-run over an already wired Pipfile.lock wires a pylock made since. A pylock that can't be rewritten in place (a symlink, a non-registry source) is still named by pypi_multiple_lockfiles. Refs #1368 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/e2e_vex_build/pipenv.rs | 14 +- crates/socket-patch-core/src/vendor/pypi.rs | 730 +++++++++++++++--- .../socket-patch-core/src/vendor/pypi_lock.rs | 117 +++ docs/testing/pipenv-compatibility.md | 2 +- 5 files changed, 765 insertions(+), 100 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index b9fb42f18..e9d72bf23 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1432,7 +1432,7 @@ command's integration tests check its output. | `redirect_unconfirmed` | `skipped` (`details.mode: "hosted"`) | hosted `scan` / `get` (v5.0, additive): the patch was granted but no lockfile entry pinning it could be rewritten. The status and exit code are unchanged for now, pending the open hosted exit-policy decision (#704); `--silent` hides the human line. | | `lockfile_unreadable` / `lockfile_unparseable` / `patched_ref_invalid` / `patched_ref_unattributable` | run-level `warnings[]` | vex (every form): lockfile-discovery diagnostics — see "Manifest-less VEX (lockfile discovery)". Never flip the exit on their own. | | `vex_npm_shrinkwrap_only` | run warning and `failed[].reason` | vex (every form, #899): the patch is wired only in a root `npm-shrinkwrap.json` with no `package-lock.json` twin, which npm >= 12 never reads; no statement until the twin exists. `list` / `rollback` / `remove` still manage the wiring. | -| `vendor_multiple_lockfiles` / `pypi_multiple_lockfiles` | `skipped` (warning) | vendor: a sibling lockfile of another package manager (for PyPI, also a root `requirements.txt` that pins the package beside the wired tool lock) will still install UNPATCHED bytes; names the wired winner + the ignored locks. Beside `Pipfile.lock` or `uv.lock`, an exact registry pin of the package in `requirements.txt` or an in-root `-r` include (`pipenv requirements` or `uv export` output) is wired with the lock instead and never named here (#612, #1368): both files then refer to the committed wheel, the ledger entry records both, every revert restores both, a superseding patch re-wires both, and a re-run over a project whose lock is already wired (a vendor from before, or an export made since) wires the export too (`pypi_requirements_sibling_wired`). A pin the requirements wiring cannot rewrite (a range, extras, an include outside the root) stays a loser. | +| `vendor_multiple_lockfiles` / `pypi_multiple_lockfiles` | `skipped` (warning) | vendor: a sibling lockfile of another package manager (for PyPI, also a root `requirements.txt` that pins the package beside the wired tool lock) will still install UNPATCHED bytes; names the wired winner + the ignored locks. Beside `Pipfile.lock` or `uv.lock`, an exact registry pin of the package in `requirements.txt` or an in-root `-r` include (`pipenv requirements` or `uv export` output) is wired with the lock instead and never named here (#612, #1368): both files then refer to the committed wheel, the ledger entry records both, every revert restores both, a superseding patch re-wires both, and a re-run over a project whose lock is already wired (a vendor from before, or an export made since) wires the export too (`pypi_requirements_sibling_wired`). A pin the requirements wiring cannot rewrite (a range, extras, an include outside the root) stays a loser. Likewise, a `pylock.toml` / `pylock..toml` that `pipenv lock` writes beside `Pipfile.lock` under `[pipenv] use_pylock = true` is wired with it (#1368), with the same ledger, revert, supersede and re-run behavior (`pypi_pipenv_pylock_wired`); a pylock the wiring cannot rewrite in place (a symlink, a non-registry source) stays a loser. | | `vendor_npm_shrinkwrap_only` | `skipped` (warning) | vendor / scan / get `--mode vendored` (npm, #899): the package was wired into `npm-shrinkwrap.json`, the only npm lock (also on an in-sync re-run). npm >= 12 never reads the shrinkwrap and installs the unpatched registry bytes; rename the lock to `package-lock.json` (or commit a copy under that name) and re-run. | | `vendor_workspace_member_skipped` | `skipped` (warning) | vendor / scan / get `--mode vendored` (npm, #688): a lock entry with the package's `name@version` is the project's own source (a workspace member or a `file:` directory), so it is left alone while the lock's registry copies are vendored; patch that source directly if it needs the fix. When it is the only instance, vendoring refuses instead. | | `vendor_npm_allow_file` | `skipped` (warning, printed as `Warning (vendor_npm_allow_file)`) | vendor / scan / get `--mode vendored` (package-lock, #969): the effective npm `allow-file` setting (env > project `.npmrc` > user > global > builtin) is not `all`, so npm >= 11.14 refuses the vendored `file:` tarball (EALLOWFILE) on install. The setting is respected, never rewritten; the detail names where it comes from and the remedy (`allow-file=all`). `vendor --check` fails the entry for the same reason. | diff --git a/crates/socket-patch-cli/tests/e2e_vex_build/pipenv.rs b/crates/socket-patch-cli/tests/e2e_vex_build/pipenv.rs index 7d0e39360..c36a1b63d 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_build/pipenv.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_build/pipenv.rs @@ -357,8 +357,10 @@ const PYLOCK_PIPFILE: &str = "[[source]]\nurl = \"https://pypi.org/simple\"\nver /// `pipenv lock` writes both `Pipfile.lock` and `pylock.toml`: /// /// * #1122: vendored wires `Pipfile.lock` (what Pipenv installs from when -/// both exist), leaves the pylock alone, and a fresh -/// `pipenv install --deploy` gets the PATCHED six. +/// both exist), and a fresh `pipenv install --deploy` gets the PATCHED +/// six. +/// * #1368: the pylock, which PEP 751 installers (`uv pip install -r +/// pylock.toml`, pip) read, is wired to the same vendored wheel. /// * #912: the pylock-only checkout (no `Pipfile.lock`) is refused in both /// modes — Pipenv would drop the `archive` entry and install the upstream /// release — and the pylock is left byte-identical. @@ -422,10 +424,10 @@ fn pylock_flow(pipenv: &Pipenv, root: &Path) { lock.contains(&format!(".socket/vendor/pypi/{}/", mode.uuid())), "{what}: Pipfile.lock must point at the vendored wheel: {lock}" ); - assert_eq!( - read(&proj.join("pylock.toml")), - pristine_pylock, - "{what}: the pylock Pipenv ignores is left alone" + let pylock = String::from_utf8(read(&proj.join("pylock.toml"))).unwrap(); + assert!( + pylock.contains(&format!(".socket/vendor/pypi/{}/", mode.uuid())), + "{what}: pylock.toml must point at the vendored wheel too: {pylock}" ); let fresh = root.join("pylock").join("fresh"); copy_tree(&proj, &fresh, &[".venv"]); diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index fcf56cc7e..45cb7f2d5 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -253,8 +253,9 @@ async fn requirements_pins_target(project_root: &Path, target: Option<(&str, &st /// 1. `uv.lock` → uv; /// 2. standalone `pylock*.toml` / `*.py.lock` locks containing this package /// → python-lock — except a pylock beside a `Pipfile`, which Pipenv -/// reads: it loses to `Pipfile.lock` (#1122) and, with no -/// `Pipfile.lock`, refuses `pypi_pipenv_pylock_unsupported` (#912); +/// reads: `Pipfile.lock` governs (#1122) and the pylock is wired with +/// it (#1368), and with no `Pipfile.lock` it refuses +/// `pypi_pipenv_pylock_unsupported` (#912); /// 3. `poetry.lock` → poetry; 4. `pdm.lock` → pdm; 5. `Pipfile.lock` → pipenv; /// 6. lock-less `[tool.uv]`/`[tool.poetry]`/`[tool.pdm]`/`Pipfile` → /// `_no_lockfile` refusal unless requirements.txt exists; @@ -359,7 +360,23 @@ async fn detect_pypi_flavor( ), )); } - present.extend(pipenv_pylocks.iter().map(String::as_str)); + // #1368: a pylock `pipenv lock` wrote beside Pipfile.lock is wired + // with it, so only one the wiring can't rewrite is a loser. + let cowired: Vec = match target { + Some((name, version)) => super::pypi_lock::pipenv_pylocks(project_root, name, version) + .await + .into_iter() + .filter(|(_, state)| *state != super::pypi_lock::PipenvPylock::Unwirable) + .map(|(path, _)| path) + .collect(), + None => Vec::new(), + }; + present.extend( + pipenv_pylocks + .iter() + .filter(|path| !cowired.contains(path)) + .map(String::as_str), + ); } let mut warnings = Vec::new(); let matching_additional_lock = if has_uv_lock { @@ -534,9 +551,15 @@ enum WiringPlan { Pdm(Box), /// The ledger entry of an OLDER patch uuid whose Pipfile.lock wiring the /// guards admitted for an in-place re-wire (#769), if any. - /// The Pipfile.lock plan, the superseded entry it re-wires, and whether - /// the exported requirements file is wired too (#612). - Pipenv(Box, Option>, bool), + /// The Pipfile.lock plan, the superseded entry it re-wires, whether + /// the exported requirements file is wired too (#612), and the Pipenv + /// pylocks wired with it (#1368). + Pipenv( + Box, + Option>, + bool, + Vec, + ), /// The uv, script-lock, Hatch, Poetry or PDM wiring routes this package /// through an OLDER patch uuid's vendored wheel that the ledger still /// records (#742, #650, #1136): replay that entry's revert, then wire @@ -1144,17 +1167,40 @@ async fn pypi_prelude<'p>( .iter() .any(|r| r.kind == super::pypi_requirements::RECORD_KIND) }); + // #1368: a pylock `pipenv lock` wrote beside Pipfile.lock + // (`use_pylock = true`) still pinning the registry release. + let registry_pylocks = + registry_pipenv_pylocks(project_root, &canon_name, version).await; match target { PipenvTarget::InSync => { wired_pin = pipenv_wired_pin(&project.lock, &uuid_dir_rel); - sibling_pending = sibling; + sibling_pending = sibling || !registry_pylocks.is_empty(); WiringPlan::InSync } PipenvTarget::Fresh => { if sibling && dry_run { warnings.push(sibling_dry_run_notice(flavor, &canon_name, version)); } - WiringPlan::Pipenv(Box::new(project), superseded.map(Box::new), sibling) + let superseded_pylocks = superseded.as_ref().map_or(Vec::new(), |prev| { + prev.wiring + .iter() + .filter(|r| super::pypi_lock::is_lock_record(r)) + .map(|r| r.file.clone()) + .collect() + }); + let mut pylocks = registry_pylocks; + pylocks.extend(superseded_pylocks); + pylocks.sort_unstable(); + pylocks.dedup(); + if !pylocks.is_empty() && dry_run { + warnings.push(pylock_dry_run_notice(&pylocks, &canon_name, version)); + } + WiringPlan::Pipenv( + Box::new(project), + superseded.map(Box::new), + sibling, + pylocks, + ) } } } @@ -1299,6 +1345,36 @@ fn sibling_dry_run_notice(flavor: PypiFlavor, canon_name: &str, version: &str) - ) } +/// #1368: the Pipenv pylocks beside Pipfile.lock that still pin +/// `canon_name==version` from the registry, in files the wiring can +/// rewrite. +async fn registry_pipenv_pylocks( + project_root: &Path, + canon_name: &str, + version: &str, +) -> Vec { + super::pypi_lock::pipenv_pylocks(project_root, canon_name, version) + .await + .into_iter() + .filter(|(_, state)| *state == super::pypi_lock::PipenvPylock::Registry) + .map(|(path, _)| path) + .collect() +} + +/// The dry-run preview of wiring the Pipenv pylocks `pylocks` together +/// with Pipfile.lock (#1368). +fn pylock_dry_run_notice(pylocks: &[String], canon_name: &str, version: &str) -> VendorWarning { + VendorWarning::new( + "pypi_pipenv_pylock_wired", + format!( + "would wire the {canon_name}=={version} entry of {} to the vendored wheel together \ + with {}", + pylocks.join(", "), + super::pypi_pipenv::LOCK_FILE + ), + ) +} + /// #612 / #1368 for a Pipenv or uv lock already wired to this patch: wire /// the exported requirements pin to the same committed wheel and extend /// the ledger entry with its records, so the revert restores both files. @@ -1320,36 +1396,64 @@ async fn wire_in_sync_sibling( let Some((lock, export)) = sibling_lock(flavor) else { return done(result, None, warnings); }; + let export_pending = + super::pypi_requirements::sibling_pin_wirable(project_root, canon_name, version).await; + // #1368: a pylock `pipenv lock` wrote beside the wired Pipfile.lock. + let pylocks = if flavor == PypiFlavor::Pipenv { + registry_pipenv_pylocks(project_root, canon_name, version).await + } else { + Vec::new() + }; let unwired = |why: &str| { - VendorWarning::new( - "pypi_multiple_lockfiles", - format!( - "{lock} is wired, but {} still pins {canon_name}=={version} from the \ - registry and {why}; installs driven by it will still install the UNPATCHED \ - registry bytes (re-export it from {lock} with `{export}`)", - crate::formats::governing_locks::PYPI_REQUIREMENTS - ), - ) + let mut unwired = Vec::new(); + if export_pending { + unwired.push(VendorWarning::new( + "pypi_multiple_lockfiles", + format!( + "{lock} is wired, but {} still pins {canon_name}=={version} from the \ + registry and {why}; installs driven by it will still install the \ + UNPATCHED registry bytes (re-export it from {lock} with `{export}`)", + crate::formats::governing_locks::PYPI_REQUIREMENTS + ), + )); + } + if !pylocks.is_empty() { + unwired.push(VendorWarning::new( + "pypi_multiple_lockfiles", + format!( + "{lock} is wired, but {} still pins {canon_name}=={version} from the \ + registry and {why}; installs driven by it will still install the \ + UNPATCHED registry bytes (re-lock with `pipenv lock`)", + pylocks.join(", ") + ), + )); + } + unwired }; let prior = reuse::prior_entry(project_root, "pypi", record, None) .await .ok() .filter(|e| e.flavor.as_deref() == Some(flavor.as_str())); let Some(entry) = prior else { - warnings.push(unwired( + warnings.extend(unwired( "the vendor ledger has no entry to record its wiring in", )); return done(result, None, warnings); }; if dry_run { - warnings.push(VendorWarning::new( - "pypi_requirements_sibling_wired", - format!( - "would wire the exported {} pin of {canon_name}=={version} to the vendored \ - wheel already wired in {lock}", - crate::formats::governing_locks::PYPI_REQUIREMENTS - ), - )); + if export_pending { + warnings.push(VendorWarning::new( + "pypi_requirements_sibling_wired", + format!( + "would wire the exported {} pin of {canon_name}=={version} to the vendored \ + wheel already wired in {lock}", + crate::formats::governing_locks::PYPI_REQUIREMENTS + ), + )); + } + if !pylocks.is_empty() { + warnings.push(pylock_dry_run_notice(&pylocks, canon_name, version)); + } return done(result, None, warnings); } // The ledger is committed and tamper-able: pin only a path that is this @@ -1358,44 +1462,89 @@ async fn wire_in_sync_sibling( let committed = match reuse::verify_committed_artifact(project_root, &entry, record).await { Ok(committed) => committed, Err(miss) => { - warnings.push(unwired(&format!( + warnings.extend(unwired(&format!( "the committed wheel the vendor ledger names did not verify ({miss:?})" ))); return done(result, None, warnings); } }; let mut entry = committed.entry; - match super::pypi_requirements::wire_requirements( - project_root, - canon_name, - version, - &committed.rel_path, - &entry.artifact.sha256, - ) - .await - { - Ok(records) => { - warnings.push(VendorWarning::new( - "pypi_requirements_sibling_wired", - format!( - "wired the exported {} pin of {canon_name}=={version} to the vendored \ - wheel already wired in {lock}", - crate::formats::governing_locks::PYPI_REQUIREMENTS - ), - )); - // A re-export (`pipenv requirements > requirements.txt`) put the - // registry pin back: the records of the earlier wiring of that - // file are superseded by these, not kept beside them. - let files: std::collections::HashSet<&str> = - records.iter().map(|r| r.file.as_str()).collect(); - entry.wiring.retain(|r| { - r.kind != super::pypi_requirements::RECORD_KIND || !files.contains(r.file.as_str()) - }); - entry.wiring.extend(records); - done(result, Some(entry), warnings) - } - Err((code, detail)) => refused(code, detail), + // The pylocks go first: if the export then refuses, they are put back. + let pylock_files: Vec<&str> = pylocks.iter().map(String::as_str).collect(); + let pylocks_before = snapshot_files(project_root, &pylock_files).await; + if !pylocks.is_empty() { + match super::pypi_lock::wire_pipenv_pylocks( + project_root, + pylocks.clone(), + canon_name, + version, + &record.uuid, + &committed.rel_path, + &entry.artifact.sha256, + ) + .await + { + Ok(records) => { + warnings.push(VendorWarning::new( + "pypi_pipenv_pylock_wired", + format!( + "wired the {canon_name}=={version} entry of {} to the vendored wheel \ + already wired in {lock}", + pylocks.join(", ") + ), + )); + // A re-lock put the registry entry back: the records of the + // earlier wiring of that file are superseded by these. + replace_records(&mut entry, records); + } + Err((code, detail)) => return refused(code, detail), + } } + if export_pending { + match super::pypi_requirements::wire_requirements( + project_root, + canon_name, + version, + &committed.rel_path, + &entry.artifact.sha256, + ) + .await + { + Ok(records) => { + warnings.push(VendorWarning::new( + "pypi_requirements_sibling_wired", + format!( + "wired the exported {} pin of {canon_name}=={version} to the vendored \ + wheel already wired in {lock}", + crate::formats::governing_locks::PYPI_REQUIREMENTS + ), + )); + // A re-export (`pipenv requirements > requirements.txt`) put + // the registry pin back: the records of the earlier wiring of + // that file are superseded by these, not kept beside them. + replace_records(&mut entry, records); + } + Err((code, mut detail)) => { + restore_snapshot(project_root, &pylocks_before, &mut detail).await; + return refused(code, detail); + } + } + } + done(result, Some(entry), warnings) +} + +/// Add `records` to `entry`, dropping its records of the same kind for the +/// same files: a re-export or re-lock rewrote them from the registry, so +/// the earlier wiring of those files is superseded, not kept beside it. +fn replace_records(entry: &mut VendorEntry, records: Vec) { + let replaced: std::collections::HashSet<(String, String)> = records + .iter() + .map(|r| (r.kind.clone(), r.file.clone())) + .collect(); + entry + .wiring + .retain(|r| !replaced.contains(&(r.kind.clone(), r.file.clone()))); + entry.wiring.extend(records); } /// Whether a rebuilt wheel reproduces the in-sync pin. An empty pinned @@ -1808,9 +1957,13 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( .await .map(|(wiring, meta)| (wiring, MetaSlot::Pdm(meta))) } - WiringPlan::Pipenv(project, superseded, sibling) => { - let lock_before = if sibling { - snapshot_files(project_root, &[super::pypi_pipenv::LOCK_FILE]).await + WiringPlan::Pipenv(project, superseded, sibling, pylocks) => { + // The files as they are before the wiring, to put back if a + // later file of the set refuses. + let lock_before = if sibling || !pylocks.is_empty() { + let mut files = vec![super::pypi_pipenv::LOCK_FILE]; + files.extend(pylocks.iter().map(String::as_str)); + snapshot_files(project_root, &files).await } else { Vec::new() }; @@ -1826,6 +1979,24 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( superseded.as_deref(), ) .await; + let wired = match wired { + Ok((mut wiring, meta)) if !pylocks.is_empty() => wire_pipenv_pylocks( + project_root, + &lock_before, + superseded.as_deref(), + &canon_name, + version, + &record.uuid, + &rel_wheel, + &artifact.sha256_hex, + ) + .await + .map(|records| { + wiring.extend(records); + (wiring, meta) + }), + other => other, + }; match wired { Ok((mut wiring, meta)) if sibling => wire_sibling_requirements( project_root, @@ -2649,11 +2820,82 @@ async fn wire_sibling_requirements( } } +/// Wire the Pipenv pylocks beside a just-wired Pipfile.lock to the same +/// wheel (#1368). A superseded entry's pylock wiring is reverted first, so +/// its files hold the registry entry again and the new records carry the +/// pre-vendor text forward. On a refusal the files in `lock_before` are +/// put back, so no file of the set is left half wired. +#[allow(clippy::too_many_arguments)] +async fn wire_pipenv_pylocks( + project_root: &Path, + lock_before: &Snapshot, + superseded: Option<&VendorEntry>, + canon_name: &str, + version: &str, + uuid: &str, + rel_wheel: &str, + sha256_hex: &str, +) -> Result, (&'static str, String)> { + let wired = async { + if let Some(prev) = superseded { + let wiring: Vec<_> = prev + .wiring + .iter() + .filter(|r| super::pypi_lock::is_lock_record(r)) + .cloned() + .collect(); + if !wiring.is_empty() { + let pylocks = VendorEntry { + wiring, + ..prev.clone() + }; + let outcome = + super::pypi_lock::revert_python_locks(&pylocks, project_root, false).await; + if !outcome.success || outcome.drift_skipped() { + return Err(( + "pypi_lock_changed", + format!( + "the pylock wiring of patch {} could not be unwound: {}", + prev.uuid, + outcome.error.unwrap_or_else(|| outcome + .warnings + .iter() + .map(|w| w.detail.as_str()) + .collect::>() + .join("; ")) + ), + )); + } + } + } + let pylocks = registry_pipenv_pylocks(project_root, canon_name, version).await; + super::pypi_lock::wire_pipenv_pylocks( + project_root, + pylocks, + canon_name, + version, + uuid, + rel_wheel, + sha256_hex, + ) + .await + } + .await; + match wired { + Ok(records) => Ok(records), + Err((code, mut detail)) => { + restore_snapshot(project_root, lock_before, &mut detail).await; + Err((code, detail)) + } + } +} + /// Revert a Pipenv or uv entry: the exported requirements lines wired with -/// it (#612, #1368) through the requirements backend, then its lock records -/// through the flavor's backend. The export goes first and is put back if -/// the lock's revert then fails, so a failure on either side leaves both -/// files wired to the vendored wheel (and the entry kept) instead of half +/// it (#612, #1368) through the requirements backend, and a Pipenv entry's +/// pylocks (#1368) through the python-lock backend, then its lock records +/// through the flavor's backend. Those files go first and are put back if +/// a later revert then fails, so a failure on any side leaves every file +/// wired to the vendored wheel (and the entry kept) instead of half /// reverted. async fn revert_with_sibling( entry: &VendorEntry, @@ -2678,30 +2920,58 @@ async fn revert_with_sibling( )), } } - let (lines, lock): (Vec<_>, Vec<_>) = entry + let (lines, rest): (Vec<_>, Vec<_>) = entry .wiring .iter() .cloned() .partition(|r| r.kind == super::pypi_requirements::RECORD_KIND); - if lines.is_empty() { + // #1368: a Pipenv entry's pylock records, reverted with the export. + let (pylocks, lock): (Vec<_>, Vec<_>) = rest + .into_iter() + .partition(|r| flavor == PypiFlavor::Pipenv && super::pypi_lock::is_lock_record(r)); + if lines.is_empty() && pylocks.is_empty() { return revert_lock(flavor, entry, project_root, dry_run).await; } - // The exported files as they are now, to put back if the lock fails. + // The exported files and pylocks as they are now, to put back if the + // lock fails. let snapshot = if dry_run { Vec::new() } else { - let mut files: Vec<&str> = lines.iter().map(|r| r.file.as_str()).collect(); + let mut files: Vec<&str> = lines + .iter() + .chain(&pylocks) + .map(|r| r.file.as_str()) + .collect(); files.sort_unstable(); files.dedup(); snapshot_files(project_root, &files).await }; - let lines_entry = VendorEntry { - wiring: lines, - ..entry.clone() - }; - let lines_outcome = revert_requirements(&lines_entry, project_root, dry_run).await; - if !lines_outcome.success { - return lines_outcome; + let mut lines_outcome = RevertOutcome::ok(); + if !lines.is_empty() { + let lines_entry = VendorEntry { + wiring: lines, + ..entry.clone() + }; + lines_outcome = revert_requirements(&lines_entry, project_root, dry_run).await; + if !lines_outcome.success { + return lines_outcome; + } + } + if !pylocks.is_empty() { + let pylocks_entry = VendorEntry { + wiring: pylocks, + ..entry.clone() + }; + let mut pylocks_outcome = + super::pypi_lock::revert_python_locks(&pylocks_entry, project_root, dry_run).await; + if !pylocks_outcome.success { + let mut detail = pylocks_outcome.error.take().unwrap_or_default(); + restore_snapshot(project_root, &snapshot, &mut detail).await; + pylocks_outcome.error = Some(detail); + return pylocks_outcome; + } + lines_outcome.warnings.extend(pylocks_outcome.warnings); + lines_outcome.kept_artifact |= pylocks_outcome.kept_artifact; } let lock_entry = VendorEntry { wiring: lock, @@ -2724,10 +2994,7 @@ async fn revert_with_sibling( if !detail.is_empty() { outcome.warnings.push(VendorWarning::new( "vendor_lock_entry_drifted", - format!( - "{} kept wired with the drifted lock{detail}", - crate::formats::governing_locks::PYPI_REQUIREMENTS - ), + format!("the files wired with the drifted lock stay wired{detail}"), )); } } @@ -3251,8 +3518,8 @@ mod tests { const PIPENV_PYLOCK: &str = "lock-version = \"1.0\"\ncreated-by = \"pipenv\"\n\n[[packages]]\nname = \"six\"\nversion = \"1.16.0\"\nindex = \"https://pypi.org/simple\"\nwheels = [{ name = \"six-1.16.0-py2.py3-none-any.whl\", url = \"https://files.pythonhosted.org/packages/six-1.16.0-py2.py3-none-any.whl\", hashes = { sha256 = \"8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254\" } }]\n\n[tool.pipenv]\ngenerated_from = \"Pipfile.lock\"\n"; /// #1122: with a `Pipfile` beside both locks, Pipenv installs from - /// `Pipfile.lock` and ignores the pylock, so `Pipfile.lock` is the one - /// wired — the pylock is named as a loud loser, not wired instead. + /// `Pipfile.lock` and ignores the pylock, so `Pipfile.lock` governs. + /// #1368: the pylock is wired with it, so it is no loser. #[tokio::test] async fn pipenv_pylock_beside_pipfile_lock_routes_to_pipenv() { for lock in ["pylock.toml", "pylock.dev.toml"] { @@ -3269,6 +3536,33 @@ mod tests { .await .unwrap(); assert_eq!(selected, PypiFlavor::Pipenv, "{lock}: {warnings:?}"); + assert!( + warnings.iter().all(|w| w.code != "pypi_multiple_lockfiles"), + "{lock}: {warnings:?}" + ); + } + } + + /// A pylock the Pipenv wiring can't rewrite in place (a symlink) is + /// still named as a loud loser beside the wired `Pipfile.lock`. + #[cfg(unix)] + #[tokio::test] + async fn pipenv_symlinked_pylock_beside_pipfile_lock_is_a_loser() { + for lock in ["pylock.toml", "pylock.dev.toml"] { + let tmp = tempfile::tempdir().unwrap(); + touch( + tmp.path(), + "Pipfile", + "[packages]\nsix = \"==1.16.0\"\n\n[pipenv]\nuse_pylock = true\n", + ) + .await; + touch(tmp.path(), "Pipfile.lock", "{}").await; + touch(tmp.path(), "shared.lock.txt", PIPENV_PYLOCK).await; + std::os::unix::fs::symlink("shared.lock.txt", tmp.path().join(lock)).unwrap(); + let (selected, warnings) = detect_pypi_flavor(tmp.path(), Some(("six", "1.16.0"))) + .await + .unwrap(); + assert_eq!(selected, PypiFlavor::Pipenv, "{lock}: {warnings:?}"); let loud: Vec<_> = warnings .iter() .filter(|w| w.code == "pypi_multiple_lockfiles") @@ -6226,6 +6520,256 @@ wheels = [ ); } + /// A Pipfile that has `pipenv lock` write a pylock too. + const USE_PYLOCK_PIPFILE: &str = + "[packages]\nsix = \"==1.16.0\"\n\n[pipenv]\nuse_pylock = true\n"; + + /// A [`pipenv_e2e_fixture`] whose Pipfile sets `use_pylock = true`, so + /// `pipenv lock` wrote [`PIPENV_PYLOCK`] beside Pipfile.lock (#1368). + async fn pipenv_pylock_fixture() -> E2eFixture { + let fx = pipenv_e2e_fixture().await; + touch(&fx.root, "Pipfile", USE_PYLOCK_PIPFILE).await; + touch(&fx.root, "pylock.toml", PIPENV_PYLOCK).await; + fx + } + + async fn pylock_wired_to(fx: &E2eFixture, uuid: &str) -> bool { + tokio::fs::read_to_string(fx.root.join("pylock.toml")) + .await + .unwrap() + .contains(&format!(".socket/vendor/pypi/{uuid}/")) + } + + fn pylock_records(entry: &VendorEntry) -> usize { + entry + .wiring + .iter() + .filter(|r| r.kind == "python_lock_document") + .count() + } + + /// #1368: with `use_pylock = true`, `pipenv lock` writes a pylock.toml + /// beside Pipfile.lock, and installers that read PEP 751 locks + /// (`uv pip install -r pylock.toml`, pip) install from it. Vendored mode + /// wires it with Pipfile.lock (and an exported requirements.txt), so + /// `vendor --check` and `vex` see one uncontested wiring, and revert + /// restores every file byte for byte. + #[tokio::test] + async fn pipenv_vendor_wires_the_pipenv_pylock() { + for with_export in [false, true] { + let fx = pipenv_pylock_fixture().await; + if with_export { + touch(&fx.root, "requirements.txt", SIBLING_EXPORT).await; + } + let VendorOutcome::Done { + entry, warnings, .. + } = pipenv_vendor(&fx, &fx.record).await + else { + panic!("vendor did not complete"); + }; + assert!( + warnings.iter().all(|w| w.code != "pypi_multiple_lockfiles"), + "export={with_export}: {warnings:?}" + ); + assert!( + pylock_wired_to(&fx, UUID).await, + "export={with_export}: pylock.toml left unpatched" + ); + let lock = tokio::fs::read_to_string(fx.root.join("Pipfile.lock")) + .await + .unwrap(); + assert!(lock.contains(UUID), "Pipfile.lock not wired:\n{lock}"); + if with_export { + assert!(requirements_wired(&fx).await); + } + let entry = entry.expect("a wet vendor records its entry"); + assert_eq!(pylock_records(&entry), 1, "{:?}", entry.wiring); + + let discovery = crate::vex::discover::discover_patched_refs(&fx.root).await; + assert!(discovery.contested.is_empty(), "{:?}", discovery.contested); + let wired_in: std::collections::BTreeSet = discovery + .refs + .iter() + .filter(|r| r.uuid == UUID) + .map(|r| r.source_file.to_string_lossy().replace('\\', "/")) + .collect(); + assert!( + wired_in.contains("Pipfile.lock") && wired_in.contains("pylock.toml"), + "{:?}", + discovery.refs + ); + + let outcome = revert_pypi(&entry, &fx.root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(!outcome.kept_artifact, "{:?}", outcome.warnings); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("pylock.toml")) + .await + .unwrap(), + PIPENV_PYLOCK + ); + assert_eq!( + read_json(&fx.root, "Pipfile.lock").await, + serde_json::from_str::(PIPENV_REGISTRY_LOCK).unwrap() + ); + if with_export { + assert_eq!( + tokio::fs::read_to_string(fx.root.join("requirements.txt")) + .await + .unwrap(), + SIBLING_EXPORT + ); + } + } + } + + /// #1368: a fresh `--dry-run` previews the pylock wiring and writes + /// nothing. + #[tokio::test] + async fn pipenv_pylock_dry_run_previews_the_wiring() { + let fx = pipenv_pylock_fixture().await; + let sources = PatchSources::blobs_only(&fx.blobs); + let outcome = crate::vendor::test_support::vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &fx.record, + &sources, + "2026-06-09T00:00:00Z", + true, + false, + None, + ) + .await; + let VendorOutcome::Done { warnings, .. } = outcome else { + panic!("dry run did not complete: {outcome:?}"); + }; + assert!( + warnings + .iter() + .any(|w| w.code == "pypi_pipenv_pylock_wired" && w.detail.contains("pylock.toml")), + "{warnings:?}" + ); + assert!( + warnings.iter().all(|w| w.code != "pypi_multiple_lockfiles"), + "{warnings:?}" + ); + assert!(!pylock_wired_to(&fx, UUID).await); + } + + /// #1368 on an in-sync re-run: a project vendored before `use_pylock` + /// was turned on gets its later pylock wired to the committed wheel, + /// recorded once; a re-lock (the registry entry is back) replaces that + /// record instead of adding a duplicate. + #[tokio::test] + async fn pipenv_in_sync_rerun_wires_a_later_pylock_once() { + let fx = pipenv_e2e_fixture().await; + touch(&fx.root, "Pipfile", USE_PYLOCK_PIPFILE).await; + pipenv_vendored_without_export(&fx).await; + for _ in 0..2 { + touch(&fx.root, "pylock.toml", PIPENV_PYLOCK).await; + let VendorOutcome::Done { + entry: Some(entry), + warnings, + .. + } = pipenv_vendor(&fx, &fx.record).await + else { + panic!("the in-sync re-run must record the pylock's wiring"); + }; + assert!(pylock_wired_to(&fx, UUID).await, "{warnings:?}"); + assert_eq!(pylock_records(&entry), 1, "{:?}", entry.wiring); + assert!( + warnings.iter().all(|w| w.code != "pypi_multiple_lockfiles"), + "{warnings:?}" + ); + save_ledger_entry(&fx.root, &entry).await; + } + let entry = super::super::state::load_state(&fx.root) + .await + .unwrap() + .entries + .into_values() + .next() + .unwrap(); + let outcome = revert_pypi(&entry, &fx.root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("pylock.toml")) + .await + .unwrap(), + PIPENV_PYLOCK + ); + } + + /// #1368: a superseding patch re-wires the pylock to the new wheel with + /// Pipfile.lock, and reverting the new entry restores the registry + /// pylock. + #[tokio::test] + async fn pipenv_superseding_uuid_rewires_the_pylock() { + const UUID2: &str = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; + let fx = pipenv_pylock_fixture().await; + let VendorOutcome::Done { + entry: Some(first), .. + } = pipenv_vendor(&fx, &fx.record).await + else { + panic!("first vendor must be Done"); + }; + save_ledger_entry(&fx.root, &first).await; + + let mut record2 = fx.record.clone(); + record2.uuid = UUID2.to_string(); + let outcome = pipenv_vendor(&fx, &record2).await; + let VendorOutcome::Done { + result, + entry: Some(second), + warnings, + } = outcome + else { + panic!("superseding uuid must re-vendor, got {outcome:?}"); + }; + assert!(result.success, "{:?}", result.error); + assert!( + warnings.iter().all(|w| w.code != "pypi_multiple_lockfiles"), + "{warnings:?}" + ); + assert!(pylock_wired_to(&fx, UUID2).await); + assert!(!pylock_wired_to(&fx, UUID).await); + assert_eq!(pylock_records(&second), 1, "{:?}", second.wiring); + + save_ledger_entry(&fx.root, &second).await; + let reverted = revert_pypi(&second, &fx.root, false).await; + assert!(reverted.success, "{:?}", reverted.error); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("pylock.toml")) + .await + .unwrap(), + PIPENV_PYLOCK + ); + } + + /// A Pipfile.lock revert failure must not leave the pylock reverted on + /// its own: both stay wired to the vendored wheel. + #[tokio::test] + async fn pipenv_pylock_revert_keeps_both_wired_when_the_lock_fails() { + let fx = pipenv_pylock_fixture().await; + let VendorOutcome::Done { + entry: Some(entry), .. + } = pipenv_vendor(&fx, &fx.record).await + else { + panic!("vendor did not complete"); + }; + touch(&fx.root, "Pipfile.lock", "{ not json").await; + let outcome = revert_pypi(&entry, &fx.root, false).await; + assert!( + !outcome.success, + "a corrupt Pipfile.lock cannot be reverted" + ); + assert!( + pylock_wired_to(&fx, UUID).await, + "the pylock must not be reverted alone" + ); + } + async fn read_json(root: &Path, name: &str) -> serde_json::Value { serde_json::from_str(&tokio::fs::read_to_string(root.join(name)).await.unwrap()).unwrap() } @@ -7708,8 +8252,8 @@ wheels = [ /// #1122: a `use_pylock = true` Pipenv project carries `Pipfile`, /// `Pipfile.lock` and `pylock.toml`; Pipenv installs from `Pipfile.lock`, - /// so vendoring wires `Pipfile.lock` (pipenv flavor), leaves the pylock - /// byte-identical, and reverts cleanly. + /// so vendoring wires `Pipfile.lock` (pipenv flavor). #1368: the pylock + /// is wired with it, and both revert cleanly. #[tokio::test] async fn pipenv_use_pylock_project_vendors_into_pipfile_lock() { let fx = e2e_fixture().await; @@ -7743,16 +8287,12 @@ wheels = [ .await .unwrap(); assert!(wired.contains(&entry.artifact.path), "{wired}"); - assert_eq!( - tokio::fs::read_to_string(fx.root.join("pylock.toml")) - .await - .unwrap(), - PIPENV_PYLOCK - ); + let pylock = tokio::fs::read_to_string(fx.root.join("pylock.toml")) + .await + .unwrap(); + assert!(pylock.contains(&entry.artifact.path), "{pylock}"); assert!( - warnings - .iter() - .any(|w| w.code == "pypi_multiple_lockfiles" && w.detail.contains("pylock.toml")), + warnings.iter().all(|w| w.code != "pypi_multiple_lockfiles"), "{warnings:?}" ); @@ -7764,6 +8304,12 @@ wheels = [ .unwrap(), PIPENV_LOCK_REGISTRY ); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("pylock.toml")) + .await + .unwrap(), + PIPENV_PYLOCK + ); } /// The `vendor_platform_locked` advisory names the file the platform diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index e2f0baaf6..1415b2daf 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -163,6 +163,17 @@ pub(super) async fn load_python_locks( ) -> Result { let paths = python_lock_paths(root).map_err(|error| ("pypi_lock_read_failed", error.to_string()))?; + load_listed_locks(root, paths, name, version, uuid).await +} + +/// [`load_python_locks`] over `paths` only. +async fn load_listed_locks( + root: &Path, + paths: Vec, + name: &str, + version: &str, + uuid: &str, +) -> Result { let directory = format!(".socket/vendor/pypi/{uuid}/"); let placeholder = format!("{directory}{name}-{version}-py3-none-any.whl"); let mut files = Vec::new(); @@ -265,6 +276,112 @@ pub(super) async fn load_python_locks( }) } +/// #1368: the PEP 751 locks `pipenv lock` writes beside `Pipfile.lock` +/// under `[pipenv] use_pylock = true` (a `Pipfile` sits beside them). +/// Pipenv installs from `Pipfile.lock`, but `uv pip install -r +/// pylock.toml` and pip read the pylock, so it is wired with the lock. +fn pipenv_pylock_paths(root: &Path) -> Vec { + if !root.join("Pipfile").is_file() { + return Vec::new(); + } + python_lock_paths(root) + .unwrap_or_default() + .into_iter() + .filter(|path| crate::utils::python_lock::is_pep751_lock_name(path)) + .collect() +} + +/// How a Pipenv pylock holds the target `name==version`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum PipenvPylock { + /// From the registry, in a regular file the wiring can rewrite. + Registry, + /// From a vendored wheel under `.socket/vendor/pypi/`. + Vendored, + /// In a file the wiring can't rewrite in place: a symlink (the writer + /// would replace the link), or an entry `rewrite_python_lock` refuses. + Unwirable, +} + +/// Each Pipenv pylock ([`pipenv_pylock_paths`]) that holds +/// `name==version`, and how it holds it. A pylock that does not hold it +/// is left out; one that can't be read or parsed is [`Unwirable`]. +/// +/// [`Unwirable`]: PipenvPylock::Unwirable +pub(super) async fn pipenv_pylocks( + root: &Path, + name: &str, + version: &str, +) -> Vec<(String, PipenvPylock)> { + let mut found = Vec::new(); + for path in pipenv_pylock_paths(root) { + let Ok(text) = read_file(&root.join(&path)).await else { + found.push((path, PipenvPylock::Unwirable)); + continue; + }; + let Ok(document) = lock_document(&text) else { + found.push((path, PipenvPylock::Unwirable)); + continue; + }; + let Some(package) = package(&document, name, version) else { + continue; + }; + let state = if let Some(source) = source_path(package) { + if source + .trim_start_matches("./") + .starts_with(".socket/vendor/pypi/") + { + PipenvPylock::Vendored + } else { + PipenvPylock::Unwirable + } + } else if tokio::fs::symlink_metadata(root.join(&path)) + .await + .map_or(true, |meta| meta.file_type().is_symlink()) + { + PipenvPylock::Unwirable + } else { + match rewrite_python_lock( + &text, + name, + version, + ArtifactSource::Path(".socket/vendor/pypi/x/placeholder.whl"), + &"0".repeat(64), + ) { + Ok(Some(_)) => PipenvPylock::Registry, + _ => PipenvPylock::Unwirable, + } + }; + found.push((path, state)); + } + found +} + +/// Wire the registry entries of the Pipenv pylocks `paths` to `wheel` +/// (#1368), recording each rewritten file as a python-lock document so the +/// revert restores it. +pub(super) async fn wire_pipenv_pylocks( + root: &Path, + paths: Vec, + name: &str, + version: &str, + uuid: &str, + wheel: &str, + sha: &str, +) -> Result, Failure> { + if paths.is_empty() { + return Ok(Vec::new()); + } + let project = load_listed_locks(root, paths, name, version, uuid).await?; + wire_python_locks(&project, root, name, version, wheel, sha).await +} + +/// Whether `record` is one the python-lock revert replays: a pylock wired +/// beside a Pipenv lock (#1368) is recorded this way. +pub(super) fn is_lock_record(record: &WiringRecord) -> bool { + record.kind == KIND +} + pub(super) async fn wire_python_locks( project: &PythonLocks, root: &Path, diff --git a/docs/testing/pipenv-compatibility.md b/docs/testing/pipenv-compatibility.md index a268dffcb..e48a1c1c6 100644 --- a/docs/testing/pipenv-compatibility.md +++ b/docs/testing/pipenv-compatibility.md @@ -19,7 +19,7 @@ requirements.txt lanes of the same ecosystem. |-------|--------|----------|-------| | `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 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; the pylock is named in `pypi_multiple_lockfiles`. With no `Pipfile.lock`, refused (`pypi_pipenv_pylock_unsupported`), and `vex` does not trust a Socket reference in that pylock. | 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. | Pipenv command timing and dotenv behavior changed across supported releases. Current commands resolve complete python-dotenv records with preceding bindings ahead of process variables, then apply their active-environment settings. Older commands can cache placement before loading dotenv, so discovery also retains the process-only result. The 2018 shell instead resolves dotenv references from the complete final mapping with process variables first; its Project settings were cached before loading, and it marks itself active before final placement. The 2020 shell uses modern parsing but still caches Project settings such as `IGNORE_VIRTUALENVS` and `VENV_IN_PROJECT`; it reads the active prefix before setting `PIPENV_ACTIVE`. These two cached profiles do not support custom venv names. Discovery combines only project-owned environments from these observed profiles, without executing Pipenv or searching unrelated venvs. From e271891794aea47045de0ec29ae9993e7eb865bf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:46:52 +0000 Subject: [PATCH 3/5] Keep repair reading a Pipenv entry's own lock A Pipenv entry now also records the pylock wired beside Pipfile.lock. Repair stopped at that pylock record when it carried no hash-pinned wheel, instead of falling back to the Pipfile.lock digests it used before. It now reads the Pipfile.lock fragment as it did. The real-Pipenv use_pylock e2e now expects the pylock to point at the vendored wheel too. Refs #1368 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/vendor/lock_inventory/recover.rs | 11 ++++--- .../vendor/lock_inventory/recover_tests.rs | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs b/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs index 5806fa995..b889544a1 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs @@ -171,10 +171,13 @@ pub async fn recover_lock_entry( } } } - if entry - .wiring - .iter() - .any(|wiring| wiring.kind == "python_lock_document") + // A Pipenv entry's pylock records (#1368) ride beside its own + // Pipfile.lock fragment, which the fallback below still reads. + if entry.flavor.as_deref() != Some("pipenv") + && entry + .wiring + .iter() + .any(|wiring| wiring.kind == "python_lock_document") { return Err("the pre-vendor Python lock has no hash-pinned pure wheel for this package; reinstall it before repair".to_string()); } diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/recover_tests.rs b/crates/socket-patch-core/src/vendor/lock_inventory/recover_tests.rs index af62fbd16..0cc70c323 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/recover_tests.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/recover_tests.rs @@ -410,6 +410,36 @@ async fn recover_pypi_urlless_locks_report_no_fetchable_url() { .unwrap_err(); assert!(err.contains("no sha256 digests"), "pipenv: {err}"); + // #1368: a Pipenv entry also records the pylock wired beside its + // Pipfile.lock. A pylock entry with no hash-pinned wheel must not hide + // the Pipfile.lock fragment's digest set. + let mut with_pylock = entry( + "pypi", + "pkg:pypi/six@1.16.0", + vec![ + rec( + "pipenv_lock_entry", + serde_json::json!({ + "hashes": [format!("sha256:{}", "a".repeat(64))], + "version": "==1.16.0", + }), + ), + rec( + "python_lock_document", + serde_json::json!( + "lock-version = \"1.0\"\n[[packages]]\nname = \"six\"\nversion = \"1.16.0\"\n" + ), + ), + ], + ); + with_pylock.flavor = Some("pipenv".into()); + let recovered = recover_lock_entry(tmp.path(), &with_pylock).await.unwrap(); + assert_eq!( + recovered.integrity, + LockIntegrity::Sha256AnyOf(vec!["a".repeat(64)]), + "the Pipfile.lock digest set" + ); + // A ledger with no pypi fragment at all is still a hard error. let bare = entry("pypi", "pkg:pypi/six@1.16.0", vec![]); assert!(recover_lock_entry(tmp.path(), &bare).await.is_err()); From 558462ead06eca58b37321428ad7393e68d321fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 21:16:08 +0000 Subject: [PATCH 4/5] Harden Pipenv pylock wiring from review - A pylock already pointing at another patch's wheel that no ledger entry records is named in pypi_multiple_lockfiles instead of being silently treated as wired. - A superseding patch whose old pylock wiring drifted refuses, as a drifted Pipfile.lock does, and --dry-run now predicts that refusal. - A drifted pylock on revert keeps every file wired (Pipfile.lock and the export too), so the tree matches the kept ledger entry instead of being half reverted. - A pylock Hatch derives for locked environments is left alone. - Repair reads a Pipenv entry's Pipfile.lock fragment whatever order its records are in. - The real-Pipenv e2e now installs from the wired pylock with `uv pip install -r pylock.toml` and checks it gets the patched six. Refs #1368 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_vex_build/pipenv.rs | 38 +++ .../src/vendor/lock_inventory/recover.rs | 34 ++- .../vendor/lock_inventory/recover_tests.rs | 14 +- crates/socket-patch-core/src/vendor/pypi.rs | 279 +++++++++++++++--- .../socket-patch-core/src/vendor/pypi_lock.rs | 26 +- 5 files changed, 326 insertions(+), 65 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_vex_build/pipenv.rs b/crates/socket-patch-cli/tests/e2e_vex_build/pipenv.rs index c36a1b63d..5c1404998 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_build/pipenv.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_build/pipenv.rs @@ -448,6 +448,44 @@ fn pylock_flow(pipenv: &Pipenv, root: &Path) { if ok { "pass" } else { "FAIL" }, ); assert!(ok, "{what}: the fresh install must carry the PATCHED six"); + + // #1368: a PEP 751 installer reading the wired pylock gets the PATCHED + // six too, from the committed wheel. + let pep751 = root.join("pylock").join("pep751"); + copy_tree(&proj, &pep751, &[".venv"]); + make_venv( + &pipenv.uv, + python.to_str().unwrap(), + &pep751.join(".venv"), + &[], + ) + .unwrap_or_else(|e| panic!("{what}: pylock venv: {e}")); + let py = venv_bin(&pep751.join(".venv"), "python"); + let out = tool( + &pipenv.uv, + &pep751, + &[ + "pip", + "install", + "--python", + py.to_str().unwrap(), + "-r", + "pylock.toml", + ], + &[], + ); + assert_ok(&out, &format!("{what}: `uv pip install -r pylock.toml`")); + let (_, bytes, is_patched) = six_oracle(&py, &pep751) + .unwrap_or_else(|| panic!("{what}: six not importable from the pylock install")); + let ok = is_patched && bytes == patched; + record( + "pipenv", + v, + "use_pylock/vendored", + "pylock-install-patched", + if ok { "pass" } else { "FAIL" }, + ); + assert!(ok, "{what}: the pylock install must carry the PATCHED six"); } #[test] diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs b/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs index b889544a1..d6b98962c 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs @@ -186,18 +186,28 @@ pub async fn recover_lock_entry( // `pdm_lock_package`, poetry `poetry_lock_package`, pipenv // `pipenv_lock_entry`, bare pip `requirements_line`. Accept them all // so recovery is not blind to non-uv projects. - let fragment = wiring_original( - entry, - &[ - "uv_lock_package", - "python_lock_document", - "pdm_lock_package", - "poetry_lock_package", - "pipenv_lock_entry", - "requirements_line", - ], - ) - .ok_or_else(|| "no pre-vendor pypi lock fragment recorded".to_string())?; + // A Pipenv entry's own Pipfile.lock fragment first, whatever + // order its pylock records (#1368) sit in. + let pipenv_fragment = if entry.flavor.as_deref() == Some("pipenv") { + wiring_original(entry, &["pipenv_lock_entry"]) + } else { + None + }; + let fragment = pipenv_fragment + .or_else(|| { + wiring_original( + entry, + &[ + "uv_lock_package", + "python_lock_document", + "pdm_lock_package", + "poetry_lock_package", + "pipenv_lock_entry", + "requirements_line", + ], + ) + }) + .ok_or_else(|| "no pre-vendor pypi lock fragment recorded".to_string())?; // Only uv.lock and pdm's `static_urls` locks inline the wheel's // registry URL (`url = "…", hash = "sha256:…"`), which is all a // registry rebuild can fetch from. Default pdm/poetry (`file = …`), diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/recover_tests.rs b/crates/socket-patch-core/src/vendor/lock_inventory/recover_tests.rs index 0cc70c323..b1129b73d 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/recover_tests.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/recover_tests.rs @@ -412,11 +412,17 @@ async fn recover_pypi_urlless_locks_report_no_fetchable_url() { // #1368: a Pipenv entry also records the pylock wired beside its // Pipfile.lock. A pylock entry with no hash-pinned wheel must not hide - // the Pipfile.lock fragment's digest set. + // the Pipfile.lock fragment's digest set, in either record order. let mut with_pylock = entry( "pypi", "pkg:pypi/six@1.16.0", vec![ + rec( + "python_lock_document", + serde_json::json!( + "lock-version = \"1.0\"\n[[packages]]\nname = \"six\"\nversion = \"1.16.0\"\n" + ), + ), rec( "pipenv_lock_entry", serde_json::json!({ @@ -424,12 +430,6 @@ async fn recover_pypi_urlless_locks_report_no_fetchable_url() { "version": "==1.16.0", }), ), - rec( - "python_lock_document", - serde_json::json!( - "lock-version = \"1.0\"\n[[packages]]\nname = \"six\"\nversion = \"1.16.0\"\n" - ), - ), ], ); with_pylock.flavor = Some("pipenv".into()); diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 45cb7f2d5..de9b23f98 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -1169,8 +1169,39 @@ async fn pypi_prelude<'p>( }); // #1368: a pylock `pipenv lock` wrote beside Pipfile.lock // (`use_pylock = true`) still pinning the registry release. - let registry_pylocks = - registry_pipenv_pylocks(project_root, &canon_name, version).await; + let pylock_states = + super::pypi_lock::pipenv_pylocks(project_root, &canon_name, version).await; + let registry_pylocks: Vec = pylock_states + .iter() + .filter(|(_, state)| *state == super::pypi_lock::PipenvPylock::Registry) + .map(|(path, _)| path.clone()) + .collect(); + let superseded_pylocks: Vec = superseded.as_ref().map_or(Vec::new(), |prev| { + prev.wiring + .iter() + .filter(|r| super::pypi_lock::is_lock_record(r)) + .map(|r| r.file.clone()) + .collect() + }); + // A pylock already on another patch's wheel that no ledger + // entry this run replays: nothing re-wires it, so name it. + for (path, state) in &pylock_states { + if let super::pypi_lock::PipenvPylock::Vendored(uuid) = state { + if !uuid.eq_ignore_ascii_case(&record.uuid) + && !superseded_pylocks.contains(path) + { + warnings.push(VendorWarning::new( + "pypi_multiple_lockfiles", + format!( + "{path} points {canon_name}=={version} at the vendored wheel of \ + patch {uuid}, which no vendor ledger entry records; this run \ + does not re-wire it, so installs driven by it will not get \ + this patch (re-lock with `pipenv lock` and re-run vendor)" + ), + )); + } + } + } match target { PipenvTarget::InSync => { wired_pin = pipenv_wired_pin(&project.lock, &uuid_dir_rel); @@ -1181,13 +1212,17 @@ async fn pypi_prelude<'p>( if sibling && dry_run { warnings.push(sibling_dry_run_notice(flavor, &canon_name, version)); } - let superseded_pylocks = superseded.as_ref().map_or(Vec::new(), |prev| { - prev.wiring - .iter() - .filter(|r| super::pypi_lock::is_lock_record(r)) - .map(|r| r.file.clone()) - .collect() - }); + // The wet run unwinds the superseded pylock wiring + // first and refuses when it drifted: preview that. + if dry_run { + if let Some(prev) = superseded.as_ref() { + if let Err((code, detail)) = + unwind_superseded_pylocks(project_root, prev, true).await + { + return Err(refused(code, detail)); + } + } + } let mut pylocks = registry_pylocks; pylocks.extend(superseded_pylocks); pylocks.sort_unstable(); @@ -2820,6 +2855,49 @@ async fn wire_sibling_requirements( } } +/// Revert the pylock records of `prev`, the Pipenv entry a superseding +/// patch replaces (#1368), so its pylocks hold the registry entry again. +/// A failed or drifted unwind refuses, as a drifted Pipfile.lock does: +/// the old entry holds the only pre-vendor originals. `dry_run` only +/// checks. +async fn unwind_superseded_pylocks( + project_root: &Path, + prev: &VendorEntry, + dry_run: bool, +) -> Result<(), (&'static str, String)> { + let wiring: Vec<_> = prev + .wiring + .iter() + .filter(|r| super::pypi_lock::is_lock_record(r)) + .cloned() + .collect(); + if wiring.is_empty() { + return Ok(()); + } + let pylocks = VendorEntry { + wiring, + ..prev.clone() + }; + let outcome = super::pypi_lock::revert_python_locks(&pylocks, project_root, dry_run).await; + if outcome.success && !outcome.drift_skipped() { + return Ok(()); + } + Err(( + "pypi_lock_changed", + format!( + "the pylock wiring of patch {} could not be unwound ({}); restore the pylock from \ + version control, or run vendor --revert before vendoring this patch", + prev.uuid, + outcome.error.unwrap_or_else(|| outcome + .warnings + .iter() + .map(|w| w.detail.as_str()) + .collect::>() + .join("; ")) + ), + )) +} + /// Wire the Pipenv pylocks beside a just-wired Pipfile.lock to the same /// wheel (#1368). A superseded entry's pylock wiring is reverted first, so /// its files hold the registry entry again and the new records carry the @@ -2838,35 +2916,7 @@ async fn wire_pipenv_pylocks( ) -> Result, (&'static str, String)> { let wired = async { if let Some(prev) = superseded { - let wiring: Vec<_> = prev - .wiring - .iter() - .filter(|r| super::pypi_lock::is_lock_record(r)) - .cloned() - .collect(); - if !wiring.is_empty() { - let pylocks = VendorEntry { - wiring, - ..prev.clone() - }; - let outcome = - super::pypi_lock::revert_python_locks(&pylocks, project_root, false).await; - if !outcome.success || outcome.drift_skipped() { - return Err(( - "pypi_lock_changed", - format!( - "the pylock wiring of patch {} could not be unwound: {}", - prev.uuid, - outcome.error.unwrap_or_else(|| outcome - .warnings - .iter() - .map(|w| w.detail.as_str()) - .collect::>() - .join("; ")) - ), - )); - } - } + unwind_superseded_pylocks(project_root, prev, false).await?; } let pylocks = registry_pipenv_pylocks(project_root, canon_name, version).await; super::pypi_lock::wire_pipenv_pylocks( @@ -2970,6 +3020,21 @@ async fn revert_with_sibling( pylocks_outcome.error = Some(detail); return pylocks_outcome; } + // A drift-kept pylock keeps the whole entry: put back the files + // reverted so far, so the tree still matches it and a re-run + // reverts everything once the drift is undone. + if pylocks_outcome.drift_skipped() { + let mut detail = String::new(); + restore_snapshot(project_root, &snapshot, &mut detail).await; + if !detail.is_empty() { + pylocks_outcome.warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("the files wired with the drifted pylock stay wired{detail}"), + )); + } + pylocks_outcome.warnings.extend(lines_outcome.warnings); + return pylocks_outcome; + } lines_outcome.warnings.extend(pylocks_outcome.warnings); lines_outcome.kept_artifact |= pylocks_outcome.kept_artifact; } @@ -6770,6 +6835,144 @@ wheels = [ ); } + /// [`PIPENV_PYLOCK`] with its six entry already on patch `uuid`'s wheel. + fn pylock_on(uuid: &str) -> String { + format!( + "lock-version = \"1.0\"\ncreated-by = \"pipenv\"\n\n[[packages]]\nname = \"six\"\nversion = \"1.16.0\"\narchive = {{ path = \".socket/vendor/pypi/{uuid}/{WHEEL_NAME}\", hashes = {{ sha256 = \"{}\" }} }}\n", + "c".repeat(64) + ) + } + + /// A pylock already on another patch's wheel that no ledger entry + /// records is not re-wired by this run, so it is named, not silently + /// treated as wired. + #[tokio::test] + async fn pipenv_pylock_on_an_unrecorded_patch_is_named() { + const OTHER: &str = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; + let fx = pipenv_e2e_fixture().await; + touch(&fx.root, "Pipfile", USE_PYLOCK_PIPFILE).await; + let stale = pylock_on(OTHER); + touch(&fx.root, "pylock.toml", &stale).await; + let VendorOutcome::Done { warnings, .. } = pipenv_vendor(&fx, &fx.record).await else { + panic!("vendor did not complete"); + }; + assert!( + warnings.iter().any(|w| w.code == "pypi_multiple_lockfiles" + && w.detail.contains("pylock.toml") + && w.detail.contains(OTHER)), + "{warnings:?}" + ); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("pylock.toml")) + .await + .unwrap(), + stale + ); + } + + /// The pylock entry hand-edited after vendoring. + async fn drift_pylock(fx: &E2eFixture) { + let live = tokio::fs::read_to_string(fx.root.join("pylock.toml")) + .await + .unwrap(); + let mut doc: toml_edit::DocumentMut = live.parse().unwrap(); + doc["packages"][0]["archive"]["hashes"]["sha256"] = toml_edit::value("d".repeat(64)); + touch(&fx.root, "pylock.toml", &doc.to_string()).await; + } + + /// A superseding patch whose old pylock wiring drifted refuses, as a + /// drifted Pipfile.lock does, and `--dry-run` predicts the refusal. + #[tokio::test] + async fn pipenv_superseding_uuid_refuses_a_drifted_pylock_in_dry_run_too() { + const UUID2: &str = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; + let fx = pipenv_pylock_fixture().await; + let VendorOutcome::Done { + entry: Some(first), .. + } = pipenv_vendor(&fx, &fx.record).await + else { + panic!("first vendor must be Done"); + }; + save_ledger_entry(&fx.root, &first).await; + drift_pylock(&fx).await; + let mut record2 = fx.record.clone(); + record2.uuid = UUID2.to_string(); + let sources = PatchSources::blobs_only(&fx.blobs); + let dry = crate::vendor::test_support::vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &record2, + &sources, + "2026-06-09T00:00:00Z", + true, + false, + None, + ) + .await; + let wet = pipenv_vendor(&fx, &record2).await; + for (label, outcome) in [("dry", dry), ("wet", wet)] { + let failed = match &outcome { + VendorOutcome::Done { result, .. } => result + .error + .as_deref() + .filter(|_| !result.success) + .map(str::to_string), + other => Some(format!("{other:?}")), + }; + assert!( + failed + .as_deref() + .is_some_and(|e| e.contains("pypi_lock_changed")), + "{label}: {outcome:?}" + ); + } + let lock = tokio::fs::read_to_string(fx.root.join("Pipfile.lock")) + .await + .unwrap(); + assert!(lock.contains(UUID) && !lock.contains(UUID2), "{lock}"); + } + + /// A drifted pylock keeps the entry: the revert leaves every file wired + /// (Pipfile.lock and the export too), not half reverted. + #[tokio::test] + async fn pipenv_pylock_drift_keeps_every_file_wired() { + let fx = pipenv_pylock_fixture().await; + touch(&fx.root, "requirements.txt", SIBLING_EXPORT).await; + let VendorOutcome::Done { + entry: Some(entry), .. + } = pipenv_vendor(&fx, &fx.record).await + else { + panic!("vendor did not complete"); + }; + drift_pylock(&fx).await; + let outcome = revert_pypi(&entry, &fx.root, false).await; + assert!(outcome.drift_skipped(), "{outcome:?}"); + assert!(outcome.kept_artifact, "{outcome:?}"); + let lock = tokio::fs::read_to_string(fx.root.join("Pipfile.lock")) + .await + .unwrap(); + assert!(lock.contains(UUID), "Pipfile.lock reverted alone:\n{lock}"); + assert!(requirements_wired(&fx).await, "export reverted alone"); + } + + /// A pylock Hatch derives for its locked environments is Hatch's own, + /// not one `pipenv lock` wrote: the Pipenv wiring leaves it alone. + #[tokio::test] + async fn pipenv_leaves_a_hatch_locked_env_pylock_alone() { + let fx = pipenv_pylock_fixture().await; + touch( + &fx.root, + "pyproject.toml", + "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"app\"\nversion = \"0.1.0\"\n\n[tool.hatch.envs.default]\nlocked = true\ninstaller = \"uv\"\n", + ) + .await; + assert!( + super::super::pypi_lock::pipenv_pylocks(&fx.root, "six", "1.16.0") + .await + .is_empty() + ); + } + async fn read_json(root: &Path, name: &str) -> serde_json::Value { serde_json::from_str(&tokio::fs::read_to_string(root.join(name)).await.unwrap()).unwrap() } diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index 1415b2daf..0b1c23a1a 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -280,24 +280,34 @@ async fn load_listed_locks( /// under `[pipenv] use_pylock = true` (a `Pipfile` sits beside them). /// Pipenv installs from `Pipfile.lock`, but `uv pip install -r /// pylock.toml` and pip read the pylock, so it is wired with the lock. +/// A pylock Hatch derives from pyproject (locked environments) is Hatch's +/// own, regenerated on its next sync, so it is left out (#479). fn pipenv_pylock_paths(root: &Path) -> Vec { if !root.join("Pipfile").is_file() { return Vec::new(); } + let mut hatch_files = std::collections::BTreeMap::new(); + for file in crate::utils::hatch::HATCH_FILES { + if let Ok(text) = std::fs::read_to_string(root.join(file)) { + hatch_files.insert(file.to_string(), text); + } + } python_lock_paths(root) .unwrap_or_default() .into_iter() .filter(|path| crate::utils::python_lock::is_pep751_lock_name(path)) + .filter(|path| !crate::utils::hatch::is_hatch_lock(&hatch_files, path)) .collect() } /// How a Pipenv pylock holds the target `name==version`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(super) enum PipenvPylock { /// From the registry, in a regular file the wiring can rewrite. Registry, - /// From a vendored wheel under `.socket/vendor/pypi/`. - Vendored, + /// From a vendored wheel under `.socket/vendor/pypi//`; holds + /// that uuid. + Vendored(String), /// In a file the wiring can't rewrite in place: a symlink (the writer /// would replace the link), or an entry `rewrite_python_lock` refuses. Unwirable, @@ -327,13 +337,13 @@ pub(super) async fn pipenv_pylocks( continue; }; let state = if let Some(source) = source_path(package) { - if source + match source .trim_start_matches("./") - .starts_with(".socket/vendor/pypi/") + .strip_prefix(".socket/vendor/pypi/") + .and_then(|rest| rest.split_once('/')) { - PipenvPylock::Vendored - } else { - PipenvPylock::Unwirable + Some((uuid, _)) => PipenvPylock::Vendored(uuid.to_string()), + None => PipenvPylock::Unwirable, } } else if tokio::fs::symlink_metadata(root.join(&path)) .await From b5a36757610532a43b9312df297273c2274fb6ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 21:26:13 +0000 Subject: [PATCH 5/5] Read Hatch files FIFO-safely in pylock probe The Pipenv pylock probe read pyproject.toml and hatch.toml with a plain read, so a FIFO at either path blocked the vendor run forever. It now uses the same regular-file read as flavor detection. Refs #1368 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/vendor/pypi.rs | 21 +++++++++++++++++++ .../socket-patch-core/src/vendor/pypi_lock.rs | 6 +++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index de9b23f98..12b67afcb 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -6973,6 +6973,27 @@ wheels = [ ); } + /// A FIFO at pyproject.toml or hatch.toml must not wedge the Pipenv + /// pylock probe: it reads them as regular files only. + #[cfg(unix)] + #[tokio::test] + async fn pipenv_pylock_probe_skips_a_fifo_hatch_file() { + use std::os::unix::ffi::OsStrExt; + for file in ["pyproject.toml", "hatch.toml"] { + let fx = pipenv_pylock_fixture().await; + let path = std::ffi::CString::new(fx.root.join(file).as_os_str().as_bytes()).unwrap(); + // SAFETY: a valid NUL-terminated path. + assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o644) }, 0, "{file}"); + let found = tokio::time::timeout( + std::time::Duration::from_secs(10), + super::super::pypi_lock::pipenv_pylocks(&fx.root, "six", "1.16.0"), + ) + .await + .expect("the probe must not block on a FIFO"); + assert_eq!(found.len(), 1, "{file}: {found:?}"); + } + } + async fn read_json(root: &Path, name: &str) -> serde_json::Value { serde_json::from_str(&tokio::fs::read_to_string(root.join(name)).await.unwrap()).unwrap() } diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index 0b1c23a1a..165dabda4 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -282,13 +282,13 @@ async fn load_listed_locks( /// pylock.toml` and pip read the pylock, so it is wired with the lock. /// A pylock Hatch derives from pyproject (locked environments) is Hatch's /// own, regenerated on its next sync, so it is left out (#479). -fn pipenv_pylock_paths(root: &Path) -> Vec { +async fn pipenv_pylock_paths(root: &Path) -> Vec { if !root.join("Pipfile").is_file() { return Vec::new(); } let mut hatch_files = std::collections::BTreeMap::new(); for file in crate::utils::hatch::HATCH_FILES { - if let Ok(text) = std::fs::read_to_string(root.join(file)) { + if let Ok(text) = read_regular_to_string(&root.join(file)).await { hatch_files.insert(file.to_string(), text); } } @@ -324,7 +324,7 @@ pub(super) async fn pipenv_pylocks( version: &str, ) -> Vec<(String, PipenvPylock)> { let mut found = Vec::new(); - for path in pipenv_pylock_paths(root) { + for path in pipenv_pylock_paths(root).await { let Ok(text) = read_file(&root.join(&path)).await else { found.push((path, PipenvPylock::Unwirable)); continue;