From 0eee2dd7e17a66a1edccf7a3fd3f4a48b9bf5854 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 20:23:31 +0000 Subject: [PATCH 1/7] Start fix for #1603 (pylock lane) Assisted-by: Claude Code:claude-opus-5-5 From 4d61d0d5cfe5b0bd6090235d67d955da7402b91f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 20:46:56 +0000 Subject: [PATCH 2/7] Refuse uv pylocks that forbid the patched wheel A PEP 751 lock from `uv pip compile --no-binary six` records six with only its sdist, and `uv pip` installs six only from an sdist under that flag or a uv `no-binary` setting. Both scan modes still swapped in the patched wheel and exited 0, so the matching `uv pip sync` then failed ("marked as `--no-binary` but is itself a binary distribution"). Hosted and vendored scans now refuse such a lock before writing anything when the `uv pip compile` command line in its header passes `--no-binary` naming the package or `:all:`, or the root's uv.toml / `[tool.uv]` covers it through `[pip] no-binary`, `no-binary = true` or `no-binary-package`. Checked against uv 0.11.32: `[pip]` and top-level settings add up, and `no-binary = false` turns off only the top-level package list. Locks written by other tools are left alone. Fixes #1603 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/mode_migration_pypi.rs | 79 ++++++++ crates/socket-patch-core/src/hosted/engine.rs | 8 +- .../src/patch/redirect/mod.rs | 190 +++++++++++++++++- .../src/patch/redirect/upstream/mod.rs | 5 +- .../src/patch/redirect/upstream/uv.rs | 132 +++++++++++- .../socket-patch-core/src/vendor/pypi_lock.rs | 122 ++++++++++- docs/testing/uv-compatibility.md | 12 +- 7 files changed, 531 insertions(+), 17 deletions(-) diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index bb592832b..bddcfc6c3 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -1028,6 +1028,85 @@ async fn uv_no_binary_is_refused_in_both_modes() { } } +/// #1603: `uv pip compile --no-binary six --format pylock.toml` records six +/// with only its sdist, and `uv pip` installs six only from an sdist under +/// that flag or a uv `no-binary` setting covering it, so the patched wheel +/// both modes wire fails the matching `uv pip sync`. Both used to wire it +/// and exit 0; they must refuse and write nothing. A flag naming another +/// package leaves the wheel installable. +#[tokio::test] +async fn uv_pylock_no_binary_is_refused_in_both_modes() { + let pylock = |flags: &str| { + format!( + "# This file was autogenerated by uv via the following command:\n\ + # uv pip compile req.in{flags} --format pylock.toml -o pylock.toml\n\ + lock-version = \"1.0\"\ncreated-by = \"uv\"\nrequires-python = \">=3.9\"\n\n\ + [[packages]]\nname = \"six\"\nversion = \"1.16.0\"\n\ + sdist = {{ url = \"https://files.pythonhosted.org/six-1.16.0.tar.gz\", \ + hashes = {{ sha256 = \"{SDIST_SHA}\" }} }}\n\ + wheels = [{{ url = \"https://files.pythonhosted.org/{WHEEL}\", \ + hashes = {{ sha256 = \"{WHEEL_SHA}\" }} }}]\n" + ) + }; + let sdist_only = |flags: &str| { + let lock = pylock(flags); + let wheels = lock.find("wheels = ").unwrap(); + lock[..wheels].to_string() + }; + for (lock, config, refused) in [ + (sdist_only(" --no-binary six"), None, true), + (sdist_only(" --no-binary :all:"), None, true), + ( + pylock(""), + Some(("uv.toml", "[pip]\nno-binary = [\"six\"]\n")), + true, + ), + (pylock(" --no-binary idna"), None, false), + ] { + let label = format!("{config:?} {}", lock.lines().nth(1).unwrap()); + let stage = |root: &Path| { + std::fs::write(root.join("pylock.toml"), &lock).unwrap(); + if let Some((file, text)) = config { + std::fs::write(root.join(file), text).unwrap(); + } + }; + let (_tmp, root) = project(); + stage(&root); + let before = tree(&root); + let server = MockServer::start().await; + mount_hosted_api(&server, true).await; + let (code, env) = hosted_scan(&root, &server); + assert_eq!(code, 0, "hosted {label}: {env:#}"); + if refused { + assert_eq!(hosted_pinned(&env), 0, "hosted {label}: {env:#}"); + assert!( + env.to_string().contains("redirect_uv_lock_unsupported") + && env.to_string().contains("only from an sdist"), + "hosted {label}: {env:#}" + ); + assert_eq!(tree(&root), before, "hosted {label} wrote nothing"); + } else { + assert_eq!(hosted_pinned(&env), 1, "hosted {label}: {env:#}"); + } + + let (_tmp, root) = project(); + stage(&root); + stage_manifest(&root); + let before = tree(&root); + let (code, env) = run_cli(&root, &["vendor"], &[]); + if refused { + assert_ne!(code, 0, "vendored {label}: {env:#}"); + assert!( + env.to_string().contains("pypi_uv_no_binary"), + "vendored {label}: {env:#}" + ); + assert_eq!(tree(&root), before, "vendored {label} wrote nothing"); + } else { + assert_eq!(code, 0, "vendored {label}: {env:#}"); + } + } +} + /// #1501: a PEP 723 script takes uv's settings from the nearest directory /// at or above it that holds a `uv.toml` or a `pyproject.toml` with /// `[tool.uv]`. A parent project's `no-sources = true` therefore drops the diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 0fb484b36..56dec96ed 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -709,7 +709,13 @@ pub async fn read_candidate_files( .files .keys() .any(|path| path == "uv.lock" || crate::utils::python_lock::is_script_lock_name(path)); - if uv_lane { + // A PEP 751 lock is installed with `uv pip`, which refuses the patched + // wheel under the `no-binary` settings of the root's `uv.toml` (#1603). + let pylock_lane = out + .files + .keys() + .any(|path| crate::utils::python_lock::is_pep751_lock_name(path)); + if uv_lane || pylock_lane { let rel = crate::utils::python_script::UV_TOML; if let Some(text) = read_advisory(view, unreadable, rel).await { out.files.insert(rel.to_string(), text); diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 83e5c6561..8934cc48b 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -6187,8 +6187,8 @@ fn rewrite_uv_lock( result: &mut RewriteResult, ) { use crate::utils::python_lock::{ - is_python_lock_name, paired_metadata_rel, pipenv_reads_pylock, ArtifactSource, - PythonLockSession, + is_pep751_lock_name, is_python_lock_name, paired_metadata_rel, pipenv_reads_pylock, + ArtifactSource, PythonLockSession, }; let locks: Vec<(&String, &String)> = files @@ -6216,9 +6216,32 @@ fn rewrite_uv_lock( let mut content = original.clone(); let mut session = PythonLockSession::new(original); let pipenv_reads = pipenv_reads_pylock(path, |rel| files.contains_key(rel)); + // A PEP 751 lock is installed with `uv pip`, which reads its own + // settings (#1603). + let pip_settings = is_pep751_lock_name(path.rsplit('/').next().unwrap_or(path)) + .then(|| pylock_uv_settings(files)); for &(dep, sha256) in &usable { let artifact = ArtifactSource::Url(&dep.artifact_url); - let plan = match session.plan(&content, &dep.name, &dep.version, artifact) { + let planned = session.plan(&content, &dep.name, &dep.version, artifact); + // Every patched artifact is a wheel, which uv refuses to + // install under no-binary (#1603). + let no_binary = match (&planned, &pip_settings) { + (Ok(Some(_)), Some(settings)) => { + upstream::pylock_no_binary(original, &dep.name, settings) + } + _ => None, + }; + if let Some(why) = no_binary { + result + .refused_python_lock_uuids + .insert(dep.patch_uuid.clone()); + result.warnings.push(RewriteWarning { + code: "redirect_uv_lock_unsupported".into(), + detail: format!("{path}: {why}"), + }); + continue; + } + let plan = match planned { Ok(Some(_)) if pipenv_reads => { result .refused_python_lock_uuids @@ -6514,6 +6537,21 @@ const EMPTY_NUGET_CONFIG: &str = "\n) -> Vec<(String, toml_edit::Table)> { + upstream::root_uv_settings( + files + .get(crate::utils::python_script::UV_TOML) + .map(String::as_str), + files.get("pyproject.toml").map(String::as_str), + ) + .into_iter() + .collect() +} + /// The synthetic candidate key carrying the locks the engine found every /// project under the root restoring into (#353, #514), one per line: /// `lock\t`, `unresolved\t\t` for a `NuGetLockFilePath` @@ -26236,6 +26274,152 @@ mod python_lock_warning_tests { "{codes:?}" ); } + + /// #1603: a PEP 751 lock is installed with `uv pip`, which installs a + /// package only from an sdist under a `uv pip compile --no-binary` flag + /// naming it (or `:all:`) or a `no-binary` setting covering it in the + /// root's settings. The patched wheel is refused before writing. + #[test] + fn pylock_no_binary_refuses_a_wheel_uv_would_not_install() { + let dep = DepOverride { + ecosystem: "pypi".into(), + name: "six".into(), + namespace: None, + version: "1.16.0".into(), + token: "11111111-1111-4111-8111-111111111111".into(), + patch_uuid: "22222222-2222-4222-8222-222222222222".into(), + artifact_url: "https://patch.socket.dev/six-1.16.0-py2.py3-none-any.whl".into(), + registry_override: None, + integrity: Integrity { + sha256: Some("b".repeat(64)), + ..Integrity::default() + }, + }; + let pylock = |flags: &str| { + format!( + "# This file was autogenerated by uv via the following command:\n\ + # uv pip compile req.in{flags} --format pylock.toml -o pylock.toml\n\ + lock-version = \"1.0\"\ncreated-by = \"uv\"\nrequires-python = \">=3.8\"\n\n\ + [[packages]]\nname = \"six\"\nversion = \"1.16.0\"\n\ + sdist = {{ url = \"https://files.pythonhosted.org/packages/71/six-1.16.0.tar.gz\", \ + size = 34041, hashes = {{ sha256 = \"{}\" }} }}\n", + "a".repeat(64) + ) + }; + let run = |flags: &str, extra: &[(&str, &str)]| { + let mut files = BTreeMap::from([("pylock.toml".to_string(), pylock(flags))]); + for (rel, text) in extra { + files.insert(rel.to_string(), text.to_string()); + } + rewrite_registry_redirect(&files, std::slice::from_ref(&dep)) + }; + let refusals: &[(&str, &[(&str, &str)])] = &[ + (" --no-binary six", &[]), + (" --no-binary=Six", &[]), + (" --no-binary idna,six", &[]), + (" --no-binary :all:", &[]), + ("", &[("uv.toml", "[pip]\nno-binary = [\"six\"]\n")]), + ("", &[("uv.toml", "no-binary = true\n")]), + ("", &[("uv.toml", "no-binary-package = [\"six\"]\n")]), + // The top level and [pip] add up (uv 0.11). + ( + "", + &[( + "uv.toml", + "no-binary-package = [\"six\"]\n[pip]\nno-binary = [\"idna\"]\n", + )], + ), + ( + "", + &[( + "pyproject.toml", + "[project]\nname = \"p\"\nversion = \"0\"\n[tool.uv.pip]\nno-binary = [\"six\"]\n", + )], + ), + ]; + for (flags, extra) in refusals { + let result = run(flags, extra); + assert!( + result.files.is_empty(), + "{flags:?} {extra:?}: {:?}", + result.files + ); + assert!( + result.refused_python_lock_uuids.contains(&dep.patch_uuid), + "{flags:?} {extra:?}" + ); + let warning = result + .warnings + .iter() + .find(|w| w.code == "redirect_uv_lock_unsupported") + .unwrap_or_else(|| panic!("{flags:?} {extra:?}: {:?}", result.warnings)); + assert!( + warning.detail.starts_with("pylock.toml: "), + "{}", + warning.detail + ); + assert!( + warning.detail.contains("installs six only from an sdist"), + "{}", + warning.detail + ); + } + // Another package's flag or setting, no-build, a list turned off by + // no-binary = false, a uv.toml replacing pyproject's settings, and + // settings `uv pip compile --no-config` never read leave the wheel + // installable. + let wired: &[(&str, &[(&str, &str)])] = &[ + ("", &[]), + (" --no-binary idna", &[]), + (" --no-build", &[]), + ("", &[("uv.toml", "[pip]\nno-binary = [\"idna\"]\n")]), + ( + "", + &[( + "uv.toml", + "no-binary = false\nno-binary-package = [\"six\"]\n", + )], + ), + ( + "", + &[ + ("uv.toml", "offline = false\n"), + ( + "pyproject.toml", + "[project]\nname = \"p\"\nversion = \"0\"\n[tool.uv]\nno-binary = true\n", + ), + ], + ), + (" --no-config", &[("uv.toml", "no-binary = true\n")]), + ]; + for (flags, extra) in wired { + let result = run(flags, extra); + assert!( + result.files.contains_key("pylock.toml"), + "{flags:?} {extra:?}: {:?}", + result.warnings + ); + assert!( + result.confirmed_python_lock_uuids.contains(&dep.patch_uuid), + "{flags:?} {extra:?}" + ); + } + // Another installer's lock reads no uv setting. + let mut files = BTreeMap::from([( + "pylock.toml".to_string(), + pylock(" --no-binary six").replace("created-by = \"uv\"", "created-by = \"pdm\""), + )]); + files.insert("uv.toml".into(), "no-binary = true\n".into()); + let result = rewrite_registry_redirect(&files, std::slice::from_ref(&dep)); + assert!( + !result + .warnings + .iter() + .any(|w| w.detail.contains("only from an sdist")), + "{:?}", + result.warnings + ); + } } /// Which metadata file `plan_python_metadata` pairs a native Python lock 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 a7f1b615d..5b49adf6d 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs @@ -44,7 +44,10 @@ mod vlt; use client::rubygems_base; pub(crate) use client::UpstreamClient; -pub(crate) use uv::{declared_lock_specifier, respell_lock_specifier, LockRequirementArray}; +pub(crate) use uv::{ + declared_lock_specifier, pylock_no_binary, respell_lock_specifier, root_uv_settings, + 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 c7358e1bc..83e5573d9 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs @@ -706,6 +706,130 @@ fn uv_header_command(text: &str) -> Option> { }) } +/// #1603: why uv would refuse a patched wheel wired into the PEP 751 lock +/// `text` for `name`, if it would. uv installs a package only from an sdist +/// under `--no-binary` (naming it or `:all:`), and every Socket artifact is +/// a wheel, so `uv pip sync` / `uv pip install -r` of the wired lock fails +/// ("marked as `--no-binary` but is itself a binary distribution"). The +/// signals are the `uv pip compile` command line the header records and +/// the uv `settings` files (as [`settings_no_binary`] reads them), which +/// `uv pip compile --no-config` did not read. Only a lock uv wrote is +/// checked: another installer reads no uv setting. +pub(crate) fn pylock_no_binary( + text: &str, + name: &str, + settings: &[(String, toml_edit::Table)], +) -> Option { + let doc = text.parse::().ok()?; + if doc.get("created-by").and_then(Item::as_str) != Some("uv") { + return None; + } + let name = canonicalize_pypi_name(name); + let command = uv_header_command(text).filter(|c| is_pip_compile(c)); + let consequence = format!( + "so uv installs {name} only from an sdist and `uv pip sync` of the lock would fail on \ + the patched wheel (\"marked as `--no-binary` but is itself a binary distribution\"); \ + patches ship as wheels, so to wire one let uv install {name} from a wheel" + ); + if let Some(command) = &command { + if compile_no_binary(command, &name) { + return Some(format!( + "the lock's `uv pip compile --no-binary` covers {name}, {consequence}" + )); + } + if command.contains(&"--no-config") { + return None; + } + } + let (path, key) = settings_no_binary(settings, &name)?; + Some(format!( + "{path}'s uv `{key}` setting covers {name}, {consequence}" + )) +} + +/// Whether the `uv pip compile` `command` passes `--no-binary` naming +/// `name` (PEP 503 canonical) or `:all:`. +fn compile_no_binary(command: &[&str], name: &str) -> bool { + let mut args = command.iter(); + while let Some(arg) = args.next() { + let value = match arg.split_once('=') { + Some(("--no-binary", value)) => Some(value), + None if *arg == "--no-binary" => args.next().copied(), + _ => None, + }; + if value.is_some_and(|v| { + v.split(',') + .any(|v| v == ":all:" || canonicalize_pypi_name(v) == name) + }) { + return true; + } + } + false +} + +/// The uv setting in `files` under which `uv pip` installs `name` (PEP 503 +/// canonical) only from an sdist, as `(file that sets it, key)`. Checked +/// against uv 0.11: `[pip] no-binary` (naming it or `:all:`) and the +/// top-level settings add up rather than one replacing the other; at the +/// top level the first file setting `no-binary` decides it for every +/// package, and `no-binary = false` turns off the `no-binary-package` +/// lists, which otherwise add up across files. +fn settings_no_binary( + files: &[(String, toml_edit::Table)], + name: &str, +) -> Option<(String, String)> { + let pip = files.iter().find(|(_, uv)| { + pip_table(uv) + .and_then(|pip| pip.get("no-binary")) + .is_some_and(|v| v.as_array().is_some() && names_package(v, name)) + }); + if let Some((path, _)) = pip { + return Some((path.clone(), "[pip] no-binary".to_string())); + } + let all = files + .iter() + .find_map(|(path, uv)| Some((path, uv.get("no-binary")?.as_bool()?))); + match all { + Some((path, true)) => return Some((path.clone(), "no-binary".to_string())), + Some((_, false)) => return None, + None => {} + } + files + .iter() + .find(|(_, uv)| { + uv.get("no-binary-package") + .is_some_and(|v| v.as_array().is_some() && names_package(v, name)) + }) + .map(|(path, _)| (path.clone(), "no-binary-package".to_string())) +} + +/// The uv settings table of a project's `uv.toml` text, else of its +/// `pyproject` text's `[tool.uv]`, labelled with the file it came from: +/// the settings uv reads in the project root. Like uv, a `uv.toml` +/// replaces pyproject's settings, and a pyproject that does not parse, or +/// has no `[tool.uv]`, sets nothing. An unparsable `uv.toml` also sets +/// nothing here: uv reports it itself. +pub(crate) fn root_uv_settings( + uv_toml: Option<&str>, + pyproject: Option<&str>, +) -> Option<(String, toml_edit::Table)> { + if let Some(text) = uv_toml { + let doc = text.parse::().ok()?; + return Some(("uv.toml".to_string(), doc.as_table().clone())); + } + let uv = pyproject_uv_table(pyproject?)?; + Some(("pyproject.toml".to_string(), uv)) +} + +/// A pyproject text's `[tool.uv]` table (also spelled inline). +fn pyproject_uv_table(text: &str) -> Option { + let doc = text.parse::().ok()?; + let uv = doc.get("tool")?.get("uv")?; + uv.as_table() + .cloned() + .or_else(|| uv.as_inline_table().map(|t| t.clone().into_table())) +} + /// Whether a filter setting's value reaches `name`: `true`, a cutoff date, /// a list naming it (or `:all:`), or a per-package cutoff table naming it /// with anything but `false` (uv's "no cutoff"). @@ -824,13 +948,7 @@ async fn pip_settings( } let pyproject = path("pyproject.toml"); if let Some(text) = view.read(&pyproject).await? { - let uv = text.parse::().ok().and_then(|doc| { - let uv = doc.get("tool")?.get("uv")?; - uv.as_table() - .cloned() - .or_else(|| uv.as_inline_table().map(|t| t.clone().into_table())) - }); - if let Some(uv) = uv { + if let Some(uv) = pyproject_uv_table(&text) { return Ok(Some((pyproject, uv))); } } diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index 72ef3a5b2..d9b57d454 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -7,7 +7,8 @@ use toml_edit::{DocumentMut, Item, Table, TableLike, Value}; use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; use crate::utils::python_lock::{ - is_python_lock_name, python_lock_paths, rewrite_python_lock, script_of_lock, ArtifactSource, + is_pep751_lock_name, is_python_lock_name, python_lock_paths, rewrite_python_lock, + script_of_lock, ArtifactSource, }; use crate::utils::python_script::{ find_ancestor_uv_config, refuse_uv_script_no_binary, refuse_uv_script_no_sources, @@ -15,7 +16,9 @@ use crate::utils::python_script::{ UvConfig, }; -use crate::patch::redirect::upstream::{respell_lock_specifier, LockRequirementArray}; +use crate::patch::redirect::upstream::{ + pylock_no_binary, respell_lock_specifier, root_uv_settings, LockRequirementArray, +}; use super::common::{record, refuse_symlinked}; use super::parse_memo::ParseMemo; @@ -186,6 +189,9 @@ async fn load_listed_locks( // The project's pyproject.toml and uv.toml, read once on the first // script lock for the no-sources check (#564). let mut project_config: Option<(Option, Option, Option)> = None; + // The root's uv settings `uv pip` reads for a PEP 751 lock, read once + // on the first one for the no-binary check (#1603). + let mut pip_settings: Option> = None; for path in paths.into_iter().filter(|path| path != "uv.lock") { let text = read_file(&root.join(&path)).await?; let rewritten = rewrite_python_lock( @@ -228,6 +234,27 @@ async fn load_listed_locks( } else { in_sync = false; } + if is_pep751_lock_name(path.rsplit('/').next().unwrap_or(&path)) { + let settings = match &pip_settings { + Some(settings) => settings, + None => { + let uv_toml = super::pypi_uv::read_uv_toml(root).await; + let pyproject = read_regular_to_string(&root.join("pyproject.toml")) + .await + .ok(); + pip_settings.insert( + root_uv_settings(uv_toml.as_deref(), pyproject.as_deref()) + .into_iter() + .collect(), + ) + } + }; + // The vendored artifact is a wheel, which uv refuses to install + // under no-binary (#1603). + if let Some(why) = pylock_no_binary(&text, name, settings) { + return Err(("pypi_uv_no_binary", format!("{path}: {why}"))); + } + } let script = if let Some(script_name) = script_of_lock(&path) { if document .get("package") @@ -1704,6 +1731,97 @@ mod tests { } } + /// #1603: a PEP 751 lock is installed with `uv pip`, which installs a + /// package only from an sdist under a `uv pip compile --no-binary` flag + /// naming it (or `:all:`) or a uv `no-binary` setting covering it, so + /// the vendored wheel is refused before any write. + #[tokio::test] + async fn pylock_no_binary_refuses_before_writing() { + let pylock = |flags: &str| { + format!( + "# This file was autogenerated by uv via the following command:\n\ + # uv pip compile req.in{flags} --format pylock.toml -o pylock.toml\n\ + lock-version = \"1.0\"\ncreated-by = \"uv\"\nrequires-python = \">=3.8\"\n\n\ + [[packages]]\nname = \"two\"\nversion = \"2\"\n\ + sdist = {{ url = \"https://files.pythonhosted.org/packages/aa/two-2.tar.gz\", \ + size = 10, hashes = {{ sha256 = \"{}\" }} }}\n", + "a".repeat(64) + ) + }; + let load = |lock: String, config: Option<(&'static str, &'static str)>| async move { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + tokio::fs::write(root.join("pylock.toml"), &lock) + .await + .unwrap(); + if let Some((file, text)) = config { + tokio::fs::write(root.join(file), text).await.unwrap(); + } + let loaded = + load_python_locks(root, "two", "2", "11111111-1111-4111-8111-111111111111").await; + let after = tokio::fs::read_to_string(root.join("pylock.toml")) + .await + .unwrap(); + assert_eq!(after, lock, "nothing is written"); + loaded.map(|_| ()) + }; + let pyproject = |uv: &'static str| -> &'static str { + Box::leak(format!("[project]\nname = \"p\"\nversion = \"0\"\n{uv}").into_boxed_str()) + }; + for (flags, config) in [ + (" --no-binary two", None), + (" --no-binary=Two", None), + (" --no-binary six,two", None), + (" --no-binary :all:", None), + ("", Some(("uv.toml", "[pip]\nno-binary = [\"two\"]\n"))), + ("", Some(("uv.toml", "no-binary = true\n"))), + ("", Some(("uv.toml", "no-binary-package = [\"Two\"]\n"))), + ( + "", + Some(( + "pyproject.toml", + pyproject("[tool.uv.pip]\nno-binary = [\":all:\"]\n"), + )), + ), + ( + "", + Some(( + "pyproject.toml", + pyproject("[tool.uv]\nno-binary-package = [\"two\"]\n"), + )), + ), + ] { + let Err(error) = load(pylock(flags), config).await else { + panic!("{flags:?} with {config:?} must refuse"); + }; + assert_eq!(error.0, "pypi_uv_no_binary", "{}", error.1); + assert!(error.1.starts_with("pylock.toml: "), "{}", error.1); + assert!(error.1.contains("only from an sdist"), "{}", error.1); + } + // Another package's flag or setting, no-build, a list turned off by + // no-binary = false, a uv.toml that replaces pyproject's settings, + // and settings `uv pip compile --no-config` never read leave the + // wheel installable. + for (flags, config) in [ + ("", None), + (" --no-binary six", None), + (" --no-build", None), + ("", Some(("uv.toml", "[pip]\nno-binary = [\"six\"]\n"))), + ( + "", + Some(( + "uv.toml", + "no-binary = false\nno-binary-package = [\"two\"]\n", + )), + ), + (" --no-config", Some(("uv.toml", "no-binary = true\n"))), + ] { + if let Err(error) = load(pylock(flags), config).await { + panic!("{flags:?} with {config:?} must load: {error:?}"); + } + } + } + /// #1501: a script scanned from a subdirectory takes the parent /// project's settings when its own directory sets none, so the parent's /// `no-sources = true` refuses it the same way. diff --git a/docs/testing/uv-compatibility.md b/docs/testing/uv-compatibility.md index c2ca61344..9d74c4ec6 100644 --- a/docs/testing/uv-compatibility.md +++ b/docs/testing/uv-compatibility.md @@ -216,9 +216,15 @@ frozen, locked, and ordinary installation outcomes separately where supported. parent's settings for a script whose root sets none). `no-build*` allows wheels and is not refused. `UV_NO_BINARY`, `UV_NO_BINARY_PACKAGE`, the matching flags and a user-level `uv.toml` belong to the machine that - installs, which the scan cannot see. A `uv pip compile --no-binary` pylock.toml - records nothing of the setting in its own data; its hosted lane is - tracked in #1603. + installs, which the scan cannot see. A PEP 751 lock uv wrote is + installed with `uv pip`, which reads the same settings plus `[pip] + no-binary` (the two add up), and `uv pip compile --no-binary six` + records six with only its sdist: both modes refuse the lock before any + write when the `uv pip compile` command line in its header passes + `--no-binary` naming the package or `:all:`, or the root's `uv.toml` / + `[tool.uv]` covers it (vendored `pypi_uv_no_binary`, hosted + `redirect_uv_lock_unsupported`, #1603). A header with `--no-config` + read no settings file. - Native projects and scripts resolving multiple versions of the same package are refused when a global uv source would replace another version. Supporting those cases requires marker-specific source mappings. Standalone PEP 751 From a33ca5edd77a163f1f525c7b5288c2ef17aa8c8b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 21:25:12 +0000 Subject: [PATCH 3/7] Read parent uv settings for pylocks; skip Pipenv Review follow-ups for the pylock no-binary refusal: - uv takes `uv pip`'s settings from the nearest parent directory when the project root sets none, so a parent `uv.toml` or `[tool.uv]` with `no-binary` now refuses too, as it already does for uv projects and scripts. - Pipenv installs the pylocks it reads and ignores uv settings, so those locks keep Pipenv's own refusal (hosted) or are wired as before (vendored) instead of getting a uv error. - `--no-binary :none:` clears the earlier values, as in uv, so a lock compiled with `--no-binary :all: --no-binary :none:` is not refused. The restore side now reads the flag the same way. - The root settings are read once per run instead of once per lock. Refs #1603 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/hosted/engine.rs | 8 +- .../src/patch/redirect/mod.rs | 69 ++++++++-- .../src/patch/redirect/upstream/uv.rs | 76 ++++++----- .../socket-patch-core/src/vendor/pypi_lock.rs | 119 ++++++++++++------ 4 files changed, 190 insertions(+), 82 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 56dec96ed..b4987bc27 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -721,10 +721,10 @@ pub async fn read_candidate_files( out.files.insert(rel.to_string(), text); } } - // A PEP 723 script (#1501) or a project (#1545) also takes uv's - // settings from above the root when the root holds none; only a disk - // view has an above. - if uv_lane + // A PEP 723 script (#1501), a project (#1545) or `uv pip` installing a + // PEP 751 lock (#1603) also takes uv's settings from above the root + // when the root holds none; only a disk view has an above. + if (uv_lane || pylock_lane) && !crate::utils::python_script::root_governs_script( out.files.get("pyproject.toml").map(String::as_str), out.files diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 8934cc48b..bbc2254fd 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -6212,20 +6212,26 @@ fn rewrite_uv_lock( }), } } + // A PEP 751 lock is installed with `uv pip`, which reads uv's settings + // (#1603). + let pip_settings = locks + .iter() + .any(|(path, _)| is_pep751_lock_name(path)) + .then(|| pylock_uv_settings(files)); for (path, original) in locks { let mut content = original.clone(); let mut session = PythonLockSession::new(original); let pipenv_reads = pipenv_reads_pylock(path, |rel| files.contains_key(rel)); - // A PEP 751 lock is installed with `uv pip`, which reads its own - // settings (#1603). - let pip_settings = is_pep751_lock_name(path.rsplit('/').next().unwrap_or(path)) - .then(|| pylock_uv_settings(files)); + // Pipenv installs the pylocks it reads, and reads no uv setting. + let uv_pip_settings = pip_settings + .as_ref() + .filter(|_| is_pep751_lock_name(path) && !pipenv_reads); for &(dep, sha256) in &usable { let artifact = ArtifactSource::Url(&dep.artifact_url); let planned = session.plan(&content, &dep.name, &dep.version, artifact); // Every patched artifact is a wheel, which uv refuses to // install under no-binary (#1603). - let no_binary = match (&planned, &pip_settings) { + let no_binary = match (&planned, uv_pip_settings) { (Ok(Some(_)), Some(settings)) => { upstream::pylock_no_binary(original, &dep.name, settings) } @@ -6537,16 +6543,25 @@ const EMPTY_NUGET_CONFIG: &str = "\n) -> Vec<(String, toml_edit::Table)> { + let ancestor = files.get(UV_ANCESTOR_CONFIG_KEY).and_then(|value| { + let (rel, text) = value.split_once('\n')?; + Some(crate::utils::python_script::UvConfig { + rel: rel.to_string(), + text: text.to_string(), + }) + }); upstream::root_uv_settings( files .get(crate::utils::python_script::UV_TOML) .map(String::as_str), files.get("pyproject.toml").map(String::as_str), + ancestor.as_ref(), ) .into_iter() .collect() @@ -26336,6 +26351,20 @@ mod python_lock_warning_tests { "[project]\nname = \"p\"\nversion = \"0\"\n[tool.uv.pip]\nno-binary = [\"six\"]\n", )], ), + // uv takes the settings from above a root that sets none. + ( + "", + &[(UV_ANCESTOR_CONFIG_KEY, "../uv.toml\n[pip]\nno-binary = [\"six\"]\n")], + ), + ( + "", + &[( + UV_ANCESTOR_CONFIG_KEY, + "../pyproject.toml\n[tool.uv]\nno-binary-package = [\"six\"]\n", + )], + ), + // `:none:` clears only the values before it. + (" --no-binary :none: --no-binary six", &[]), ]; for (flags, extra) in refusals { let result = run(flags, extra); @@ -26391,6 +26420,15 @@ mod python_lock_warning_tests { ], ), (" --no-config", &[("uv.toml", "no-binary = true\n")]), + (" --no-binary :all: --no-binary :none:", &[]), + // The root's own settings stop the lookup above it. + ( + "", + &[ + ("uv.toml", "offline = false\n"), + (UV_ANCESTOR_CONFIG_KEY, "../uv.toml\nno-binary = true\n"), + ], + ), ]; for (flags, extra) in wired { let result = run(flags, extra); @@ -26404,6 +26442,19 @@ mod python_lock_warning_tests { "{flags:?} {extra:?}" ); } + // Pipenv installs a pylock beside a Pipfile and reads no uv + // setting: its own refusal is the one that applies. + let result = run( + " --no-binary six", + &[("Pipfile", "[packages]\nsix = \"==1.16.0\"\n")], + ); + let codes: Vec<&str> = result.warnings.iter().map(|w| w.code.as_str()).collect(); + assert_eq!( + codes, + ["redirect_pipenv_pylock_unsupported"], + "{:?}", + result.warnings + ); // Another installer's lock reads no uv setting. let mut files = BTreeMap::from([( "pylock.toml".to_string(), 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 83e5573d9..a653a6e76 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs @@ -732,7 +732,7 @@ pub(crate) fn pylock_no_binary( patches ship as wheels, so to wire one let uv install {name} from a wheel" ); if let Some(command) = &command { - if compile_no_binary(command, &name) { + if binary_flag_covers(command, "--no-binary", &name) { return Some(format!( "the lock's `uv pip compile --no-binary` covers {name}, {consequence}" )); @@ -747,24 +747,29 @@ pub(crate) fn pylock_no_binary( )) } -/// Whether the `uv pip compile` `command` passes `--no-binary` naming -/// `name` (PEP 503 canonical) or `:all:`. -fn compile_no_binary(command: &[&str], name: &str) -> bool { +/// Whether the `uv pip compile` `command`'s `flag` (`--no-binary` or +/// `--only-binary`) covers `name` (PEP 503 canonical): pip's +/// comma-separated values naming it or `:all:`, where `:none:` clears the +/// values before it. +fn binary_flag_covers(command: &[&str], flag: &str, name: &str) -> bool { + let mut covered = false; let mut args = command.iter(); while let Some(arg) = args.next() { let value = match arg.split_once('=') { - Some(("--no-binary", value)) => Some(value), - None if *arg == "--no-binary" => args.next().copied(), + Some((f, value)) if f == flag => Some(value), + None if *arg == flag => args.next().copied(), _ => None, }; - if value.is_some_and(|v| { - v.split(',') - .any(|v| v == ":all:" || canonicalize_pypi_name(v) == name) - }) { - return true; + for value in value.into_iter().flat_map(|v| v.split(',')) { + match value { + ":none:" => covered = false, + ":all:" => covered = true, + value if canonicalize_pypi_name(value) == name => covered = true, + _ => {} + } } } - false + covered } /// The uv setting in `files` under which `uv pip` installs `name` (PEP 503 @@ -803,22 +808,33 @@ fn settings_no_binary( .map(|(path, _)| (path.clone(), "no-binary-package".to_string())) } -/// The uv settings table of a project's `uv.toml` text, else of its -/// `pyproject` text's `[tool.uv]`, labelled with the file it came from: -/// the settings uv reads in the project root. Like uv, a `uv.toml` -/// replaces pyproject's settings, and a pyproject that does not parse, or -/// has no `[tool.uv]`, sets nothing. An unparsable `uv.toml` also sets -/// nothing here: uv reports it itself. +/// The uv settings table `uv pip` reads for a lock in the project root, +/// labelled with the file it came from: the root's `uv.toml` text, else +/// its `pyproject` text's `[tool.uv]`, else (when the root sets none) the +/// `ancestor` settings file above it ([`find_ancestor_uv_config`]). Like +/// uv, a `uv.toml` replaces pyproject's settings. A file that does not +/// parse sets nothing here: uv reports it itself. +/// +/// [`find_ancestor_uv_config`]: crate::utils::python_script::find_ancestor_uv_config pub(crate) fn root_uv_settings( uv_toml: Option<&str>, pyproject: Option<&str>, + ancestor: Option<&crate::utils::python_script::UvConfig>, ) -> Option<(String, toml_edit::Table)> { if let Some(text) = uv_toml { let doc = text.parse::().ok()?; return Some(("uv.toml".to_string(), doc.as_table().clone())); } - let uv = pyproject_uv_table(pyproject?)?; - Some(("pyproject.toml".to_string(), uv)) + if let Some(uv) = pyproject.and_then(pyproject_uv_table) { + return Some(("pyproject.toml".to_string(), uv)); + } + let ancestor = ancestor?; + if ancestor.is_uv_toml() { + let doc = ancestor.text.parse::().ok()?; + Some((ancestor.rel.clone(), doc.as_table().clone())) + } else { + Some((ancestor.rel.clone(), pyproject_uv_table(&ancestor.text)?)) + } } /// A pyproject text's `[tool.uv]` table (also spelled inline). @@ -899,19 +915,19 @@ fn compile_filter(command: &[&str], name: &str, exempt: bool) -> Option "--exclude-newer" => { inline.or_else(|| args.next().copied()) != Some("false") && !exempt } - "--only-binary" | "--no-binary" | "--exclude-newer-package" => { + "--only-binary" | "--no-binary" => { + if inline.is_none() { + args.next(); + } + binary_flag_covers(command, flag, name) + } + "--exclude-newer-package" => { let Some(value) = inline.or_else(|| args.next().copied()) else { continue; }; - if flag == "--exclude-newer-package" { - value.split_once('=').is_some_and(|(package, cutoff)| { - canonicalize_pypi_name(package) == name && cutoff != "false" - }) - } else { - value - .split(',') - .any(|v| v == ":all:" || canonicalize_pypi_name(v) == name) - } + value.split_once('=').is_some_and(|(package, cutoff)| { + canonicalize_pypi_name(package) == name && cutoff != "false" + }) } _ => false, }; diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index d9b57d454..d8bd00f4c 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -170,28 +170,50 @@ pub(super) async fn load_python_locks( ) -> Result { let paths = python_lock_paths(root).map_err(|error| ("pypi_lock_read_failed", error.to_string()))?; - load_listed_locks(root, paths, name, version, uuid).await + load_listed_locks(root, paths, name, version, uuid, true).await } -/// [`load_python_locks`] over `paths` only. +/// The project's pyproject.toml and uv.toml, and the uv settings file +/// above the root that uv applies when the root holds none (#1501), read +/// into `cache` on the first call. +async fn project_config_of<'a>( + root: &Path, + cache: &'a mut Option<(Option, Option, Option)>, +) -> &'a (Option, Option, Option) { + if cache.is_none() { + let pyproject = read_regular_to_string(&root.join("pyproject.toml")) + .await + .ok(); + let uv_toml = super::pypi_uv::read_uv_toml(root).await; + let ancestor = (!root_governs_script(pyproject.as_deref(), uv_toml.as_deref())) + .then(|| find_ancestor_uv_config(root)) + .flatten(); + *cache = Some((pyproject, uv_toml, ancestor)); + } + cache.as_ref().expect("filled above") +} + +/// [`load_python_locks`] over `paths` only. `uv_pip`: uv installs the +/// PEP 751 locks among them (Pipenv, which reads no uv setting, installs +/// the ones [`wire_pipenv_pylocks`] wires). async fn load_listed_locks( root: &Path, paths: Vec, name: &str, version: &str, uuid: &str, + uv_pip: bool, ) -> Result { let directory = format!(".socket/vendor/pypi/{uuid}/"); let placeholder = format!("{directory}{name}-{version}-py3-none-any.whl"); let mut files = Vec::new(); let mut in_sync = true; let mut pin = None; - // The project's pyproject.toml and uv.toml, read once on the first - // script lock for the no-sources check (#564). + // The project's pyproject.toml and uv.toml, and the uv settings file + // above the root when the root holds none, read once on the first + // script lock for the no-sources check (#564) or PEP 751 lock for the + // no-binary check (#1603). let mut project_config: Option<(Option, Option, Option)> = None; - // The root's uv settings `uv pip` reads for a PEP 751 lock, read once - // on the first one for the no-binary check (#1603). - let mut pip_settings: Option> = None; for path in paths.into_iter().filter(|path| path != "uv.lock") { let text = read_file(&root.join(&path)).await?; let rewritten = rewrite_python_lock( @@ -234,24 +256,15 @@ async fn load_listed_locks( } else { in_sync = false; } - if is_pep751_lock_name(path.rsplit('/').next().unwrap_or(&path)) { - let settings = match &pip_settings { - Some(settings) => settings, - None => { - let uv_toml = super::pypi_uv::read_uv_toml(root).await; - let pyproject = read_regular_to_string(&root.join("pyproject.toml")) - .await - .ok(); - pip_settings.insert( - root_uv_settings(uv_toml.as_deref(), pyproject.as_deref()) - .into_iter() - .collect(), - ) - } - }; + if uv_pip && is_pep751_lock_name(&path) { + let (pyproject, uv_toml, ancestor) = project_config_of(root, &mut project_config).await; + let settings: Vec<_> = + root_uv_settings(uv_toml.as_deref(), pyproject.as_deref(), ancestor.as_ref()) + .into_iter() + .collect(); // The vendored artifact is a wheel, which uv refuses to install // under no-binary (#1603). - if let Some(why) = pylock_no_binary(&text, name, settings) { + if let Some(why) = pylock_no_binary(&text, name, &settings) { return Err(("pypi_uv_no_binary", format!("{path}: {why}"))); } } @@ -275,21 +288,7 @@ async fn load_listed_locks( return Err(("pypi_script_multiple_versions", format!("{path} resolves multiple versions of {name}; a script source override cannot select only {version}"))); } let script = read_file(&root.join(script_name)).await?; - let (pyproject, uv_toml, ancestor) = match &project_config { - Some(config) => config, - None => { - let pyproject = read_regular_to_string(&root.join("pyproject.toml")) - .await - .ok(); - let uv_toml = super::pypi_uv::read_uv_toml(root).await; - // uv applies a parent's settings to the script when - // the root holds none (#1501). - let ancestor = (!root_governs_script(pyproject.as_deref(), uv_toml.as_deref())) - .then(|| find_ancestor_uv_config(root)) - .flatten(); - project_config.insert((pyproject, uv_toml, ancestor)) - } - }; + let (pyproject, uv_toml, ancestor) = project_config_of(root, &mut project_config).await; refuse_uv_script_no_sources( name, &script, @@ -449,7 +448,7 @@ pub(super) async fn wire_pipenv_pylocks( if paths.is_empty() { return Ok(Vec::new()); } - let project = load_listed_locks(root, paths, name, version, uuid).await?; + let project = load_listed_locks(root, paths, name, version, uuid, false).await?; wire_python_locks(&project, root, name, version, wheel, sha).await } @@ -1815,11 +1814,53 @@ mod tests { )), ), (" --no-config", Some(("uv.toml", "no-binary = true\n"))), + (" --no-binary :all: --no-binary :none:", None), ] { if let Err(error) = load(pylock(flags), config).await { panic!("{flags:?} with {config:?} must load: {error:?}"); } } + // uv takes the settings from above a root that sets none, and the + // root's own settings stop that lookup. + for (root_config, refused) in [(None, true), (Some("offline = false\n"), false)] { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("proj"); + tokio::fs::create_dir_all(&root).await.unwrap(); + tokio::fs::write(temp.path().join("uv.toml"), "no-binary = true\n") + .await + .unwrap(); + if let Some(text) = root_config { + tokio::fs::write(root.join("uv.toml"), text).await.unwrap(); + } + tokio::fs::write(root.join("pylock.toml"), pylock("")) + .await + .unwrap(); + let loaded = + load_python_locks(&root, "two", "2", "11111111-1111-4111-8111-111111111111").await; + match loaded { + Err(error) if refused => { + assert_eq!(error.0, "pypi_uv_no_binary", "{}", error.1); + assert!(error.1.contains("../uv.toml"), "{}", error.1); + } + Ok(_) if !refused => {} + other => panic!("{root_config:?}: {:?}", other.err()), + } + } + // Pipenv installs the pylocks it reads, and reads no uv setting. + let temp = tempfile::tempdir().unwrap(); + tokio::fs::write(temp.path().join("pylock.toml"), pylock(" --no-binary two")) + .await + .unwrap(); + let loaded = load_listed_locks( + temp.path(), + vec!["pylock.toml".to_string()], + "two", + "2", + "11111111-1111-4111-8111-111111111111", + false, + ) + .await; + assert!(loaded.is_ok(), "{:?}", loaded.err()); } /// #1501: a script scanned from a subdirectory takes the parent From 06561639948b22ec0116b6c13118ce7be1567a58 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 21:45:27 +0000 Subject: [PATCH 4/7] Stage no-binary restore cases after wiring The uv pylock restore test made its hosted lock by running the hosted rewrite on a lock already under `--no-binary`. That rewrite now refuses such a lock, so the test wires the plain lock first and then adds the flag or setting. That is how such a lock reaches the restore in real use: it was wired before this check existed, or filtered later. Refs #1603 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/upstream_restore_golden.rs | 53 ++++++++++++++++--- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-core/tests/upstream_restore_golden.rs b/crates/socket-patch-core/tests/upstream_restore_golden.rs index 32bcd318e..3493f9e4a 100644 --- a/crates/socket-patch-core/tests/upstream_restore_golden.rs +++ b/crates/socket-patch-core/tests/upstream_restore_golden.rs @@ -1291,8 +1291,22 @@ async fn pypi_refusal( deps: &[DepOverride], opts: &RestoreOptions, ) -> (String, BTreeMap, BTreeMap) { - let rewritten = hosted(input, deps, None); + pypi_refusal_after(input, deps, opts, |_| {}).await +} + +/// [`pypi_refusal`] of a tree `edit` changes after the hosted rewrite, +/// before the restore: since #1603 the rewrite itself refuses a pylock +/// under a `--no-binary` filter naming the package, but a lock wired +/// before then, or filtered after, still comes back to the restore. +async fn pypi_refusal_after( + input: &BTreeMap, + deps: &[DepOverride], + opts: &RestoreOptions, + edit: impl FnOnce(&mut BTreeMap), +) -> (String, BTreeMap, BTreeMap) { + let mut rewritten = hosted(input, deps, None); assert_ne!(&rewritten, input, "the hosted rewrite changed nothing"); + edit(&mut rewritten); let (after, statuses) = restore_tree(&rewritten, opts).await; let refusals: Vec = statuses .iter() @@ -2247,9 +2261,25 @@ async fn pip_compile_pylock_build_filters_refuse_the_filtered_entry() { // Exempting another package leaves the global cutoff on urllib3. " --exclude-newer 2024-01-01 --exclude-newer-package idna=false", ] { - let input = tree(&[("pylock.toml", with_flags(flags))]); - let (why, rewritten, after) = - pypi_refusal(&input, &[urllib3_dep()], &RestoreOptions::default()).await; + let (why, rewritten, after) = if flags.contains("--no-binary") { + // The hosted rewrite refuses these (#1603): wire the lock, then + // filter it. + let input = tree(&[("pylock.toml", uv_pylock(false))]); + let filter = |tree: &mut BTreeMap| { + let lock = tree.get_mut("pylock.toml").unwrap(); + *lock = lock.replace( + header, + &format!( + "# uv pip compile --format pylock.toml req.in{flags} -o pylock.toml\n" + ), + ); + assert!(lock.contains(flags), "{lock}"); + }; + pypi_refusal_after(&input, &[urllib3_dep()], &RestoreOptions::default(), filter).await + } else { + let input = tree(&[("pylock.toml", with_flags(flags))]); + pypi_refusal(&input, &[urllib3_dep()], &RestoreOptions::default()).await + }; assert_eq!( after, rewritten, "{flags}: a refused pin leaves the lock hosted" @@ -2289,9 +2319,18 @@ async fn pip_compile_pylock_build_filters_refuse_the_filtered_entry() { "[project]\nname = \"p\"\nversion = \"0\"\n\n[tool.uv.pip]\nexclude-newer = \"2024-01-01T00:00:00Z\"\n", ), ] { - let input = tree(&[("pylock.toml", plain.clone()), (rel, settings.to_string())]); - let (why, _, after) = - pypi_refusal(&input, &[urllib3_dep()], &RestoreOptions::default()).await; + let (why, _, after) = if settings.contains("no-binary") { + // The hosted rewrite refuses this (#1603): wire the lock, then + // add the setting. + let input = tree(&[("pylock.toml", plain.clone())]); + let filter = |tree: &mut BTreeMap| { + tree.insert(rel.to_string(), settings.to_string()); + }; + pypi_refusal_after(&input, &[urllib3_dep()], &RestoreOptions::default(), filter).await + } else { + let input = tree(&[("pylock.toml", plain.clone()), (rel, settings.to_string())]); + pypi_refusal(&input, &[urllib3_dep()], &RestoreOptions::default()).await + }; assert!(after["pylock.toml"].contains("patch.socket.dev"), "{rel}"); assert!(why.contains(rel), "{rel}: {why}"); } From db67fc00ecd177f6693275b7047910d58ad7eb6e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 21:48:39 +0000 Subject: [PATCH 5/7] Check uv settings even under --no-config `uv pip compile --no-config` only keeps settings files away from the compile. The later `uv pip sync` still reads uv.toml, so a `no-binary` setting there still makes it refuse the patched wheel. The pylock check now reads the settings whatever the header says. Refs #1603 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/mod.rs | 9 +++++---- .../src/patch/redirect/upstream/uv.rs | 18 +++++++----------- .../socket-patch-core/src/vendor/pypi_lock.rs | 9 +++++---- docs/testing/uv-compatibility.md | 5 +++-- 4 files changed, 20 insertions(+), 21 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index bbc2254fd..7b5d500a0 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -26365,6 +26365,9 @@ mod python_lock_warning_tests { ), // `:none:` clears only the values before it. (" --no-binary :none: --no-binary six", &[]), + // `uv pip sync` reads the settings `uv pip compile --no-config` + // passed over. + (" --no-config", &[("uv.toml", "no-binary = true\n")]), ]; for (flags, extra) in refusals { let result = run(flags, extra); @@ -26394,9 +26397,8 @@ mod python_lock_warning_tests { ); } // Another package's flag or setting, no-build, a list turned off by - // no-binary = false, a uv.toml replacing pyproject's settings, and - // settings `uv pip compile --no-config` never read leave the wheel - // installable. + // no-binary = false, `:none:` after `:all:`, and a uv.toml replacing + // pyproject's settings leave the wheel installable. let wired: &[(&str, &[(&str, &str)])] = &[ ("", &[]), (" --no-binary idna", &[]), @@ -26419,7 +26421,6 @@ mod python_lock_warning_tests { ), ], ), - (" --no-config", &[("uv.toml", "no-binary = true\n")]), (" --no-binary :all: --no-binary :none:", &[]), // The root's own settings stop the lookup above it. ( 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 a653a6e76..aa32a076a 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs @@ -713,8 +713,9 @@ fn uv_header_command(text: &str) -> Option> { /// ("marked as `--no-binary` but is itself a binary distribution"). The /// signals are the `uv pip compile` command line the header records and /// the uv `settings` files (as [`settings_no_binary`] reads them), which -/// `uv pip compile --no-config` did not read. Only a lock uv wrote is -/// checked: another installer reads no uv setting. +/// `uv pip sync` reads whether or not `uv pip compile` ran with +/// `--no-config`. Only a lock uv wrote is checked: another installer reads +/// no uv setting. pub(crate) fn pylock_no_binary( text: &str, name: &str, @@ -731,15 +732,10 @@ pub(crate) fn pylock_no_binary( the patched wheel (\"marked as `--no-binary` but is itself a binary distribution\"); \ patches ship as wheels, so to wire one let uv install {name} from a wheel" ); - if let Some(command) = &command { - if binary_flag_covers(command, "--no-binary", &name) { - return Some(format!( - "the lock's `uv pip compile --no-binary` covers {name}, {consequence}" - )); - } - if command.contains(&"--no-config") { - return None; - } + if command.is_some_and(|command| binary_flag_covers(&command, "--no-binary", &name)) { + return Some(format!( + "the lock's `uv pip compile --no-binary` covers {name}, {consequence}" + )); } let (path, key) = settings_no_binary(settings, &name)?; Some(format!( diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index d8bd00f4c..5e283c397 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -1789,6 +1789,9 @@ mod tests { pyproject("[tool.uv]\nno-binary-package = [\"two\"]\n"), )), ), + // `uv pip sync` reads the settings `uv pip compile --no-config` + // passed over. + (" --no-config", Some(("uv.toml", "no-binary = true\n"))), ] { let Err(error) = load(pylock(flags), config).await else { panic!("{flags:?} with {config:?} must refuse"); @@ -1798,9 +1801,8 @@ mod tests { assert!(error.1.contains("only from an sdist"), "{}", error.1); } // Another package's flag or setting, no-build, a list turned off by - // no-binary = false, a uv.toml that replaces pyproject's settings, - // and settings `uv pip compile --no-config` never read leave the - // wheel installable. + // no-binary = false and `:none:` after `:all:` leave the wheel + // installable. for (flags, config) in [ ("", None), (" --no-binary six", None), @@ -1813,7 +1815,6 @@ mod tests { "no-binary = false\nno-binary-package = [\"two\"]\n", )), ), - (" --no-config", Some(("uv.toml", "no-binary = true\n"))), (" --no-binary :all: --no-binary :none:", None), ] { if let Err(error) = load(pylock(flags), config).await { diff --git a/docs/testing/uv-compatibility.md b/docs/testing/uv-compatibility.md index 9d74c4ec6..fc95c5d4c 100644 --- a/docs/testing/uv-compatibility.md +++ b/docs/testing/uv-compatibility.md @@ -223,8 +223,9 @@ frozen, locked, and ordinary installation outcomes separately where supported. write when the `uv pip compile` command line in its header passes `--no-binary` naming the package or `:all:`, or the root's `uv.toml` / `[tool.uv]` covers it (vendored `pypi_uv_no_binary`, hosted - `redirect_uv_lock_unsupported`, #1603). A header with `--no-config` - read no settings file. + `redirect_uv_lock_unsupported`, #1603). The settings count even when + the header shows `--no-config`: that flag only kept them from the + compile, and `uv pip sync` reads them. - Native projects and scripts resolving multiple versions of the same package are refused when a global uv source would replace another version. Supporting those cases requires marker-specific source mappings. Standalone PEP 751 From 6ef706ea888ed5b72ce08deda0c7b962b0258794 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 00:09:33 +0000 Subject: [PATCH 6/7] Apply :none: in a uv no-binary settings list uv reads `[pip] no-binary = [":all:", ":none:"]` in order, the same way it reads repeated `--no-binary` flags: `:none:` clears the values before it, so `uv pip sync` installs the patched wheel (checked with uv 0.11.32). The pylock check treated any `:all:` in the list as covering the package and refused a lock that would install fine. Both the list and the header flags now go through one ordered reader. Refs #1603 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/mod.rs | 5 +++ .../src/patch/redirect/upstream/uv.rs | 37 ++++++++++++++----- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 7b5d500a0..426148197 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -26365,6 +26365,7 @@ mod python_lock_warning_tests { ), // `:none:` clears only the values before it. (" --no-binary :none: --no-binary six", &[]), + ("", &[("uv.toml", "[pip]\nno-binary = [\":none:\", \"six\"]\n")]), // `uv pip sync` reads the settings `uv pip compile --no-config` // passed over. (" --no-config", &[("uv.toml", "no-binary = true\n")]), @@ -26422,6 +26423,10 @@ mod python_lock_warning_tests { ], ), (" --no-binary :all: --no-binary :none:", &[]), + ( + "", + &[("uv.toml", "[pip]\nno-binary = [\":all:\", \":none:\"]\n")], + ), // The root's own settings stop the lookup above it. ( "", 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 aa32a076a..dd9a3e818 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs @@ -756,13 +756,28 @@ fn binary_flag_covers(command: &[&str], flag: &str, name: &str) -> bool { None if *arg == flag => args.next().copied(), _ => None, }; - for value in value.into_iter().flat_map(|v| v.split(',')) { - match value { - ":none:" => covered = false, - ":all:" => covered = true, - value if canonicalize_pypi_name(value) == name => covered = true, - _ => {} - } + if let Some(value) = value { + covered = specifiers_cover(covered, value.split(','), name); + } + } + covered +} + +/// Whether pip-style package specifiers, applied in order to an earlier +/// verdict `covered`, cover `name` (PEP 503 canonical): a value naming it +/// or `:all:` covers it, and `:none:` clears the values before it (uv +/// reads a settings list the same way as repeated flags). +fn specifiers_cover<'a>( + mut covered: bool, + values: impl IntoIterator, + name: &str, +) -> bool { + for value in values { + match value { + ":none:" => covered = false, + ":all:" => covered = true, + value if canonicalize_pypi_name(value) == name => covered = true, + _ => {} } } covered @@ -770,7 +785,8 @@ fn binary_flag_covers(command: &[&str], flag: &str, name: &str) -> bool { /// The uv setting in `files` under which `uv pip` installs `name` (PEP 503 /// canonical) only from an sdist, as `(file that sets it, key)`. Checked -/// against uv 0.11: `[pip] no-binary` (naming it or `:all:`) and the +/// against uv 0.11: `[pip] no-binary` (naming it or `:all:`, read in order +/// with `:none:` clearing the values before it) and the /// top-level settings add up rather than one replacing the other; at the /// top level the first file setting `no-binary` decides it for every /// package, and `no-binary = false` turns off the `no-binary-package` @@ -782,7 +798,10 @@ fn settings_no_binary( let pip = files.iter().find(|(_, uv)| { pip_table(uv) .and_then(|pip| pip.get("no-binary")) - .is_some_and(|v| v.as_array().is_some() && names_package(v, name)) + .and_then(Item::as_array) + .is_some_and(|list| { + specifiers_cover(false, list.iter().filter_map(Value::as_str), name) + }) }); if let Some((path, _)) = pip { return Some((path.clone(), "[pip] no-binary".to_string())); From 02faef71d9109223d613b2b3ecf8527831e589bf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 00:17:48 +0000 Subject: [PATCH 7/7] Pin the uv workspace-member pylock case In a uv workspace member, `uv pip` reads the workspace root's settings and never the member's own `[tool.uv]`, so a root-level no-binary setting would break a member pylock that the no-binary check reads from the member. Both modes already refuse a run in a governed member before reading any lock (#1138), so that pylock is never wired. This test pins that. Refs #1603 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/mode_migration_pypi.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index bddcfc6c3..5494b8126 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -1107,6 +1107,66 @@ async fn uv_pylock_no_binary_is_refused_in_both_modes() { } } +/// #1603: in a uv workspace member, `uv pip` reads the workspace root's +/// settings and never the member's own `[tool.uv]`, so a workspace-level +/// `no-binary-package` naming six makes the member's `uv pip sync` refuse +/// the patched wheel even though the member's pyproject (here holding only +/// `[tool.uv.sources]`) sets nothing. A run in a governed member is refused +/// whole in both modes (#1138) before any lock is read, so its pylock is +/// never wired. +#[tokio::test] +async fn uv_pylock_in_a_workspace_member_is_not_wired() { + let lock = format!( + "# This file was autogenerated by uv via the following command:\n\ + # uv pip compile req.in --format pylock.toml -o pylock.toml\n\ + lock-version = \"1.0\"\ncreated-by = \"uv\"\nrequires-python = \">=3.9\"\n\n\ + [[packages]]\nname = \"six\"\nversion = \"1.16.0\"\n\ + sdist = {{ url = \"https://files.pythonhosted.org/six-1.16.0.tar.gz\", \ + hashes = {{ sha256 = \"{SDIST_SHA}\" }} }}\n\ + wheels = [{{ url = \"https://files.pythonhosted.org/{WHEEL}\", \ + hashes = {{ sha256 = \"{WHEEL_SHA}\" }} }}]\n" + ); + let stage = |root: &Path| { + std::fs::write( + root.parent().unwrap().join("pyproject.toml"), + "[tool.uv.workspace]\nmembers = [\"proj\"]\n[tool.uv]\nno-binary-package = [\"six\"]\n", + ) + .unwrap(); + std::fs::write( + root.join("pyproject.toml"), + "[project]\nname = \"proj\"\nversion = \"0\"\n[tool.uv.sources]\nlib = { workspace = true }\n", + ) + .unwrap(); + std::fs::write(root.join("pylock.toml"), &lock).unwrap(); + }; + + let (_tmp, root) = project(); + stage(&root); + let before = tree(&root); + let server = MockServer::start().await; + mount_hosted_api(&server, true).await; + let (code, env) = hosted_scan(&root, &server); + assert_ne!(code, 0, "hosted: {env:#}"); + assert!( + env.to_string() + .contains("redirect_workspace_lockfile_elsewhere"), + "hosted: {env:#}" + ); + assert_eq!(tree(&root), before, "hosted wrote nothing"); + + let (_tmp, root) = project(); + stage(&root); + stage_manifest(&root); + let before = tree(&root); + let (code, env) = run_cli(&root, &["vendor"], &[]); + assert_ne!(code, 0, "vendored: {env:#}"); + assert!( + env.to_string().contains("pypi_uv_workspace_unsupported"), + "vendored: {env:#}" + ); + assert_eq!(tree(&root), before, "vendored wrote nothing"); +} + /// #1501: a PEP 723 script takes uv's settings from the nearest directory /// at or above it that holds a `uv.toml` or a `pyproject.toml` with /// `[tool.uv]`. A parent project's `no-sources = true` therefore drops the