diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index a005049ae..7618c5653 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -922,6 +922,80 @@ async fn uv_script_no_sources_in_a_parent_is_refused_in_both_modes() { } } +/// #1545: a uv project whose own pyproject has no `[tool.uv]` (and no +/// `uv.toml` beside it) takes uv's settings from a parent `uv.toml` or +/// `[tool.uv]`. The `[tool.uv]` table a wired source adds would shadow +/// that parent, dropping its `resolution` / `exclude-newer`, so the +/// lock written with them fails `uv sync --locked`. Both modes used to +/// wire it and exit 0; they must refuse naming the parent and write +/// nothing. A parent holding only a workspace root's own metadata sets +/// nothing the project inherits, and the project is still wired. +#[tokio::test] +async fn uv_project_under_parent_settings_is_refused_in_both_modes() { + let parent_pyproject = "[project]\nname = \"mono\"\nversion = \"0.1.0\"\n"; + for (file, setting, refused) in [ + ( + "uv.toml", + "resolution = \"lowest-direct\"\n".to_string(), + true, + ), + ( + "pyproject.toml", + format!("{parent_pyproject}\n[tool.uv]\nexclude-newer = \"2024-01-01T00:00:00Z\"\n"), + true, + ), + ( + "pyproject.toml", + format!("{parent_pyproject}\n[tool.uv.workspace]\nmembers = []\n"), + false, + ), + ] { + let (_tmp, root) = project(); + stage_uv(&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_project_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:#}"); + // Vendored: a fresh project, so hosted's wiring is not taken over. + let (_tmp, root) = project(); + stage_uv(&root); + std::fs::write(root.parent().unwrap().join(file), &setting).unwrap(); + stage_manifest(&root); + let (code, env) = run_cli(&root, &["vendor"], &[]); + assert_eq!(code, 0, "vendored {file}: {env:#}"); + assert!( + !env.to_string().contains("pypi_uv_ancestor_settings"), + "vendored {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_ancestor_settings") + && env.to_string().contains(&format!("../{file}")), + "vendored {file}: {env:#}" + ); + assert_eq!(tree(&root), before, "vendored {file} wrote nothing"); + } +} + /// 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 diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index f443bc187..76e7f802a 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -704,22 +704,20 @@ pub async fn read_candidate_files( // `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 + let uv_lane = out .files .keys() - .any(|path| path == "uv.lock" || crate::utils::python_lock::is_script_lock_name(path)) - { + .any(|path| path == "uv.lock" || crate::utils::python_lock::is_script_lock_name(path)); + if uv_lane { let rel = crate::utils::python_script::UV_TOML; if let Some(text) = read_advisory(view, unreadable, rel).await { out.files.insert(rel.to_string(), text); } } - // 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)) + // A PEP 723 script (#1501) or a project (#1545) also takes uv's + // settings from above the root when the root holds none; only a disk + // view has an above. + if uv_lane && !crate::utils::python_script::root_governs_script( out.files.get("pyproject.toml").map(String::as_str), out.files diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index d58fe30a8..8e060bd9c 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -5928,14 +5928,14 @@ fn plan_python_metadata_with( }; source_scope().map_err(unsupported)?; let project_file = |rel: &str| files.get(rel).map(String::as_str); + 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(), + }) + }); 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, @@ -5950,6 +5950,13 @@ fn plan_python_metadata_with( Some(&original), project_file(crate::utils::python_script::UV_TOML), ) + .and_then(|()| { + crate::utils::python_script::refuse_uv_project_shadowing( + &original, + project_file(crate::utils::python_script::UV_TOML), + ancestor.as_ref(), + ) + }) } .map_err(unsupported)?; let rewritten = if script { @@ -6343,7 +6350,8 @@ 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 +/// root that uv applies to a PEP 723 script (#1501) or a project (#1545) +/// there, 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`]). @@ -25995,8 +26003,7 @@ 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. + /// [`UV_ANCESTOR_CONFIG_KEY`]. The root's own settings stop the lookup. #[test] fn no_sources_above_the_root_refuses_a_script() { let parent = |rel: &str, text: &str| format!("{rel}\n{text}"); @@ -26050,11 +26057,59 @@ mod python_metadata_pairing_tests { wired( "uv.lock", &[ - ("pyproject.toml", PYPROJECT), + ("pyproject.toml", &format!("{PYPROJECT}[tool.uv]\n")), (UV_ANCESTOR_CONFIG_KEY, &uv_toml), ], ); } + + /// #1545: a project whose root sets no uv settings takes them from the + /// parent file the engine hands over; the `[tool.uv]` table the rewrite + /// adds would shadow it, so the project lane refuses before writing. + #[test] + fn ancestor_settings_refuse_a_project_that_would_shadow_them() { + let parent = |rel: &str, text: &str| format!("{rel}\n{text}"); + for (ancestor, named) in [ + ( + parent("../uv.toml", "resolution = \"lowest-direct\"\n"), + "../uv.toml sets resolution", + ), + ( + parent( + "../pyproject.toml", + "[tool.uv]\nexclude-newer = \"2024-01-01T00:00:00Z\"\n", + ), + "../pyproject.toml [tool.uv] sets exclude-newer", + ), + ] { + let Err(warning) = plan( + "uv.lock", + &[ + ("pyproject.toml", PYPROJECT), + (UV_ANCESTOR_CONFIG_KEY, &ancestor), + ], + &[], + ) else { + panic!("{ancestor} must refuse"); + }; + assert_eq!(warning.code, "redirect_uv_project_unsupported"); + assert!(warning.detail.contains(named), "{}", warning.detail); + } + // A parent workspace root's own metadata is not inherited. + let (edit, _) = plan( + "uv.lock", + &[ + ("pyproject.toml", PYPROJECT), + ( + UV_ANCESTOR_CONFIG_KEY, + &parent("../pyproject.toml", "[tool.uv.workspace]\nmembers = []\n"), + ), + ], + &[], + ) + .unwrap_or_else(|w| panic!("{}: {}", w.code, w.detail)); + assert!(edit.is_some()); + } } #[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 548c3f118..68ed68494 100644 --- a/crates/socket-patch-core/src/utils/python_script.rs +++ b/crates/socket-patch-core/src/utils/python_script.rs @@ -385,7 +385,8 @@ fn contains_dependency(item: Option<&Item>, name: &str) -> bool { pub const UV_TOML: &str = "uv.toml"; /// A uv settings file above the scanned root that uv applies to a PEP 723 -/// script there (#1501), found by [`find_ancestor_uv_config`]. +/// script (#1501) or a project (#1545) there, 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`. @@ -399,10 +400,21 @@ impl UvConfig { pub fn is_uv_toml(&self) -> bool { Path::new(&self.rel).file_name() == Some(UV_TOML.as_ref()) } + + /// The file as refusals name it, with the settings table it holds: + /// (label, path of the table within the file). + fn settings_location(&self) -> (String, &'static [&'static str]) { + if self.is_uv_toml() { + (self.rel.clone(), &[]) + } else { + (format!("{} [tool.uv]", self.rel), &["tool", "uv"]) + } + } } -/// 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. +/// Whether a PEP 723 script or a project 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) } @@ -421,12 +433,12 @@ fn pyproject_has_uv_settings(pyproject: &str) -> bool { } /// The uv settings file above `root` that uv applies to a PEP 723 script -/// in `root`, when `root` itself holds none. +/// or a project 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` +/// uv reads a script's or a project's settings from the nearest directory +/// at or above its own that holds a `uv.toml`, or a `pyproject.toml` with +/// a `[tool.uv]` table, with `uv.toml` first in each directory. 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) { @@ -444,6 +456,82 @@ pub fn find_ancestor_uv_config(root: &Path) -> Option { None } +/// `[tool.uv]` keys that describe the project declaring them rather than +/// settings another project inherits: uv documents them as project +/// metadata, read from the project's (or its workspace root's) own +/// pyproject. Checked against uv 0.11: a parent's `override-dependencies` +/// or `constraint-dependencies` never reaches a child project's lock. +const UV_PROJECT_ONLY_KEYS: &[&str] = &[ + "build-backend", + "build-constraint-dependencies", + "conflicts", + "constraint-dependencies", + "default-groups", + "dependency-groups", + "dev-dependencies", + "environments", + "exclude-dependencies", + "managed", + "override-dependencies", + "package", + "required-environments", + "sources", + "workspace", +]; + +/// Refuse to wire a project whose `pyproject` would shadow the uv settings +/// it inherits from `ancestor` (#1545). +/// +/// uv reads a project's settings from the nearest directory at or above it +/// that holds a `uv.toml` or a `pyproject.toml` with `[tool.uv]`, the same +/// lookup as a script's ([`find_ancestor_uv_config`]). When the project's +/// own `pyproject` and `uv_toml` set none, the `[tool.uv]` table a wired +/// source adds makes the project's pyproject the settings file, so every +/// setting of the parent (`exclude-newer`, `resolution`, indexes) is +/// dropped: `uv sync --locked` fails on those the lock records, and a +/// plain `uv lock` re-resolves without them. Every inherited setting is +/// refused, not only the recorded ones, since socket-patch must not +/// silently change how uv installs the project. A parent that sets only project metadata +/// ([`UV_PROJECT_ONLY_KEYS`], e.g. a workspace root's `workspace`), or +/// does not parse (uv reports it itself), loses nothing. +pub fn refuse_uv_project_shadowing( + pyproject: &str, + uv_toml: Option<&str>, + ancestor: Option<&UvConfig>, +) -> Result<(), String> { + let Some(config) = ancestor else { + return Ok(()); + }; + if root_governs_script(Some(pyproject), uv_toml) { + return Ok(()); + } + let Ok(document) = config.text.parse::() else { + return Ok(()); + }; + let (label, path) = config.settings_location(); + let mut settings = Some(document.as_table() as &dyn TableLike); + for key in path { + settings = settings + .and_then(|table| table.get(key)) + .and_then(Item::as_table_like); + } + let keys: Vec<&str> = settings + .into_iter() + .flat_map(|table| table.iter().map(|(key, _)| key)) + .filter(|key| !UV_PROJECT_ONLY_KEYS.contains(key)) + .collect(); + if keys.is_empty() { + return Ok(()); + } + Err(format!( + "{label} sets {} for this project, and the [tool.uv] table a wired source adds to \ + pyproject.toml would make uv read its settings from pyproject.toml instead and drop \ + them (a lock written with them then fails `uv sync --locked`); add a [tool.uv] table \ + to pyproject.toml carrying the settings the project needs to wire a patch", + keys.join(", ") + )) +} + /// 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)> { @@ -517,16 +605,12 @@ pub fn refuse_uv_script_no_sources( ) -> Result<(), String> { match ancestor { Some(config) if !root_governs_script(pyproject, uv_toml) => { - let project = if config.is_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)) + 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), } @@ -1439,4 +1523,77 @@ mod ancestor_config_tests { Ok(()) ); } + + /// #1545: a project whose root sets no uv settings takes a parent's + /// `uv.toml` / `[tool.uv]`, and the `[tool.uv]` table a patch adds + /// would make uv read the project's pyproject instead and drop them. + #[test] + fn project_refuses_to_shadow_an_ancestors_settings() { + let ancestor = |rel: &str, text: &str| UvConfig { + rel: rel.into(), + text: text.into(), + }; + let parent_uv = ancestor("../uv.toml", "resolution = \"lowest-direct\"\n"); + let error = refuse_uv_project_shadowing(PYPROJECT, None, Some(&parent_uv)).unwrap_err(); + assert!(error.starts_with("../uv.toml sets resolution"), "{error}"); + let parent_pyproject = ancestor( + "../pyproject.toml", + "[project]\nname = \"mono\"\n[tool.uv]\nexclude-newer = \"2024-01-01T00:00:00Z\"\n", + ); + let error = + refuse_uv_project_shadowing(PYPROJECT, None, Some(&parent_pyproject)).unwrap_err(); + assert!( + error.starts_with("../pyproject.toml [tool.uv] sets exclude-newer"), + "{error}" + ); + + // The root's own settings already stop uv's lookup at the root. + let root_uv = format!("{PYPROJECT}[tool.uv]\nmanaged = true\n"); + assert_eq!( + refuse_uv_project_shadowing(&root_uv, None, Some(&parent_uv)), + Ok(()) + ); + assert_eq!( + refuse_uv_project_shadowing(PYPROJECT, Some(""), Some(&parent_uv)), + Ok(()) + ); + assert_eq!(refuse_uv_project_shadowing(PYPROJECT, None, None), Ok(())); + // A parent that holds only another project's metadata (a workspace + // root, its sources) sets nothing this project inherits. + for text in [ + "[tool.uv]\n", + "[tool.uv.workspace]\nmembers = [\"libs/*\"]\n[tool.uv.sources]\nlib = { workspace = true }\n", + "[tool.uv]\nmanaged = true\npackage = false\ndev-dependencies = []\n", + "[tool.uv]\noverride-dependencies = [\"six<2\"]\nconstraint-dependencies = []\n", + ] { + assert_eq!( + refuse_uv_project_shadowing( + PYPROJECT, + None, + Some(&ancestor("../pyproject.toml", text)) + ), + Ok(()), + "{text}" + ); + } + // An index is a setting as well as project metadata. + assert!(refuse_uv_project_shadowing( + PYPROJECT, + None, + Some(&ancestor( + "../../uv.toml", + "[[index]]\nurl = \"https://mirror.test/simple\"\n" + )) + ) + .is_err()); + // A parent that does not parse sets nothing: uv reports it itself. + assert_eq!( + refuse_uv_project_shadowing( + PYPROJECT, + None, + Some(&ancestor("../uv.toml", "resolution =\n")) + ), + Ok(()) + ); + } } diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 5da3546b8..babe183f9 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -37,7 +37,10 @@ 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 crate::utils::python_script::{ + find_ancestor_uv_config, refuse_uv_no_sources, refuse_uv_project_shadowing, + root_governs_script, UvConfig, UV_TOML, +}; use super::common::{ ensure_unchanged, item_get, pep508_name, pyproject_dependency_specs, record, refuse_symlinked, @@ -96,6 +99,10 @@ pub(super) struct UvProject { pub lock_revision: Option, /// The project's `uv.toml`, for the no-sources check (#564). pub uv_toml: Option, + /// The uv settings file above the project that uv applies to it when + /// the project's own files set none, which a wired `[tool.uv]` would + /// shadow (#1545). + pub ancestor: Option, /// Non-fatal advisories raised during load (untested lock revision). pub warnings: Vec, } @@ -223,6 +230,9 @@ pub(super) async fn load_uv_project(root: &Path) -> Result Result