From d79690b1dc71883a4a341e6bd6df58e570f9f778 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:22:36 +0000 Subject: [PATCH 1/6] Start fix for #1524 Assisted-by: Claude Code:claude-opus-5-5 From 5dc58c2043dddf1828260a230a9f4b6f3890a604 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:25:58 +0000 Subject: [PATCH 2/6] Revert both files of a mixed PyPI vendor entry Vendoring requirements.txt and then adding a pylock.toml left a ledger entry that `vendor --revert`, `remove` and the hosted takeover could never unwind: the re-run restamped the entry's flavor as python-lock but kept the requirements.txt record, and the python-lock revert called that record drift. Revert exited 0 and restored nothing. Every lock flavor now reverts its requirements lines through the requirements backend before its lock, and a requirements entry that carries pylock records reverts them through the python-lock backend. The routing note no longer says an already vendored requirements.txt keeps its registry source. Fixes #1524 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/vendor/pypi.rs | 164 ++++++++++++++++-- .../socket-patch-core/src/vendor/pypi_lock.rs | 6 + 2 files changed, 155 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 1dc99acdc..5afafc8dc 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -390,13 +390,41 @@ async fn detect_pypi_flavor( if exists(PYPI_REQUIREMENTS).await { present.push(PYPI_REQUIREMENTS); } - if !present.is_empty() { + // #1524: a file an earlier run already vendored keeps that wiring, + // and its records stay in the ledger entry, so say so rather than + // lumping it in with the registry-sourced files. + let mut vendored = Vec::new(); + let mut unchanged = Vec::new(); + for file in present { + let wired = tokio::fs::read_to_string(project_root.join(file)) + .await + .is_ok_and(|text| text.contains(".socket/vendor/pypi/")); + if wired { + vendored.push(file); + } else { + unchanged.push(file); + } + } + let mut clauses = Vec::new(); + if !unchanged.is_empty() { + clauses.push(format!( + "installs driven by {} retain their existing sources", + unchanged.join(", ") + )); + } + if !vendored.is_empty() { + clauses.push(format!( + "{} stay wired to the vendored wheel and revert with it", + vendored.join(", ") + )); + } + if !clauses.is_empty() { warnings.push(VendorWarning::new( "pypi_multiple_lockfiles", format!( - "wiring {}; installs driven by {} retain their existing sources", + "wiring {}; {}", additional_locks.join(", "), - present.join(", ") + clauses.join("; ") ), )); } @@ -2996,10 +3024,11 @@ async fn wire_pipenv_pylocks( } } -/// Revert a Pipenv or uv entry: the exported requirements lines wired with -/// 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 +/// Revert a lock-flavor entry: the requirements lines wired with it (a +/// Pipenv or uv export, #612 and #1368, or a requirements.txt vendored +/// before the lock appeared, #1524) 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. @@ -3020,10 +3049,17 @@ async fn revert_with_sibling( PypiFlavor::Pipenv => { super::pypi_pipenv::revert_pipenv(entry, project_root, dry_run).await } - other => RevertOutcome::failed(format!( - "no exported-requirements revert for the {} flavor", - other.as_str() - )), + PypiFlavor::PythonLocks => { + super::pypi_lock::revert_python_locks(entry, project_root, dry_run).await + } + PypiFlavor::Hatch => super::pypi_hatch::revert(entry, project_root, dry_run).await, + PypiFlavor::Poetry => { + super::pypi_poetry::revert_poetry(entry, project_root, dry_run).await + } + PypiFlavor::Pdm => super::pypi_pdm::revert_pdm(entry, project_root, dry_run).await, + PypiFlavor::Requirements => { + RevertOutcome::failed("no exported-requirements revert for the requirements flavor") + } } } let (lines, rest): (Vec<_>, Vec<_>) = entry @@ -3192,13 +3228,31 @@ pub async fn revert_pypi_opts( Some("uv") => { revert_with_sibling(entry, project_root, PypiFlavor::UvProject, dry_run).await } + // #1524: a re-run that wires a lock beside an already vendored + // requirements.txt restamps the flavor but carries the + // `requirements_line` records forward, so every lock flavor + // reverts those lines too instead of calling them drift. Some("python-lock") => { - super::pypi_lock::revert_python_locks(entry, project_root, dry_run).await + revert_with_sibling(entry, project_root, PypiFlavor::PythonLocks, dry_run).await + } + Some("hatch") => { + revert_with_sibling(entry, project_root, PypiFlavor::Hatch, dry_run).await + } + // ... and the other way round: a pylock wired by an earlier run + // stays recorded when the next run wires requirements.txt. + Some("requirements") + if entry + .wiring + .iter() + .any(super::pypi_lock::is_python_lock_kind_record) => + { + revert_with_sibling(entry, project_root, PypiFlavor::PythonLocks, dry_run).await } - Some("hatch") => super::pypi_hatch::revert(entry, project_root, dry_run).await, Some("requirements") => revert_requirements(entry, project_root, dry_run).await, - Some("poetry") => super::pypi_poetry::revert_poetry(entry, project_root, dry_run).await, - Some("pdm") => super::pypi_pdm::revert_pdm(entry, project_root, dry_run).await, + Some("poetry") => { + revert_with_sibling(entry, project_root, PypiFlavor::Poetry, dry_run).await + } + Some("pdm") => revert_with_sibling(entry, project_root, PypiFlavor::Pdm, dry_run).await, Some("pipenv") => { revert_with_sibling(entry, project_root, PypiFlavor::Pipenv, dry_run).await } @@ -10779,6 +10833,86 @@ wheels = [{url = "https://files.pythonhosted.org/six.whl", hashes = {sha256 = "u } } + /// #1524: requirements.txt is vendored, then a `pylock.toml` pinning the + /// same release appears and the next vendored run wires it too. The + /// entry's flavor becomes `python-lock` while the carried-forward + /// `requirements_line` record stays, so the revert must restore both + /// files and delete the wheel, not call the CLI's own requirements + /// wiring drift and keep everything. + #[tokio::test] + async fn requirements_then_pylock_entry_reverts_both_files() { + const PYLOCK: &str = "lock-version = \"1.0\"\ncreated-by = \"pip\"\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"; + let key = "pkg:pypi/six@1.16.0"; + let fx = e2e_fixture().await; + let sources = PatchSources::blobs_only(&fx.blobs); + let (result, entry, _) = + crate::vendor::test_support::expect_done(vendor_six(&fx, &sources, None).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("entry on success"); + assert_eq!(entry.flavor.as_deref(), Some("requirements")); + crate::vendor::test_support::persist(&fx.root, key, entry).await; + assert_ne!(read_requirements(&fx).await, "six==1.16.0\n"); + + touch(&fx.root, "pylock.toml", PYLOCK).await; + let (result, entry, warnings) = + crate::vendor::test_support::expect_done(vendor_six(&fx, &sources, None).await); + assert!(result.success, "{:?}", result.error); + // The routing note names requirements.txt as still vendored, not + // as a file left on its registry source. + let routing: Vec<&str> = warnings + .iter() + .filter(|w| w.code == "pypi_multiple_lockfiles") + .map(|w| w.detail.as_str()) + .collect(); + assert!( + routing.iter().any(|d| d + .contains("requirements.txt stay wired to the vendored wheel and revert with it")) + && routing + .iter() + .all(|d| !d.contains("retain their existing sources")), + "{routing:?}" + ); + crate::vendor::test_support::persist(&fx.root, key, entry.expect("entry on success")).await; + let state = crate::vendor::state::load_state(&fx.root).await.unwrap(); + let entry = state.entries.get(key).expect("ledger entry").clone(); + assert_eq!(entry.flavor.as_deref(), Some("python-lock")); + let kinds: Vec<(&str, &str)> = entry + .wiring + .iter() + .map(|r| (r.file.as_str(), r.kind.as_str())) + .collect(); + assert!( + kinds.contains(&("pylock.toml", "python_lock_document")) + && kinds.contains(&("requirements.txt", "requirements_line")), + "{kinds:?}" + ); + + let preview = revert_pypi_opts( + &entry, + &fx.root, + RevertOpts { + dry_run: true, + keep_artifact: false, + }, + ) + .await; + assert!(preview.success, "{:?}", preview.error); + assert!(!preview.drift_skipped(), "{:?}", preview.warnings); + + let reverted = revert_pypi(&entry, &fx.root, false).await; + assert!(reverted.success, "{:?}", reverted.error); + assert!(!reverted.kept_artifact, "{:?}", reverted.warnings); + assert!(reverted.warnings.is_empty(), "{:?}", reverted.warnings); + assert_eq!(read_requirements(&fx).await, "six==1.16.0\n"); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("pylock.toml")) + .await + .unwrap(), + PYLOCK + ); + assert!(!uuid_dir_of(&fx).exists()); + } + #[tokio::test] async fn script_python_lock_pairs_metadata_and_restores_original_bytes() { let fx = e2e_fixture().await; diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index 965903f0b..5c7c384f6 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -421,6 +421,12 @@ pub(super) fn is_lock_record(record: &WiringRecord) -> bool { record.kind == KIND } +/// Whether `record` is one the python-lock revert owns: a PEP 751 lock +/// document or a script's inline metadata. +pub(super) fn is_python_lock_kind_record(record: &WiringRecord) -> bool { + record.kind == KIND || record.kind == SCRIPT_KIND +} + pub(super) async fn wire_python_locks( project: &PythonLocks, root: &Path, From f95f1d8fd693469d27a1bb50d88450425bc00347 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:34:45 +0000 Subject: [PATCH 3/6] Name only this package's wheel as vendored The routing note's "already vendored" check matched any line under .socket/vendor/pypi/, so another package's vendored line would mark requirements.txt as wired for this one. It now matches only a line that pins this package's own wheel at this version. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/vendor/pypi.rs | 45 ++++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 5afafc8dc..d79f3dc37 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -396,9 +396,12 @@ async fn detect_pypi_flavor( let mut vendored = Vec::new(); let mut unchanged = Vec::new(); for file in present { - let wired = tokio::fs::read_to_string(project_root.join(file)) - .await - .is_ok_and(|text| text.contains(".socket/vendor/pypi/")); + let wired = match target { + Some((name, version)) => tokio::fs::read_to_string(project_root.join(file)) + .await + .is_ok_and(|text| references_vendored_wheel(&text, name, version)), + None => false, + }; if wired { vendored.push(file); } else { @@ -1433,6 +1436,22 @@ async fn pypi_prelude<'p>( }) } +/// Whether `text` has a line pinning `name==version` to a wheel under +/// `.socket/vendor/pypi/` (a wheel file name spells the project name with +/// `_` for every run of `-`, `_` and `.`, PEP 427). +fn references_vendored_wheel(text: &str, name: &str, version: &str) -> bool { + let wheel = format!( + "/{}-{}-", + canonicalize_pypi_name(name).replace('-', "_"), + version + ) + .to_ascii_lowercase(); + text.lines().any(|line| { + let line = line.to_ascii_lowercase(); + line.contains(".socket/vendor/pypi/") && line.contains(&wheel) + }) +} + /// The lock an exported requirements pin is wired with, and the command /// that exports it, for the flavors that co-wire one (#612, #1368). fn sibling_lock(flavor: PypiFlavor) -> Option<(&'static str, &'static str)> { @@ -3028,8 +3047,8 @@ async fn wire_pipenv_pylocks( /// Pipenv or uv export, #612 and #1368, or a requirements.txt vendored /// before the lock appeared, #1524) 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 +/// 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( @@ -10833,6 +10852,22 @@ wheels = [{url = "https://files.pythonhosted.org/six.whl", hashes = {sha256 = "u } } + /// #1524: only a line pinning this package's own vendored wheel counts + /// as "already vendored"; another package's vendored line does not. + #[test] + fn references_vendored_wheel_matches_only_this_package() { + let text = "./.socket/vendor/pypi/aaaa/zope_event-5.0-py3-none-any.whl\n"; + assert!(references_vendored_wheel(text, "zope.event", "5.0")); + assert!(references_vendored_wheel(text, "Zope-Event", "5.0")); + assert!(!references_vendored_wheel(text, "zope.event", "5.1")); + assert!(!references_vendored_wheel(text, "six", "1.16.0")); + assert!(!references_vendored_wheel( + "zope.event==5.0\n", + "zope.event", + "5.0" + )); + } + /// #1524: requirements.txt is vendored, then a `pylock.toml` pinning the /// same release appears and the next vendored run wires it too. The /// entry's flavor becomes `python-lock` while the carried-forward From 4bc92facbfaa5214ccabcc82096ef4e8da34d14f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:56:49 +0000 Subject: [PATCH 4/6] Revert every PyPI record through its own backend The first fix covered one pair of record kinds, but a re-run carries any earlier wiring forward, so other mixes stayed stuck: requirements lines under a poetry or pdm entry were kept as drift, and a pylock record under a requirements entry was read as a requirements line. The revert now sends each record to the backend that owns its kind: requirements lines first, then pylocks, then other backends' records, and the entry's own flavor last. If a later group fails or drifts, the files reverted so far are put back, as the uv and Pipenv exports already did. The routing note now uses the requirements vendor tag to tell that requirements.txt is already vendored for this package. It no longer guesses from paths, and it doesn't claim lock files revert with it. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/vendor/pypi.rs | 418 ++++++++++-------- .../socket-patch-core/src/vendor/pypi_lock.rs | 8 +- .../src/vendor/pypi_requirements.rs | 6 + 3 files changed, 245 insertions(+), 187 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index d79f3dc37..ede5496b6 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -390,17 +390,24 @@ async fn detect_pypi_flavor( if exists(PYPI_REQUIREMENTS).await { present.push(PYPI_REQUIREMENTS); } - // #1524: a file an earlier run already vendored keeps that wiring, - // and its records stay in the ledger entry, so say so rather than - // lumping it in with the registry-sourced files. + // #1524: a requirements.txt an earlier run vendored for this + // package keeps that wiring (and its records stay in the ledger + // entry), so don't say it keeps its registry source. let mut vendored = Vec::new(); let mut unchanged = Vec::new(); for file in present { let wired = match target { - Some((name, version)) => tokio::fs::read_to_string(project_root.join(file)) - .await - .is_ok_and(|text| references_vendored_wheel(&text, name, version)), - None => false, + Some((name, _)) if file == PYPI_REQUIREMENTS => { + read_regular_to_string(&project_root.join(file)) + .await + .is_ok_and(|text| { + super::pypi_requirements::has_vendor_line( + &text, + &canonicalize_pypi_name(name), + ) + }) + } + _ => false, }; if wired { vendored.push(file); @@ -416,10 +423,7 @@ async fn detect_pypi_flavor( )); } if !vendored.is_empty() { - clauses.push(format!( - "{} stay wired to the vendored wheel and revert with it", - vendored.join(", ") - )); + clauses.push(format!("{} keeps its vendored wiring", vendored.join(", "))); } if !clauses.is_empty() { warnings.push(VendorWarning::new( @@ -1436,22 +1440,6 @@ async fn pypi_prelude<'p>( }) } -/// Whether `text` has a line pinning `name==version` to a wheel under -/// `.socket/vendor/pypi/` (a wheel file name spells the project name with -/// `_` for every run of `-`, `_` and `.`, PEP 427). -fn references_vendored_wheel(text: &str, name: &str, version: &str) -> bool { - let wheel = format!( - "/{}-{}-", - canonicalize_pypi_name(name).replace('-', "_"), - version - ) - .to_ascii_lowercase(); - text.lines().any(|line| { - let line = line.to_ascii_lowercase(); - line.contains(".socket/vendor/pypi/") && line.contains(&wheel) - }) -} - /// The lock an exported requirements pin is wired with, and the command /// that exports it, for the flavors that co-wire one (#612, #1368). fn sibling_lock(flavor: PypiFlavor) -> Option<(&'static str, &'static str)> { @@ -3043,140 +3031,140 @@ async fn wire_pipenv_pylocks( } } -/// Revert a lock-flavor entry: the requirements lines wired with it (a -/// Pipenv or uv export, #612 and #1368, or a requirements.txt vendored -/// before the lock appeared, #1524) 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 +/// The backend that owns a wiring record of `kind`. One entry can hold +/// records of several backends: a re-run carries the earlier wiring forward +/// (`carry_forward_wiring`) even when it restamps the flavor, e.g. +/// requirements.txt vendored before a pylock.toml appeared (#1524), and a +/// Pipenv or uv lock wired with its export (#612, #1368). A kind no backend +/// claims stays with the entry's own `flavor`, whose backend reports it. +fn record_owner(kind: &str, flavor: PypiFlavor) -> PypiFlavor { + if kind == super::pypi_requirements::RECORD_KIND { + PypiFlavor::Requirements + } else if super::pypi_lock::is_python_lock_kind(kind) { + PypiFlavor::PythonLocks + } else if kind.starts_with("uv_") { + PypiFlavor::UvProject + } else if kind.starts_with("hatch_") { + PypiFlavor::Hatch + } else if kind.starts_with("poetry_") { + PypiFlavor::Poetry + } else if kind.starts_with("pdm_") { + PypiFlavor::Pdm + } else if kind.starts_with("pipenv_") { + PypiFlavor::Pipenv + } else { + flavor + } +} + +/// Revert `entry`'s records through `flavor`'s backend alone. +async fn revert_flavor_records( + flavor: PypiFlavor, + entry: &VendorEntry, + project_root: &Path, + dry_run: bool, +) -> RevertOutcome { + match flavor { + PypiFlavor::UvProject => revert_uv(entry, project_root, dry_run).await, + PypiFlavor::Pipenv => super::pypi_pipenv::revert_pipenv(entry, project_root, dry_run).await, + PypiFlavor::PythonLocks => { + super::pypi_lock::revert_python_locks(entry, project_root, dry_run).await + } + PypiFlavor::Hatch => super::pypi_hatch::revert(entry, project_root, dry_run).await, + PypiFlavor::Poetry => super::pypi_poetry::revert_poetry(entry, project_root, dry_run).await, + PypiFlavor::Pdm => super::pypi_pdm::revert_pdm(entry, project_root, dry_run).await, + PypiFlavor::Requirements => revert_requirements(entry, project_root, dry_run).await, + } +} + +/// Revert an entry whose flavor is `flavor`, each record through the +/// backend that owns its kind ([`record_owner`]): requirements lines first, +/// then the pylocks, then any other backend's records, and the flavor's own +/// records last. The files of the earlier groups are put back if a later +/// group fails or is drift-kept, 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( +async fn revert_by_record_kind( entry: &VendorEntry, project_root: &Path, flavor: PypiFlavor, dry_run: bool, ) -> RevertOutcome { - async fn revert_lock( - flavor: PypiFlavor, - entry: &VendorEntry, - project_root: &Path, - dry_run: bool, - ) -> RevertOutcome { - match flavor { - PypiFlavor::UvProject => revert_uv(entry, project_root, dry_run).await, - PypiFlavor::Pipenv => { - super::pypi_pipenv::revert_pipenv(entry, project_root, dry_run).await - } - PypiFlavor::PythonLocks => { - super::pypi_lock::revert_python_locks(entry, project_root, dry_run).await - } - PypiFlavor::Hatch => super::pypi_hatch::revert(entry, project_root, dry_run).await, - PypiFlavor::Poetry => { - super::pypi_poetry::revert_poetry(entry, project_root, dry_run).await - } - PypiFlavor::Pdm => super::pypi_pdm::revert_pdm(entry, project_root, dry_run).await, - PypiFlavor::Requirements => { - RevertOutcome::failed("no exported-requirements revert for the requirements flavor") - } - } - } - let (lines, rest): (Vec<_>, Vec<_>) = entry - .wiring + const ORDER: [PypiFlavor; 7] = [ + PypiFlavor::Requirements, + PypiFlavor::PythonLocks, + PypiFlavor::Hatch, + PypiFlavor::Poetry, + PypiFlavor::Pdm, + PypiFlavor::Pipenv, + PypiFlavor::UvProject, + ]; + let mut groups: Vec<(PypiFlavor, Vec)> = ORDER .iter() - .cloned() - .partition(|r| r.kind == super::pypi_requirements::RECORD_KIND); - // #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; + .filter(|owner| **owner != flavor) + .chain(std::iter::once(&flavor)) + .map(|owner| { + let records: Vec<_> = entry + .wiring + .iter() + .filter(|r| record_owner(&r.kind, flavor) == *owner) + .cloned() + .collect(); + (*owner, records) + }) + .filter(|(_, records)| !records.is_empty()) + .collect(); + if groups.len() <= 1 { + return revert_flavor_records(flavor, entry, project_root, dry_run).await; } - // The exported files and pylocks as they are now, to put back if the - // lock fails. + // The files of every group but the last as they are now, to put back + // if a later group fails or is drift-kept. let snapshot = if dry_run { Vec::new() } else { - let mut files: Vec<&str> = lines + let mut files: Vec<&str> = groups[..groups.len() - 1] .iter() - .chain(&pylocks) - .map(|r| r.file.as_str()) + .flat_map(|(_, records)| records.iter().map(|r| r.file.as_str())) .collect(); files.sort_unstable(); files.dedup(); snapshot_files(project_root, &files).await }; - let mut lines_outcome = RevertOutcome::ok(); - if !lines.is_empty() { - let lines_entry = VendorEntry { - wiring: lines, + let mut merged = RevertOutcome::ok(); + for (owner, records) in groups.drain(..) { + let group_entry = VendorEntry { + wiring: records, ..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(); + let mut outcome = revert_flavor_records(owner, &group_entry, project_root, dry_run).await; + if !outcome.success { + let mut detail = outcome.error.take().unwrap_or_default(); restore_snapshot(project_root, &snapshot, &mut detail).await; - pylocks_outcome.error = Some(detail); - return pylocks_outcome; + outcome.error = Some(detail); + return 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() { + // A drift-kept group (uv's pair gate writes neither file) makes the + // caller keep the artifact and the whole ledger entry. Put the + // files reverted so far back on the wheel too, so the tree matches + // that entry and a re-run reverts everything once the drift is + // undone. + if 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( + outcome.warnings.push(VendorWarning::new( "vendor_lock_entry_drifted", - format!("the files wired with the drifted pylock stay wired{detail}"), + format!("the files wired with the drifted lock 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; - } - let lock_entry = VendorEntry { - wiring: lock, - ..entry.clone() - }; - let mut outcome = revert_lock(flavor, &lock_entry, project_root, dry_run).await; - if !outcome.success { - let mut detail = outcome.error.take().unwrap_or_default(); - restore_snapshot(project_root, &snapshot, &mut detail).await; - outcome.error = Some(detail); - return outcome; - } - // A drift-kept lock (uv's pair gate writes neither file) makes the - // caller keep the artifact and the whole ledger entry, export lines - // included. Put the export back on the wheel too, so the tree matches - // that entry and a re-run reverts both once the drift is undone. - if outcome.drift_skipped() { - let mut detail = String::new(); - restore_snapshot(project_root, &snapshot, &mut detail).await; - if !detail.is_empty() { - outcome.warnings.push(VendorWarning::new( - "vendor_lock_entry_drifted", - format!("the files wired with the drifted lock stay wired{detail}"), - )); + outcome.warnings.extend(merged.warnings); + outcome.kept_artifact |= merged.kept_artifact; + return outcome; } + merged.warnings.extend(outcome.warnings); + merged.kept_artifact |= outcome.kept_artifact; } - outcome.warnings.extend(lines_outcome.warnings); - outcome.kept_artifact |= lines_outcome.kept_artifact; - outcome + merged } /// `VendorEntry::flavor` values the dispatch below knows how to revert — @@ -3243,42 +3231,25 @@ pub async fn revert_pypi_opts( } RevertOutcome::ok() } else { - match entry.flavor.as_deref() { - Some("uv") => { - revert_with_sibling(entry, project_root, PypiFlavor::UvProject, dry_run).await - } - // #1524: a re-run that wires a lock beside an already vendored - // requirements.txt restamps the flavor but carries the - // `requirements_line` records forward, so every lock flavor - // reverts those lines too instead of calling them drift. - Some("python-lock") => { - revert_with_sibling(entry, project_root, PypiFlavor::PythonLocks, dry_run).await - } - Some("hatch") => { - revert_with_sibling(entry, project_root, PypiFlavor::Hatch, dry_run).await - } - // ... and the other way round: a pylock wired by an earlier run - // stays recorded when the next run wires requirements.txt. - Some("requirements") - if entry - .wiring - .iter() - .any(super::pypi_lock::is_python_lock_kind_record) => - { - revert_with_sibling(entry, project_root, PypiFlavor::PythonLocks, dry_run).await - } - Some("requirements") => revert_requirements(entry, project_root, dry_run).await, - Some("poetry") => { - revert_with_sibling(entry, project_root, PypiFlavor::Poetry, dry_run).await - } - Some("pdm") => revert_with_sibling(entry, project_root, PypiFlavor::Pdm, dry_run).await, - Some("pipenv") => { - revert_with_sibling(entry, project_root, PypiFlavor::Pipenv, dry_run).await - } - other => { + // Each record goes to the backend that owns its kind, whatever + // flavor the entry was last stamped with (#1524). + let flavor = match entry.flavor.as_deref() { + Some("uv") => Some(PypiFlavor::UvProject), + Some("python-lock") => Some(PypiFlavor::PythonLocks), + Some("hatch") => Some(PypiFlavor::Hatch), + Some("requirements") => Some(PypiFlavor::Requirements), + Some("poetry") => Some(PypiFlavor::Poetry), + Some("pdm") => Some(PypiFlavor::Pdm), + Some("pipenv") => Some(PypiFlavor::Pipenv), + _ => None, + }; + match flavor { + Some(flavor) => revert_by_record_kind(entry, project_root, flavor, dry_run).await, + None => { + let other = entry.flavor.as_deref(); return RevertOutcome::failed(format!( "unknown pypi vendor flavor {other:?}; cannot revert" - )) + )); } } }; @@ -10852,22 +10823,6 @@ wheels = [{url = "https://files.pythonhosted.org/six.whl", hashes = {sha256 = "u } } - /// #1524: only a line pinning this package's own vendored wheel counts - /// as "already vendored"; another package's vendored line does not. - #[test] - fn references_vendored_wheel_matches_only_this_package() { - let text = "./.socket/vendor/pypi/aaaa/zope_event-5.0-py3-none-any.whl\n"; - assert!(references_vendored_wheel(text, "zope.event", "5.0")); - assert!(references_vendored_wheel(text, "Zope-Event", "5.0")); - assert!(!references_vendored_wheel(text, "zope.event", "5.1")); - assert!(!references_vendored_wheel(text, "six", "1.16.0")); - assert!(!references_vendored_wheel( - "zope.event==5.0\n", - "zope.event", - "5.0" - )); - } - /// #1524: requirements.txt is vendored, then a `pylock.toml` pinning the /// same release appears and the next vendored run wires it too. The /// entry's flavor becomes `python-lock` while the carried-forward @@ -10900,8 +10855,9 @@ wheels = [{url = "https://files.pythonhosted.org/six.whl", hashes = {sha256 = "u .map(|w| w.detail.as_str()) .collect(); assert!( - routing.iter().any(|d| d - .contains("requirements.txt stay wired to the vendored wheel and revert with it")) + routing + .iter() + .any(|d| d.contains("requirements.txt keeps its vendored wiring")) && routing .iter() .all(|d| !d.contains("retain their existing sources")), @@ -10948,6 +10904,102 @@ wheels = [{url = "https://files.pythonhosted.org/six.whl", hashes = {sha256 = "u assert!(!uuid_dir_of(&fx).exists()); } + /// #1524 for the other lock flavors: requirements.txt is vendored, then + /// a poetry.lock or pdm.lock appears and the next run wires it. The + /// carried-forward `requirements_line` record reverts with the lock. + #[tokio::test] + async fn requirements_then_tool_lock_entry_reverts_both_files() { + let key = "pkg:pypi/six@1.16.0"; + for (flavor, lock_file, lock_text) in [ + ("poetry", "poetry.lock", POETRY_LOCK_REGISTRY), + ("pdm", "pdm.lock", PDM_LOCK_REGISTRY), + ] { + let fx = e2e_fixture().await; + let sources = PatchSources::blobs_only(&fx.blobs); + let (result, entry, _) = + crate::vendor::test_support::expect_done(vendor_six(&fx, &sources, None).await); + assert!(result.success, "{flavor}: {:?}", result.error); + crate::vendor::test_support::persist(&fx.root, key, entry.unwrap()).await; + + touch(&fx.root, lock_file, lock_text).await; + let (result, entry, _) = + crate::vendor::test_support::expect_done(vendor_six(&fx, &sources, None).await); + assert!(result.success, "{flavor}: {:?}", result.error); + crate::vendor::test_support::persist(&fx.root, key, entry.unwrap()).await; + let state = crate::vendor::state::load_state(&fx.root).await.unwrap(); + let entry = state.entries.get(key).unwrap().clone(); + assert_eq!(entry.flavor.as_deref(), Some(flavor)); + assert!( + entry.wiring.iter().any(|r| r.kind == "requirements_line"), + "{flavor}: {:?}", + entry.wiring + ); + + let reverted = revert_pypi(&entry, &fx.root, false).await; + assert!(reverted.success, "{flavor}: {:?}", reverted.error); + assert!(!reverted.kept_artifact, "{flavor}: {:?}", reverted.warnings); + assert!( + !reverted.drift_skipped(), + "{flavor}: {:?}", + reverted.warnings + ); + assert_eq!(read_requirements(&fx).await, "six==1.16.0\n", "{flavor}"); + assert_eq!( + tokio::fs::read_to_string(fx.root.join(lock_file)) + .await + .unwrap(), + lock_text, + "{flavor}" + ); + assert!(!uuid_dir_of(&fx).exists(), "{flavor}"); + } + } + + /// #1524 the other way round: a requirements-flavor entry that still + /// carries a pylock record (stamped by an earlier python-lock run) + /// sends that record to the python-lock revert, not to the requirements + /// one, so neither file is called drift. + #[tokio::test] + async fn requirements_entry_with_a_pylock_record_reverts_both_files() { + const PYLOCK: &str = "lock-version = \"1.0\"\ncreated-by = \"pip\"\n\n[[packages]]\nname = \"six\"\nversion = \"1.16.0\"\nwheels = [{ url = \"https://files.pythonhosted.org/six.whl\", hashes = { sha256 = \"upstream\" } }]\n"; + let key = "pkg:pypi/six@1.16.0"; + let fx = e2e_fixture().await; + let sources = PatchSources::blobs_only(&fx.blobs); + let (_, entry, _) = + crate::vendor::test_support::expect_done(vendor_six(&fx, &sources, None).await); + let lines = entry.unwrap(); + // The same release wired into pylock.toml by a python-lock run. + let lock_fx = e2e_fixture().await; + swap_to_lock_flavor(&lock_fx, &[("pylock.toml", PYLOCK)]).await; + let (_, lock_entry, _) = crate::vendor::test_support::expect_done( + vendor_six(&lock_fx, &PatchSources::blobs_only(&lock_fx.blobs), None).await, + ); + let lock_entry = lock_entry.unwrap(); + assert_eq!(lock_entry.flavor.as_deref(), Some("python-lock")); + tokio::fs::copy( + lock_fx.root.join("pylock.toml"), + fx.root.join("pylock.toml"), + ) + .await + .unwrap(); + let mut entry = lines.clone(); + entry.wiring.extend(lock_entry.wiring.iter().cloned()); + crate::vendor::test_support::persist(&fx.root, key, entry.clone()).await; + assert_eq!(entry.flavor.as_deref(), Some("requirements")); + + let reverted = revert_pypi(&entry, &fx.root, false).await; + assert!(reverted.success, "{:?}", reverted.error); + assert!(!reverted.drift_skipped(), "{:?}", reverted.warnings); + assert!(!reverted.kept_artifact, "{:?}", reverted.warnings); + assert_eq!(read_requirements(&fx).await, "six==1.16.0\n"); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("pylock.toml")) + .await + .unwrap(), + PYLOCK + ); + } + #[tokio::test] async fn script_python_lock_pairs_metadata_and_restores_original_bytes() { let fx = e2e_fixture().await; diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index 5c7c384f6..a017943a5 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -421,10 +421,10 @@ pub(super) fn is_lock_record(record: &WiringRecord) -> bool { record.kind == KIND } -/// Whether `record` is one the python-lock revert owns: a PEP 751 lock -/// document or a script's inline metadata. -pub(super) fn is_python_lock_kind_record(record: &WiringRecord) -> bool { - record.kind == KIND || record.kind == SCRIPT_KIND +/// Whether a wiring record of `kind` is one the python-lock revert owns: a +/// PEP 751 lock document or a script's inline metadata. +pub(super) fn is_python_lock_kind(kind: &str) -> bool { + kind == KIND || kind == SCRIPT_KIND } pub(super) async fn wire_python_locks( diff --git a/crates/socket-patch-core/src/vendor/pypi_requirements.rs b/crates/socket-patch-core/src/vendor/pypi_requirements.rs index d1cb678e2..4e4af0dc9 100644 --- a/crates/socket-patch-core/src/vendor/pypi_requirements.rs +++ b/crates/socket-patch-core/src/vendor/pypi_requirements.rs @@ -265,6 +265,12 @@ fn vendor_lines<'a>( }) } +/// Whether one file's content holds a socket vendor line for `canon_name` +/// ([`vendor_lines`]). +pub(super) fn has_vendor_line(content: &str, canon_name: &str) -> bool { + vendor_lines(content, canon_name).next().is_some() +} + /// Find a socket vendor line for `canon_name` in one file's content and /// return the patch uuid its wheel path names ([`vendor_lines`]). fn vendored_uuid_for(content: &str, canon_name: &str) -> Option { From 00abb90acea02e06404c19f3d2e75b98ebd5635f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:18:36 +0000 Subject: [PATCH 5/6] Use the owning backend for a one-owner entry When every record of an entry belongs to one backend that isn't the flavor the entry was last stamped with, the revert still called the stamped flavor's backend, which kept the wheel as drift. It now calls the backend that owns the records. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/vendor/pypi.rs | 30 ++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index ede5496b6..270f4e013 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -3114,8 +3114,11 @@ async fn revert_by_record_kind( }) .filter(|(_, records)| !records.is_empty()) .collect(); + // One owner: its backend reverts the whole entry, even when that owner + // is not the flavor the entry was last stamped with. if groups.len() <= 1 { - return revert_flavor_records(flavor, entry, project_root, dry_run).await; + let owner = groups.first().map_or(flavor, |(owner, _)| *owner); + return revert_flavor_records(owner, entry, project_root, dry_run).await; } // The files of every group but the last as they are now, to put back // if a later group fails or is drift-kept. @@ -11000,6 +11003,31 @@ wheels = [{url = "https://files.pythonhosted.org/six.whl", hashes = {sha256 = "u ); } + /// #1524: an entry stamped with one flavor whose records all belong to + /// another backend (the pylock record dropped, the carried-forward + /// requirements lines left) reverts through the backend that owns them. + #[tokio::test] + async fn entry_whose_records_all_belong_to_another_flavor_reverts() { + let fx = e2e_fixture().await; + let sources = PatchSources::blobs_only(&fx.blobs); + let (_, entry, _) = + crate::vendor::test_support::expect_done(vendor_six(&fx, &sources, None).await); + let mut entry = entry.unwrap(); + assert!(entry.wiring.iter().all(|r| r.kind == "requirements_line")); + for flavor in ["python-lock", "uv", "pipenv"] { + entry.flavor = Some(flavor.to_string()); + let preview = revert_pypi(&entry, &fx.root, true).await; + assert!(preview.success, "{flavor}: {:?}", preview.error); + assert!(!preview.drift_skipped(), "{flavor}: {:?}", preview.warnings); + } + let reverted = revert_pypi(&entry, &fx.root, false).await; + assert!(reverted.success, "{:?}", reverted.error); + assert!(!reverted.drift_skipped(), "{:?}", reverted.warnings); + assert!(!reverted.kept_artifact, "{:?}", reverted.warnings); + assert_eq!(read_requirements(&fx).await, "six==1.16.0\n"); + assert!(!uuid_dir_of(&fx).exists()); + } + #[tokio::test] async fn script_python_lock_pairs_metadata_and_restores_original_bytes() { let fx = e2e_fixture().await; From faf549a1cc326a3af2f93342d9382c89d4c0a8b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:49:48 +0000 Subject: [PATCH 6/6] Check every PyPI takeover by record kind Now that a mixed entry reverts fully, the hosted takeover could revert a requirements.txt line from a -r include, or an appended transitive line, under a pylock, poetry or pdm entry. Its preflight only checked requirements lines for uv and Pipenv entries, so the package then went unpinned in requirements.txt and installed the unpatched release. The preflight now checks requirements lines under any non-requirements flavor, and the uv and Poetry checks whenever the entry holds their records, matching how the revert dispatches. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/pypi_takeover.rs | 91 ++++++++++++++++--- 1 file changed, 80 insertions(+), 11 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs b/crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs index a7595a3f4..f5529bf91 100644 --- a/crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs @@ -32,22 +32,32 @@ pub async fn preflight_pypi_takeover( if entry.ecosystem != "pypi" { return Ok(()); } + // The revert sends each record to the backend that owns its kind, + // whatever flavor the entry was last stamped with (#1524), so each + // reach check runs for the records it is about, not for the flavor. preflight_sibling_requirements_takeover(entry)?; - match entry.flavor.as_deref() { - Some("requirements") => preflight_requirements_takeover(entry), - Some("uv") => preflight_uv_takeover(entry), - Some("poetry") => preflight_poetry_takeover(root, entry).await, - _ => Ok(()), + let has_kind = |prefix: &str| entry.wiring.iter().any(|r| r.kind.starts_with(prefix)); + if entry.flavor.as_deref() == Some("requirements") { + preflight_requirements_takeover(entry)?; } + if entry.flavor.as_deref() == Some("uv") || has_kind("uv_") { + preflight_uv_takeover(entry)?; + } + if entry.flavor.as_deref() == Some("poetry") || has_kind("poetry_") { + preflight_poetry_takeover(root, entry).await?; + } + Ok(()) } -/// A Pipenv or uv entry also carries the `requirements_line` records of -/// the exported requirements pin wired with its lock (#612, #1368). After -/// the revert, the hosted requirements rewriter re-pins only the root -/// requirements.txt, so a line wired in a `-r` include is refused exactly -/// as for a requirements-flavor entry. +/// An entry of any other flavor can also carry `requirements_line` +/// records: the exported requirements pin wired with a Pipenv or uv lock +/// (#612, #1368), or a requirements.txt vendored before a pylock, Poetry +/// or PDM lock took over the entry (#1524). After the revert, the hosted +/// requirements rewriter re-pins only the root requirements.txt, so a line +/// wired in a `-r` include is refused exactly as for a requirements-flavor +/// entry. fn preflight_sibling_requirements_takeover(entry: &VendorEntry) -> Result<(), RewriteWarning> { - if !matches!(entry.flavor.as_deref(), Some("uv" | "pipenv")) { + if entry.flavor.as_deref() == Some("requirements") { return Ok(()); } let lines: Vec<_> = entry @@ -297,6 +307,65 @@ mod tests { } } + /// #1524: a requirements.txt vendored before a pylock, Poetry or PDM + /// lock took the entry over keeps its `requirements_line` records, and + /// the revert restores them, so a line in a `-r` include (or an + /// appended transitive line) is refused before the revert whatever the + /// entry's flavor. + #[tokio::test] + async fn requirements_lines_under_another_lock_flavor_are_checked() { + let root = tempfile::tempdir().unwrap(); + let lock = |file: &str, kind: &str| WiringRecord { + file: file.into(), + kind: kind.into(), + action: WiringAction::Rewritten, + key: Some("six".into()), + original: None, + new: None, + }; + for (flavor, lock) in [ + ("python-lock", lock("pylock.toml", "python_lock_document")), + ("pdm", lock("pdm.lock", "pdm_lock_package")), + ("hatch", lock("pylock.toml", "hatch_document")), + ] { + let e = entry( + flavor, + vec![lock.clone(), requirements_line("requirements.txt")], + ); + assert!( + preflight_pypi_takeover(root.path(), &e).await.is_ok(), + "{flavor}" + ); + let e = entry( + flavor, + vec![lock.clone(), requirements_line("req/base.txt")], + ); + let err = preflight_pypi_takeover(root.path(), &e).await.unwrap_err(); + assert_eq!( + err.code, "redirect_requirements_takeover_unreachable", + "{flavor}" + ); + let mut appended = requirements_line("requirements.txt"); + appended.action = WiringAction::Added; + let e = entry(flavor, vec![lock, appended]); + let err = preflight_pypi_takeover(root.path(), &e).await.unwrap_err(); + assert_eq!( + err.code, "redirect_requirements_takeover_unreachable", + "{flavor}" + ); + } + } + + /// #1524: a uv lock unit carried into an entry stamped with another + /// flavor is still checked for the #723 pinned-down version. + #[tokio::test] + async fn uv_records_under_another_flavor_are_checked() { + let root = tempfile::tempdir().unwrap(); + let e = entry("python-lock", vec![uv_package("1.17.0")]); + let err = preflight_pypi_takeover(root.path(), &e).await.unwrap_err(); + assert_eq!(err.code, "redirect_uv_takeover_version_unreachable"); + } + #[test] fn uv_entry_at_the_patch_version_is_admitted() { assert!(preflight_uv_takeover(&entry("uv", vec![uv_package("1.16.0")])).is_ok());