Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions crates/socket-patch-cli/src/commands/vex_consumed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -715,8 +715,11 @@ mod tests {
None,
)
.await;
assert_eq!(installed_again, installed);
let (paths, calls) = tracked_npm_hosted(&common, &installed_again).await;
// Since #605 the name-keyed resolver probes bundled trees itself, so
// it already returns the aliases and the nested store's peers. Feed
// the earlier, alias-free set to keep exercising alias expansion;
// the resolver's own set is checked against the same result below.
let (paths, calls) = tracked_npm_hosted(&common, &installed).await;
assert_eq!(calls.len(), 1);
let mut inputs = calls[0].clone();
inputs.sort();
Expand All @@ -738,6 +741,9 @@ mod tests {
.len(),
paths.len()
);
let (mut resolved, _) = tracked_npm_hosted(&common, &installed_again).await;
resolved.sort();
assert_eq!(resolved, expected, "the resolver's own copy set");
}

#[cfg(unix)]
Expand Down Expand Up @@ -768,14 +774,19 @@ mod tests {
None,
)
.await;
assert!(installed.is_empty(), "{installed:?}");
let (mut paths, calls) = tracked_npm_hosted(&common, &installed).await;
// Since #605 the name-keyed resolver reaches the alias and its
// sibling peers on its own. An alias-only set (what an alias-blind
// resolver returns) must still expand to the same copies.
let (mut paths, calls) = tracked_npm_hosted(&common, &HashMap::new()).await;
assert_eq!(calls, vec![vec![alias.clone()]]);
let mut expected = peers;
expected.push(alias);
paths.sort();
expected.sort();
assert_eq!(paths, expected);
let (mut resolved, _) = tracked_npm_hosted(&common, &installed).await;
resolved.sort();
assert_eq!(resolved, expected, "the resolver's own copy set");
}

#[cfg(unix)]
Expand Down
25 changes: 25 additions & 0 deletions crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1065,6 +1065,31 @@ fn uv_vendor_revert_after_manifest_overrides_relock() {
);
}

/// #840: `uv add "six>=1.16"` while six is vendored rewrites pyproject.toml
/// but leaves uv.lock byte-identical (a path source records no specifier).
/// The revert must write the NEW specifier back, not the recorded
/// `==1.16.0` that leaves `uv sync --locked` red.
#[test]
#[serial_test::serial]
fn uv_vendor_revert_after_declaration_edit() {
uv_relock_then_revert(
"uv-declaration-edit",
"[project]\nname = \"vendor-capstone\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"six==1.16.0\", \"attrs>=20\"]\n",
&["add", "-q", "six>=1.16"],
);
}

/// #840 in a PEP 735 dev group: `uv add --dev "six>=1.16"`.
#[test]
#[serial_test::serial]
fn uv_vendor_revert_after_dev_group_declaration_edit() {
uv_relock_then_revert(
"uv-dev-declaration-edit",
"[project]\nname = \"vendor-capstone\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = []\n\n[dependency-groups]\ndev = [\"six==1.16.0\", \"attrs>=20\"]\n",
&["add", "-q", "--dev", "six>=1.16"],
);
}

/// `get <uuid> --mode vendored` twin of the uv capstone above (v3.6): the
/// SAME vendor engine and wiring, driven through get's uuid path — exempt
/// from installed narrowing, so only the mocked `view/{uuid}` route is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ mod uv;
mod vlt;

pub(crate) use client::UpstreamClient;
pub(crate) use uv::{respell_lock_specifier, LockRequirementArray};

use super::staged::{flush_staged, read_rel, Staged, StagedBytes};

Expand Down
101 changes: 101 additions & 0 deletions crates/socket-patch-core/src/patch/redirect/upstream/uv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1181,6 +1181,107 @@ fn declared_clauses(
})
}

/// Which lock requirement array a vendored revert is restoring an entry
/// of, for [`respell_lock_specifier`].
#[derive(Clone, Copy, Debug)]
pub(crate) enum LockRequirementArray<'a> {
/// The root `requires-dist`: `[project]` dependencies and extras.
RequiresDist,
/// The root `requires-dev.<group>`.
RequiresDev(&'a str),
/// `[manifest] constraints` / `build-constraints`.
Manifest(&'a str),
}

/// The `specifier` a vendored revert should restore for `name`'s entry
/// in `array`, given the one it recorded when vendoring (`None` when the
/// entry had none) and the entry's `marker`.
///
/// A path source records no specifier, so a user who changes the
/// declaration while the package is vendored (`uv add "six>=1.16"`)
/// leaves uv.lock byte-identical, and the recorded specifier goes stale
/// (#840). The entry's declaration is picked the way hosted unwind picks
/// it ([`declared_clauses`]: by the extra and environment marker uv
/// lowered into the entry). Returns:
/// * `Ok(None)`: keep the recorded entry as it is. The declaration still
/// agrees with it, nothing declares the name, or no declaration of it is
/// a plain version range (the recorded spelling was uv's own, so it
/// stays the best answer);
/// * `Ok(Some(spec))`: write `spec` instead, in uv's spelling (`None` is no
/// specifier at all);
/// * `Err`: the declaration changed but uv's spelling of it can't be
/// derived (a multi-clause range), or which declaration the entry
/// mirrors is ambiguous, so restoring any spelling may break `--locked`.
pub(crate) fn respell_lock_specifier(
pyproject_text: &str,
array: LockRequirementArray<'_>,
name: &str,
recorded: Option<&str>,
marker: Option<&str>,
) -> Result<Option<Option<String>>, String> {
let Ok(doc) = pyproject_text.parse::<DocumentMut>() else {
return Ok(None);
};
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),
};
let canon = canonicalize_pypi_name(name);
let recorded = match recorded {
None => Vec::new(),
Some(r) => match spec_clauses(&format!("{canon}{r}")) {
Ok(clauses) => clauses,
Err(_) => return Ok(None),
},
};
let clauses = match declared_clauses(&meta, declared, name, marker) {
Ok(Some(clauses)) => clauses,
Ok(None) => return Ok(None),
// Not one declaration of the name is a plain version range: the
// recorded spelling was uv's own, so it stays the best answer.
// Otherwise the marker narrowing left an unreadable or conflicting
// set, which is ambiguous: fail closed.
Err(reason) => {
let all_unreadable = declarations(&meta, declared)
.iter()
.filter(|d| canonicalize_pypi_name(pep508_name(d.spec)) == canon)
.all(|d| spec_clauses(d.spec).is_err());
return if all_unreadable {
Ok(None)
} else {
Err(reason)
};
}
};
let sorted = |clauses: &[String]| {
let mut c = clauses.to_vec();
c.sort();
c
};
if sorted(&clauses) == sorted(&recorded) {
return Ok(None);
}
match clauses.as_slice() {
[] => Ok(Some(None)),
[one] => Ok(Some(Some(one.clone()))),
// How uv orders and joins clauses differs between releases (0.8
// orders them by version: `>=20,!=21.1.0,<30`), and the lock no
// longer shows this entry's spelling, so any guess may break
// `--locked`.
_ => Err(format!(
"pyproject.toml now 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)> {
Expand Down
Loading
Loading