From 4e94de6c67dd44dc5747f7c829663ac33e5437e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 15:24:33 +0000 Subject: [PATCH 1/5] Start fix for #564 Assisted-by: Claude Code:claude-opus-5-5 From e2d5d29f6578a4b0034f3ffc0a5158fba684d41b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 15:35:26 +0000 Subject: [PATCH 2/5] Refuse uv wiring that no-sources would ignore With `no-sources = true` in pyproject's [tool.uv] or a project uv.toml (or a PEP 723 script's [tool.uv]), uv ignores every [tool.uv.sources] entry. Hosted scan and vendor still wrote one and reported success, so `uv sync --locked` then failed and a plain `uv sync` installed the unpatched registry wheel. Both modes now refuse before writing anything: hosted reports redirect_uv_project_unsupported / redirect_uv_script_unsupported and vendored reports pypi_uv_no_sources. The check follows uv's own precedence: a uv.toml replaces pyproject's [tool.uv] settings, and a script's own [tool.uv] wins over uv.toml. Hosted mode now reads uv.toml as a read-only input. Fixes #564 Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-core/src/formats/registry.rs | 2 + .../src/patch/redirect/mod.rs | 88 +++++++++++++++++++ .../src/utils/python_script.rs | 59 +++++++++++++ .../socket-patch-core/src/vendor/pypi_lock.rs | 50 ++++++++++- .../socket-patch-core/src/vendor/pypi_uv.rs | 39 ++++++++ 5 files changed, 237 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/formats/registry.rs b/crates/socket-patch-core/src/formats/registry.rs index ff65ee51e..b1fee2831 100644 --- a/crates/socket-patch-core/src/formats/registry.rs +++ b/crates/socket-patch-core/src/formats/registry.rs @@ -113,6 +113,8 @@ const REGISTRY: &[FormatFile] = &[ row("Pipfile", "pypi", HOSTED | PRESENCE_ONLY), row("pyproject.toml", "pypi", HOSTED | VENDORED | PROBE), row("hatch.toml", "pypi", HOSTED | VENDORED | PROBE), + // Never edited: uv's project settings, read for `no-sources` (#564). + row("uv.toml", "pypi", HOSTED), // ── cargo ── row("Cargo.toml", "cargo", HOSTED | VENDORED | PROBE), row("Cargo.lock", "cargo", HOSTED | VENDORED | ROOT), diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 7a8d54452..026e18a62 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -5782,6 +5782,14 @@ fn plan_python_metadata_with( detail: format!("{metadata_path}: {detail}"), }; source_scope().map_err(unsupported)?; + crate::utils::python_script::refuse_uv_no_sources( + &original, + script, + files + .get(crate::utils::python_script::UV_TOML) + .map(String::as_str), + ) + .map_err(unsupported)?; let rewritten = if script { rewrite_script_metadata( &original, @@ -25356,6 +25364,86 @@ mod python_metadata_pairing_tests { }; assert_eq!(bad.code, "redirect_uv_project_unsupported"); } + + /// #564: under `no-sources = true` uv ignores every `[tool.uv.sources]` + /// entry, so a wired source fails `uv sync --locked` and a plain + /// `uv sync` installs the unpatched registry wheel. The planner refuses + /// before writing. A project `uv.toml` replaces pyproject's `[tool.uv]` + /// settings, while a script's own `[tool.uv]` wins over `uv.toml`. + #[test] + fn no_sources_refuses_a_source_uv_would_ignore() { + let refused = |path: &str, files: &[(&str, &str)]| match plan(path, files, &[]) { + Err(warning) => { + assert!(warning.detail.contains("no-sources"), "{}", 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_no_sources = format!("{PYPROJECT}\n[tool.uv]\nno-sources = true\n"); + let script_no_sources = |value: bool| { + SCRIPT.replace( + "# ///\n", + &format!("#\n# [tool.uv]\n# no-sources = {value}\n# ///\n"), + ) + }; + let script_true = script_no_sources(true); + let script_false = script_no_sources(false); + + assert_eq!( + refused("uv.lock", &[("pyproject.toml", &project_no_sources)]), + "redirect_uv_project_unsupported" + ); + assert_eq!( + refused( + "uv.lock", + &[ + ("pyproject.toml", PYPROJECT), + ("uv.toml", "no-sources = true\n") + ] + ), + "redirect_uv_project_unsupported" + ); + assert_eq!( + refused("tool.py.lock", &[("tool.py", &script_true)]), + "redirect_uv_script_unsupported" + ); + assert_eq!( + refused( + "tool.py.lock", + &[("tool.py", SCRIPT), ("uv.toml", "no-sources = true\n")] + ), + "redirect_uv_script_unsupported" + ); + + // uv.toml without the key overrides pyproject's [tool.uv] entirely. + wired( + "uv.lock", + &[ + ("pyproject.toml", &project_no_sources), + ("uv.toml", "offline = false\n"), + ], + ); + wired( + "uv.lock", + &[ + ("pyproject.toml", PYPROJECT), + ("uv.toml", "no-sources = false\n"), + ], + ); + // The script's own setting wins over uv.toml. + wired( + "tool.py.lock", + &[ + ("tool.py", &script_false), + ("uv.toml", "no-sources = true\n"), + ], + ); + } } #[cfg(test)] diff --git a/crates/socket-patch-core/src/utils/python_script.rs b/crates/socket-patch-core/src/utils/python_script.rs index cf750924a..d38e1574a 100644 --- a/crates/socket-patch-core/src/utils/python_script.rs +++ b/crates/socket-patch-core/src/utils/python_script.rs @@ -304,6 +304,65 @@ fn contains_dependency(item: Option<&Item>, name: &str) -> bool { }) } +/// The project-level uv settings file, read beside `pyproject.toml` (and +/// beside a PEP 723 script, from uv's working directory). +pub const UV_TOML: &str = "uv.toml"; + +/// Refuse to wire a `[tool.uv.sources]` entry that uv would ignore (#564). +/// +/// Under `no-sources = true` uv drops every 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. `metadata` is pyproject.toml, +/// or a PEP 723 script when `script` is set; `uv_toml` is the project's +/// `uv.toml`, if there is one. The precedence is uv's (checked against uv +/// 0.11): a `uv.toml` replaces pyproject's `[tool.uv]` settings outright, +/// while a script's own `[tool.uv]` wins over `uv.toml`. Metadata that does +/// not parse is left for the rewriters to report. +pub fn refuse_uv_no_sources( + metadata: &str, + script: bool, + uv_toml: Option<&str>, +) -> Result<(), String> { + let declared = || -> Option { + let toml = if script { + script_metadata(metadata).ok()?.1 + } else { + metadata.to_string() + }; + let document: DocumentMut = toml.parse().ok()?; + document + .get("tool")? + .as_table_like()? + .get("uv")? + .as_table_like()? + .get("no-sources")? + .as_bool() + }; + let config = uv_toml + .map(|text| { + text.parse::() + .map(|document| document.get("no-sources").and_then(Item::as_bool)) + .map_err(|error| format!("{UV_TOML} does not parse: {error}")) + }) + .transpose()?; + let (file, no_sources) = match (script, config) { + (false, Some(setting)) => (UV_TOML, setting), + (false, None) => ("pyproject.toml [tool.uv]", declared()), + (true, config) => match declared() { + Some(setting) => ("the script's [tool.uv]", Some(setting)), + None => (UV_TOML, config.flatten()), + }, + }; + if no_sources == Some(true) { + return Err(format!( + "{file} sets no-sources = true, so uv ignores [tool.uv.sources] and a plain \ + `uv sync` would install the unpatched registry wheel; remove the setting to \ + wire a patch" + )); + } + Ok(()) +} + pub fn rewrite_project_metadata( text: &str, name: &str, diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index 165dabda4..2102ade75 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -10,7 +10,7 @@ use crate::utils::python_lock::{ is_python_lock_name, python_lock_paths, rewrite_python_lock, script_of_lock, ArtifactSource, }; use crate::utils::python_script::{ - replace_script_metadata, rewrite_script_metadata, script_metadata, + refuse_uv_no_sources, replace_script_metadata, rewrite_script_metadata, script_metadata, }; use super::common::{record, refuse_symlinked}; @@ -241,6 +241,12 @@ 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?; + refuse_uv_no_sources( + &script, + true, + super::pypi_uv::read_uv_toml(root).await?.as_deref(), + ) + .map_err(|detail| ("pypi_uv_no_sources", 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| { @@ -1233,6 +1239,48 @@ mod tests { ); } + /// #564: a script lock whose script (or the uv.toml beside it) sets + /// `no-sources = true` is refused before anything is written, since uv + /// would ignore the vendored `[tool.uv.sources]` entry. + #[tokio::test] + async fn script_no_sources_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_sources = + "# /// script\n# dependencies = [\"two==2\"]\n#\n# [tool.uv]\n# no-sources = true\n# ///\n"; + for (script, uv_toml) in [(no_sources, None), (plain, Some("no-sources = true\n"))] { + let temp = tempfile::tempdir().unwrap(); + tokio::fs::write(temp.path().join("job.py.lock"), lock) + .await + .unwrap(); + tokio::fs::write(temp.path().join("job.py"), script) + .await + .unwrap(); + if let Some(uv_toml) = uv_toml { + tokio::fs::write(temp.path().join("uv.toml"), uv_toml) + .await + .unwrap(); + } + let Err(error) = load_python_locks( + temp.path(), + "two", + "2", + "11111111-1111-4111-8111-111111111111", + ) + .await + else { + panic!("{script:?} with uv.toml {uv_toml:?} must refuse"); + }; + assert_eq!(error.0, "pypi_uv_no_sources", "{}", error.1); + assert_eq!( + tokio::fs::read_to_string(temp.path().join("job.py")) + .await + .unwrap(), + script + ); + } + } + #[tokio::test] async fn script_global_sources_cannot_replace_multiple_versions() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 090960e55..63c9e5055 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -37,6 +37,7 @@ use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_strin 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::{refuse_uv_no_sources, UV_TOML}; use super::common::{ ensure_unchanged, item_get, pep508_name, pyproject_dependency_specs, record, refuse_symlinked, @@ -97,6 +98,19 @@ pub(super) struct UvProject { pub warnings: Vec, } +/// The project's `uv.toml` beside `root`'s pyproject.toml (or a script), +/// `None` when there is none, for [`refuse_uv_no_sources`]. +pub(super) async fn read_uv_toml(root: &Path) -> Result, (&'static str, String)> { + match read_regular_to_string(&root.join(UV_TOML)).await { + Ok(text) => Ok(Some(text)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(( + "pypi_uv_lock_parse_failed", + format!("cannot read {UV_TOML}: {e}"), + )), + } +} + /// Read + parse the pair and run every project-level guard. Refuses before /// ANY write — the orchestrator runs this (and the target guards) before the /// wheel is even built, so a refusal leaves the tree byte-untouched. @@ -212,6 +226,11 @@ pub(super) async fn load_uv_project(root: &Path) -> Result Date: Mon, 12 Oct 2026 16:04:52 +0000 Subject: [PATCH 3/5] Test and document the uv no-sources refusal Add a CLI test that both hosted scan and vendor refuse a uv project with no-sources = true (in pyproject or uv.toml) and write nothing. Pin uv.toml in the hosted candidate list and describe the refusal in the uv compatibility notes. Refs #564 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/commands/scan/hosted.rs | 1 + .../tests/mode_migration_pypi.rs | 45 +++++++++++++++++++ docs/testing/uv-compatibility.md | 9 ++++ 3 files changed, 55 insertions(+) diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 7c5405c86..255dd46af 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -4213,6 +4213,7 @@ mod tests { "Pipfile", "pyproject.toml", "hatch.toml", + "uv.toml", "Cargo.toml", "Cargo.lock", ".cargo/config.toml", diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 02197d8f0..2f54cf1c1 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -824,6 +824,51 @@ async fn uv_vendored_to_hosted() { assert_vendored_to_hosted(&root, files).await; } +/// #564: under `no-sources = true` (pyproject's `[tool.uv]` or a project +/// `uv.toml`) uv ignores `[tool.uv.sources]`, so a wired source fails +/// `uv sync --locked` and a plain `uv sync` installs the unpatched wheel. +/// Both modes used to wire it and exit 0; they must refuse and write +/// nothing. +#[tokio::test] +async fn uv_no_sources_is_refused_in_both_modes() { + for (file, setting) in [ + ("pyproject.toml", "\n[tool.uv]\nno-sources = true\n"), + ("uv.toml", "no-sources = true\n"), + ] { + 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(); + let before = tree(&root); + + let server = MockServer::start().await; + mount_hosted_api(&server, true).await; + let (code, env) = hosted_scan(&root, &server); + // Skipped with the refusal as its warning, like the other uv + // project shapes hosted mode cannot wire. + assert_eq!(code, 0, "hosted {file}: {env:#}"); + assert_eq!(hosted_pinned(&env), 0, "hosted {file}: {env:#}"); + assert!( + env.to_string().contains("redirect_uv_project_unsupported") + && env.to_string().contains("no-sources"), + "hosted {file}: {env:#}" + ); + assert_eq!(tree(&root), before, "hosted {file} wrote nothing"); + + stage_manifest(&root); + let before = tree(&root); + let (code, env) = run_cli(&root, &["vendor"], &[]); + assert_ne!(code, 0, "vendored {file}: {env:#}"); + assert!( + env.to_string().contains("pypi_uv_no_sources"), + "vendored {file}: {env:#}" + ); + assert_eq!(tree(&root), before, "vendored {file} wrote nothing"); + } +} + /// A Hatch project; returns its wiring files. fn stage_hatch(root: &Path) -> &'static [&'static str] { std::fs::write( diff --git a/docs/testing/uv-compatibility.md b/docs/testing/uv-compatibility.md index 85eb0833d..f155d5e50 100644 --- a/docs/testing/uv-compatibility.md +++ b/docs/testing/uv-compatibility.md @@ -175,6 +175,15 @@ frozen, locked, and ordinary installation outcomes separately where supported. `redirect_uv_lock_unsupported` (#767). Before, vendored left a lock that `uv sync --locked` rejects, and hosted replaced the user's URL with one its own rollback then refused to restore. +- `no-sources = true` makes uv ignore every `[tool.uv.sources]` entry, so a + wired source fails `uv sync --locked` and a plain `uv sync` installs the + unpatched registry wheel. Both modes refuse it before any file is written: + vendored with `pypi_uv_no_sources`, hosted with + `redirect_uv_project_unsupported` / `redirect_uv_script_unsupported` + (#564). The setting is read where uv reads it: a project `uv.toml` + replaces pyproject's `[tool.uv]` settings, and a PEP 723 script's own + `[tool.uv]` wins over `uv.toml`. `UV_NO_SOURCES` and `--no-sources` are + install-time choices the scan cannot see. - 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 f9b97ff744f9ecd3f781d21a8acfb3441123dc2a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 16:44:10 +0000 Subject: [PATCH 4/5] Follow uv's config lookup for scripts' no-sources A PEP 723 script without its own no-sources setting takes the project's: uv.toml if present, else pyproject's [tool.uv]. A script next to a pyproject with no-sources = true was still wired. uv.toml is now read only beside a uv lock, as advisory input, so a symlinked or unreadable uv.toml no longer refuses unrelated hosted runs. A uv.toml that fails to parse sets nothing here (uv reports it) instead of surfacing as a no-sources or lock-parse refusal. The vendored script check reads the project config once per run. Refs #564 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/commands/scan/hosted.rs | 1 - .../socket-patch-core/src/formats/registry.rs | 2 - crates/socket-patch-core/src/hosted/engine.rs | 15 ++++ .../src/patch/redirect/mod.rs | 38 ++++++++-- .../src/utils/python_script.rs | 74 +++++++++---------- .../socket-patch-core/src/vendor/pypi_lock.rs | 41 ++++++---- .../socket-patch-core/src/vendor/pypi_uv.rs | 22 +++--- docs/testing/uv-compatibility.md | 9 ++- 8 files changed, 124 insertions(+), 78 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 255dd46af..7c5405c86 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -4213,7 +4213,6 @@ mod tests { "Pipfile", "pyproject.toml", "hatch.toml", - "uv.toml", "Cargo.toml", "Cargo.lock", ".cargo/config.toml", diff --git a/crates/socket-patch-core/src/formats/registry.rs b/crates/socket-patch-core/src/formats/registry.rs index b1fee2831..ff65ee51e 100644 --- a/crates/socket-patch-core/src/formats/registry.rs +++ b/crates/socket-patch-core/src/formats/registry.rs @@ -113,8 +113,6 @@ const REGISTRY: &[FormatFile] = &[ row("Pipfile", "pypi", HOSTED | PRESENCE_ONLY), row("pyproject.toml", "pypi", HOSTED | VENDORED | PROBE), row("hatch.toml", "pypi", HOSTED | VENDORED | PROBE), - // Never edited: uv's project settings, read for `no-sources` (#564). - row("uv.toml", "pypi", HOSTED), // ── cargo ── row("Cargo.toml", "cargo", HOSTED | VENDORED | PROBE), row("Cargo.lock", "cargo", HOSTED | VENDORED | ROOT), diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 88a7fdecd..a75386d00 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -698,6 +698,21 @@ pub async fn read_candidate_files( out.read(view, unreadable, &path).await; } + // 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 + // unreadable in-memory entry is left out rather than refused. + if out + .files + .keys() + .any(|path| path == "uv.lock" || crate::utils::python_lock::is_script_lock_name(path)) + { + 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); + } + } + // Rush monorepos have no root package.json/lock pair: the single pnpm // source-of-truth lock lives at common/config/rush/pnpm-lock.yaml, and // (when subspaces are enabled) one lock per subspace under diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 026e18a62..52b52fd54 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -5782,12 +5782,15 @@ fn plan_python_metadata_with( detail: format!("{metadata_path}: {detail}"), }; source_scope().map_err(unsupported)?; + let project_file = |rel: &str| files.get(rel).map(String::as_str); crate::utils::python_script::refuse_uv_no_sources( - &original, - script, - files - .get(crate::utils::python_script::UV_TOML) - .map(String::as_str), + script.then_some(original.as_str()), + if script { + project_file("pyproject.toml") + } else { + Some(&original) + }, + project_file(crate::utils::python_script::UV_TOML), ) .map_err(unsupported)?; let rewritten = if script { @@ -25443,6 +25446,31 @@ mod python_metadata_pairing_tests { ("uv.toml", "no-sources = true\n"), ], ); + // A script without its own setting takes the project's: uv.toml, + // else pyproject's [tool.uv]. + assert_eq!( + refused( + "tool.py.lock", + &[("tool.py", SCRIPT), ("pyproject.toml", &project_no_sources)] + ), + "redirect_uv_script_unsupported" + ); + wired( + "tool.py.lock", + &[ + ("tool.py", SCRIPT), + ("pyproject.toml", &project_no_sources), + ("uv.toml", "offline = false\n"), + ], + ); + // A uv.toml that does not parse sets nothing (uv reports it). + wired( + "uv.lock", + &[ + ("pyproject.toml", PYPROJECT), + ("uv.toml", "no-sources = [\n"), + ], + ); } } diff --git a/crates/socket-patch-core/src/utils/python_script.rs b/crates/socket-patch-core/src/utils/python_script.rs index d38e1574a..dce77a75c 100644 --- a/crates/socket-patch-core/src/utils/python_script.rs +++ b/crates/socket-patch-core/src/utils/python_script.rs @@ -1,6 +1,6 @@ use std::ops::Range; -use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value}; +use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, TableLike, Value}; use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::utils::python_lock::ArtifactSource; @@ -312,55 +312,47 @@ pub const UV_TOML: &str = "uv.toml"; /// /// Under `no-sources = true` uv drops every 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. `metadata` is pyproject.toml, -/// or a PEP 723 script when `script` is set; `uv_toml` is the project's -/// `uv.toml`, if there is one. The precedence is uv's (checked against uv -/// 0.11): a `uv.toml` replaces pyproject's `[tool.uv]` settings outright, -/// while a script's own `[tool.uv]` wins over `uv.toml`. Metadata that does -/// not parse is left for the rewriters to report. +/// 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, when present. The precedence is uv's (checked +/// against uv 0.11): a script's own `[tool.uv]` wins; otherwise a `uv.toml` +/// replaces pyproject's `[tool.uv]` settings outright. A file that does not +/// parse sets nothing here: uv reports it itself, and the rewriters report +/// a metadata file they cannot edit. pub fn refuse_uv_no_sources( - metadata: &str, - script: bool, + script: Option<&str>, + pyproject: Option<&str>, uv_toml: Option<&str>, ) -> Result<(), String> { - let declared = || -> Option { - let toml = if script { - script_metadata(metadata).ok()?.1 - } else { - metadata.to_string() - }; + fn setting(toml: &str, path: &[&str]) -> Option { let document: DocumentMut = toml.parse().ok()?; - document - .get("tool")? - .as_table_like()? - .get("uv")? - .as_table_like()? - .get("no-sources")? - .as_bool() - }; - let config = uv_toml - .map(|text| { - text.parse::() - .map(|document| document.get("no-sources").and_then(Item::as_bool)) - .map_err(|error| format!("{UV_TOML} does not parse: {error}")) - }) - .transpose()?; - let (file, no_sources) = match (script, config) { - (false, Some(setting)) => (UV_TOML, setting), - (false, None) => ("pyproject.toml [tool.uv]", declared()), - (true, config) => match declared() { - Some(setting) => ("the script's [tool.uv]", Some(setting)), - None => (UV_TOML, config.flatten()), - }, + let mut table = document.as_table() as &dyn TableLike; + for key in path { + table = table.get(key)?.as_table_like()?; + } + table.get("no-sources")?.as_bool() + } + let from_script = script.and_then(|text| { + let (_, metadata) = script_metadata(text).ok()?; + Some(( + "the script's [tool.uv]", + setting(&metadata, &["tool", "uv"])?, + )) + }); + let from_project = || match uv_toml { + Some(text) => setting(text, &[]).map(|value| (UV_TOML, value)), + None => pyproject + .and_then(|text| setting(text, &["tool", "uv"])) + .map(|value| ("pyproject.toml [tool.uv]", value)), }; - if no_sources == Some(true) { - return Err(format!( + match from_script.or_else(from_project) { + Some((file, true)) => Err(format!( "{file} sets no-sources = true, so uv ignores [tool.uv.sources] and a plain \ `uv sync` would install the unpatched registry wheel; remove the setting to \ wire a patch" - )); + )), + _ => Ok(()), } - Ok(()) } pub fn rewrite_project_metadata( diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index 2102ade75..72441d434 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -179,6 +179,9 @@ async fn load_listed_locks( 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). + let mut project_config: Option<(Option, 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( @@ -241,12 +244,17 @@ 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?; - refuse_uv_no_sources( - &script, - true, - super::pypi_uv::read_uv_toml(root).await?.as_deref(), - ) - .map_err(|detail| ("pypi_uv_no_sources", format!("{script_name}: {detail}")))?; + let (pyproject, uv_toml) = match &project_config { + Some(config) => config, + None => project_config.insert(( + read_regular_to_string(&root.join("pyproject.toml")) + .await + .ok(), + super::pypi_uv::read_uv_toml(root).await, + )), + }; + refuse_uv_no_sources(Some(&script), pyproject.as_deref(), uv_toml.as_deref()) + .map_err(|detail| ("pypi_uv_no_sources", 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| { @@ -1239,16 +1247,23 @@ mod tests { ); } - /// #564: a script lock whose script (or the uv.toml beside it) sets - /// `no-sources = true` is refused before anything is written, since uv - /// would ignore the vendored `[tool.uv.sources]` entry. + /// #564: a script lock whose script (or, without its own setting, the + /// project's uv.toml or pyproject `[tool.uv]`) sets `no-sources = true` + /// is refused before anything is written, since uv would ignore the + /// vendored `[tool.uv.sources]` entry. #[tokio::test] async fn script_no_sources_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_sources = "# /// script\n# dependencies = [\"two==2\"]\n#\n# [tool.uv]\n# no-sources = true\n# ///\n"; - for (script, uv_toml) in [(no_sources, None), (plain, Some("no-sources = true\n"))] { + let pyproject_no_sources = + "[project]\nname = \"p\"\nversion = \"0\"\n[tool.uv]\nno-sources = true\n"; + for (script, file, config) in [ + (no_sources, None, ""), + (plain, Some("uv.toml"), "no-sources = true\n"), + (plain, Some("pyproject.toml"), pyproject_no_sources), + ] { let temp = tempfile::tempdir().unwrap(); tokio::fs::write(temp.path().join("job.py.lock"), lock) .await @@ -1256,8 +1271,8 @@ mod tests { tokio::fs::write(temp.path().join("job.py"), script) .await .unwrap(); - if let Some(uv_toml) = uv_toml { - tokio::fs::write(temp.path().join("uv.toml"), uv_toml) + if let Some(file) = file { + tokio::fs::write(temp.path().join(file), config) .await .unwrap(); } @@ -1269,7 +1284,7 @@ mod tests { ) .await else { - panic!("{script:?} with uv.toml {uv_toml:?} must refuse"); + panic!("{script:?} with {file:?} must refuse"); }; assert_eq!(error.0, "pypi_uv_no_sources", "{}", error.1); assert_eq!( diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 63c9e5055..0d4ab6197 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -99,16 +99,10 @@ pub(super) struct UvProject { } /// The project's `uv.toml` beside `root`'s pyproject.toml (or a script), -/// `None` when there is none, for [`refuse_uv_no_sources`]. -pub(super) async fn read_uv_toml(root: &Path) -> Result, (&'static str, String)> { - match read_regular_to_string(&root.join(UV_TOML)).await { - Ok(text) => Ok(Some(text)), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(( - "pypi_uv_lock_parse_failed", - format!("cannot read {UV_TOML}: {e}"), - )), - } +/// for [`refuse_uv_no_sources`]. Advisory: one that is missing or cannot +/// be read sets nothing (uv reports an unreadable one itself). +pub(super) async fn read_uv_toml(root: &Path) -> Option { + read_regular_to_string(&root.join(UV_TOML)).await.ok() } /// Read + parse the pair and run every project-level guard. Refuses before @@ -228,8 +222,12 @@ pub(super) async fn load_uv_project(root: &Path) -> Result Date: Mon, 12 Oct 2026 17:51:24 +0000 Subject: [PATCH 5/5] Refuse uv wiring that no-sources-package would ignore uv 0.10+ also drops the [tool.uv.sources] entries of the packages named in no-sources-package, so patching a listed package left the same unpatched install as no-sources = true. Read the list beside the boolean, with uv's rules: a uv.toml replaces pyproject's [tool.uv], a script's list combines with the project's, and an explicit no-sources = false disables it. Names are compared normalized. The vendored project check moves into check_target_guards, which knows the package being wired. Refs #564 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014C6hY72T6RCtYwyGvxAmoT --- .../src/patch/redirect/mod.rs | 42 +++++++++ .../src/utils/python_script.rs | 88 +++++++++++++------ .../socket-patch-core/src/vendor/pypi_lock.rs | 10 ++- .../socket-patch-core/src/vendor/pypi_uv.rs | 81 +++++++++++------ docs/testing/uv-compatibility.md | 23 ++--- 5 files changed, 178 insertions(+), 66 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 52b52fd54..cfac846fe 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -5784,6 +5784,7 @@ fn plan_python_metadata_with( source_scope().map_err(unsupported)?; let project_file = |rel: &str| files.get(rel).map(String::as_str); crate::utils::python_script::refuse_uv_no_sources( + &dep.name, script.then_some(original.as_str()), if script { project_file("pyproject.toml") @@ -25471,6 +25472,47 @@ mod python_metadata_pairing_tests { ("uv.toml", "no-sources = [\n"), ], ); + + // no-sources-package drops the source of the packages it names + // (normalized), in either file; a script's list combines with the + // project's, and an explicit no-sources = false disables it. + let names = |list: &str| format!("{PYPROJECT}\n[tool.uv]\nno-sources-package = {list}\n"); + assert_eq!( + refused("uv.lock", &[("pyproject.toml", &names("[\"Click\"]"))]), + "redirect_uv_project_unsupported" + ); + assert_eq!( + refused( + "uv.lock", + &[ + ("pyproject.toml", PYPROJECT), + ("uv.toml", "no-sources-package = [\"click\"]\n") + ] + ), + "redirect_uv_project_unsupported" + ); + wired("uv.lock", &[("pyproject.toml", &names("[\"requests\"]"))]); + let script_names = SCRIPT.replace( + "# ///\n", + "#\n# [tool.uv]\n# no-sources-package = [\"requests\"]\n# ///\n", + ); + assert_eq!( + refused( + "tool.py.lock", + &[ + ("tool.py", &script_names), + ("uv.toml", "no-sources-package = [\"click\"]\n") + ] + ), + "redirect_uv_script_unsupported" + ); + wired( + "tool.py.lock", + &[ + ("tool.py", &script_false), + ("uv.toml", "no-sources-package = [\"click\"]\n"), + ], + ); } } diff --git a/crates/socket-patch-core/src/utils/python_script.rs b/crates/socket-patch-core/src/utils/python_script.rs index dce77a75c..88f1fbd93 100644 --- a/crates/socket-patch-core/src/utils/python_script.rs +++ b/crates/socket-patch-core/src/utils/python_script.rs @@ -308,50 +308,84 @@ fn contains_dependency(item: Option<&Item>, name: &str) -> bool { /// beside a PEP 723 script, from uv's working directory). pub const UV_TOML: &str = "uv.toml"; -/// Refuse to wire a `[tool.uv.sources]` entry that uv would ignore (#564). +/// Refuse to wire a `[tool.uv.sources]` entry for `name` that uv would +/// ignore (#564). /// -/// Under `no-sources = true` uv drops every 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, when present. The precedence is uv's (checked -/// against uv 0.11): a script's own `[tool.uv]` wins; otherwise a `uv.toml` -/// replaces pyproject's `[tool.uv]` settings outright. A file that does not -/// parse sets nothing here: uv reports it itself, and the rewriters report -/// a metadata file they cannot edit. +/// 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, +/// 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` +/// lists combine; and an explicit `no-sources = false` disables the list. +/// A file that does not parse sets nothing here: uv reports it itself, and +/// 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> { - fn setting(toml: &str, path: &[&str]) -> Option { + struct Settings { + file: &'static str, + no_sources: Option, + packages: Vec, + } + fn settings(file: &'static str, toml: &str, path: &[&str]) -> Option { let document: DocumentMut = toml.parse().ok()?; let mut table = document.as_table() as &dyn TableLike; for key in path { table = table.get(key)?.as_table_like()?; } - table.get("no-sources")?.as_bool() + Some(Settings { + file, + no_sources: table.get("no-sources").and_then(Item::as_bool), + packages: table + .get("no-sources-package") + .and_then(Item::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(canonicalize_pypi_name) + .collect(), + }) } - let from_script = script.and_then(|text| { + let script = script.and_then(|text| { let (_, metadata) = script_metadata(text).ok()?; - Some(( - "the script's [tool.uv]", - setting(&metadata, &["tool", "uv"])?, - )) + settings("the script's [tool.uv]", &metadata, &["tool", "uv"]) }); - let from_project = || match uv_toml { - Some(text) => setting(text, &[]).map(|value| (UV_TOML, value)), - None => pyproject - .and_then(|text| setting(text, &["tool", "uv"])) - .map(|value| ("pyproject.toml [tool.uv]", value)), + let project = match uv_toml { + Some(text) => settings(UV_TOML, text, &[]), + None => { + pyproject.and_then(|text| settings("pyproject.toml [tool.uv]", text, &["tool", "uv"])) + } }; - match from_script.or_else(from_project) { - Some((file, true)) => Err(format!( - "{file} sets no-sources = true, so uv ignores [tool.uv.sources] and a plain \ + let levels = [script, project]; + let levels = levels.iter().flatten(); + let refusal = |file: &str, setting: &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" - )), - _ => Ok(()), + )) + }; + match levels + .clone() + .find_map(|level| Some((level.file, level.no_sources?))) + { + Some((file, true)) => return refusal(file, "no-sources = true"), + 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}")), + 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 72441d434..65b7c7d47 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -253,8 +253,13 @@ async fn load_listed_locks( super::pypi_uv::read_uv_toml(root).await, )), }; - refuse_uv_no_sources(Some(&script), pyproject.as_deref(), uv_toml.as_deref()) - .map_err(|detail| ("pypi_uv_no_sources", format!("{script_name}: {detail}")))?; + refuse_uv_no_sources( + name, + Some(&script), + pyproject.as_deref(), + uv_toml.as_deref(), + ) + .map_err(|detail| ("pypi_uv_no_sources", 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| { @@ -1263,6 +1268,7 @@ mod tests { (no_sources, None, ""), (plain, Some("uv.toml"), "no-sources = true\n"), (plain, Some("pyproject.toml"), pyproject_no_sources), + (plain, Some("uv.toml"), "no-sources-package = [\"Two\"]\n"), ] { let temp = tempfile::tempdir().unwrap(); tokio::fs::write(temp.path().join("job.py.lock"), lock) diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 0d4ab6197..96a6afa53 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -94,6 +94,8 @@ pub(super) struct UvProject { pub lock: Arc, /// uv.lock `revision` (diagnostics; recorded into [`UvMeta`]). pub lock_revision: Option, + /// The project's `uv.toml`, for the no-sources check (#564). + pub uv_toml: Option, /// Non-fatal advisories raised during load (untested lock revision). pub warnings: Vec, } @@ -220,14 +222,7 @@ pub(super) async fn load_uv_project(root: &Path) -> Result Result Result { + // 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))?; // The same name at multiple versions/sources (platform forks) means one // surgical [[package]] rewrite would mispin the other forks — refuse. let units = p @@ -2887,25 +2893,45 @@ wheels = [ let err = load_uv_project(tmp.path()).await.unwrap_err(); assert_eq!(err.0, "pypi_uv_dynamic_dependencies"); - // #564: uv ignores [tool.uv.sources] under no-sources = true, set in - // pyproject's [tool.uv] or in a project uv.toml (which replaces it). - let tmp = write_pair( - &format!("{DIRECT_REGISTRY_PYPROJECT}\n[tool.uv]\nno-sources = true\n"), - DIRECT_REGISTRY_LOCK, - ) - .await; - let err = load_uv_project(tmp.path()).await.unwrap_err(); - assert_eq!(err.0, "pypi_uv_no_sources", "{}", err.1); - tokio::fs::write(tmp.path().join("uv.toml"), "offline = false\n") - .await - .unwrap(); - load_uv_project(tmp.path()).await.unwrap(); - let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; - tokio::fs::write(tmp.path().join("uv.toml"), "no-sources = true\n") - .await - .unwrap(); - let err = load_uv_project(tmp.path()).await.unwrap_err(); - assert_eq!(err.0, "pypi_uv_no_sources", "{}", err.1); + // #564: uv ignores [tool.uv.sources] under no-sources = true, or a + // no-sources-package list naming the package, set in pyproject's + // [tool.uv] or in a project uv.toml (which replaces it). + let target = |p: &UvProject| { + check_target_guards(p, "six", "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f").map(|_| ()) + }; + for setting in ["no-sources = true", "no-sources-package = [\"Six\"]"] { + let tmp = write_pair( + &format!("{DIRECT_REGISTRY_PYPROJECT}\n[tool.uv]\n{setting}\n"), + DIRECT_REGISTRY_LOCK, + ) + .await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let err = target(&p).unwrap_err(); + assert_eq!(err.0, "pypi_uv_no_sources", "{setting}: {}", err.1); + tokio::fs::write(tmp.path().join("uv.toml"), "offline = false\n") + .await + .unwrap(); + target(&load_uv_project(tmp.path()).await.unwrap()).unwrap(); + 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_sources", "{setting}: {}", err.1); + } + // A list naming other packages, or disabled by no-sources = false, + // leaves the source in force. + for setting in [ + "no-sources-package = [\"requests\"]", + "no-sources = false\nno-sources-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!( @@ -5719,6 +5745,7 @@ wheels = [ pyproject: Arc::new(pyproject.parse().unwrap()), lock: Arc::new(lock.parse().unwrap()), lock_revision: None, + uv_toml: None, warnings: Vec::new(), } } diff --git a/docs/testing/uv-compatibility.md b/docs/testing/uv-compatibility.md index ba10b90a4..f944d1b6b 100644 --- a/docs/testing/uv-compatibility.md +++ b/docs/testing/uv-compatibility.md @@ -175,16 +175,19 @@ frozen, locked, and ordinary installation outcomes separately where supported. `redirect_uv_lock_unsupported` (#767). Before, vendored left a lock that `uv sync --locked` rejects, and hosted replaced the user's URL with one its own rollback then refused to restore. -- `no-sources = true` makes uv ignore every `[tool.uv.sources]` entry, so a - wired source fails `uv sync --locked` and a plain `uv sync` installs the - unpatched registry wheel. Both modes refuse it before any file is written: - vendored with `pypi_uv_no_sources`, hosted with - `redirect_uv_project_unsupported` / `redirect_uv_script_unsupported` - (#564). The setting is read where uv reads it: a PEP 723 script's own - `[tool.uv]` wins; otherwise a project `uv.toml` replaces pyproject's - `[tool.uv]` settings outright. `UV_NO_SOURCES`, `--no-sources` and a - user-level `uv.toml` belong to the machine that installs, which the scan - cannot see. +- `no-sources = true` makes uv ignore every `[tool.uv.sources]` entry, and + `no-sources-package = [...]` ignores the entries of the packages it names + (uv 0.10+), so a wired source fails `uv sync --locked` and a plain + `uv sync` installs the unpatched registry wheel. Both modes refuse it + before any file is written: vendored with `pypi_uv_no_sources`, hosted + with `redirect_uv_project_unsupported` / `redirect_uv_script_unsupported` + (#564). The settings are read where uv reads them (checked against uv + 0.11): a project `uv.toml` replaces pyproject's `[tool.uv]` outright; a + PEP 723 script's own `no-sources` wins over the project's, while the two + `no-sources-package` lists combine; and an explicit `no-sources = false` + disables the list. `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. - 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