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..afac1baab 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -3178,6 +3178,243 @@ 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}/"); + + // 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 { + assert!( + std::fs::read_to_string(root.join(file)) + .unwrap() + .contains(&needle), + "{file} is wired: {env:#}" + ); + } + assert!( + !env.to_string().contains("pypi_multiple_lockfiles"), + "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:#}"); + 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}"); + } + + 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: 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 +/// 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..a033717ce 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -387,13 +387,22 @@ 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 // 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 { @@ -409,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(", ") ), )); } @@ -576,7 +573,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). @@ -1032,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, } @@ -1092,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 { @@ -1146,9 +1145,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 +1589,36 @@ 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", + "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" } } @@ -1591,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) ), ) } @@ -1628,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 @@ -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 ), )); @@ -1699,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( @@ -1782,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 @@ -2241,16 +2279,48 @@ 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 files as they are before the wiring, to put back if the + // requirements pin then refuses. + let mut lock_before = if sibling { + snapshot_files(project_root, &project.file_names()).await + } 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 => { + // 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)), + } + } WiringPlan::Hatch(project) => super::pypi_hatch::wire( &project, project_root, @@ -2466,7 +2536,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 +2893,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?; @@ -3220,8 +3297,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. @@ -11663,20 +11740,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 5e283c397..726314b21 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: 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.as_str()); + if file.script.is_some() { + files.extend(script_of_lock(&file.name)); + } + } + 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 —