From 0bb747edfbf3f550faeaffc146168de58b5f89e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 02:23:23 +0000 Subject: [PATCH 1/4] Start fix for #1758 Assisted-by: Claude Code:claude-opus-5-5 From 701b87b115d0dbc0939966ce96049a4fff83bbdd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 02:36:21 +0000 Subject: [PATCH 2/4] Wire requirements.txt beside a pylock too A pip project that installs from both requirements.txt and the pylock.toml `pip lock` wrote from it had only the pylock wired to the vendored wheel. requirements.txt was named as a file that "retains its existing sources", but a hosted -> vendored switch (plain `vendor` or `scan --mode vendored`) had just restored its hosted pin to plain PyPI, so `pip install -r requirements.txt` silently went from patched to unpatched while the run exited 0 and `vendor --check` then failed. The Python-lock flavor now wires an exact registry pin in requirements.txt (and in-root -r includes) to the same wheel, the way the Pipenv and uv flavors already wire their exported requirements (#612, #1368). The ledger entry records both files, so revert restores both, and an already-wired lock re-run wires a pin added since. Fixes #1758 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/mode_migration_pypi.rs | 171 ++++++++++++++++++ crates/socket-patch-core/src/vendor/pypi.rs | 127 ++++++++++--- .../socket-patch-core/src/vendor/pypi_lock.rs | 15 ++ 4 files changed, 288 insertions(+), 27 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 5acc22d01..7acc3e645 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1581,7 +1581,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. 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_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`, `uv.lock` or a wired PEP 751 / script lock, an exact registry pin of the package in `requirements.txt` or an in-root `-r` include (`pipenv requirements` or `uv export` output, or the file `pip lock -r requirements.txt` read) is wired with the lock instead and never named here (#612, #1368, #1758): 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_npm_deno_lock` | `skipped` (warning) | vendor / scan / get `--mode vendored` (npm, every lockfile flavor, #406): the package was vendored (also on an in-sync re-run), but the project root's `deno.lock` locks the same `name@version`. `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads the rewired lock, so installs Deno runs keep the registry copy and `vex` omits the patch (`vex_deno_lock_copy`); npm / pnpm / yarn / bun installs are patched. To patch what Deno installs, run `deno install` and then `socket-patch scan --mode agent`. | | `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. | diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 38be57446..95e7a5236 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -3178,6 +3178,177 @@ async fn pipenv_hosted_to_vendored_keeps_the_requirements_patched() { ); } +/// `pip lock -r requirements.txt -o pylock.toml` (pip 26.2.2) over +/// [`stage_pip_lock`]'s requirements.txt, byte for byte. +const PIP_LOCK: &str = r#"lock-version = "1.0" +created-by = "pip" + +[[packages]] +name = "idna" +version = "3.7" + +[[packages.wheels]] +name = "idna-3.7-py3-none-any.whl" +url = "https://files.pythonhosted.org/packages/e5/3e/741d8c82801c347547f8a2a06aa57dbb1992be9e948df2ea0eda2c8b79e8/idna-3.7-py3-none-any.whl" + +[packages.wheels.hashes] +sha256 = "82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0" + +[[packages]] +name = "six" +version = "1.16.0" + +[[packages.wheels]] +name = "six-1.16.0-py2.py3-none-any.whl" +url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl" + +[packages.wheels.hashes] +sha256 = "8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" +"#; + +/// A `pip lock` project: the requirements.txt and the PEP 751 lock pip +/// wrote from it, both pinning six (and idna); returns its wiring files. +fn stage_pip_lock(root: &Path) -> &'static [&'static str] { + std::fs::write(root.join("requirements.txt"), "idna==3.7\nsix==1.16.0\n").unwrap(); + std::fs::write(root.join("pylock.toml"), PIP_LOCK).unwrap(); + &["requirements.txt", "pylock.toml"] +} + +/// #1758: a pip project installs from both `requirements.txt` and the +/// `pylock.toml` `pip lock` wrote from it. Vendored mode wires both to the +/// same wheel (as hosted mode pins both), `vendor --check` is green, and +/// the revert restores both files byte for byte. Before the fix only +/// pylock.toml was wired and requirements.txt was named as an unpatched +/// loser. +#[tokio::test] +async fn pip_lock_vendor_wires_requirements_with_the_pylock() { + let (_tmp, root) = project(); + let files = stage_pip_lock(&root); + let originals: Vec<(&str, Vec)> = files + .iter() + .map(|f| (*f, std::fs::read(root.join(f)).unwrap())) + .collect(); + stage_manifest(&root); + let needle = format!(".socket/vendor/pypi/{UUID}/"); + + let (code, env) = run_cli(&root, &["vendor"], &[]); + assert_eq!(code, 0, "{env:#}"); + for file in files { + assert!( + std::fs::read_to_string(root.join(file)) + .unwrap() + .contains(&needle), + "{file} is wired: {env:#}" + ); + } + assert!( + !env.to_string().contains("UNPATCHED") + && !env.to_string().contains("retain their existing sources"), + "requirements.txt is wired, not an unpatched loser: {env:#}" + ); + + let (code, env) = run_cli(&root, &["vendor", "--check"], &[]); + assert_eq!(code, 0, "both install sources are wired: {env:#}"); + + // Re-running is settled: nothing rewritten. + let wired: Vec> = files + .iter() + .map(|f| std::fs::read(root.join(f)).unwrap()) + .collect(); + let (code, env) = run_cli(&root, &["vendor"], &[]); + assert_eq!(code, 0, "{env:#}"); + for (file, bytes) in files.iter().zip(&wired) { + assert_eq!(&std::fs::read(root.join(file)).unwrap(), bytes, "{file}"); + } + + let (code, env) = run_cli(&root, &["vendor", "--revert"], &[]); + assert_eq!(code, 0, "{env:#}"); + for (file, bytes) in &originals { + assert_eq!( + &std::fs::read(root.join(file)).unwrap(), + bytes, + "{file} is restored" + ); + } + assert!(!root.join(format!(".socket/vendor/pypi/{UUID}")).exists()); +} + +/// #1758: hosted mode pins six in both `requirements.txt` and the +/// `pip lock` pylock.toml. The hosted → vendored switch (plain `vendor`, +/// the eject, and `scan --mode vendored`) restores both files to their +/// registry entry and must then wire BOTH to the vendored wheel. Before +/// the fix only pylock.toml was wired, so `pip install -r +/// requirements.txt` went from patched to unpatched while the run exited +/// 0, and the run's own `vendor --check` failed on contested wiring. +#[tokio::test] +async fn pip_lock_hosted_to_vendored_keeps_the_requirements_patched() { + let needle = format!(".socket/vendor/pypi/{UUID}/"); + for takeover in [false, true] { + // A fresh mock per lane: the fixture server's project routes are + // mounted per project root. + let server = MockServer::start().await; + let hosted_url = mount_hosted_api(&server, true).await; + mount_patch_view(&server).await; + let uri = server.uri(); + let switch: Vec<&str> = if takeover { + vec![ + "scan", + "--mode", + "vendored", + "--yes", + "--api-url", + &uri, + "--org", + ORG, + "--api-token", + "fake-token", + "--patch-server-url", + &uri, + "--vendor-url", + &uri, + ] + } else { + let mut args = vec!["vendor", "--patch-server-url", &uri, "--vendor-url", &uri]; + args.extend(api_args(&uri)); + args + }; + let (_tmp, root) = project(); + let files = stage_pip_lock(&root); + let (code, env) = hosted_scan(&root, &server); + assert_eq!(code, 0, "hosted scan: {env:#}"); + for file in files { + let text = std::fs::read_to_string(root.join(file)).unwrap(); + assert!( + text.contains(&hosted_url), + "hosted mode pins {file}:\n{text}\n{env:#}" + ); + } + + stage_manifest(&root); + prebuilt_common::mount_project(&server, &root).await; + let (code, env) = run_cli(&root, &switch, &[]); + assert_eq!(code, 0, "{switch:?}: {env:#}"); + for file in files { + let text = std::fs::read_to_string(root.join(file)).unwrap(); + assert!( + text.contains(&needle) && !text.contains(&hosted_url), + "{switch:?}: {file} is wired to the vendored wheel:\n{text}\n{env:#}" + ); + } + assert!( + !env.to_string().contains("UNPATCHED") + && !env.to_string().contains("retain their existing sources"), + "{switch:?}: no install source is left unpatched: {env:#}" + ); + + let (code, env) = run_cli(&root, &["vendor", "--check"], &[]); + assert_eq!( + code, 0, + "{switch:?}: both install sources are wired: {env:#}" + ); + } +} + /// The `pipenv requirements > requirements.txt` export of [`stage_pipenv`]'s /// lock: the file Docker / plain-pip installs read. const PIPENV_EXPORT: &str = "-i https://pypi.org/simple\nsix==1.16.0 ; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'\n"; diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 080d08589..0d79d983b 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -387,7 +387,16 @@ async fn detect_pypi_flavor( !additional_locks.is_empty() }; if !has_uv_lock && matching_additional_lock { - if exists(PYPI_REQUIREMENTS).await { + // #1758: a requirements.txt beside the lock (`pip lock -r + // requirements.txt`) pinning this release is wired with it, as + // hosted mode pins both, so it is no loser. + let wired_with_lock = match target { + Some((name, version)) => { + super::pypi_requirements::sibling_pin_wirable(project_root, name, version).await + } + None => false, + }; + if !wired_with_lock && exists(PYPI_REQUIREMENTS).await { present.push(PYPI_REQUIREMENTS); } // #1524: a requirements.txt an earlier run vendored for this @@ -576,7 +585,9 @@ enum WiringPlan { /// The uv.lock plan, and whether the exported requirements file is /// wired too (#1368). Uv(Box, bool), - PythonLocks(super::pypi_lock::PythonLocks), + /// The PEP 751 / script lock plan, and whether the requirements file + /// beside it is wired too (#1758). + PythonLocks(super::pypi_lock::PythonLocks, bool), Requirements, /// Re-wire the vendor lines an OLDER patch uuid's ledger entry recorded /// to this uuid in place (#765). @@ -1146,9 +1157,29 @@ async fn pypi_prelude<'p>( { Ok(project) if project.in_sync => { wired_pin = project.pin; + sibling_pending = super::pypi_requirements::sibling_pin_wirable( + project_root, + &canon_name, + version, + ) + .await; WiringPlan::InSync } - Ok(project) => WiringPlan::PythonLocks(project), + Ok(project) => { + // #1758: the requirements.txt the pylock was locked + // from is an install path of its own; wire its pin + // with the lock. + let sibling = super::pypi_requirements::sibling_pin_wirable( + project_root, + &canon_name, + version, + ) + .await; + if sibling && dry_run { + warnings.push(sibling_dry_run_notice(flavor, &canon_name, version)); + } + WiringPlan::PythonLocks(project, sibling) + } Err(refusal) => { supersede_or_refuse(project_root, flavor, &canon_name, version, record, refusal) .await? @@ -1570,17 +1601,24 @@ async fn pypi_prelude<'p>( }) } -/// The lock an exported requirements pin is wired with, and the command -/// that exports it, for the flavors that co-wire one (#612, #1368). +/// The lock an exported requirements pin is wired with, and how to bring +/// the pin back in line with it, for the flavors that co-wire one (#612, +/// #1368, #1758). fn sibling_lock(flavor: PypiFlavor) -> Option<(&'static str, &'static str)> { match flavor { - PypiFlavor::Pipenv => Some((super::pypi_pipenv::LOCK_FILE, "pipenv requirements")), - PypiFlavor::UvProject => Some(("uv.lock", "uv export --frozen -o requirements.txt")), - PypiFlavor::PythonLocks - | PypiFlavor::Poetry - | PypiFlavor::Pdm - | PypiFlavor::Requirements - | PypiFlavor::Hatch => None, + PypiFlavor::Pipenv => Some(( + super::pypi_pipenv::LOCK_FILE, + "re-export it from Pipfile.lock with `pipenv requirements`", + )), + PypiFlavor::UvProject => Some(( + "uv.lock", + "re-export it from uv.lock with `uv export --frozen -o requirements.txt`", + )), + PypiFlavor::PythonLocks => Some(( + "the Python lockfile", + "run `socket-patch vendor --revert` and vendor again", + )), + PypiFlavor::Poetry | PypiFlavor::Pdm | PypiFlavor::Requirements | PypiFlavor::Hatch => None, } } @@ -1646,7 +1684,7 @@ async fn wire_in_sync_sibling( dry_run: bool, mut warnings: Vec, ) -> VendorOutcome { - let Some((lock, export)) = sibling_lock(flavor) else { + let Some((lock, remedy)) = sibling_lock(flavor) else { return done(result, None, warnings); }; let export_pending = @@ -1665,7 +1703,7 @@ async fn wire_in_sync_sibling( 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}`)", + UNPATCHED registry bytes ({remedy})", crate::formats::governing_locks::PYPI_REQUIREMENTS ), )); @@ -2241,16 +2279,46 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( Err(refusal) => Err(refusal), } } - WiringPlan::PythonLocks(project) => super::pypi_lock::wire_python_locks( - &project, - project_root, - &canon_name, - version, - &rel_wheel, - &artifact.sha256_hex, - ) - .await - .map(|wiring| (wiring, MetaSlot::None)), + WiringPlan::PythonLocks(project, sibling) => { + // The locks as `wire_python_locks` checks they still are, to + // put back if the requirements pin then refuses. + let lock_before: Snapshot = if sibling { + project + .loaded_files() + .into_iter() + .map(|(file, text)| (file, Some(text.into_bytes()))) + .collect() + } else { + Vec::new() + }; + match super::pypi_lock::wire_python_locks( + &project, + project_root, + &canon_name, + version, + &rel_wheel, + &artifact.sha256_hex, + ) + .await + { + // #1758: the requirements pin goes to the same wheel. + Ok(mut wiring) if sibling => wire_sibling_requirements( + project_root, + &lock_before, + None, + &canon_name, + version, + &rel_wheel, + &artifact.sha256_hex, + ) + .await + .map(|lines| { + wiring.extend(lines); + (wiring, MetaSlot::None) + }), + other => other.map(|wiring| (wiring, MetaSlot::None)), + } + } WiringPlan::Hatch(project) => super::pypi_hatch::wire( &project, project_root, @@ -2466,7 +2534,9 @@ async fn planned_requirements_targets( let fresh = || super::pypi_requirements::planned_files(project_root, canon_name, version, rel_wheel); let mut targets = match plan { - WiringPlan::Requirements | WiringPlan::Uv(_, true) => fresh().await, + WiringPlan::Requirements | WiringPlan::Uv(_, true) | WiringPlan::PythonLocks(_, true) => { + fresh().await + } WiringPlan::Pipenv(_, superseded, true, _) => { let mut files = fresh().await; if let Some(prev) = superseded { @@ -2821,7 +2891,12 @@ async fn fresh_pyproject_plan( if project.in_sync { return not_fresh("pypi_lock_source_already_exists"); } - Ok((WiringPlan::PythonLocks(project), Vec::new())) + // The revert just put the requirements pin back (#1758): wire + // it again with the lock. + let sibling = + super::pypi_requirements::sibling_pin_wirable(project_root, canon_name, version) + .await; + Ok((WiringPlan::PythonLocks(project, sibling), Vec::new())) } PypiFlavor::Hatch => { let project = super::pypi_hatch::load(project_root, canon_name, version, uuid).await?; diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index 5e283c397..c019e9ac6 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -41,6 +41,21 @@ pub(super) struct PythonLocks { pub pin: Option<(String, String)>, } +impl PythonLocks { + /// Each file [`wire_python_locks`] may rewrite, with its text as + /// loaded: every lock, and a script lock's script. + pub(super) fn loaded_files(&self) -> Vec<(String, String)> { + let mut files = Vec::new(); + for file in &self.files { + files.push((file.name.clone(), file.text.clone())); + if let (Some(script), Some(name)) = (&file.script, script_of_lock(&file.name)) { + files.push((name.to_string(), script.clone())); + } + } + files + } +} + /// FIFO-safe read that FOLLOWS a symlink to a regular file. Discovery /// (`python_lock_paths`) follows links too, so a shared `pylock.dev.toml` /// link beside a regular `pylock.toml` reads like the file it points at — From 46e40d2c888b9f60280362f1859f35993d65b92a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 03:04:32 +0000 Subject: [PATCH 3/4] Tidy pylock sibling wiring after review A settled re-run over a pip-lock project no longer names the wired requirements.txt in a pypi_multiple_lockfiles note. The rollback snapshot taken before wiring now skips symlinked files instead of rewriting them, the notes no longer call requirements.txt an export of the pylock, and the remedy shown when the ledger is missing names a re-lock instead of a revert that cannot help. Adds coverage for the dry run, the settled re-run, and a pin added after the pylock was wired. Refs #1758 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/mode_migration_pypi.rs | 70 +++++++++++++- crates/socket-patch-core/src/vendor/pypi.rs | 95 +++++++++---------- .../socket-patch-core/src/vendor/pypi_lock.rs | 12 +-- 3 files changed, 117 insertions(+), 60 deletions(-) diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 95e7a5236..afac1baab 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -3231,6 +3231,19 @@ async fn pip_lock_vendor_wires_requirements_with_the_pylock() { stage_manifest(&root); let needle = format!(".socket/vendor/pypi/{UUID}/"); + // The dry run previews the requirements wiring and writes nothing. + let (code, env) = run_cli(&root, &["vendor", "--dry-run"], &[]); + assert_eq!(code, 0, "{env:#}"); + let text = env.to_string(); + assert!( + text.contains("pypi_requirements_sibling_wired") + && !text.contains("pypi_multiple_lockfiles"), + "{env:#}" + ); + for (file, bytes) in &originals { + assert_eq!(&std::fs::read(root.join(file)).unwrap(), bytes, "{file}"); + } + let (code, env) = run_cli(&root, &["vendor"], &[]); assert_eq!(code, 0, "{env:#}"); for file in files { @@ -3242,8 +3255,7 @@ async fn pip_lock_vendor_wires_requirements_with_the_pylock() { ); } assert!( - !env.to_string().contains("UNPATCHED") - && !env.to_string().contains("retain their existing sources"), + !env.to_string().contains("pypi_multiple_lockfiles"), "requirements.txt is wired, not an unpatched loser: {env:#}" ); @@ -3257,6 +3269,10 @@ async fn pip_lock_vendor_wires_requirements_with_the_pylock() { .collect(); let (code, env) = run_cli(&root, &["vendor"], &[]); assert_eq!(code, 0, "{env:#}"); + assert!( + !env.to_string().contains("pypi_multiple_lockfiles"), + "a settled re-run names no lockfile: {env:#}" + ); for (file, bytes) in files.iter().zip(&wired) { assert_eq!(&std::fs::read(root.join(file)).unwrap(), bytes, "{file}"); } @@ -3273,6 +3289,56 @@ async fn pip_lock_vendor_wires_requirements_with_the_pylock() { assert!(!root.join(format!(".socket/vendor/pypi/{UUID}")).exists()); } +/// #1758: a pylock vendored while requirements.txt did not pin six yet +/// (a project vendored before the fix wired only the pylock). Once the +/// file pins six==1.16.0, the next `vendor` wires it to the committed +/// wheel and records it in the ledger entry: `vendor --check` is green +/// and `vendor --revert` restores both files. +#[tokio::test] +async fn pip_lock_rerun_wires_a_requirements_pin_added_later() { + let (_tmp, root) = project(); + stage_pip_lock(&root); + std::fs::write(root.join("requirements.txt"), "idna==3.7\n").unwrap(); + stage_manifest(&root); + let needle = format!(".socket/vendor/pypi/{UUID}/"); + let (code, env) = run_cli(&root, &["vendor"], &[]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!( + std::fs::read_to_string(root.join("requirements.txt")).unwrap(), + "idna==3.7\n", + "{env:#}" + ); + + std::fs::write(root.join("requirements.txt"), "idna==3.7\nsix==1.16.0\n").unwrap(); + let (code, env) = run_cli(&root, &["vendor"], &[]); + assert_eq!(code, 0, "{env:#}"); + assert!( + env.to_string().contains("pypi_requirements_sibling_wired"), + "{env:#}" + ); + assert!( + std::fs::read_to_string(root.join("requirements.txt")) + .unwrap() + .contains(&needle), + "{env:#}" + ); + let (code, env) = run_cli(&root, &["vendor", "--check"], &[]); + assert_eq!(code, 0, "{env:#}"); + + let (code, env) = run_cli(&root, &["vendor", "--revert"], &[]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!( + std::fs::read(root.join("requirements.txt")).unwrap(), + b"idna==3.7\nsix==1.16.0\n", + "{env:#}" + ); + assert_eq!( + std::fs::read(root.join("pylock.toml")).unwrap(), + PIP_LOCK.as_bytes(), + "{env:#}" + ); +} + /// #1758: hosted mode pins six in both `requirements.txt` and the /// `pip lock` pylock.toml. The hosted → vendored switch (plain `vendor`, /// the eject, and `scan --mode vendored`) restores both files to their diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 0d79d983b..f3ff4e9cd 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -401,8 +401,8 @@ async fn detect_pypi_flavor( } // #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(); + // entry), and one wired with the lock (#1758) is no loser either: + // name neither as keeping its registry source. let mut unchanged = Vec::new(); for file in present { let wired = match target { @@ -418,29 +418,17 @@ async fn detect_pypi_flavor( } _ => false, }; - if wired { - vendored.push(file); - } else { + if !wired { 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!("{} keeps its vendored wiring", vendored.join(", "))); - } - if !clauses.is_empty() { warnings.push(VendorWarning::new( "pypi_multiple_lockfiles", format!( - "wiring {}; {}", + "wiring {}; installs driven by {} retain their existing sources", additional_locks.join(", "), - clauses.join("; ") + unchanged.join(", ") ), )); } @@ -1043,8 +1031,8 @@ struct PypiPrelude<'p> { prior: Option, expected_pin: Option<(String, String)>, reused_wheel: Option, - /// An in-sync Pipenv or uv lock whose exported requirements pin still - /// needs wiring once the missing wheel is rebuilt (#612, #1368). + /// An in-sync Pipenv, uv or Python lock whose requirements pin still + /// needs wiring once the missing wheel is rebuilt (#612, #1368, #1758). sibling_pending: bool, } @@ -1103,9 +1091,9 @@ async fn pypi_prelude<'p>( // entry left (a state.json lost in a merge, clobbered, or never // committed — the exact window the state `repair` exists for). let mut wired_pin: Option<(String, String)> = None; - // An in-sync Pipenv or uv lock whose exported requirements pin is not - // wired yet (a project vendored before #612 / #1368, or an export made - // since). + // An in-sync Pipenv, uv or Python lock whose requirements pin is not + // wired yet (a project vendored before #612 / #1368 / #1758, or an + // export made since). let mut sibling_pending = false; let mut warnings: Vec = flavor_warnings; let plan = match flavor { @@ -1616,12 +1604,24 @@ fn sibling_lock(flavor: PypiFlavor) -> Option<(&'static str, &'static str)> { )), PypiFlavor::PythonLocks => Some(( "the Python lockfile", - "run `socket-patch vendor --revert` and vendor again", + "restore the lockfile's registry entry, e.g. with `pip lock -r requirements.txt`, \ + and vendor again", )), PypiFlavor::Poetry | PypiFlavor::Pdm | PypiFlavor::Requirements | PypiFlavor::Hatch => None, } } +/// How the sibling notes name the requirements file wired with `flavor`'s +/// lock: an export of a Pipenv or uv lock, but the file a `pip lock` +/// pylock was locked from (#1758). +fn sibling_requirements(flavor: PypiFlavor) -> &'static str { + if flavor == PypiFlavor::PythonLocks { + "requirements.txt" + } else { + "exported requirements.txt" + } +} + /// The dry-run preview of a fresh vendor that wires the exported /// requirements pin together with `flavor`'s lock. fn sibling_dry_run_notice(flavor: PypiFlavor, canon_name: &str, version: &str) -> VendorWarning { @@ -1629,9 +1629,9 @@ fn sibling_dry_run_notice(flavor: PypiFlavor, canon_name: &str, version: &str) - VendorWarning::new( "pypi_requirements_sibling_wired", format!( - "would wire the exported {} pin of {canon_name}=={version} to the vendored wheel \ - together with {lock}", - crate::formats::governing_locks::PYPI_REQUIREMENTS + "would wire the {} pin of {canon_name}=={version} to the vendored wheel together \ + with {lock}", + sibling_requirements(flavor) ), ) } @@ -1666,8 +1666,8 @@ fn pylock_dry_run_notice(pylocks: &[String], canon_name: &str, version: &str) -> ) } -/// #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 +/// #612 / #1368 / #1758 for a Pipenv, uv or Python lock already wired to +/// this patch: wire the requirements pin to the same committed wheel and extend /// the ledger entry with its records, so the revert restores both files. /// `result` is the run's outcome so far (the in-sync skip, or a rebuilt /// wheel). Without a ledger entry (whose records the revert replays), or @@ -1737,9 +1737,9 @@ async fn wire_in_sync_sibling( 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 + "would wire the {} pin of {canon_name}=={version} to the vendored wheel \ + already wired in {lock}", + sibling_requirements(flavor) ), )); targets.extend( @@ -1820,9 +1820,9 @@ async fn wire_in_sync_sibling( 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 + "wired the {} pin of {canon_name}=={version} to the vendored wheel \ + already wired in {lock}", + sibling_requirements(flavor) ), )); // A re-export (`pipenv requirements > requirements.txt`) put @@ -2280,14 +2280,10 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( } } WiringPlan::PythonLocks(project, sibling) => { - // The locks as `wire_python_locks` checks they still are, to - // put back if the requirements pin then refuses. - let lock_before: Snapshot = if sibling { - project - .loaded_files() - .into_iter() - .map(|(file, text)| (file, Some(text.into_bytes()))) - .collect() + // The files as they are before the wiring, to put back if the + // requirements pin then refuses. + let lock_before = if sibling { + snapshot_files(project_root, &project.file_names()).await } else { Vec::new() }; @@ -3295,8 +3291,8 @@ async fn snapshot_files(project_root: &Path, files: &[&str]) -> Snapshot { snapshot } -/// Wire the exported requirements pin beside a just-wired Pipenv or uv lock -/// to the same wheel (#612, #1368): a superseded entry's recorded lines are +/// Wire the requirements pin beside a just-wired Pipenv, uv or Python lock +/// to the same wheel (#612, #1368, #1758): a superseded entry's recorded lines are /// re-wired in place, a registry pin is rewritten fresh. On a refusal the /// lock files in `lock_before` are put back, so neither file is left half /// wired. @@ -11738,20 +11734,15 @@ wheels = [{url = "https://files.pythonhosted.org/six.whl", hashes = {sha256 = "u 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. + // requirements.txt stays vendored: no routing note names it as a + // file left on its registry source (#1524), or at all (#1758). 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 keeps its vendored wiring")) - && routing - .iter() - .all(|d| !d.contains("retain their existing sources")), + routing.iter().all(|d| !d.contains("requirements.txt")), "{routing:?}" ); crate::vendor::test_support::persist(&fx.root, key, entry.expect("entry on success")).await; diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index c019e9ac6..726314b21 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -42,14 +42,14 @@ pub(super) struct PythonLocks { } impl PythonLocks { - /// Each file [`wire_python_locks`] may rewrite, with its text as - /// loaded: every lock, and a script lock's script. - pub(super) fn loaded_files(&self) -> Vec<(String, String)> { + /// Each file [`wire_python_locks`] may rewrite: every lock, and a + /// script lock's script. + pub(super) fn file_names(&self) -> Vec<&str> { let mut files = Vec::new(); for file in &self.files { - files.push((file.name.clone(), file.text.clone())); - if let (Some(script), Some(name)) = (&file.script, script_of_lock(&file.name)) { - files.push((name.to_string(), script.clone())); + files.push(file.name.as_str()); + if file.script.is_some() { + files.extend(script_of_lock(&file.name)); } } files From 09523b8b833466a25c130e4f8a53e5d1bc421044 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 03:28:59 +0000 Subject: [PATCH 4/4] Restore only rewritten pylock files on refusal If wiring requirements.txt fails after the pylock was wired, the rollback put back every lock and script it had read, so a symlinked uv script the run never rewrote was replaced by a regular copy. Now only the files the pylock wiring actually rewrote are put back. Refs #1758 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/vendor/pypi.rs | 36 ++++++++++++--------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index f3ff4e9cd..a033717ce 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -2282,7 +2282,7 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( WiringPlan::PythonLocks(project, sibling) => { // The files as they are before the wiring, to put back if the // requirements pin then refuses. - let lock_before = if sibling { + let mut lock_before = if sibling { snapshot_files(project_root, &project.file_names()).await } else { Vec::new() @@ -2298,20 +2298,26 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( .await { // #1758: the requirements pin goes to the same wheel. - Ok(mut wiring) if sibling => wire_sibling_requirements( - project_root, - &lock_before, - None, - &canon_name, - version, - &rel_wheel, - &artifact.sha256_hex, - ) - .await - .map(|lines| { - wiring.extend(lines); - (wiring, MetaSlot::None) - }), + Ok(mut wiring) if sibling => { + // Put back only what the wiring rewrote (it refused a + // symlink there): a file it left alone, such as a + // symlinked script, must not be replaced by a copy. + lock_before.retain(|(file, _)| wiring.iter().any(|r| &r.file == file)); + wire_sibling_requirements( + project_root, + &lock_before, + None, + &canon_name, + version, + &rel_wheel, + &artifact.sha256_hex, + ) + .await + .map(|lines| { + wiring.extend(lines); + (wiring, MetaSlot::None) + }) + } other => other.map(|wiring| (wiring, MetaSlot::None)), } }