diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index abfd8da37..4b4e5ee01 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1985,7 +1985,9 @@ wiring: an entry whose lockfile or config no longer references its version from elsewhere (`wiring contested`, naming both locks; delete the one the project does not install from), or, for npm and PyPI, a dependency no lock resolves any more (`dependency removed`; `scan --mode vendored --prune` reverts -the entry). For a package-lock entry, drift also includes a +the entry), or, for PyPI, a project file that still names the artifact while +nothing installs from it (`wiring incomplete`, naming the file; re-lock, then +re-vendor or revert, #1374). For a package-lock entry, drift also includes a `package-lock.json` / `npm-shrinkwrap.json` entry for the vendored `name@version` that `vendor` would rewire but that does not resolve to the vendored artifact (#588); the reason names that entry. It also includes any entry for that diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 517a00f3a..befc92dc6 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -442,6 +442,9 @@ pub(crate) async fn dispatch_revert_one_opts( /// verdict the prune GC reverts by ([`Discovery::vendor_entry_in_use`]) /// says so and no lock resolves the package any more, so `scan --prune` /// is the fix, as `scan`'s own `vendor_ledger_entry_unwired` hint says; +/// * for PyPI, a project file still names the artifact although nothing +/// installs from it (a half-unwound uv pair, #1374): name it, and +/// re-lock before re-vendoring or reverting; /// * otherwise a relock dropped the reference while the package stayed. async fn unwired_check_failure( discovery: &socket_patch_core::vex::discover::Discovery, @@ -475,6 +478,20 @@ async fn unwired_check_failure( strip_purl_qualifiers(key) ); } + // A half-unwound pair (uv.lock still routing a root requirement through + // the wheel after the rest of the wiring is gone, #1374) does reference + // the dir: say so instead of claiming nothing does. + if entry.ecosystem == "pypi" { + if let Some(clause) = vendor::pypi::pypi_reference(root, &entry.uuid).await { + return format!( + "wiring incomplete: nothing installs the package from {dir} any more, but \ + {clause}, so the project files disagree and a fresh install gets the \ + unpatched package or fails; re-lock the project (e.g. `uv lock`), then \ + re-run `socket-patch vendor` to rewire it or `socket-patch vendor --revert` \ + to finish unwinding it" + ); + } + } format!( "wiring missing: no lockfile or config references {dir} any more, so a fresh install \ gets the unpatched package; re-run `socket-patch vendor` to rewire it" diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs index 637bb40b3..44f59333c 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs @@ -1234,6 +1234,22 @@ fn uv_vendor_revert_after_uv_remove_of_the_transitive_parent() { ); } +/// #1374: six vendored TRANSITIVELY, then promoted to a direct dependency +/// with `uv add six==1.16.0`. uv writes a root `requires-dist` element +/// routed through the socket-written source, which no wiring record +/// covers. `vendor --revert` must put it back on the registry too, instead +/// of writing a pair whose lock still names the wheel (`uv sync --locked` +/// failed while revert exited 0). +#[test] +#[serial_test::serial] +fn uv_vendor_revert_after_promoting_the_transitive_to_direct() { + uv_relock_then_revert( + "uv-promote-transitive", + "[project]\nname = \"vendor-capstone\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"python-dateutil==2.9.0.post0\", \"attrs>=20\"]\n\n[tool.uv]\nconstraint-dependencies = [\"six==1.16.0\"]\n", + &["add", "-q", "six==1.16.0"], + ); +} + /// #821: six in a PEP 735 dev group, then `uv add --dev zipp` rewrites the /// whole `requires-dev` group line. #[test] diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 448ba51af..0ddea1e59 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -1618,6 +1618,48 @@ async fn vendor_check_fails_after_uv_relock() { assert_check_catches_relock(&root, files); } +/// #1374: a half-unwound uv pair (pyproject.toml and the six unit back on +/// PyPI, but the root requires-dist still routing through the wheel, as a +/// revert left it once `uv add` had promoted a vendored transitive) fails +/// `vendor --check`, and the reason names uv.lock instead of claiming no +/// file references the artifact. +#[tokio::test] +async fn vendor_check_names_the_file_of_a_half_unwound_uv_pair() { + let (_tmp, root) = project(); + let files = stage_uv(&root); + let pristine_pyproject = std::fs::read_to_string(root.join("pyproject.toml")).unwrap(); + let pristine_lock = std::fs::read_to_string(root.join("uv.lock")).unwrap(); + vendor_project(&root, files); + let wired_lock = std::fs::read_to_string(root.join("uv.lock")).unwrap(); + let wired_element = wired_lock + .lines() + .find(|l| l.starts_with("requires-dist = ") && l.contains(".socket/vendor/pypi/")) + .expect("the wired root requires-dist element"); + std::fs::write(root.join("pyproject.toml"), &pristine_pyproject).unwrap(); + std::fs::write( + root.join("uv.lock"), + pristine_lock.replace( + "requires-dist = [{ name = \"six\", specifier = \"==1.16.0\" }]", + wired_element, + ), + ) + .unwrap(); + + let (code, env) = run_cli(&root, &["vendor", "--check"], &[]); + assert_eq!(code, 1, "{env:#}"); + let event = &env["events"][0]; + assert_eq!(event["errorCode"], "vendor_check_failed", "{env:#}"); + let reason = event["reason"].as_str().unwrap_or_default(); + assert!( + reason.contains("wiring incomplete") && reason.contains("uv.lock still resolves"), + "{env:#}" + ); + assert!( + !reason.contains("no lockfile or config references"), + "{env:#}" + ); +} + #[tokio::test] async fn vendor_check_fails_after_hatch_dependency_reset() { let (_tmp, root) = project(); diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs index 2d9b4a1dc..15ee3aa7f 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs @@ -43,7 +43,7 @@ mod uv; mod vlt; pub(crate) use client::UpstreamClient; -pub(crate) use uv::{respell_lock_specifier, LockRequirementArray}; +pub(crate) use uv::{declared_lock_specifier, respell_lock_specifier, LockRequirementArray}; use super::staged::{flush_staged, read_rel, Staged, StagedBytes}; diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs index b78632a04..15a7f5b03 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs @@ -1360,6 +1360,45 @@ pub(crate) fn respell_lock_specifier( } } +/// The `specifier` uv writes for `name`'s entry in `array` (with +/// `marker`) when nothing recorded a prior spelling: the declaration's +/// one clause, or `None` for a bare name. `Err` when pyproject.toml +/// doesn't declare the name there, or uv's spelling of the declaration +/// isn't derivable (unreadable, multi-clause, or ambiguous), so the +/// caller can fail closed rather than guess. +pub(crate) fn declared_lock_specifier( + pyproject_text: &str, + array: LockRequirementArray<'_>, + name: &str, + marker: Option<&str>, +) -> Result, String> { + let doc = pyproject_text + .parse::() + .map_err(|e| format!("pyproject.toml does not parse: {e}"))?; + let meta = Metadata { + rel: "pyproject.toml".to_string(), + text: String::new(), + script: false, + doc, + }; + let declared = match array { + LockRequirementArray::RequiresDist => Declared::Dist, + LockRequirementArray::RequiresDev(group) => Declared::Dev(group), + LockRequirementArray::Manifest(key) => Declared::Manifest(key), + }; + match declared_clauses(&meta, declared, name, marker)? { + None => Err(format!("pyproject.toml no longer declares {name}")), + Some(clauses) => match clauses.as_slice() { + [] => Ok(None), + [one] => Ok(Some(one.clone())), + _ => Err(format!( + "pyproject.toml declares {name} with a multi-clause specifier, whose \ + spelling in uv.lock is not derivable" + )), + }, + } +} + /// Every lock requirement array with the declarations it mirrors /// (read-only twin of [`restore_requirements`]'s walk). fn requirement_arrays_ref(doc: &DocumentMut) -> Vec<(Declared<'_>, &toml_edit::Array)> { diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 7ec7a2f89..f8e80cb26 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -2223,6 +2223,17 @@ async fn guard_unwired_pypi_revert( }) } +/// Why `.socket/vendor/pypi//` may still be referenced, probed the +/// way a revert's residual-reference guard probes +/// ([`pypi_reference_clause`]): a clause naming the project file that +/// still resolves through it, or the file or listing that couldn't be read +/// to prove none does. `None` when every file was read and none mentions +/// it. `vendor --check` reports it for an entry nothing installs from any +/// more, so the verdict doesn't claim no file references the dir (#1374). +pub async fn pypi_reference(project_root: &Path, uuid: &str) -> Option { + pypi_reference_clause(project_root, uuid, &[]).await +} + /// The in-use probe behind [`guard_unwired_pypi_revert`] and the /// post-restore keep in [`revert_pypi_opts`]: `None` when every Python /// project file was read and none mentions the uuid dir; otherwise the human diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 1ebb645d7..d3d4beaf7 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -30,9 +30,12 @@ use crate::crawlers::python_crawler::canonicalize_pypi_name; // revert) forever in an `open(2)` that waits for a writer — the // flavor-routing probes ahead of the load are metadata-only, so these are // the first opens. -use crate::patch::redirect::upstream::{respell_lock_specifier, LockRequirementArray}; +use crate::patch::redirect::upstream::{ + declared_lock_specifier, respell_lock_specifier, LockRequirementArray, +}; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; use crate::utils::line_endings::terminator; +use crate::utils::purl::purl_name_version; use crate::utils::python_lock::preserve_line_endings; use super::common::{ @@ -869,6 +872,7 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - // touches them). Those are this entry's own references, which the loop // below reverts, not the user's: they are set aside before probing. let uuid_lower = entry.uuid.to_ascii_lowercase(); + let needle_lower = needle.to_ascii_lowercase(); let in_pyproject = |kind: &str| matches!(kind, "uv_sources_entry" | "uv_override"); let own_fragments_removed = |text: &str, pyproject: bool| { let mut residual = text.to_string(); @@ -1121,9 +1125,48 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - // wired and the other not, which `uv sync --locked` rejects (#806, // #821). Write neither; the drift warning keeps the artifact and ledger // entry so a re-run can finish once the drift is undone. - let drift_kept = warnings + let mut drift_kept = warnings .iter() .any(|w| w.code == "vendor_lock_entry_drifted"); + if !drift_kept { + // A root element uv wrote through our source after vendoring (a + // transitive package promoted by `uv add`, #1374) has no record. + let canon = purl_name_version(&entry.base_purl) + .map(|(name, _)| canonicalize_pypi_name(name)) + .unwrap_or_default(); + match respell_unrecorded_path_entries(&lock_text, &canon, &needle_lower, &pyproject_text) { + Ok(t) => lock_text = t, + Err(reason) => { + drift_kept = true; + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "uv.lock entry for {canon:?} that `uv add` routed through {needle} \ + can't be put back on the registry ({reason}); left both files \ + untouched (pin it with `==` in pyproject.toml or remove it, then \ + revert again)" + ), + )); + } + } + } + // Never write a half-reverted pair: a lock that still routes through + // the uuid dir after pyproject.toml stopped declaring any route to it + // is what `uv sync --locked` rejects. Keep both files instead. + if !drift_kept + && lock_text.to_ascii_lowercase().contains(&needle_lower) + && !pyproject_text.to_ascii_lowercase().contains(&needle_lower) + { + drift_kept = true; + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "uv.lock still references {needle} in an entry the vendor ledger did not \ + record, while the reverted pyproject.toml would not; left both files \ + untouched (remove that reference, then revert again)" + ), + )); + } if !dry_run && !drift_kept { // Reverse of the wire order: the lock first, then the pyproject. let write = atomic_write_bytes_preserving_mode(&lock_path, lock_text.as_bytes()).await; @@ -1222,6 +1265,96 @@ fn root_requires_dist(lock_text: &str) -> Option<&str> { Some(&unit_text[open..end]) } +/// `lock_text` with every root `requires-dist` / `requires-dev` element for +/// `canon` that routes through `needle` (this entry's uuid dir, lower-case) +/// put back on the registry, spelled the way pyproject.toml declares it +/// now (#840). +/// +/// These elements are the ones no wiring record covers: a package vendored +/// as TRANSITIVE has no root element, and when the user later promotes it +/// to a direct dependency (`uv add six==1.16.0`), uv writes one routed +/// through the source vendor wrote (#1374). Reverting every record without +/// it leaves a lock that routes through a path pyproject.toml no longer +/// declares, which `uv sync --locked` rejects. `Err` when pyproject.toml no +/// longer declares the package or uv's spelling of the declaration isn't +/// derivable: the caller then keeps both files. +fn respell_unrecorded_path_entries( + lock_text: &str, + canon: &str, + needle: &str, + pyproject_text: &str, +) -> Result { + let Some(unit) = find_unit_span(lock_text, unit_is_root) else { + return Ok(lock_text.to_string()); + }; + let unit_text = &lock_text[unit.clone()]; + let routes_here = |element: &str| { + entry_value(element, "name").is_some_and(|n| canonicalize_pypi_name(n) == canon) + && entry_value(element, "path").is_some_and(|p| { + let p = p.to_ascii_lowercase(); + p == needle || p.starts_with(&format!("{needle}/")) + }) + }; + // Every root requirement array: (offset of its `[`, dev group name; + // `None` is requires-dist, which sits in [package.metadata], ahead of + // the requires-dev sub-table). + let dev_hdr = unit_text.find("[package.metadata.requires-dev]"); + let mut arrays: Vec<(usize, Option<&str>)> = Vec::new(); + if let Some(at) = unit_text[..dev_hdr.unwrap_or(unit_text.len())].find("requires-dist = [") { + arrays.push((at + "requires-dist = ".len(), None)); + } + if let Some(hdr) = dev_hdr { + let sect_start = hdr + "[package.metadata.requires-dev]".len(); + let sect_end = unit_text[sect_start..] + .find("\n[") + .map_or(unit_text.len(), |i| sect_start + i + 1); + let mut cursor = sect_start; + while let Some(open_rel) = unit_text[cursor..sect_end].find('[') { + let arr_open = cursor + open_rel; + let key_start = unit_text[..arr_open].rfind('\n').map_or(0, |i| i + 1); + let group = unit_text[key_start..arr_open] + .trim() + .trim_end_matches('=') + .trim() + .trim_matches('"'); + arrays.push((arr_open, Some(group))); + cursor = balanced_span(unit_text, arr_open) + .ok_or_else(|| "uv.lock root requires-dev array is unbalanced".to_string())?; + } + } + let mut edits: Vec<(Range, LockRequirementArray<'_>)> = Vec::new(); + for (arr_open, group) in arrays { + let arr_end = balanced_span(unit_text, arr_open) + .ok_or_else(|| "uv.lock root requirement array is unbalanced".to_string())?; + let array = group.map_or(LockRequirementArray::RequiresDist, |g| { + LockRequirementArray::RequiresDev(g) + }); + let at = unit.start + arr_open; + for (s, e) in top_level_brace_groups(&unit_text[arr_open..arr_end]) { + if routes_here(&lock_text[at + s..at + e]) { + edits.push(((at + s)..(at + e), array)); + } + } + } + // Back-to-front so the earlier spans stay valid. + edits.sort_by_key(|(span, _)| std::cmp::Reverse(span.start)); + let mut out = lock_text.to_string(); + for (span, array) in edits { + let element = &lock_text[span.clone()]; + let marker = entry_value(element, "marker"); + let spec = declared_lock_specifier(pyproject_text, array, canon, marker)?; + let inner = element.trim().trim_start_matches('{').trim_end_matches('}'); + let kept: Vec<&str> = split_top_level_commas(inner) + .into_iter() + .map(str::trim) + .filter(|part| !part.is_empty() && !part.starts_with("path =")) + .collect(); + let base = format!("{{ {} }}", kept.join(", ")); + out.replace_range(span, &with_specifier(&base, spec.as_deref())); + } + Ok(out) +} + /// The string value of `key` in a one-line `{ k = "v", … }` lock entry. fn entry_value<'a>(entry: &'a str, key: &str) -> Option<&'a str> { let inner = entry.trim().strip_prefix('{')?.strip_suffix('}')?; @@ -3133,6 +3266,190 @@ wheels = [ ); } + // ── a vendored transitive package promoted to direct (#1374) ─────── + + const ROOT_DATEUTIL_ONLY: &str = "dependencies = [\n { name = \"python-dateutil\" },\n]\n\n\ + [package.metadata]\nrequires-dist = [{ name = \"python-dateutil\", specifier = \"==2.8.2\" }]\n"; + + /// The root unit's dependencies and requires-dist with `six` added as + /// uv 0.12 writes them, `six_entry` being its requires-dist element. + fn root_with_six(six_entry: &str) -> String { + format!( + "dependencies = [\n {{ name = \"python-dateutil\" }},\n {{ name = \"six\" }},\n]\n\n\ + [package.metadata]\nrequires-dist = [\n \ + {{ name = \"python-dateutil\", specifier = \"==2.8.2\" }},\n {six_entry},\n]\n" + ) + } + + /// Vendor `six` as a transitive override, then apply `promote` to the + /// wired (pyproject, lock) pair the way a later `uv add` rewrites it. + async fn wire_transitive_then( + promote: impl Fn(&str, &str) -> (String, String), + ) -> (tempfile::TempDir, VendorEntry, String, String) { + let tmp = write_pair(TRANSITIVE_REGISTRY_PYPROJECT, TRANSITIVE_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta, _) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + let entry = entry_for(wiring, meta); + let (wired_pyproject, wired_lock) = read_pair(tmp.path()).await; + let (pyproject, lock) = promote(&wired_pyproject, &wired_lock); + assert_ne!((&pyproject, &lock), (&wired_pyproject, &wired_lock)); + tokio::fs::write(tmp.path().join("pyproject.toml"), &pyproject) + .await + .unwrap(); + tokio::fs::write(tmp.path().join("uv.lock"), &lock) + .await + .unwrap(); + (tmp, entry, pyproject, lock) + } + + /// `uv add six==1.16.0` on a project that vendored six as transitive + /// writes a root requires-dist element routed through our source, with + /// no wiring record. The revert puts it back on the registry with the + /// declared specifier, so the pair is what uv writes without vendoring. + #[tokio::test] + async fn revert_after_promoting_a_transitive_to_direct_respells_the_root_entry() { + let declare = |text: &str| { + text.replace( + "dependencies = [\"python-dateutil==2.8.2\"]", + "dependencies = [\"python-dateutil==2.8.2\", \"six==1.16.0\"]", + ) + }; + let (tmp, entry, _, _) = wire_transitive_then(|pyproject, lock| { + let path_entry = format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}"); + ( + declare(pyproject), + lock.replace(ROOT_DATEUTIL_ONLY, &root_with_six(&path_entry)), + ) + }) + .await; + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + let (pyproject, lock) = read_pair(tmp.path()).await; + assert_eq!(pyproject, declare(TRANSITIVE_REGISTRY_PYPROJECT)); + assert_eq!( + lock, + TRANSITIVE_REGISTRY_LOCK.replace( + ROOT_DATEUTIL_ONLY, + &root_with_six("{ name = \"six\", specifier = \"==1.16.0\" }") + ) + ); + } + + /// The same promotion into a dependency group (`uv add --dev`) lands + /// in the root's requires-dev, which is respelled the same way. + #[tokio::test] + async fn revert_after_promoting_a_transitive_to_a_dev_group_respells_it() { + let declare = + |text: &str| format!("{text}\n[dependency-groups]\ndev = [\"six==1.16.0\"]\n"); + let dev_lock = |text: &str, entry: &str| { + text.replace( + ROOT_DATEUTIL_ONLY, + &format!( + "dependencies = [\n {{ name = \"python-dateutil\" }},\n]\n\n\ + [package.dev-dependencies]\ndev = [\n {{ name = \"six\" }},\n]\n\n\ + [package.metadata]\nrequires-dist = [{{ name = \"python-dateutil\", \ + specifier = \"==2.8.2\" }}]\n\n[package.metadata.requires-dev]\n\ + dev = [{entry}]\n" + ), + ) + }; + let (tmp, entry, _, _) = wire_transitive_then(|pyproject, lock| { + ( + declare(pyproject), + dev_lock( + lock, + &format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}"), + ), + ) + }) + .await; + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + let (pyproject, lock) = read_pair(tmp.path()).await; + assert_eq!(pyproject, declare(TRANSITIVE_REGISTRY_PYPROJECT)); + assert_eq!( + lock, + dev_lock( + TRANSITIVE_REGISTRY_LOCK, + "{ name = \"six\", specifier = \"==1.16.0\" }" + ) + ); + } + + /// When the unrecorded element can't be respelled (a multi-clause or + /// `===` declaration uv's spelling of isn't derivable, or no declaration + /// at all), or the lock routes another name through the uuid dir, the + /// revert writes neither file instead of a half-reverted + /// pair `uv sync --locked` rejects. + #[tokio::test] + async fn revert_keeps_both_files_when_an_unrecorded_reference_survives() { + let path_entry = format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}"); + let cases: [(&str, Box (String, String)>); 4] = [ + ( + "arbitrary-equality declaration", + Box::new(|pyproject: &str, lock: &str| { + ( + pyproject.replace( + "dependencies = [\"python-dateutil==2.8.2\"]", + "dependencies = [\"python-dateutil==2.8.2\", \"six===1.16.0\"]", + ), + lock.replace(ROOT_DATEUTIL_ONLY, &root_with_six(&path_entry)), + ) + }), + ), + ( + "multi-clause declaration", + Box::new(|pyproject: &str, lock: &str| { + ( + pyproject.replace( + "dependencies = [\"python-dateutil==2.8.2\"]", + "dependencies = [\"python-dateutil==2.8.2\", \"six>=1.16,<2\"]", + ), + lock.replace(ROOT_DATEUTIL_ONLY, &root_with_six(&path_entry)), + ) + }), + ), + ( + "undeclared", + Box::new(|pyproject: &str, lock: &str| { + ( + format!("{pyproject}# promoted\n"), + lock.replace(ROOT_DATEUTIL_ONLY, &root_with_six(&path_entry)), + ) + }), + ), + ( + "another name routed through the wheel", + Box::new(|pyproject: &str, lock: &str| { + let other = format!("{{ name = \"six-fork\", path = \"{REL_WHEEL}\" }}"); + ( + format!("{pyproject}# promoted\n"), + lock.replace(ROOT_DATEUTIL_ONLY, &root_with_six(&other)), + ) + }), + ), + ]; + for (name, promote) in cases { + let (tmp, entry, pyproject, lock) = wire_transitive_then(promote).await; + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.drift_skipped(), "{name}: {:?}", outcome.warnings); + assert_eq!(read_pair(tmp.path()).await, (pyproject, lock), "{name}"); + } + } + // ── two packages sharing a created [tool.uv.sources] (#670) ──────── const TWO_REGISTRY_PYPROJECT: &str = r#"[project]