diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 7618c5653..c51caec6b 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -864,6 +864,101 @@ async fn uv_no_sources_is_refused_in_both_modes() { } } +/// #1603: under `no-binary = true` or a `no-binary-package` list naming the +/// package (pyproject's `[tool.uv]`, a project `uv.toml`, or a PEP 723 +/// script's own `[tool.uv]`), uv installs the package only from an sdist, +/// so the patched wheel both modes wire fails every `uv sync`. Both used to +/// wire it and exit 0; they must refuse and write nothing. `no-build-package` +/// allows wheels, so it is still wired. +#[tokio::test] +async fn uv_no_binary_is_refused_in_both_modes() { + for (script, file, setting, refused) in [ + ( + false, + "pyproject.toml", + "\n[tool.uv]\nno-binary-package = [\"Six\"]\n", + true, + ), + ( + false, + "pyproject.toml", + "\n[tool.uv]\nno-binary = true\n", + true, + ), + (false, "uv.toml", "no-binary-package = [\"six\"]\n", true), + (true, "job.py", "", true), + ( + false, + "pyproject.toml", + "\n[tool.uv]\nno-build-package = [\"six\"]\n", + false, + ), + ] { + let (_tmp, root) = project(); + let label = if script { "script" } else { file }; + if script { + stage_script_lock(&root); + let path = root.join(file); + let text = std::fs::read_to_string(&path).unwrap().replace( + "# ///\n", + "#\n# [tool.uv]\n# no-binary-package = [\"six\"]\n# ///\n", + ); + assert!(text.contains("no-binary-package"), "{text}"); + std::fs::write(&path, text).unwrap(); + } else { + stage_uv(&root); + let path = root.join(file); + let mut text = std::fs::read_to_string(&path).unwrap_or_default(); + text.push_str(setting); + std::fs::write(&path, text).unwrap(); + } + 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), 1, "hosted {label}: {env:#}"); + let (_tmp, root) = project(); + stage_uv(&root); + let path = root.join(file); + let mut text = std::fs::read_to_string(&path).unwrap_or_default(); + text.push_str(setting); + std::fs::write(&path, text).unwrap(); + stage_manifest(&root); + let (code, env) = run_cli(&root, &["vendor"], &[]); + assert_eq!(code, 0, "vendored {label}: {env:#}"); + assert!( + !env.to_string().contains("pypi_uv_no_binary"), + "vendored {label}: {env:#}" + ); + continue; + } + assert_eq!(hosted_pinned(&env), 0, "hosted {label}: {env:#}"); + let unsupported = if script { + "redirect_uv_script_unsupported" + } else { + "redirect_uv_project_unsupported" + }; + assert!( + env.to_string().contains(unsupported) && env.to_string().contains("no-binary"), + "hosted {label}: {env:#}" + ); + assert_eq!(tree(&root), before, "hosted {label} wrote nothing"); + + stage_manifest(&root); + let before = tree(&root); + let (code, env) = run_cli(&root, &["vendor"], &[]); + 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"); + } +} + /// #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 04b0c6d65..b6e0328e8 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -702,7 +702,8 @@ pub async fn read_candidate_files( // Beside a uv project or script lock, the project's `uv.toml` may set // `no-sources`, under which uv ignores the `[tool.uv.sources]` entry the - // rewrite would add (#564). Advisory: never rewritten, so a link or an + // rewrite would add (#564), or `no-binary`, under which uv refuses the + // patched wheel (#1603). Advisory: never rewritten, so a link or an // unreadable in-memory entry is left out rather than refused. let uv_lane = 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 b9e81d265..24be8ad66 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -6019,6 +6019,17 @@ fn plan_python_metadata_with( project_file(crate::utils::python_script::UV_TOML), ancestor.as_ref(), ) + .and_then(|()| { + // Every patched artifact is a wheel, which uv refuses to + // install under no-binary (#1603). + crate::utils::python_script::refuse_uv_script_no_binary( + &dep.name, + &original, + project_file("pyproject.toml"), + project_file(crate::utils::python_script::UV_TOML), + ancestor.as_ref(), + ) + }) } else { crate::utils::python_script::refuse_uv_no_sources( &dep.name, @@ -6026,6 +6037,14 @@ fn plan_python_metadata_with( 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), + ) + }) .and_then(|()| { crate::utils::python_script::refuse_uv_project_shadowing( &original, @@ -26357,6 +26376,110 @@ mod python_metadata_pairing_tests { ); } + /// #1603: under `no-binary = true`, or a `no-binary-package` list naming + /// the package, uv installs it only from an sdist, so the patched wheel + /// a source pins fails every `uv sync`. The planner refuses before + /// writing, with the same file precedence as `no-sources`. + #[test] + fn no_binary_refuses_a_wheel_uv_would_not_install() { + let refused = |path: &str, files: &[(&str, &str)]| match plan(path, files, &[]) { + Err(warning) => { + assert!(warning.detail.contains("no-binary"), "{}", warning.detail); + // The refusal names the package, also under the bool. + assert!( + warning.detail.contains("installs click only from an sdist"), + "{}", + warning.detail + ); + warning.code + } + Ok(_) => panic!("{path} with {files:?} must refuse"), + }; + let wired = |path: &str, files: &[(&str, &str)]| { + let (edit, _) = + plan(path, files, &[]).unwrap_or_else(|w| panic!("{}: {}", w.code, w.detail)); + assert!(edit.is_some(), "{path} with {files:?} must be wired"); + }; + let project = |setting: &str| format!("{PYPROJECT}\n[tool.uv]\n{setting}\n"); + let script = |setting: &str| { + SCRIPT.replace("# ///\n", &format!("#\n# [tool.uv]\n# {setting}\n# ///\n")) + }; + for setting in ["no-binary = true", "no-binary-package = [\"Click\"]"] { + assert_eq!( + refused("uv.lock", &[("pyproject.toml", &project(setting))]), + "redirect_uv_project_unsupported", + "{setting}" + ); + assert_eq!( + refused( + "uv.lock", + &[ + ("pyproject.toml", PYPROJECT), + ("uv.toml", &format!("{setting}\n")) + ] + ), + "redirect_uv_project_unsupported", + "{setting}" + ); + assert_eq!( + refused("tool.py.lock", &[("tool.py", &script(setting))]), + "redirect_uv_script_unsupported", + "{setting}" + ); + assert_eq!( + refused( + "tool.py.lock", + &[("tool.py", SCRIPT), ("pyproject.toml", &project(setting))] + ), + "redirect_uv_script_unsupported", + "{setting}" + ); + // uv.toml replaces pyproject's [tool.uv] entirely. + wired( + "uv.lock", + &[ + ("pyproject.toml", &project(setting)), + ("uv.toml", "offline = false\n"), + ], + ); + } + // Another package's entry, no-build, or a list disabled by + // no-binary = false leave the wheel installable. + for setting in [ + "no-binary-package = [\"requests\"]", + "no-build-package = [\"click\"]", + "no-build = true", + "no-binary = false\nno-binary-package = [\"click\"]", + ] { + wired("uv.lock", &[("pyproject.toml", &project(setting))]); + } + // A script's own no-binary = false wins over the project's list. + wired( + "tool.py.lock", + &[ + ("tool.py", &script("no-binary = false")), + ("uv.toml", "no-binary-package = [\"click\"]\n"), + ], + ); + // A parent's setting reaches a script whose root sets none. + let Err(warning) = plan( + "tool.py.lock", + &[ + ("tool.py", SCRIPT), + (UV_ANCESTOR_CONFIG_KEY, "../uv.toml\nno-binary = true\n"), + ], + &[], + ) else { + panic!("a parent uv.toml must refuse"); + }; + assert_eq!(warning.code, "redirect_uv_script_unsupported"); + assert!( + warning.detail.contains("../uv.toml sets no-binary = true"), + "{}", + warning.detail + ); + } + /// #1501: a script takes uv's settings from above the root when the /// root sets none; the engine hands that file over under /// [`UV_ANCESTOR_CONFIG_KEY`]. The root's own settings stop the lookup. diff --git a/crates/socket-patch-core/src/utils/python_script.rs b/crates/socket-patch-core/src/utils/python_script.rs index 82ea51384..c61fb3871 100644 --- a/crates/socket-patch-core/src/utils/python_script.rs +++ b/crates/socket-patch-core/src/utils/python_script.rs @@ -627,6 +627,74 @@ pub fn ancestor_uv_config_files(root: &Path) -> Vec<(String, PathBuf)> { files } +/// A uv setting pair that applies to some packages: a bool that sets it +/// for every package and a list naming packages (uv's `Combine` keeps the +/// nearer bool and adds the lists up). +struct PackageSetting { + all: &'static str, + packages: &'static str, + /// Why the setting makes a wired patch fail, after "{file} sets {key}, ". + /// `{name}` stands for the package. + consequence: &'static str, + /// How to wire the patch; `{name}` stands for the package. + remedy: &'static str, +} + +const NO_SOURCES: PackageSetting = PackageSetting { + all: "no-sources", + packages: "no-sources-package", + consequence: "so uv ignores the [tool.uv.sources] entry and a plain `uv sync` would \ + install the unpatched registry wheel", + remedy: "remove the setting to wire a patch", +}; + +const NO_BINARY: PackageSetting = PackageSetting { + all: "no-binary", + packages: "no-binary-package", + consequence: "so uv installs {name} only from an sdist and every `uv sync` would fail on \ + the patched wheel (\"marked as `--no-binary` but is itself a binary \ + distribution\")", + remedy: "patches ship as wheels, so to wire one let uv install {name} from a wheel \ + (drop it from no-binary-package, or replace no-binary = true with a \ + no-binary-package list of the other packages)", +}; + +/// The governing project settings file, as (label, text, path of its +/// settings table): a `uv.toml` replaces pyproject's `[tool.uv]` outright. +fn project_settings<'a>( + pyproject: Option<&'a str>, + uv_toml: Option<&'a str>, +) -> Option<(String, &'a str, &'static [&'static str])> { + match uv_toml { + Some(text) => Some((UV_TOML.to_string(), text, &[][..])), + None => pyproject.map(|text| { + ( + "pyproject.toml [tool.uv]".to_string(), + text, + &["tool", "uv"][..], + ) + }), + } +} + +/// 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). +fn script_project_settings<'a>( + pyproject: Option<&'a str>, + uv_toml: Option<&'a str>, + ancestor: Option<&'a UvConfig>, +) -> Option<(String, &'a str, &'static [&'static str])> { + match ancestor { + Some(config) if !root_governs_script(pyproject, uv_toml) => { + let (label, path) = config.settings_location(); + Some((label, config.text.as_str(), path)) + } + _ => project_settings(pyproject, uv_toml), + } +} + /// Refuse to wire a `[tool.uv.sources]` entry for `name` that uv would /// ignore (#564). /// @@ -647,17 +715,12 @@ pub fn refuse_uv_no_sources( pyproject: Option<&str>, uv_toml: Option<&str>, ) -> Result<(), String> { - let project = match uv_toml { - Some(text) => Some((UV_TOML.to_string(), text, &[][..])), - None => pyproject.map(|text| { - ( - "pyproject.toml [tool.uv]".to_string(), - text, - &["tool", "uv"][..], - ) - }), - }; - refuse_no_sources(name, script, project) + refuse_package_setting( + &NO_SOURCES, + name, + script, + project_settings(pyproject, uv_toml), + ) } /// [`refuse_uv_no_sources`] for a PEP 723 script in a root whose own @@ -671,32 +734,70 @@ pub fn refuse_uv_script_no_sources( uv_toml: Option<&str>, ancestor: Option<&UvConfig>, ) -> Result<(), String> { - match ancestor { - Some(config) if !root_governs_script(pyproject, uv_toml) => { - let (label, path) = config.settings_location(); - refuse_no_sources( - name, - Some(script), - Some((label, config.text.as_str(), path)), - ) - } - _ => refuse_uv_no_sources(name, Some(script), pyproject, uv_toml), - } + refuse_package_setting( + &NO_SOURCES, + name, + Some(script), + script_project_settings(pyproject, uv_toml, ancestor), + ) } -/// The check behind [`refuse_uv_no_sources`]: `project` is the governing -/// settings file as (label, text, path of its settings table). -fn refuse_no_sources( +/// Refuse to wire a patched wheel for `name` that uv may not install +/// (#1603). +/// +/// Under `no-binary = true`, or a `no-binary-package` list naming the +/// package, uv installs the package only from an sdist, and every Socket +/// artifact is a wheel: once wired, `uv sync` (locked, frozen or plain) +/// and `uv run --locked` fail. The settings combine as uv's +/// ([`refuse_uv_no_sources`]): a `uv.toml` replaces pyproject's +/// `[tool.uv]`, a script's own `no-binary` wins over the project's, the +/// 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_uv_no_binary`] for a PEP 723 script, with the project settings +/// chosen as in [`refuse_uv_script_no_sources`]. +pub fn refuse_uv_script_no_binary( + name: &str, + script: &str, + pyproject: Option<&str>, + uv_toml: Option<&str>, + ancestor: Option<&UvConfig>, +) -> Result<(), String> { + refuse_package_setting( + &NO_BINARY, + name, + Some(script), + script_project_settings(pyproject, uv_toml, ancestor), + ) +} + +/// The check behind [`refuse_uv_no_sources`] and [`refuse_uv_no_binary`]: +/// `project` is the governing settings file as (label, text, path of its +/// settings table). +fn refuse_package_setting( + setting: &PackageSetting, name: &str, script: Option<&str>, project: Option<(String, &str, &[&str])>, ) -> Result<(), String> { struct Settings { file: String, - no_sources: Option, + all: Option, packages: Vec, } - fn settings(file: String, toml: &str, path: &[&str]) -> Option { + let settings = |file: String, toml: &str, path: &[&str]| -> Option { let document: DocumentMut = toml.parse().ok()?; let mut table = document.as_table() as &dyn TableLike; for key in path { @@ -704,9 +805,9 @@ fn refuse_no_sources( } Some(Settings { file, - no_sources: table.get("no-sources").and_then(Item::as_bool), + all: table.get(setting.all).and_then(Item::as_bool), packages: table - .get("no-sources-package") + .get(setting.packages) .and_then(Item::as_array) .into_iter() .flatten() @@ -714,7 +815,7 @@ fn refuse_no_sources( .map(canonicalize_pypi_name) .collect(), }) - } + }; let script = script.and_then(|text| { let (_, metadata) = script_metadata(text).ok()?; settings( @@ -726,27 +827,27 @@ fn refuse_no_sources( let project = project.and_then(|(file, text, path)| settings(file, text, path)); let levels = [script, project]; let levels = levels.iter().flatten(); - let refusal = |file: &str, setting: &str| { + let name = canonicalize_pypi_name(name); + let refusal = |file: &str, key: &str| { Err(format!( - "{file} sets {setting}, so uv ignores the [tool.uv.sources] entry and a plain \ - `uv sync` would install the unpatched registry wheel; remove the setting to \ - wire a patch" + "{file} sets {key}, {}; {}", + setting.consequence.replace("{name}", &name), + setting.remedy.replace("{name}", &name) )) }; match levels .clone() - .find_map(|level| Some((level.file.as_str(), level.no_sources?))) + .find_map(|level| Some((level.file.as_str(), level.all?))) { - Some((file, true)) => return refusal(file, "no-sources = true"), + Some((file, true)) => return refusal(file, &format!("{} = true", setting.all)), Some((_, false)) => return Ok(()), None => {} } - let name = canonicalize_pypi_name(name); match levels .into_iter() .find(|level| level.packages.contains(&name)) { - Some(level) => refusal(&level.file, &format!("no-sources-package naming {name}")), + Some(level) => refusal(&level.file, &format!("{} naming {name}", setting.packages)), None => Ok(()), } } diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index a017943a5..8c1c90d27 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -10,8 +10,9 @@ use crate::utils::python_lock::{ 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_sources, replace_script_metadata, - rewrite_script_metadata, root_governs_script, script_metadata, UvConfig, + find_ancestor_uv_config, refuse_uv_script_no_binary, refuse_uv_script_no_sources, + replace_script_metadata, rewrite_script_metadata, root_governs_script, script_metadata, + UvConfig, }; use crate::patch::redirect::upstream::{respell_lock_specifier, LockRequirementArray}; @@ -270,6 +271,16 @@ async fn load_listed_locks( ancestor.as_ref(), ) .map_err(|detail| ("pypi_uv_no_sources", format!("{script_name}: {detail}")))?; + // The vendored artifact is a wheel, which uv refuses to + // install under no-binary (#1603). + refuse_uv_script_no_binary( + name, + &script, + pyproject.as_deref(), + uv_toml.as_deref(), + ancestor.as_ref(), + ) + .map_err(|detail| ("pypi_uv_no_binary", format!("{script_name}: {detail}")))?; let script_wheel = source_path(package).unwrap_or(&placeholder); if rewrite_script_metadata(&script, name, version, ArtifactSource::Path(script_wheel)) .map_err(|error| { @@ -1510,6 +1521,58 @@ mod tests { } } + /// #1603: uv refuses to install the vendored wheel for a script whose + /// settings (its own, the project's, or a parent's) forbid wheels for the + /// package, so the scan refuses before writing. + #[tokio::test] + async fn script_no_binary_refuses_before_writing() { + let lock = "version = 1\n[[package]]\nname = \"two\"\nversion = \"2\"\nsource = {registry = \"https://pypi.org/simple\"}\n"; + let plain = "# /// script\n# dependencies = [\"two==2\"]\n# ///\n"; + let no_binary = + "# /// script\n# dependencies = [\"two==2\"]\n#\n# [tool.uv]\n# no-binary-package = [\"Two\"]\n# ///\n"; + for (script, dir, file, config) in [ + (no_binary, "", None, ""), + (plain, "", Some("uv.toml"), "no-binary = true\n"), + ( + plain, + "", + Some("pyproject.toml"), + "[project]\nname = \"p\"\nversion = \"0\"\n[tool.uv]\nno-binary = true\n", + ), + ( + plain, + "scripts", + Some("uv.toml"), + "no-binary-package = [\"two\"]\n", + ), + ] { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join(dir); + tokio::fs::create_dir_all(&root).await.unwrap(); + tokio::fs::write(root.join("job.py.lock"), lock) + .await + .unwrap(); + tokio::fs::write(root.join("job.py"), script).await.unwrap(); + if let Some(file) = file { + tokio::fs::write(temp.path().join(file), config) + .await + .unwrap(); + } + let Err(error) = + load_python_locks(&root, "two", "2", "11111111-1111-4111-8111-111111111111").await + else { + panic!("{script:?} with {file:?} in {dir:?} must refuse"); + }; + assert_eq!(error.0, "pypi_uv_no_binary", "{}", error.1); + assert_eq!( + tokio::fs::read_to_string(root.join("job.py")) + .await + .unwrap(), + script + ); + } + } + /// #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/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index c1a6be023..d1e4bf9df 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -38,8 +38,8 @@ use crate::utils::line_endings::terminator; use crate::utils::purl::purl_name_version; use crate::utils::python_lock::preserve_line_endings; use crate::utils::python_script::{ - find_ancestor_uv_config, refuse_uv_no_sources, refuse_uv_project_shadowing, - root_governs_script, UvConfig, UV_TOML, + find_ancestor_uv_config, refuse_uv_no_binary, refuse_uv_no_sources, + refuse_uv_project_shadowing, root_governs_script, UvConfig, UV_TOML, }; use super::common::{ @@ -353,6 +353,15 @@ pub(super) fn check_target_guards( 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))?; // 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). @@ -2995,6 +3004,37 @@ wheels = [ target(&load_uv_project(tmp.path()).await.unwrap()).unwrap(); } + // #1603: the vendored artifact is a wheel, which uv refuses to + // install under no-binary = true or a no-binary-package list naming + // the package, in pyproject's [tool.uv] or a project uv.toml. + for setting in ["no-binary = true", "no-binary-package = [\"Six\"]"] { + let tmp = write_pair( + &format!("{DIRECT_REGISTRY_PYPROJECT}\n[tool.uv]\n{setting}\n"), + DIRECT_REGISTRY_LOCK, + ) + .await; + let err = target(&load_uv_project(tmp.path()).await.unwrap()).unwrap_err(); + assert_eq!(err.0, "pypi_uv_no_binary", "{setting}: {}", err.1); + let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; + tokio::fs::write(tmp.path().join("uv.toml"), format!("{setting}\n")) + .await + .unwrap(); + let err = target(&load_uv_project(tmp.path()).await.unwrap()).unwrap_err(); + assert_eq!(err.0, "pypi_uv_no_binary", "{setting}: {}", err.1); + } + for setting in [ + "no-binary-package = [\"requests\"]", + "no-build-package = [\"six\"]", + "no-binary = false\nno-binary-package = [\"six\"]", + ] { + let tmp = write_pair( + &format!("{DIRECT_REGISTRY_PYPROJECT}\n[tool.uv]\n{setting}\n"), + DIRECT_REGISTRY_LOCK, + ) + .await; + target(&load_uv_project(tmp.path()).await.unwrap()).unwrap(); + } + // forked package (six at two versions) let fork = format!( "{DIRECT_REGISTRY_LOCK}\n[[package]]\nname = \"six\"\nversion = \"1.17.0\"\nsource = {{ registry = \"https://pypi.org/simple\" }}\n" diff --git a/docs/testing/uv-compatibility.md b/docs/testing/uv-compatibility.md index 33220e4dc..c2ca61344 100644 --- a/docs/testing/uv-compatibility.md +++ b/docs/testing/uv-compatibility.md @@ -204,6 +204,21 @@ frozen, locked, and ordinary installation outcomes separately where supported. sets nothing the project inherits and is not refused. `UV_NO_SOURCES`, `UV_NO_SOURCES_PACKAGE`, the matching flags and a user-level `uv.toml` belong to the machine that installs, which the scan cannot see. +- `no-binary = true` and `no-binary-package = [...]` make uv install the + package only from an sdist, and every Socket artifact is a wheel, so a + wired patch fails every `uv sync` ("marked as `--no-binary` but is itself + a binary distribution"). Both modes refuse it before any file is written: + vendored with `pypi_uv_no_binary`, hosted with + `redirect_uv_project_unsupported` / `redirect_uv_script_unsupported` + (#1603). The settings are read with the same precedence as `no-sources` + (project `uv.toml` over `[tool.uv]`, a script's own `no-binary` over the + project's, lists combined, `no-binary = false` disabling the list, a + 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. - 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