From 9f23a42c2c23c97c3f8bc19490d522ec520ce35a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 20:24:57 +0000 Subject: [PATCH 1/6] Start fix for #1501 Assisted-by: Claude Code:claude-opus-5-5 From de988896c8ff6feb1f54914737666cf2c07f9e19 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 20:37:17 +0000 Subject: [PATCH 2/6] Refuse uv scripts under a parent no-sources A PEP 723 script takes uv's settings from the nearest directory at or above it holding a uv.toml or a pyproject.toml with [tool.uv]. When a script was scanned from a subdirectory (scripts/ under a project whose root sets no-sources = true), both modes read only the scanned root, wired the script and exited 0. uv then ignored the source: `uv run --locked` failed and a plain `uv run` imported the unpatched package. The script lane now walks above the root when the root sets nothing, the way uv does, and refuses with the parent file named (vendored pypi_uv_no_sources, hosted redirect_uv_script_unsupported). The project lane is unchanged, since a project stops uv's lookup at itself. Fixes #1501 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/mode_migration_pypi.rs | 58 +++++ crates/socket-patch-core/src/hosted/engine.rs | 21 ++ .../src/patch/redirect/mod.rs | 105 +++++++- .../src/utils/python_script.rs | 232 +++++++++++++++++- .../socket-patch-core/src/vendor/pypi_lock.rs | 50 +++- 5 files changed, 440 insertions(+), 26 deletions(-) diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 122b8de57..6264e6d0a 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -864,6 +864,64 @@ async fn uv_no_sources_is_refused_in_both_modes() { } } +/// #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 +/// script's source too, so both modes must refuse a script scanned from a +/// subdirectory just as they do beside the setting. A parent pyproject +/// without `[tool.uv]` sets nothing, and the script is still wired. +#[tokio::test] +async fn uv_script_no_sources_in_a_parent_is_refused_in_both_modes() { + let parent_pyproject = "[project]\nname = \"root\"\nversion = \"0.1.0\"\n"; + for (file, setting, refused) in [ + ("uv.toml", "no-sources = true\n".to_string(), true), + ( + "pyproject.toml", + format!("{parent_pyproject}\n[tool.uv]\nno-sources = true\n"), + true, + ), + ( + "pyproject.toml", + format!("{parent_pyproject}\n[tool.uv]\nno-sources-package = [\"Six\"]\n"), + true, + ), + ("pyproject.toml", parent_pyproject.to_string(), false), + ] { + let (_tmp, root) = project(); + stage_script_lock(&root); + std::fs::write(root.parent().unwrap().join(file), &setting).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 {file}: {env:#}"); + if refused { + assert_eq!(hosted_pinned(&env), 0, "hosted {file}: {env:#}"); + assert!( + env.to_string().contains("redirect_uv_script_unsupported") + && env.to_string().contains(&format!("../{file}")), + "hosted {file}: {env:#}" + ); + assert_eq!(tree(&root), before, "hosted {file} wrote nothing"); + } else { + assert_eq!(hosted_pinned(&env), 1, "hosted {file}: {env:#}"); + continue; + } + + 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") + && env.to_string().contains(&format!("../{file}")), + "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/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 37fcb0388..624600644 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -714,6 +714,27 @@ pub async fn read_candidate_files( out.files.insert(rel.to_string(), text); } } + // A PEP 723 script also takes uv's settings from above the root when + // the root holds none (#1501); only a disk view has an above. + if out + .files + .keys() + .any(|path| crate::utils::python_lock::is_script_lock_name(path)) + { + let files = view + .disk_root_reading(std::iter::empty::<&std::path::Path>()) + .map(crate::utils::python_script::ancestor_uv_config_files) + .unwrap_or_default(); + if let Some(config) = view + .disk_root_reading(files.iter().map(|(_, path)| path)) + .and_then(crate::utils::python_script::find_ancestor_uv_config) + { + out.files.insert( + crate::patch::redirect::UV_ANCESTOR_CONFIG_KEY.to_string(), + format!("{}\n{}", config.rel, config.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 diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 5f967b573..4e2981668 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -5948,16 +5948,29 @@ 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") - } else { - Some(&original) - }, - project_file(crate::utils::python_script::UV_TOML), - ) + if script { + 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(), + }) + }); + crate::utils::python_script::refuse_uv_script_no_sources( + &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, + None, + Some(&original), + project_file(crate::utils::python_script::UV_TOML), + ) + } .map_err(unsupported)?; let rewritten = if script { rewrite_script_metadata( @@ -6349,6 +6362,15 @@ pub const NUGET_INHERITED_MAPPING_KEY: &str = "\n\n \n \n\n"; +/// The synthetic candidate key carrying the uv settings file above the +/// root that uv applies to a PEP 723 script there (#1501), as +/// `\n` ([`find_ancestor_uv_config`]). Absent (the in-memory +/// engine, unit tests, no such file), only the root's own files are read. +/// Never a path (see [`sbt::SYNTHETIC_KEY_PREFIX`]). +/// +/// [`find_ancestor_uv_config`]: crate::utils::python_script::find_ancestor_uv_config +pub const UV_ANCESTOR_CONFIG_KEY: &str = ""; + /// 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` @@ -25990,6 +26012,69 @@ mod python_metadata_pairing_tests { ], ); } + + /// #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, + /// and a project lane never reads it. + #[test] + fn no_sources_above_the_root_refuses_a_script() { + let parent = |rel: &str, text: &str| format!("{rel}\n{text}"); + let uv_toml = parent("../uv.toml", "no-sources = true\n"); + let Err(warning) = plan( + "tool.py.lock", + &[("tool.py", SCRIPT), (UV_ANCESTOR_CONFIG_KEY, &uv_toml)], + &[], + ) else { + panic!("a parent uv.toml must refuse"); + }; + assert_eq!(warning.code, "redirect_uv_script_unsupported"); + assert!( + warning.detail.contains("../uv.toml sets no-sources = true"), + "{}", + warning.detail + ); + let pyproject = parent("../../pyproject.toml", "[tool.uv]\nno-sources = true\n"); + let Err(warning) = plan( + "tool.py.lock", + &[ + ("tool.py", SCRIPT), + ("pyproject.toml", PYPROJECT), + (UV_ANCESTOR_CONFIG_KEY, &pyproject), + ], + &[], + ) else { + panic!("a parent pyproject [tool.uv] must refuse"); + }; + assert!( + warning + .detail + .contains("../../pyproject.toml [tool.uv] sets no-sources = true"), + "{}", + warning.detail + ); + + 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"); + }; + wired( + "tool.py.lock", + &[ + ("tool.py", SCRIPT), + ("uv.toml", "offline = false\n"), + (UV_ANCESTOR_CONFIG_KEY, &uv_toml), + ], + ); + wired( + "uv.lock", + &[ + ("pyproject.toml", PYPROJECT), + (UV_ANCESTOR_CONFIG_KEY, &uv_toml), + ], + ); + } } #[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 b5fb19a71..ef6ec396b 100644 --- a/crates/socket-patch-core/src/utils/python_script.rs +++ b/crates/socket-patch-core/src/utils/python_script.rs @@ -1,4 +1,5 @@ use std::ops::Range; +use std::path::{Component, Path, PathBuf}; use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, TableLike, Value}; @@ -383,6 +384,75 @@ 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"; +/// A uv settings file above the scanned root that uv applies to a PEP 723 +/// script there (#1501), found by [`find_ancestor_uv_config`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UvConfig { + /// The file relative to the scanned root, e.g. `../uv.toml`. + pub rel: String, + pub text: String, +} + +/// Whether uv takes a script's settings from `pyproject`: it has a +/// `[tool.uv]` table. One that does not parse counts, since uv stops its +/// lookup there and reports it. +fn pyproject_has_uv_settings(pyproject: &str) -> bool { + pyproject.parse::().map_or(true, |document| { + document + .get("tool") + .and_then(Item::as_table_like) + .and_then(|tool| tool.get("uv")) + .is_some() + }) +} + +/// The uv settings file above `root` that uv applies to a PEP 723 script +/// in `root`, when `root` itself holds none. +/// +/// uv reads a script's settings from the nearest directory at or above the +/// script's that holds a `uv.toml`, or a `pyproject.toml` with a `[tool.uv]` +/// table, with `uv.toml` first in each directory. A project stops that +/// lookup at itself, so only the script lane asks. A user-level `uv.toml` +/// (`~/.config/uv`) is the installing machine's and is not looked up. +pub fn find_ancestor_uv_config(root: &Path) -> Option { + for (rel, path) in ancestor_uv_config_files(root) { + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + if rel.ends_with(&format!("/{UV_TOML}")) || pyproject_has_uv_settings(&text) { + return Some(UvConfig { rel, text }); + } + } + None +} + +/// The files [`find_ancestor_uv_config`] may read, in lookup order, as +/// (path relative to `root`, absolute path). +pub fn ancestor_uv_config_files(root: &Path) -> Vec<(String, PathBuf)> { + let Ok(root) = std::path::absolute(root) else { + return Vec::new(); + }; + let mut normalized = PathBuf::new(); + for component in root.components() { + match component { + Component::ParentDir => { + normalized.pop(); + } + Component::CurDir => {} + other => normalized.push(other), + } + } + let mut up = String::new(); + let mut files = Vec::new(); + for dir in normalized.ancestors().skip(1) { + up.push_str("../"); + for name in [UV_TOML, "pyproject.toml"] { + files.push((format!("{up}{name}"), dir.join(name))); + } + } + files +} + /// Refuse to wire a `[tool.uv.sources]` entry for `name` that uv would /// ignore (#564). /// @@ -402,13 +472,62 @@ pub fn refuse_uv_no_sources( script: Option<&str>, 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_uv_no_sources`] for a PEP 723 script in a root whose own +/// `pyproject` and `uv_toml` may not govern it: when neither sets uv's +/// settings, `ancestor` (the [`find_ancestor_uv_config`] above the root) +/// does (#1501). +pub fn refuse_uv_script_no_sources( + name: &str, + script: &str, + pyproject: Option<&str>, + uv_toml: Option<&str>, + ancestor: Option<&UvConfig>, +) -> Result<(), String> { + let root_governs = uv_toml.is_some() || pyproject.is_some_and(pyproject_has_uv_settings); + match ancestor { + Some(config) if !root_governs => { + let project = if config.rel.ends_with(&format!("/{UV_TOML}")) { + (config.rel.clone(), config.text.as_str(), &[][..]) + } else { + ( + format!("{} [tool.uv]", config.rel), + config.text.as_str(), + &["tool", "uv"][..], + ) + }; + refuse_no_sources(name, Some(script), Some(project)) + } + _ => refuse_uv_no_sources(name, Some(script), pyproject, uv_toml), + } +} + +/// 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( + name: &str, + script: Option<&str>, + project: Option<(String, &str, &[&str])>, ) -> Result<(), String> { struct Settings { - file: &'static str, + file: String, no_sources: Option, packages: Vec, } - fn settings(file: &'static str, toml: &str, path: &[&str]) -> Option { + fn 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 { @@ -429,14 +548,13 @@ pub fn refuse_uv_no_sources( } let script = script.and_then(|text| { let (_, metadata) = script_metadata(text).ok()?; - settings("the script's [tool.uv]", &metadata, &["tool", "uv"]) + settings( + "the script's [tool.uv]".to_string(), + &metadata, + &["tool", "uv"], + ) }); - let project = match uv_toml { - Some(text) => settings(UV_TOML, text, &[]), - None => { - pyproject.and_then(|text| settings("pyproject.toml [tool.uv]", text, &["tool", "uv"])) - } - }; + 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| { @@ -448,7 +566,7 @@ pub fn refuse_uv_no_sources( }; match levels .clone() - .find_map(|level| Some((level.file, level.no_sources?))) + .find_map(|level| Some((level.file.as_str(), level.no_sources?))) { Some((file, true)) => return refusal(file, "no-sources = true"), Some((_, false)) => return Ok(()), @@ -459,7 +577,7 @@ pub fn refuse_uv_no_sources( .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!("no-sources-package naming {name}")), None => Ok(()), } } @@ -1192,3 +1310,95 @@ mod rendering_tests { } } } + +#[cfg(test)] +mod ancestor_config_tests { + use super::*; + + const SCRIPT: &str = "# /// script\n# dependencies = [\"six==1.16.0\"]\n# ///\n"; + const PYPROJECT: &str = "[project]\nname = \"root\"\nversion = \"0\"\n"; + + /// #1501: uv takes a script's settings from the nearest directory above + /// it that holds a uv.toml, or a pyproject.toml with `[tool.uv]`; uv.toml + /// first in each directory, and a pyproject without `[tool.uv]` skipped. + #[test] + fn find_ancestor_uv_config_follows_uvs_lookup() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("a/b/scripts"); + std::fs::create_dir_all(&root).unwrap(); + // Beside the root: never an ancestor. + std::fs::write(root.join(UV_TOML), "no-sources = true\n").unwrap(); + assert_eq!(find_ancestor_uv_config(&root), None); + + // A pyproject without [tool.uv] does not stop the walk. + std::fs::write(temp.path().join("a/b/pyproject.toml"), PYPROJECT).unwrap(); + let with_uv = format!("{PYPROJECT}[tool.uv]\nno-sources = true\n"); + std::fs::write(temp.path().join("a/pyproject.toml"), &with_uv).unwrap(); + assert_eq!( + find_ancestor_uv_config(&root), + Some(UvConfig { + rel: "../../pyproject.toml".into(), + text: with_uv.clone(), + }) + ); + // uv.toml wins over pyproject.toml in the same directory, and a + // `..` in the root is resolved before walking. + std::fs::write(temp.path().join("a/uv.toml"), "offline = true\n").unwrap(); + assert_eq!( + find_ancestor_uv_config(&root.join("../scripts")), + Some(UvConfig { + rel: "../../uv.toml".into(), + text: "offline = true\n".into(), + }) + ); + } + + #[test] + fn script_takes_an_ancestor_only_when_the_root_sets_nothing() { + let ancestor = |rel: &str, text: &str| UvConfig { + rel: rel.into(), + text: text.into(), + }; + let parent_uv = ancestor("../uv.toml", "no-sources = true\n"); + let error = + refuse_uv_script_no_sources("six", SCRIPT, None, None, Some(&parent_uv)).unwrap_err(); + assert!( + error.starts_with("../uv.toml sets no-sources = true"), + "{error}" + ); + let parent_pyproject = ancestor( + "../pyproject.toml", + "[tool.uv]\nno-sources-package = [\"Six\"]\n", + ); + let error = refuse_uv_script_no_sources( + "six", + SCRIPT, + Some(PYPROJECT), + None, + Some(&parent_pyproject), + ) + .unwrap_err(); + assert!( + error.starts_with("../pyproject.toml [tool.uv] sets no-sources-package naming six"), + "{error}" + ); + + // The root's own settings stop uv's lookup at the root. + let root_uv = format!("{PYPROJECT}[tool.uv]\noffline = true\n"); + assert_eq!( + refuse_uv_script_no_sources("six", SCRIPT, Some(&root_uv), None, Some(&parent_uv)), + Ok(()) + ); + assert_eq!( + refuse_uv_script_no_sources("six", SCRIPT, None, Some(""), Some(&parent_uv)), + Ok(()) + ); + // The script's own setting still wins. + let script_false = + SCRIPT.replace("# ///\n", "#\n# [tool.uv]\n# no-sources = false\n# ///\n"); + assert_eq!( + refuse_uv_script_no_sources("six", &script_false, None, None, Some(&parent_uv)), + Ok(()) + ); + } +} diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index f4672f146..aed1f4c77 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -10,7 +10,8 @@ use crate::utils::python_lock::{ is_python_lock_name, python_lock_paths, rewrite_python_lock, script_of_lock, ArtifactSource, }; use crate::utils::python_script::{ - refuse_uv_no_sources, replace_script_metadata, rewrite_script_metadata, script_metadata, + find_ancestor_uv_config, refuse_uv_script_no_sources, replace_script_metadata, + rewrite_script_metadata, script_metadata, UvConfig, }; use crate::patch::redirect::upstream::{respell_lock_specifier, LockRequirementArray}; @@ -183,7 +184,7 @@ async fn load_listed_locks( 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; + let mut project_config: Option<(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( @@ -246,20 +247,24 @@ 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) = match &project_config { + let (pyproject, uv_toml, ancestor) = 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, + // uv applies a parent's settings to the script when + // the root holds none (#1501). + find_ancestor_uv_config(root), )), }; - refuse_uv_no_sources( + refuse_uv_script_no_sources( name, - Some(&script), + &script, pyproject.as_deref(), uv_toml.as_deref(), + ancestor.as_ref(), ) .map_err(|detail| ("pypi_uv_no_sources", format!("{script_name}: {detail}")))?; let script_wheel = source_path(package).unwrap_or(&placeholder); @@ -1496,6 +1501,41 @@ mod tests { } } + /// #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. + #[tokio::test] + async fn script_no_sources_in_a_parent_refuses_before_writing() { + let lock = "version = 1\n[[package]]\nname = \"two\"\nversion = \"2\"\nsource = {registry = \"https://pypi.org/simple\"}\n"; + let script = "# /// script\n# dependencies = [\"two==2\"]\n# ///\n"; + for (file, config) in [ + ("uv.toml", "no-sources = true\n"), + ("pyproject.toml", "[tool.uv]\nno-sources = true\n"), + ] { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("scripts"); + tokio::fs::create_dir(&root).await.unwrap(); + tokio::fs::write(temp.path().join(file), config) + .await + .unwrap(); + // A root pyproject without [tool.uv] does not stop uv's lookup. + tokio::fs::write(root.join("pyproject.toml"), "[project]\nname = \"s\"\n") + .await + .unwrap(); + tokio::fs::write(root.join("job.py.lock"), lock) + .await + .unwrap(); + tokio::fs::write(root.join("job.py"), script).await.unwrap(); + let Err(error) = + load_python_locks(&root, "two", "2", "11111111-1111-4111-8111-111111111111").await + else { + panic!("a parent {file} must refuse"); + }; + assert_eq!(error.0, "pypi_uv_no_sources", "{}", error.1); + assert!(error.1.contains(&format!("../{file}")), "{}", error.1); + } + } + #[tokio::test] async fn script_global_sources_cannot_replace_multiple_versions() { let temp = tempfile::tempdir().unwrap(); From c81db9328101d3979105b7912ca65721319509f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 20:45:51 +0000 Subject: [PATCH 3/6] Document the uv script parent config lookup Note in the #564 bullet that a script's project settings come from the nearest uv.toml or [tool.uv] at or above it (#1501). Assisted-by: Claude Code:claude-opus-5-5 --- docs/testing/uv-compatibility.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/testing/uv-compatibility.md b/docs/testing/uv-compatibility.md index 39a38afff..501513222 100644 --- a/docs/testing/uv-compatibility.md +++ b/docs/testing/uv-compatibility.md @@ -185,7 +185,11 @@ frozen, locked, and ordinary installation outcomes separately where supported. 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 + disables the list. A script's project settings come from the nearest + directory at or above it holding a `uv.toml` or a `pyproject.toml` with + `[tool.uv]` (`uv.toml` first), so a script scanned from `scripts/` under + a project that sets `no-sources` is refused too, with the parent file + named (#1501); a project stops that lookup at itself. `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 From 4b4010969cca68e6efaa8ea2e3eb6aff614861c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 20:53:25 +0000 Subject: [PATCH 4/6] Read parent uv settings safely and only if needed The parent lookup now skips the walk when the scanned root's own uv.toml or [tool.uv] already governs the script, opens each file through the FIFO-safe regular-file reader, and stops at a settings file that exists but cannot be read, the way uv does, instead of going on to a farther one. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/hosted/engine.rs | 6 ++++ .../src/utils/python_script.rs | 35 +++++++++++++++---- .../socket-patch-core/src/vendor/pypi_lock.rs | 17 +++++---- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 624600644..ccc0ec501 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -720,6 +720,12 @@ pub async fn read_candidate_files( .files .keys() .any(|path| crate::utils::python_lock::is_script_lock_name(path)) + && !crate::utils::python_script::root_governs_script( + out.files.get("pyproject.toml").map(String::as_str), + out.files + .get(crate::utils::python_script::UV_TOML) + .map(String::as_str), + ) { let files = view .disk_root_reading(std::iter::empty::<&std::path::Path>()) diff --git a/crates/socket-patch-core/src/utils/python_script.rs b/crates/socket-patch-core/src/utils/python_script.rs index ef6ec396b..83ca53758 100644 --- a/crates/socket-patch-core/src/utils/python_script.rs +++ b/crates/socket-patch-core/src/utils/python_script.rs @@ -393,6 +393,20 @@ pub struct UvConfig { pub text: String, } +impl UvConfig { + /// Whether the file is a `uv.toml` (settings at its top level) rather + /// than a `pyproject.toml` (settings under `[tool.uv]`). + pub fn is_uv_toml(&self) -> bool { + Path::new(&self.rel).file_name() == Some(UV_TOML.as_ref()) + } +} + +/// Whether a PEP 723 script in a root holding `pyproject` and `uv_toml` +/// takes uv's settings from the root itself, so uv looks no further up. +pub fn root_governs_script(pyproject: Option<&str>, uv_toml: Option<&str>) -> bool { + uv_toml.is_some() || pyproject.is_some_and(pyproject_has_uv_settings) +} + /// Whether uv takes a script's settings from `pyproject`: it has a /// `[tool.uv]` table. One that does not parse counts, since uv stops its /// lookup there and reports it. @@ -416,11 +430,15 @@ fn pyproject_has_uv_settings(pyproject: &str) -> bool { /// (`~/.config/uv`) is the installing machine's and is not looked up. pub fn find_ancestor_uv_config(root: &Path) -> Option { for (rel, path) in ancestor_uv_config_files(root) { - let Ok(text) = std::fs::read_to_string(&path) else { - continue; + let text = match crate::utils::fs::read_regular_to_string_sync(&path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + // uv stops at a settings file it cannot read and reports it. + Err(_) => return None, }; - if rel.ends_with(&format!("/{UV_TOML}")) || pyproject_has_uv_settings(&text) { - return Some(UvConfig { rel, text }); + let config = UvConfig { rel, text }; + if config.is_uv_toml() || pyproject_has_uv_settings(&config.text) { + return Some(config); } } None @@ -497,10 +515,9 @@ pub fn refuse_uv_script_no_sources( uv_toml: Option<&str>, ancestor: Option<&UvConfig>, ) -> Result<(), String> { - let root_governs = uv_toml.is_some() || pyproject.is_some_and(pyproject_has_uv_settings); match ancestor { - Some(config) if !root_governs => { - let project = if config.rel.ends_with(&format!("/{UV_TOML}")) { + Some(config) if !root_governs_script(pyproject, uv_toml) => { + let project = if config.is_uv_toml() { (config.rel.clone(), config.text.as_str(), &[][..]) } else { ( @@ -1351,6 +1368,10 @@ mod ancestor_config_tests { text: "offline = true\n".into(), }) ); + // uv stops at a settings file it cannot read (here a directory), + // so the farther one is never reached. + std::fs::create_dir(temp.path().join("a/b/uv.toml")).unwrap(); + assert_eq!(find_ancestor_uv_config(&root), None); } #[test] diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index aed1f4c77..965903f0b 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -11,7 +11,7 @@ use crate::utils::python_lock::{ }; use crate::utils::python_script::{ find_ancestor_uv_config, refuse_uv_script_no_sources, replace_script_metadata, - rewrite_script_metadata, script_metadata, UvConfig, + rewrite_script_metadata, root_governs_script, script_metadata, UvConfig, }; use crate::patch::redirect::upstream::{respell_lock_specifier, LockRequirementArray}; @@ -249,15 +249,18 @@ async fn load_listed_locks( let script = read_file(&root.join(script_name)).await?; let (pyproject, uv_toml, ancestor) = match &project_config { Some(config) => config, - None => project_config.insert(( - read_regular_to_string(&root.join("pyproject.toml")) + None => { + let pyproject = read_regular_to_string(&root.join("pyproject.toml")) .await - .ok(), - super::pypi_uv::read_uv_toml(root).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). - find_ancestor_uv_config(root), - )), + 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)) + } }; refuse_uv_script_no_sources( name, From 4715dfba3708ecd85c8ae24b1fc0f1d49562d3c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 21:49:40 +0000 Subject: [PATCH 5/6] Walk past an unparseable pyproject like uv uv warns about a pyproject.toml it cannot parse during settings discovery and goes on to the parent directory. The script lookup treated such a file as governing, so a broken pyproject beside the script or in between hid a parent uv.toml that sets no-sources, and the script was wired anyway. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/utils/python_script.rs | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-core/src/utils/python_script.rs b/crates/socket-patch-core/src/utils/python_script.rs index 83ca53758..548c3f118 100644 --- a/crates/socket-patch-core/src/utils/python_script.rs +++ b/crates/socket-patch-core/src/utils/python_script.rs @@ -408,10 +408,10 @@ pub fn root_governs_script(pyproject: Option<&str>, uv_toml: Option<&str>) -> bo } /// Whether uv takes a script's settings from `pyproject`: it has a -/// `[tool.uv]` table. One that does not parse counts, since uv stops its -/// lookup there and reports it. +/// `[tool.uv]` table. One that does not parse does not: uv warns and goes +/// on to the parent directory. fn pyproject_has_uv_settings(pyproject: &str) -> bool { - pyproject.parse::().map_or(true, |document| { + pyproject.parse::().is_ok_and(|document| { document .get("tool") .and_then(Item::as_table_like) @@ -1368,6 +1368,13 @@ mod ancestor_config_tests { text: "offline = true\n".into(), }) ); + // A pyproject that does not parse is skipped, as uv warns and walks + // on to the parent. + std::fs::write(temp.path().join("a/b/pyproject.toml"), "[project\n").unwrap(); + assert_eq!( + find_ancestor_uv_config(&root.join("../scripts")).map(|config| config.rel), + Some("../../uv.toml".into()) + ); // uv stops at a settings file it cannot read (here a directory), // so the farther one is never reached. std::fs::create_dir(temp.path().join("a/b/uv.toml")).unwrap(); @@ -1404,6 +1411,16 @@ mod ancestor_config_tests { "{error}" ); + // A root pyproject that does not parse does not govern either. + assert!(refuse_uv_script_no_sources( + "six", + SCRIPT, + Some("[project\n"), + None, + Some(&parent_uv) + ) + .is_err()); + // The root's own settings stop uv's lookup at the root. let root_uv = format!("{PYPROJECT}[tool.uv]\noffline = true\n"); assert_eq!( From 11a9e45e37fde507395d2723424120b6da45c5e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 22:41:07 +0000 Subject: [PATCH 6/6] Test a workspace member script stays unwired In a uv workspace, uv takes a member's script settings from the workspace root, so a member's own [tool.uv] does not shield the script from the root's no-sources. The #1138 workspace-member guard already refuses that run in both modes before the script lookup; this pins it. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/mode_migration_pypi.rs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 6264e6d0a..a005049ae 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -922,6 +922,47 @@ async fn uv_script_no_sources_in_a_parent_is_refused_in_both_modes() { } } +/// In a uv workspace, uv takes a member's script settings from the +/// workspace root, so a member's own `[tool.uv]` does not shield the script +/// from the root's `no-sources`. The #1138 workspace-member guard refuses +/// the run before the script lane's own lookup, in both modes. +#[tokio::test] +async fn uv_script_in_a_workspace_member_is_not_wired() { + let (_tmp, root) = project(); + stage_script_lock(&root); + std::fs::write( + root.join("pyproject.toml"), + "[project]\nname = \"s\"\nversion = \"0.1.0\"\n\n[tool.uv]\npackage = false\n", + ) + .unwrap(); + std::fs::write( + root.parent().unwrap().join("pyproject.toml"), + "[project]\nname = \"b\"\nversion = \"0.1.0\"\n\n[tool.uv.workspace]\nmembers = [\"proj\"]\n\n[tool.uv]\nno-sources = true\n", + ) + .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, 1, "hosted: {env:#}"); + assert_eq!( + env["error"]["code"], "redirect_workspace_lockfile_elsewhere", + "hosted: {env:#}" + ); + assert_eq!(tree(&root), before, "hosted wrote nothing"); + + 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"); +} + /// A Hatch project; returns its wiring files. fn stage_hatch(root: &Path) -> &'static [&'static str] { std::fs::write(