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
4 changes: 3 additions & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions crates/socket-patch-cli/src/commands/vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down
16 changes: 16 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 @@ -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]
Expand Down
42 changes: 42 additions & 0 deletions crates/socket-patch-cli/tests/mode_migration_pypi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down
39 changes: 39 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 @@ -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<Option<String>, String> {
let doc = pyproject_text
.parse::<DocumentMut>()
.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)> {
Expand Down
11 changes: 11 additions & 0 deletions crates/socket-patch-core/src/vendor/pypi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2223,6 +2223,17 @@ async fn guard_unwired_pypi_revert(
})
}

/// Why `.socket/vendor/pypi/<uuid>/` 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<String> {
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
Expand Down
Loading
Loading