diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index f214e686d..cdc8538f3 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -1343,8 +1343,8 @@ pub fn wheel_targets<'a>( candidates: &'a [Candidate], files: &BTreeMap, ) -> Vec<(&'a DepOverride, &'a str)> { - use crate::utils::python_lock::{ArtifactSource, PythonLockProbe}; - let mut probes: Option> = None; + use crate::utils::python_lock::{ArtifactSource, PythonLockSession}; + let mut probes: Option> = None; let mut out = Vec::new(); for dep in candidates .iter() @@ -1369,15 +1369,19 @@ pub fn wheel_targets<'a>( .filter(|(path, _)| { *path == "uv.lock" || crate::utils::python_lock::is_script_lock_name(path) }) - .map(|(_, text)| PythonLockProbe::new(text)) + .map(|(_, text)| (text.as_str(), PythonLockSession::new(text))) .collect() }) .iter() - .any(|probe| { - probe.rewrites( - &dep.name, - &dep.version, - ArtifactSource::Url(&dep.artifact_url), + .any(|(text, session)| { + matches!( + session.plan( + text, + &dep.name, + &dep.version, + ArtifactSource::Url(&dep.artifact_url), + ), + Ok(Some(_)) ) }); if native_target { diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index ccb8bb226..b3e64a631 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -6492,14 +6492,12 @@ fn plan_python_metadata_with( } else { crate::utils::python_script::refuse_uv_no_sources( &dep.name, - None, Some(&original), project_file(crate::utils::python_script::UV_TOML), ) .and_then(|()| { crate::utils::python_script::refuse_uv_no_binary( &dep.name, - None, Some(&original), project_file(crate::utils::python_script::UV_TOML), ) diff --git a/crates/socket-patch-core/src/utils/python_lock.rs b/crates/socket-patch-core/src/utils/python_lock.rs index 11e2c2a59..626245e7c 100644 --- a/crates/socket-patch-core/src/utils/python_lock.rs +++ b/crates/socket-patch-core/src/utils/python_lock.rs @@ -27,8 +27,6 @@ impl ArtifactSource<'_> { } } -// ── read model shared by the lock inventory and lockfile discovery ──── - /// The `[[…]]` array holding a native Python lock's packages, and whether /// the lock is PEP 751: `packages` (a `lock-version` pylock), `distribution` /// (uv 0.2.x) or `package` (uv ≥ 0.2.35 and PEP 723 script locks). The @@ -183,7 +181,7 @@ pub fn is_pep751_lock_name(name: &str) -> bool { name == "pylock.toml" || (name.starts_with("pylock.") && name.ends_with(".toml")) } -/// #912 / #1122: whether Pipenv installs from the PEP 751 lock `rel` (a +/// Whether Pipenv installs from the PEP 751 lock `rel` (a /// project-relative path). A `Pipfile` beside the lock makes Pipenv the /// installer — unless a higher-ranked tool lock (`uv.lock`, `poetry.lock`, /// `pdm.lock`) governs the directory, leaving the Pipfile a leftover — and @@ -205,33 +203,19 @@ pub fn pipenv_reads_pylock(rel: &str, exists: impl Fn(&str) -> bool) -> bool { } pub fn python_lock_paths(root: &Path) -> std::io::Result> { - let mut paths = Vec::new(); - for entry in std::fs::read_dir(root)? { - // One unreadable directory entry must not hide every other lock: - // the callers treat an `Err` as "no Python locks here", which would - // silently drop the whole project from inventory, repair, and the - // hosted candidate list. - let Ok(entry) = entry else { - continue; - }; - let Some(name) = entry.file_name().to_str().map(str::to_string) else { - continue; - }; - if !is_python_lock_name(&name) { - continue; - } - // `DirEntry::file_type` does NOT follow symlinks, so a lock that is a - // symlink (a shared pylock, a checked-in link) was never discovered - // even though every reader opens it fine. `fs::metadata` follows the - // link; a link to a directory or FIFO is still excluded by `is_file`. - if !std::fs::metadata(entry.path()) - .map(|metadata| metadata.is_file()) - .unwrap_or(false) - { - continue; - } - paths.push(name); - } + let mut paths: Vec = std::fs::read_dir(root)? + // One unreadable entry must not hide every other lock: callers + // treat an `Err` as "no Python locks here". + .filter_map(Result::ok) + .filter_map(|entry| { + let name = entry.file_name().into_string().ok()?; + // `fs::metadata` follows a symlinked lock; `is_file` still + // excludes a link to a directory or FIFO. + (is_python_lock_name(&name) + && std::fs::metadata(entry.path()).is_ok_and(|metadata| metadata.is_file())) + .then_some(name) + }) + .collect(); paths.sort(); Ok(paths) } @@ -297,6 +281,29 @@ fn inline(entries: &[(&str, Value)]) -> Value { Value::InlineTable(table) } +/// Whether the inline table `entry` is named `name` (canonical). +fn names(entry: &InlineTable, name: &str) -> bool { + entry + .get("name") + .and_then(Value::as_str) + .is_some_and(|value| canonicalize_pypi_name(value) == name) +} + +/// Point a `{ name, specifier | url | path, … }` requirement at `artifact`. +fn repoint(requirement: &mut InlineTable, artifact: ArtifactSource<'_>) { + requirement.remove("specifier"); + requirement.remove("url"); + requirement.remove("path"); + requirement.insert(artifact.key(), Value::from(artifact.location())); +} + +/// The final path segment of an artifact location, query and fragment +/// stripped. +fn artifact_filename(location: &str) -> &str { + let path = location.split(['?', '#']).next().unwrap_or(location); + path.rsplit('/').next().unwrap_or(path) +} + fn matching_package(table: &Table, name: &str, version: &str) -> bool { table .get("name") @@ -345,10 +352,7 @@ fn rewrite_reference_value( } } Value::InlineTable(table) => { - if table - .get("name") - .and_then(Value::as_str) - .is_some_and(|value| canonicalize_pypi_name(value) == name) + if names(table, name) && table .get("version") .and_then(Value::as_str) @@ -412,7 +416,7 @@ fn rewrite_references( /// Whether the lock's `[manifest]` `constraints` / `build-constraints` name /// `name`: the entries [`rewrite_manifest`] repoints at the artifact, a -/// shape uv before 0.5.5 rejects under `uv sync --locked` (#1569). +/// shape uv before 0.5.5 rejects under `uv sync --locked`. fn manifest_constrains(document: &DocumentMut, name: &str) -> bool { let name = canonicalize_pypi_name(name); let Some(manifest) = document.get("manifest").and_then(Item::as_table_like) else { @@ -425,12 +429,7 @@ fn manifest_constrains(document: &DocumentMut, name: &str) -> bool { .into_iter() .flatten() .filter_map(Value::as_inline_table) - .any(|constraint| { - constraint - .get("name") - .and_then(Value::as_str) - .is_some_and(|value| canonicalize_pypi_name(value) == name) - }) + .any(|constraint| names(constraint, &name)) }) } @@ -453,17 +452,9 @@ fn rewrite_manifest(document: &mut DocumentMut, name: &str, artifact: ArtifactSo for constraint in constraints .iter_mut() .filter_map(Value::as_inline_table_mut) + .filter(|constraint| names(constraint, name)) { - if constraint - .get("name") - .and_then(Value::as_str) - .is_some_and(|value| canonicalize_pypi_name(value) == name) - { - constraint.remove("specifier"); - constraint.remove("url"); - constraint.remove("path"); - constraint.insert(artifact.key(), Value::from(artifact.location())); - } + repoint(constraint, artifact); } } } @@ -478,18 +469,10 @@ fn rewrite_manifest(document: &mut DocumentMut, name: &str, artifact: ArtifactSo for requirement in requirements .iter_mut() .filter_map(Value::as_inline_table_mut) + .filter(|requirement| names(requirement, name)) { - if requirement - .get("name") - .and_then(Value::as_str) - .is_some_and(|value| canonicalize_pypi_name(value) == name) - { - requirement.remove("specifier"); - requirement.remove("url"); - requirement.remove("path"); - requirement.insert(artifact.key(), Value::from(artifact.location())); - direct = true; - } + repoint(requirement, artifact); + direct = true; } } if direct { @@ -501,18 +484,13 @@ fn rewrite_manifest(document: &mut DocumentMut, name: &str, artifact: ArtifactSo let Some(overrides) = overrides.as_array_mut() else { return; }; - for requirement in overrides.iter_mut().filter_map(Value::as_inline_table_mut) { - if requirement - .get("name") - .and_then(Value::as_str) - .is_some_and(|value| canonicalize_pypi_name(value) == name) - { - requirement.remove("specifier"); - requirement.remove("url"); - requirement.remove("path"); - requirement.insert(artifact.key(), Value::from(artifact.location())); - return; - } + if let Some(requirement) = overrides + .iter_mut() + .filter_map(Value::as_inline_table_mut) + .find(|requirement| names(requirement, name)) + { + repoint(requirement, artifact); + return; } overrides.push_formatted(inline(&[ ("name", Value::from(name)), @@ -553,17 +531,9 @@ fn rewrite_requirement_sources(item: &mut Item, name: &str, artifact: ArtifactSo for requirement in requirements .iter_mut() .filter_map(Value::as_inline_table_mut) + .filter(|requirement| names(requirement, name)) { - if requirement - .get("name") - .and_then(Value::as_str) - .is_some_and(|value| canonicalize_pypi_name(value) == name) - { - requirement.remove("specifier"); - requirement.remove("url"); - requirement.remove("path"); - requirement.insert(artifact.key(), Value::from(artifact.location())); - } + repoint(requirement, artifact); } } else if let Some(table) = item.as_table_like_mut() { for (_, item) in table.iter_mut() { @@ -572,61 +542,12 @@ fn rewrite_requirement_sources(item: &mut Item, name: &str, artifact: ArtifactSo } } -pub fn complete_python_lock_metadata( - text: &str, - project: Option<&str>, - name: &str, - version: &str, - artifact: ArtifactSource<'_>, - wheel_metadata: Option<&str>, -) -> Result { - let mut document: DocumentMut = text - .parse() - .map_err(|error| format!("invalid Python lock: {error}"))?; - if document - .get("package") - .and_then(Item::as_array_of_tables) - .is_none() - { - return Ok(text.to_string()); - } - complete_python_lock_metadata_in( - &mut document, - project, - name, - version, - artifact, - wheel_metadata, - )?; - Ok(preserve_line_endings(text, document.to_string())) -} - -/// [`complete_python_lock_metadata`] on an already-parsed lock, in place. -/// A lock with no `[[package]]` collection is left untouched. Every `Err` is -/// raised before the document is touched. -fn complete_python_lock_metadata_in( - document: &mut DocumentMut, - project: Option<&str>, - name: &str, - version: &str, - artifact: ArtifactSource<'_>, - wheel_metadata: Option<&str>, -) -> Result<(), String> { - let Some(completion) = prepare_metadata_completion(document, project, name, wheel_metadata)? - else { - return Ok(()); - }; - finish_metadata_completion(document, completion, version, artifact); - Ok(()) -} - -/// Everything [`complete_python_lock_metadata`] can refuse over, decided -/// before it mutates the lock. The refusals read only the metadata inputs, -/// the lock's `[[package]]` collection and its `manifest` / `overrides` -/// shapes — none of which [`apply_python_lock_plan`] changes (it replaces -/// entry sources and artifacts, and only ever inserts an ARRAY `overrides` -/// where none existed) — so preparing against the pre-rewrite lock gives the -/// verdict the post-rewrite lock would. +/// The metadata completion of [`PythonLockSession::rewrite`], decided before +/// it mutates the lock. The refusals read only the metadata inputs, the +/// lock's `[[package]]` collection and its `manifest` / `overrides` shapes, +/// none of which [`apply_python_lock_plan`] changes (it only ever inserts an +/// ARRAY `overrides` where none existed), so preparing against the +/// pre-rewrite lock gives the verdict the post-rewrite lock would. struct MetadataCompletion { name: String, /// The pyproject's `[tool.uv] override-dependencies` names the dep. @@ -721,7 +642,7 @@ fn prepare_metadata_completion( /// Refuse when `project` declares PEP 621 `dynamic` dependencies (or /// optional-dependencies) and the lock's root package lists `name` in -/// `requires-dist` (#639). That entry came from the build backend, not from +/// `requires-dist`. That entry came from the build backend, not from /// pyproject.toml, so upstream restore has no declaration to derive its /// specifier from, and rollback, remove and a vendored takeover could never /// undo the repointed entry. `requires-dev` comes from PEP 735 groups, @@ -828,12 +749,7 @@ fn finish_metadata_completion( overrides .iter() .filter_map(Value::as_inline_table) - .any(|requirement| { - requirement - .get("name") - .and_then(Value::as_str) - .is_some_and(|value| canonicalize_pypi_name(value) == name) - }) + .any(|requirement| names(requirement, &name)) }); if exists { rewrite_requirement_sources(overrides, &name, artifact); @@ -890,8 +806,8 @@ fn plan_python_lock_rewrite( { source_scope(document, name, version)?; } - let pep751 = document.get("lock-version").is_some(); - let legacy = document.get("distribution").is_some(); + let (collection, pep751) = lock_package_collection(document.as_table()); + let legacy = collection == "distribution"; if pep751 { if document.get("lock-version").and_then(Item::as_str) != Some("1.0") { return Err("unsupported PEP 751 lock version".to_string()); @@ -899,13 +815,6 @@ fn plan_python_lock_rewrite( } else if document.get("version").and_then(Item::as_integer) != Some(1) { return Err("unsupported uv lock version".to_string()); } - let collection = if pep751 { - "packages" - } else if legacy { - "distribution" - } else { - "package" - }; let name = canonicalize_pypi_name(name); let Some(packages) = document.get(collection).and_then(Item::as_array_of_tables) else { return Ok(None); @@ -971,7 +880,7 @@ fn plan_python_lock_rewrite( } // An entry resolved from a direct URL (uv.lock `source = { url }`, a // pylock `archive = { url }`) is the user's own source choice unless it - // is socket-patch's earlier hosted artifact for this release (#767). + // is socket-patch's earlier hosted artifact for this release. let direct_url = if pep751 { package .get("archive") @@ -1001,13 +910,7 @@ fn plan_python_lock_rewrite( } } let location = artifact.location(); - let filename = location - .split(['?', '#']) - .next() - .unwrap_or(&location) - .rsplit('/') - .next() - .unwrap_or(&location); + let filename = artifact_filename(&location); if !filename.ends_with(".whl") && !filename.ends_with(".tar.gz") && !filename.ends_with(".zip") @@ -1035,46 +938,13 @@ fn plan_python_lock_rewrite( })) } -/// A Python lock parsed ONCE for repeated "would [`rewrite_python_lock`] -/// rewrite this dep?" checks — the hosted wheel-metadata gate asks it for -/// every pypi dep without re-parsing and re-serializing the lock each time. -pub struct PythonLockProbe<'t> { - text: &'t str, - /// `None` when the lock does not parse (every dep then probes false, - /// as `rewrite_python_lock`'s `Err` would). - document: Option, -} - -impl<'t> PythonLockProbe<'t> { - pub fn new(text: &'t str) -> Self { - Self { - text, - document: text.parse().ok(), - } - } - - /// Exactly `matches!(rewrite_python_lock(text, …), Ok(Some(_)))`. - pub fn rewrites(&self, name: &str, version: &str, artifact: ArtifactSource<'_>) -> bool { - self.document.as_ref().is_some_and(|document| { - matches!( - plan_python_lock_rewrite(document, self.text, name, version, artifact), - Ok(Some(_)) - ) - }) - } -} - -/// A native Python lock parsed ONCE for a whole hosted rewrite: the hosted -/// rewriter plans, applies and completes every dep against this one -/// in-memory document instead of re-parsing the lock three times per dep -/// (rewrite, source scope, metadata completion). +/// A native Python lock parsed once for a whole hosted rewrite (and for the +/// hosted wheel-metadata gate's "would this dep be rewritten?" probe). /// -/// Each step answers exactly what its text twin answers on the lock's -/// current rendering: [`Self::plan`] is [`rewrite_python_lock`]'s verdict, -/// [`Self::source_scope`] is [`check_python_lock_source_scope`], and -/// [`Self::rewrite`] renders what `complete_python_lock_metadata( -/// rewrite_python_lock(…))` would. A refused dep leaves the document as it -/// was, since every refusal is decided before the first mutation. +/// [`Self::plan`] is [`rewrite_python_lock`]'s verdict on the lock's current +/// rendering and [`Self::source_scope`] is [`check_python_lock_source_scope`]. +/// A refused dep leaves the document as it was, since every refusal is +/// decided before the first mutation. pub(crate) struct PythonLockSession { document: Result, } @@ -1195,13 +1065,7 @@ fn apply_python_lock_plan( .and_then(|packages| packages.get_mut(index)) .expect("planned package index exists"); let location = artifact.location(); - let filename = location - .split(['?', '#']) - .next() - .unwrap_or(&location) - .rsplit('/') - .next() - .unwrap_or(&location); + let filename = artifact_filename(&location); let wheel = filename.ends_with(".whl"); for key in ["sdist", "wheel", "wheels", "archive"] { package.remove(key); @@ -1307,7 +1171,7 @@ pub fn paired_metadata_path(lock: &str) -> Option { } #[cfg(test)] -mod script_lock_name_tests { +mod tests { use super::*; #[test] @@ -1331,11 +1195,6 @@ mod script_lock_name_tests { ); assert_eq!(paired_metadata_path("package.json"), None); } -} - -#[cfg(test)] -mod tests { - use super::{is_python_lock_name, rewrite_python_lock, ArtifactSource}; const URL: &str = "https://patch.socket.dev/pkg/urllib3-1.26.18-py2.py3-none-any.whl"; const SHA256: &str = "ccc9a9e0b18a5efc7038c504cfc580e47d2e02e5390f2e29cad833cbccb956b6"; @@ -1365,7 +1224,7 @@ source = { registry = "https://pypi.org/simple" } wheels = [{ url = "https://pypi.org/urllib3-2.0.0-py3-none-any.whl", hash = "sha256:other" }] "#; - /// #767: a lock entry resolved from the user's own direct URL (uv.lock + /// A lock entry resolved from the user's own direct URL (uv.lock /// `source = { url }`, pylock `archive = { url }`) is refused in both /// modes; the hosted rewrite's own earlier artifact is still replaced. #[test] @@ -1397,14 +1256,21 @@ wheels = [{ url = "https://pypi.org/urllib3-2.0.0-py3-none-any.whl", hash = "sha } // The hosted rewrite's own artifact (a re-scan) is not the user's. let ours = lock.replace(USER, URL); - assert!(rewrite_python_lock( + let rewritten = rewrite_python_lock( &ours, "urllib3", "1.26.18", ArtifactSource::Url(URL), - SHA256 + SHA256, ) - .is_ok()); + .unwrap(); + // The fixture's old digest `aa` gives way to the artifact's. + assert!( + rewritten.is_some_and(|text| text.contains(SHA256) + && !text.contains("\"aa\"") + && !text.contains("sha256:aa")), + "{ours}" + ); } } @@ -1995,11 +1861,6 @@ wheels = [{url = "https://pypi.org/urllib3.whl", hashes = {sha256 = "old"}}] assert!(!is_python_lock_name(name)); } } -} - -#[cfg(test)] -mod discovery_and_line_ending_tests { - use super::*; #[test] fn crlf_locks_keep_their_line_endings_through_a_rewrite() { @@ -2069,14 +1930,6 @@ mod discovery_and_line_ending_tests { vec!["pylock.toml".to_string(), "tool.py.lock".to_string()] ); } -} - -#[cfg(test)] -mod manifest_constraints_tests { - use super::{rewrite_python_lock, ArtifactSource}; - - const URL: &str = "https://patch.socket.dev/pkg/urllib3-1.26.18-py2.py3-none-any.whl"; - const SHA256: &str = "ccc9a9e0b18a5efc7038c504cfc580e47d2e02e5390f2e29cad833cbccb956b6"; /// `[tool.uv] constraint-dependencies` / `build-constraint-dependencies` /// land in `[manifest] constraints` / `build-constraints` as @@ -2144,140 +1997,3 @@ wheels = [ ); } } - -/// `PythonLockProbe::rewrites` must agree with `rewrite_python_lock` -/// returning `Ok(Some(_))` for every lock shape and every refusal / -/// not-applicable path — the hosted wheel-metadata gate relies on it. -#[cfg(test)] -mod probe_equivalence_tests { - use super::{rewrite_python_lock, ArtifactSource, PythonLockProbe}; - - const SHA256: &str = "ccc9a9e0b18a5efc7038c504cfc580e47d2e02e5390f2e29cad833cbccb956b6"; - const NATIVE: &str = r#"version = 1 -revision = 3 - -[[package]] -name = "project" -version = "1" -source = { virtual = "." } -dependencies = [ - { name = "urllib3", version = "1.26.18", source = { registry = "https://pypi.org/simple" } }, -] - -[[package]] -name = "urllib3" -version = "1.26.18" -source = { registry = "https://pypi.org/simple" } -wheels = [{ url = "https://pypi.org/urllib3-1.26.18-py2.py3-none-any.whl", hash = "sha256:old" }] - -[[package]] -name = "Zope.Interface" -version = "6.0" -source = { git = "https://example.test/zope" } - -[[package]] -name = "six" -version = "1.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://pypi.org/six-1.16.0.tar.gz", hash = "sha256:old" } -"#; - const LEGACY_STRINGS: &str = r#"version = 1 - -[[distribution]] -name = "urllib3" -version = "1.26.18" -source = "registry+https://pypi.org/simple" - -[[distribution.wheel]] -url = "https://pypi.org/urllib3-1.26.18-py2.py3-none-any.whl" -hash = "sha256:old" - -[[distribution]] -name = "six" -version = "1.16.0" -source = "git+https://example.test/six" -"#; - const PEP751: &str = r#"lock-version = "1.0" - -[[packages]] -name = "urllib3" -version = "1.26.18" -index = "https://pypi.org/simple" -wheels = [{ url = "https://pypi.org/urllib3-1.26.18-py2.py3-none-any.whl", hashes = { sha256 = "old" } }] - -[[packages]] -name = "six" -version = "1.16.0" -vcs = { type = "git", url = "https://example.test/six", commit-id = "abc" } -"#; - - #[test] - fn probe_matches_rewrite_outcome_for_every_shape() { - let script = NATIVE.replacen( - "[[package]]", - "[manifest]\nrequirements = [{ name = \"urllib3\", specifier = \"==1.26.18\" }]\n\n[[package]]", - 1, - ); - let multi_version = format!( - "{script}\n[[package]]\nname = \"urllib3\"\nversion = \"2.0.0\"\nsource = {{ registry = \"https://pypi.org/simple\" }}\n" - ); - let duplicate = NATIVE.replace( - "name = \"six\"\nversion = \"1.16.0\"", - "name = \"urllib3\"\nversion = \"1.26.18\"", - ); - let locks = [ - NATIVE.to_string(), - NATIVE.replace("\n", "\r\n"), - script, - multi_version, - duplicate, - LEGACY_STRINGS.to_string(), - PEP751.to_string(), - "version = 2\n".to_string(), - "lock-version = '2.0'\n".to_string(), - "version = 1\n".to_string(), - "this is [not toml".to_string(), - ]; - let wheel = "https://patch.socket.dev/pkg/urllib3-1.26.18-py2.py3-none-any.whl"; - let sdist = "https://patch.socket.dev/pkg/six-1.16.0.tar.gz?token=x#frag"; - let artifacts = [ - ArtifactSource::Url(wheel), - ArtifactSource::Url(sdist), - ArtifactSource::Url("https://patch.socket.dev/pkg/urllib3.exe"), - ArtifactSource::Path(".socket/vendor/pypi/id/urllib3-1.26.18-py2.py3-none-any.whl"), - ]; - let targets = [ - ("urllib3", "1.26.18"), - ("URLLIB3", "1.26.18"), - ("urllib3", "2.0.0"), - ("urllib3", "9.9.9"), - ("six", "1.16.0"), - ("zope-interface", "6.0"), - ("absent", "1.0"), - ]; - let mut outcomes = std::collections::BTreeSet::new(); - for lock in &locks { - let probe = PythonLockProbe::new(lock); - for (name, version) in targets { - for artifact in artifacts { - let rewrite = rewrite_python_lock(lock, name, version, artifact, SHA256); - outcomes.insert(match &rewrite { - Ok(Some(_)) => "some", - Ok(None) => "none", - Err(_) => "err", - }); - assert_eq!( - probe.rewrites(name, version, artifact), - matches!(rewrite, Ok(Some(_))), - "{name}@{version} via {artifact:?} over:\n{lock}\nrewrite: {rewrite:?}" - ); - } - } - } - assert_eq!( - outcomes.len(), - 3, - "every outcome is exercised: {outcomes:?}" - ); - } -} diff --git a/crates/socket-patch-core/src/utils/python_script.rs b/crates/socket-patch-core/src/utils/python_script.rs index c61fb3871..cafa232de 100644 --- a/crates/socket-patch-core/src/utils/python_script.rs +++ b/crates/socket-patch-core/src/utils/python_script.rs @@ -62,6 +62,28 @@ fn dependency_name(specifier: &str) -> String { canonicalize_pypi_name(pep508_name(specifier)) } +/// The document's `[tool.uv]` table. +fn tool_uv(doc: &DocumentMut) -> Option<&dyn TableLike> { + doc.get("tool") + .and_then(Item::as_table_like) + .and_then(|tool| tool.get("uv")) + .and_then(Item::as_table_like) +} + +/// Whether the metadata declares `name` (canonical) directly: a PEP 723 +/// block's `dependencies` when `script`, else a pyproject dependency array +/// or the legacy `[tool.uv] dev-dependencies` (uv records those under +/// `requires-dev`, so an override for them is redundant). +fn declares_directly(doc: &DocumentMut, script: bool, name: &str) -> bool { + if script { + return contains_dependency(doc.get("dependencies"), name); + } + pyproject_dependency_specs(doc) + .into_iter() + .any(|(_, spec)| dependency_name(spec) == name) + || tool_uv(doc).is_some_and(|uv| contains_dependency(uv.get("dev-dependencies"), name)) +} + /// Whether `previous` is socket-patch's own earlier hosted source for this /// package that `current` may replace: a rotated grant token on the same /// artifact path, or (the shared PyPI recognizer) a superseding patch uuid @@ -103,31 +125,21 @@ pub(crate) fn same_hosted_artifact( /// How a freshly created `[tool.uv.sources]` is laid out. /// -/// A PEP 723 script block is reverted by DOCUMENT restore -/// (`vendor::pypi_lock::restore_document`), which can only converge on the -/// exact pre-vendor bytes after an out-of-order multi-package revert of -/// DIRECT dependencies (one `sources.` key each) when the tables we -/// created vanish once empty. Only DOTTED keys do that after a parse round -/// trip (dotted-ness is syntax; `implicit` is not), so the script block -/// keeps `tool.uv.sources.x = { … }` right after `dependencies`, where -/// placement was never a problem. TRANSITIVE pairs are different: they share -/// one `override-dependencies` array, and `restore_value`'s array branch -/// flags drift as soon as the entries stop lining up, so their revert stays -/// order-dependent whatever the layout. +/// A PEP 723 script block is reverted by document restore +/// (`vendor::pypi_lock::restore_document`), which converges on the original +/// bytes after an out-of-order multi-package revert only when the tables we +/// created vanish once empty. Only dotted keys do that after a parse round +/// trip (dotted-ness is syntax; `implicit` is not), so scripts use `Dotted`. +/// Transitive pairs share one `override-dependencies` array, whose restore +/// flags drift once entries stop lining up, so their out-of-order revert +/// stays order-dependent whatever the layout. /// -/// A `pyproject.toml` is reverted by exact text replay of the recorded -/// original, so it can use uv's own layout: header-less `[tool]` / `[tool.uv]` -/// parents and a real `[tool.uv.sources]` table. Where that header lands is -/// toml_edit's choice, not ours: a new table is rendered right after the -/// last PRE-EXISTING table visited before it in depth-first key order — -/// directly after an existing `[tool.uv]`, after `[tool.ruff]` when that is -/// the only `[tool.*]` table (even when that puts it BEFORE `[project]`), -/// and at the very end of the document — after a trailing `[build-system]` — -/// when there is no `[tool]` block yet. (Dotted keys would instead render as -/// `tool.uv.sources.x = …` in the ROOT body — i.e. ABOVE `[project]` — -/// whenever the pyproject had no `[tool]` header yet; an older CLI wrote -/// exactly that, and `rewrite_sources` keeps extending such a dotted table -/// rather than adding a second, conflicting header.) +/// A `pyproject.toml` is reverted by text replay of the recorded original, +/// so it uses uv's own layout (`Tables`): header-less `[tool]` / `[tool.uv]` +/// parents and a real `[tool.uv.sources]` table, placed by toml_edit after +/// the last pre-existing table before it in key order (or at the end of the +/// document). A dotted root table an older CLI wrote is extended in place +/// rather than given a second, conflicting header. #[derive(Clone, Copy)] enum SourcesLayout { Dotted, @@ -228,7 +240,20 @@ fn rewrite_sources( )); } } else if hosted { - push_hosted_override(overrides, specifier); + // On its own line under the mark; removing the element (its + // decor goes with it) restores the array's original bytes. + const INDENT: &str = " "; + let created = overrides.is_empty(); + let mut value = Value::from(specifier); + value + .decor_mut() + .set_prefix(format!("\n{INDENT}{HOSTED_OVERRIDE_MARK}\n{INDENT}")); + value.decor_mut().set_suffix(""); + overrides.push_formatted(value); + if created { + overrides.set_trailing_comma(true); + overrides.set_trailing("\n"); + } } else { overrides.push(specifier); } @@ -240,7 +265,7 @@ fn rewrite_sources( /// direct reference (`six @ https://…`, `six @ git+…`): that is the user's /// own source, the `[project]` spelling of a `[tool.uv.sources]` entry, and /// a source added beside it would silently replace it (hosted) or leave a -/// lock uv rejects (vendored) (#767). +/// lock uv rejects (vendored). fn refuse_direct_reference<'a>( file: &str, name: &str, @@ -262,13 +287,13 @@ fn refuse_direct_reference<'a>( /// `override-dependencies` entry it adds. v5 hosted mode keeps no ledger, /// so this comment is the only evidence the upstream restore has that the /// entry is socket-patch's to remove: an entry without it, even one spelled -/// exactly `==`, is the user's own pin and is kept (#411). +/// exactly `==`, is the user's own pin and is kept. pub(crate) const HOSTED_OVERRIDE_MARK: &str = "# socket-patch hosted: pins a patched transitive dependency; rollback removes it"; /// Whether an `override-dependencies` element carries /// [`HOSTED_OVERRIDE_MARK`] (the hosted rewrite added it). -pub(crate) fn is_hosted_override(value: &Value) -> bool { +fn is_hosted_override(value: &Value) -> bool { value .decor() .prefix() @@ -294,37 +319,15 @@ pub(crate) struct HostedOverridePin { /// its `dependencies`). The rewrite adds an override only for a transitive /// dependency, so a marked entry for a direct one is not its own. Hosted /// mode keeps no ledger: these are the only overrides socket-patch may -/// remove (#411), and the only ones a relock can leave behind as Socket's -/// own wiring (#1445). +/// remove, and the only ones a relock can leave behind as Socket's +/// own wiring. pub(crate) fn hosted_override_pins(doc: &DocumentMut, script: bool) -> Vec { - let uv = doc - .get("tool") - .and_then(Item::as_table_like) - .and_then(|t| t.get("uv")) - .and_then(Item::as_table_like); - let Some(overrides) = uv - .and_then(|u| u.get("override-dependencies")) + let Some(overrides) = tool_uv(doc) + .and_then(|uv| uv.get("override-dependencies")) .and_then(Item::as_array) else { return Vec::new(); }; - let strings = |item: Option<&Item>| -> Vec { - item.and_then(Item::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_str) - .map(|spec| canonicalize_pypi_name(pep508_name(spec))) - .collect() - }; - let direct: Vec = if script { - strings(doc.get("dependencies")) - } else { - pyproject_dependency_specs(doc) - .iter() - .map(|(_, spec)| canonicalize_pypi_name(pep508_name(spec))) - .chain(strings(uv.and_then(|u| u.get("dev-dependencies")))) - .collect() - }; overrides .iter() .enumerate() @@ -342,10 +345,12 @@ pub(crate) fn hosted_override_pins(doc: &DocumentMut, script: bool) -> Vec Vec bool { let toml = if script { @@ -371,24 +376,10 @@ pub(crate) fn wires_through_override(metadata: &str, script: bool, name: &str) - return false; }; let name = canonicalize_pypi_name(name); - let uv = doc - .get("tool") - .and_then(Item::as_table_like) - .and_then(|tool| tool.get("uv")) - .and_then(Item::as_table_like); - if !contains_dependency(uv.and_then(|uv| uv.get("override-dependencies")), &name) { - return false; - } - // The same "direct" reading the rewrites use to decide on an override. - let direct = if script { - contains_dependency(doc.get("dependencies"), &name) - } else { - pyproject_dependency_specs(&doc) - .into_iter() - .any(|(_, spec)| dependency_name(spec) == name) - || uv.is_some_and(|uv| contains_dependency(uv.get("dev-dependencies"), &name)) - }; - !direct + contains_dependency( + tool_uv(&doc).and_then(|uv| uv.get("override-dependencies")), + &name, + ) && !declares_directly(&doc, script, &name) } /// The advisory for [`wires_through_override`] wiring, shared by hosted @@ -419,26 +410,6 @@ pub(crate) fn uv_constraints_advisory(name: &str, wheel: &str) -> String { ) } -/// Append `specifier` to `overrides` on its own line, under -/// [`HOSTED_OVERRIDE_MARK`]. An array the rewrite just created (empty) is -/// laid out multi-line with a trailing comma; an existing array keeps its -/// other elements as they were. Removing the element again (its decor goes -/// with it) restores the array's original bytes. -fn push_hosted_override(overrides: &mut Array, specifier: String) { - const INDENT: &str = " "; - let created = overrides.is_empty(); - let mut value = Value::from(specifier); - value - .decor_mut() - .set_prefix(format!("\n{INDENT}{HOSTED_OVERRIDE_MARK}\n{INDENT}")); - value.decor_mut().set_suffix(""); - overrides.push_formatted(value); - if created { - overrides.set_trailing_comma(true); - overrides.set_trailing("\n"); - } -} - fn contains_dependency(item: Option<&Item>, name: &str) -> bool { item.and_then(Item::as_array).is_some_and(|dependencies| { dependencies @@ -453,7 +424,7 @@ fn contains_dependency(item: Option<&Item>, name: &str) -> bool { pub const UV_TOML: &str = "uv.toml"; /// A uv settings file above the scanned root that uv applies to a PEP 723 -/// script (#1501) or a project (#1545) there, found by +/// script or a project there, found by /// [`find_ancestor_uv_config`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UvConfig { @@ -548,7 +519,7 @@ const UV_PROJECT_ONLY_KEYS: &[&str] = &[ ]; /// Refuse to wire a project whose `pyproject` would shadow the uv settings -/// it inherits from `ancestor` (#1545). +/// it inherits from `ancestor`. /// /// uv reads a project's settings from the nearest directory at or above it /// that holds a `uv.toml` or a `pyproject.toml` with `[tool.uv]`, the same @@ -680,7 +651,7 @@ fn project_settings<'a>( /// The governing project settings for a PEP 723 script in a root whose own /// `pyproject` and `uv_toml` may not govern it: when neither sets uv's /// settings, `ancestor` (the [`find_ancestor_uv_config`] above the root) -/// does (#1501). +/// does. fn script_project_settings<'a>( pyproject: Option<&'a str>, uv_toml: Option<&'a str>, @@ -696,13 +667,12 @@ fn script_project_settings<'a>( } /// Refuse to wire a `[tool.uv.sources]` entry for `name` that uv would -/// ignore (#564). +/// ignore. /// /// Under `no-sources = true`, or a `no-sources-package` list naming the /// package, uv drops the source, so a wired entry fails `uv sync --locked`, /// and a plain `uv sync` relocks the package to the registry and installs -/// the unpatched wheel. `script` is the PEP 723 script being wired (`None` -/// for a project), and `pyproject` and `uv_toml` are the project's files, +/// the unpatched wheel. `pyproject` and `uv_toml` are the project's files, /// when present. Each file's settings are uv's (checked against uv 0.11): a /// `uv.toml` replaces pyproject's `[tool.uv]` outright; a script's own /// `no-sources` wins over the project's, while the two `no-sources-package` @@ -711,14 +681,13 @@ fn script_project_settings<'a>( /// the rewriters report a metadata file they cannot edit. pub fn refuse_uv_no_sources( name: &str, - script: Option<&str>, pyproject: Option<&str>, uv_toml: Option<&str>, ) -> Result<(), String> { refuse_package_setting( &NO_SOURCES, name, - script, + None, project_settings(pyproject, uv_toml), ) } @@ -726,7 +695,7 @@ pub fn refuse_uv_no_sources( /// [`refuse_uv_no_sources`] for a PEP 723 script in a root whose own /// `pyproject` and `uv_toml` may not govern it: when neither sets uv's /// settings, `ancestor` (the [`find_ancestor_uv_config`] above the root) -/// does (#1501). +/// does. pub fn refuse_uv_script_no_sources( name: &str, script: &str, @@ -742,8 +711,7 @@ pub fn refuse_uv_script_no_sources( ) } -/// Refuse to wire a patched wheel for `name` that uv may not install -/// (#1603). +/// Refuse to wire a patched wheel for `name` that uv may not install. /// /// Under `no-binary = true`, or a `no-binary-package` list naming the /// package, uv installs the package only from an sdist, and every Socket @@ -754,16 +722,10 @@ pub fn refuse_uv_script_no_sources( /// two lists add up, and `no-binary = false` disables the list. pub fn refuse_uv_no_binary( name: &str, - script: Option<&str>, pyproject: Option<&str>, uv_toml: Option<&str>, ) -> Result<(), String> { - refuse_package_setting( - &NO_BINARY, - name, - script, - project_settings(pyproject, uv_toml), - ) + refuse_package_setting(&NO_BINARY, name, None, project_settings(pyproject, uv_toml)) } /// [`refuse_uv_no_binary`] for a PEP 723 script, with the project settings @@ -869,12 +831,8 @@ pub fn rewrite_project_metadata( { return Err("pyproject.toml has no project table".to_string()); } - let tool_uv = document - .get("tool") - .and_then(Item::as_table_like) - .and_then(|tool| tool.get("uv")) - .and_then(Item::as_table_like); - let legacy_dev = tool_uv + let uv = tool_uv(&document); + let legacy_dev = uv .and_then(|uv| uv.get("dev-dependencies")) .and_then(Item::as_array) .into_iter() @@ -888,17 +846,8 @@ pub fn rewrite_project_metadata( .map(|(_, spec)| spec) .chain(legacy_dev), )?; - let direct = pyproject_dependency_specs(&document) - .into_iter() - .any(|(_, spec)| dependency_name(spec) == name) - // The legacy `[tool.uv] dev-dependencies` array (still honoured by uv - // with a deprecation warning) is a direct declaration too: uv records - // it under `[package.metadata.requires-dev]`, so an override here - // would be redundant — and ignored by uv < 0.5.6, which does not - // apply `[tool.uv.sources]` to `override-dependencies`. - || tool_uv - .is_some_and(|uv| contains_dependency(uv.get("dev-dependencies"), &name)); - if tool_uv.is_some_and(|uv| uv.contains_key("workspace")) { + let direct = declares_directly(&document, false, &name); + if uv.is_some_and(|uv| uv.contains_key("workspace")) { return Err( "hosted sources for uv workspaces require a package-scoped source mapping".to_string(), ); @@ -936,7 +885,7 @@ pub fn rewrite_script_metadata( .flatten() .filter_map(Value::as_str), )?; - let direct = contains_dependency(document.get("dependencies"), &name); + let direct = declares_directly(&document, true, &name); rewrite_sources( &mut document, &name, @@ -953,7 +902,7 @@ pub fn rewrite_script_metadata( mod tests { use super::*; - /// #815: the re-commented block takes the script's majority line + /// The re-commented block takes the script's majority line /// ending; one stray CRLF line no longer turns it CRLF. #[test] fn replaced_metadata_takes_the_majority_line_ending() { @@ -966,7 +915,7 @@ mod tests { assert!(out.contains("# dependencies = [\"a\"]\r\n# ///"), "{out:?}"); } - /// #767: a PEP 508 direct reference to the patched package, wherever + /// A PEP 508 direct reference to the patched package, wherever /// the project or script declares it, is the user's own source: the /// rewrite refuses instead of adding a `[tool.uv.sources]` entry beside it. #[test] @@ -1077,7 +1026,7 @@ mod tests { const OWN_A: &str = "https://patch.socket.dev/patch/pypi/six/1.16.0/11111111-1111-4111-8111-111111111111/aaaaaaaa-0000-4000-8000-000000000001/six-1.16.0-py2.py3-none-any.whl"; const OWN_B: &str = "https://patch.socket.dev/patch/pypi/six/1.16.0/22222222-2222-4222-8222-222222222222/aaaaaaaa-0000-4000-8000-000000000004/six-1.16.0-py2.py3-none-any.whl"; - /// #742: a superseding patch (a new patch uuid for the same package and + /// A superseding patch (a new patch uuid for the same package and /// version) re-pins socket-patch's OWN earlier hosted source in both a /// uv project and a PEP 723 script, instead of refusing it as a /// user-authored source. @@ -1588,7 +1537,7 @@ mod ancestor_config_tests { const SCRIPT: &str = "# /// script\n# dependencies = [\"six==1.16.0\"]\n# ///\n"; const PYPROJECT: &str = "[project]\nname = \"root\"\nversion = \"0\"\n"; - /// #1501: uv takes a script's settings from the nearest directory above + /// uv takes a script's settings from the nearest directory above /// it that holds a uv.toml, or a pyproject.toml with `[tool.uv]`; uv.toml /// first in each directory, and a pyproject without `[tool.uv]` skipped. #[test] @@ -1693,7 +1642,7 @@ mod ancestor_config_tests { ); } - /// #1545: a project whose root sets no uv settings takes a parent's + /// A project whose root sets no uv settings takes a parent's /// `uv.toml` / `[tool.uv]`, and the `[tool.uv]` table a patch adds /// would make uv read the project's pyproject instead and drop them. #[test] @@ -1765,4 +1714,42 @@ mod ancestor_config_tests { Ok(()) ); } + + /// `no-binary` follows uv's combine rules: a `uv.toml` replaces + /// pyproject's `[tool.uv]`, a script's own bool wins over the project's + /// list, and the lists name packages by their canonical name. + #[test] + fn no_binary_refuses_by_uvs_combine_rules() { + let listed = format!("{PYPROJECT}[tool.uv]\nno-binary-package = [\"Six\"]\n"); + let error = refuse_uv_no_binary("six", Some(&listed), None).unwrap_err(); + assert!( + error.starts_with("pyproject.toml [tool.uv] sets no-binary-package naming six,"), + "{error}" + ); + let other = listed.replace("Six", "other"); + assert_eq!(refuse_uv_no_binary("six", Some(&other), None), Ok(())); + // uv.toml replaces [tool.uv] outright, in both directions. + assert_eq!(refuse_uv_no_binary("six", Some(&listed), Some("")), Ok(())); + let error = + refuse_uv_no_binary("six", Some(&other), Some("no-binary = true\n")).unwrap_err(); + assert!( + error.starts_with("uv.toml sets no-binary = true,"), + "{error}" + ); + // A script's own `no-binary = false` disables the project's list... + let script_false = + SCRIPT.replace("# ///\n", "#\n# [tool.uv]\n# no-binary = false\n# ///\n"); + assert_eq!( + refuse_uv_script_no_binary("six", &script_false, Some(&listed), None, None), + Ok(()) + ); + // ...and its `no-binary = true` refuses under a silent project. + let script_true = script_false.replace("false", "true"); + let error = refuse_uv_script_no_binary("six", &script_true, Some(PYPROJECT), None, None) + .unwrap_err(); + assert!( + error.starts_with("the script's [tool.uv] sets no-binary = true,"), + "{error}" + ); + } } diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 70f880e09..c00b33934 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -346,22 +346,12 @@ pub(super) fn check_target_guards( // uv drops the [tool.uv.sources] entry under no-sources (or a // no-sources-package list naming the package), so the vendored source // would be ignored by the next `uv sync` (#564). - refuse_uv_no_sources( - canon_name, - None, - Some(&p.pyproject_text), - p.uv_toml.as_deref(), - ) - .map_err(|detail| ("pypi_uv_no_sources", detail))?; + refuse_uv_no_sources(canon_name, Some(&p.pyproject_text), p.uv_toml.as_deref()) + .map_err(|detail| ("pypi_uv_no_sources", detail))?; // The vendored artifact is a wheel, which uv refuses to install under // no-binary (or a no-binary-package list naming the package) (#1603). - refuse_uv_no_binary( - canon_name, - None, - Some(&p.pyproject_text), - p.uv_toml.as_deref(), - ) - .map_err(|detail| ("pypi_uv_no_binary", detail))?; + refuse_uv_no_binary(canon_name, Some(&p.pyproject_text), p.uv_toml.as_deref()) + .map_err(|detail| ("pypi_uv_no_binary", detail))?; // A parent uv.toml / [tool.uv] supplies the project's settings while // the project sets none; the [tool.uv] table wired below would make // uv drop them, failing `uv sync --locked` (#1545).