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()); diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 1dc99acdc..270f4e013 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -390,13 +390,48 @@ async fn detect_pypi_flavor( if exists(PYPI_REQUIREMENTS).await { present.push(PYPI_REQUIREMENTS); } - if !present.is_empty() { + // #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, _)) 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); + } 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!("{} keeps its vendored wiring", 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,132 +3031,143 @@ 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 -/// 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 - } - other => RevertOutcome::failed(format!( - "no exported-requirements revert for the {} flavor", - other.as_str() - )), - } - } - 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; - } - // The exported files and pylocks as they are now, to put back if the - // lock fails. + .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(); + // 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 { + 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. 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, - ..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, + let mut merged = RevertOutcome::ok(); + for (owner, records) in groups.drain(..) { + let group_entry = VendorEntry { + wiring: records, ..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 — @@ -3188,24 +3234,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 - } - Some("python-lock") => { - super::pypi_lock::revert_python_locks(entry, project_root, 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("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" - )) + )); } } }; @@ -10779,6 +10826,208 @@ 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 keeps its vendored wiring")) + && 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()); + } + + /// #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 + ); + } + + /// #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; diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index 965903f0b..a017943a5 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 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( project: &PythonLocks, root: &Path, 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 {