diff --git a/crates/uv-requirements/src/specification.rs b/crates/uv-requirements/src/specification.rs index 067f3b903..9934209fd 100644 --- a/crates/uv-requirements/src/specification.rs +++ b/crates/uv-requirements/src/specification.rs @@ -730,6 +730,32 @@ impl RequirementsSpecification { } } + /// Initialize a [`RequirementsSpecification`] from a list of [`Requirement`], including + /// constraints, overrides, and excludes. + pub fn from_excludes( + requirements: Vec, + constraints: Vec, + overrides: Vec, + excludes: Vec, + ) -> Self { + Self { + requirements: requirements + .into_iter() + .map(UnresolvedRequirementSpecification::from) + .collect(), + constraints: constraints + .into_iter() + .map(NameRequirementSpecification::from) + .collect(), + overrides: overrides + .into_iter() + .map(UnresolvedRequirementSpecification::from) + .collect(), + excludes, + ..Self::default() + } + } + /// Return true if the specification does not include any requirements to install. pub fn is_empty(&self) -> bool { self.requirements.is_empty() && self.source_trees.is_empty() && self.overrides.is_empty() diff --git a/crates/uv-tool/src/tool.rs b/crates/uv-tool/src/tool.rs index 0fbf59e22..e61d4bc6d 100644 --- a/crates/uv-tool/src/tool.rs +++ b/crates/uv-tool/src/tool.rs @@ -6,6 +6,7 @@ use toml_edit::{Array, Item, Table, Value, value}; use uv_distribution_types::Requirement; use uv_fs::{PortablePath, Simplified}; +use uv_normalize::PackageName; use uv_pypi_types::VerbatimParsedUrl; use uv_python::PythonRequest; use uv_settings::ToolOptions; @@ -20,6 +21,8 @@ pub struct Tool { constraints: Vec, /// The overrides requested by the user during installation. overrides: Vec, + /// The excludes requested by the user during installation. + excludes: Vec, /// The build constraints requested by the user during installation. build_constraints: Vec, /// The Python requested by the user during installation. @@ -40,6 +43,8 @@ struct ToolWire { #[serde(default)] overrides: Vec, #[serde(default)] + excludes: Vec, + #[serde(default)] build_constraint_dependencies: Vec, python: Option, entrypoints: Vec, @@ -67,6 +72,7 @@ impl From for ToolWire { .collect(), constraints: tool.constraints, overrides: tool.overrides, + excludes: tool.excludes, build_constraint_dependencies: tool.build_constraints, python: tool.python, entrypoints: tool.entrypoints, @@ -90,6 +96,7 @@ impl TryFrom for Tool { .collect(), constraints: tool.constraints, overrides: tool.overrides, + excludes: tool.excludes, build_constraints: tool.build_constraint_dependencies, python: tool.python, entrypoints: tool.entrypoints, @@ -165,6 +172,7 @@ impl Tool { requirements: Vec, constraints: Vec, overrides: Vec, + excludes: Vec, build_constraints: Vec, python: Option, entrypoints: impl IntoIterator, @@ -176,6 +184,7 @@ impl Tool { requirements, constraints, overrides, + excludes, build_constraints, python, entrypoints, @@ -259,6 +268,28 @@ impl Tool { }); } + if !self.excludes.is_empty() { + table.insert("excludes", { + let excludes = self + .excludes + .iter() + .map(|r#exclude| { + serde::Serialize::serialize( + &r#exclude, + toml_edit::ser::ValueSerializer::new(), + ) + }) + .collect::, _>>()?; + + let excludes = match excludes.as_slice() { + [] => Array::new(), + [r#exclude] => Array::from_iter([r#exclude]), + excludes => each_element_on_its_line_array(excludes.iter()), + }; + value(excludes) + }); + } + if !self.build_constraints.is_empty() { table.insert("build-constraint-dependencies", { let build_constraints = self @@ -331,6 +362,10 @@ impl Tool { &self.overrides } + pub fn excludes(&self) -> &[PackageName] { + &self.excludes + } + pub fn build_constraints(&self) -> &[Requirement] { &self.build_constraints } diff --git a/crates/uv/src/commands/tool/common.rs b/crates/uv/src/commands/tool/common.rs index 8368548a9..8ba1ee89e 100644 --- a/crates/uv/src/commands/tool/common.rs +++ b/crates/uv/src/commands/tool/common.rs @@ -180,6 +180,7 @@ pub(crate) fn finalize_tool_install( requirements: Vec, constraints: Vec, overrides: Vec, + excludes: Vec, build_constraints: Vec, printer: Printer, ) -> anyhow::Result<()> { @@ -334,6 +335,7 @@ pub(crate) fn finalize_tool_install( requirements, constraints, overrides, + excludes, build_constraints, python, installed_entrypoints, diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index a9b0ba40f..1636ac0d8 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -396,6 +396,9 @@ pub(crate) async fn install( ) .await?; + // Resolve the excludes. + let excludes = spec.excludes.clone(); + // Resolve the build constraints. let build_constraints: Vec = operations::read_constraints(build_constraints, &client_builder) @@ -753,6 +756,7 @@ pub(crate) async fn install( requirements, constraints, overrides, + excludes, build_constraints, printer, )?; diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 1137fc3ec..799ce6cc1 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -323,7 +323,7 @@ async fn upgrade_tool( Constraints::from_requirements(existing_tool_receipt.build_constraints().iter().cloned()); // Resolve the requirements. - let spec = RequirementsSpecification::from_overrides( + let spec = RequirementsSpecification::from_excludes( existing_tool_receipt.requirements().to_vec(), existing_tool_receipt .constraints() @@ -332,6 +332,7 @@ async fn upgrade_tool( .cloned() .collect(), existing_tool_receipt.overrides().to_vec(), + existing_tool_receipt.excludes().to_vec(), ); // Initialize any shared state. @@ -446,6 +447,7 @@ async fn upgrade_tool( existing_tool_receipt.requirements().to_vec(), existing_tool_receipt.constraints().to_vec(), existing_tool_receipt.overrides().to_vec(), + existing_tool_receipt.excludes().to_vec(), existing_tool_receipt.build_constraints().to_vec(), printer, )?; diff --git a/crates/uv/tests/it/tool_upgrade.rs b/crates/uv/tests/it/tool_upgrade.rs index 3e3137b8c..2d42c0ab5 100644 --- a/crates/uv/tests/it/tool_upgrade.rs +++ b/crates/uv/tests/it/tool_upgrade.rs @@ -1010,6 +1010,66 @@ fn test_tool_upgrade_additional_entrypoints() { "); } +/// Upgrade a tool with an excluded dependency. +/// +/// Compare with `tool_upgrade_respect_constraints`, which shows `pytz` being +/// upgraded alongside `babel`. Here, `pytz` is excluded, so it should remain +/// absent after the upgrade. +#[test] +fn tool_upgrade_excludes() { + let context = uv_test::test_context!("3.12") + .with_filtered_counts() + .with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + let excludes_txt = context.temp_dir.child("excludes.txt"); + excludes_txt.write_str("pytz").unwrap(); + + // Install `babel` from Test PyPI, to get an outdated version. + // `pytz` is excluded, so it won't be installed despite being a dependency. + uv_snapshot!(context.filters(), context.tool_install() + .arg("babel<2.10") + .arg("--excludes") + .arg("excludes.txt") + .arg("--index-url") + .arg("https://test.pypi.org/simple/") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + babel==2.6.0 + Installed 1 executable: pybabel + "); + + // Upgrade `babel` from PyPI. Babel should be updated (within the `<2.10` + // constraint), but `pytz` should remain excluded. + uv_snapshot!(context.filters(), context.tool_upgrade() + .arg("babel") + .arg("--index-url") + .arg("https://pypi.org/simple/") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Updated babel v2.6.0 -> v2.9.1 + - babel==2.6.0 + + babel==2.9.1 + Installed 1 executable: pybabel + "); +} + /// When upgrading a tool from an authenticated index with invalid credentials, /// the command should fail with an auth error rather than silently reporting /// "Nothing to upgrade".