Prevent uv tool upgrade from installing excluded dependencies (#18022)

## Summary

<!-- What's the purpose of the change? What does it do, and why? -->

Fixes #18021

Store dependencies excluded by uv tool install in the tool receipt and
provide these same dependencies to the requirements resolver when the
tool is upgraded.

## Test Plan

<!-- How was it tested? -->

Running the repro commands provided in issue #18021, we can see the
excluded dependency does not get reinstalled when the tool is upgraded:

```sh
$ cat /tmp/excludes.txt
markdown-it-py

$ ~/Dev/uv/target/debug/uv install --excludes /tmp/excludes.txt 2048-cli==1.0.2
Resolved 7 packages in 150ms
Installed 7 packages in 56ms
 + 2048-cli==1.0.2
 + click==8.3.1
 + maturin==1.12.0
 + numpy==2.4.2
 + pygments==2.19.2
 + rich==13.9.4
 + rich-menu==0.3.0
Installed 1 executable: 2048-cli

$ cat ~/.local/share/uv/tools/2048-cli/uv-receipt.toml
[tool]
requirements = [{ name = "2048-cli" }]
excludes = ["markdown-it-py"]
entrypoints = [
    { name = "2048-cli", install-path = "/home/brad/.local/bin/2048-cli", from = "2048-cli" },
]

$ ~/Dev/uv/target/debug/uv tool upgrade 2048-cli
Updated 2048-cli v1.0.2 -> v1.0.3
 - 2048-cli==1.0.2
 + 2048-cli==1.0.3
Installed 1 executable: 2048-cli
```

---------

Co-authored-by: Charlie Marsh <charlie.r.marsh@gmail.com>
This commit is contained in:
Brad Cowie
2026-03-04 06:06:35 +13:00
committed by GitHub
parent d426a2d568
commit 753ad06fe5
6 changed files with 130 additions and 1 deletions
@@ -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<Requirement>,
constraints: Vec<Requirement>,
overrides: Vec<Requirement>,
excludes: Vec<PackageName>,
) -> 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()
+35
View File
@@ -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<Requirement>,
/// The overrides requested by the user during installation.
overrides: Vec<Requirement>,
/// The excludes requested by the user during installation.
excludes: Vec<PackageName>,
/// The build constraints requested by the user during installation.
build_constraints: Vec<Requirement>,
/// The Python requested by the user during installation.
@@ -40,6 +43,8 @@ struct ToolWire {
#[serde(default)]
overrides: Vec<Requirement>,
#[serde(default)]
excludes: Vec<PackageName>,
#[serde(default)]
build_constraint_dependencies: Vec<Requirement>,
python: Option<PythonRequest>,
entrypoints: Vec<ToolEntrypoint>,
@@ -67,6 +72,7 @@ impl From<Tool> 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<ToolWire> 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<Requirement>,
constraints: Vec<Requirement>,
overrides: Vec<Requirement>,
excludes: Vec<PackageName>,
build_constraints: Vec<Requirement>,
python: Option<PythonRequest>,
entrypoints: impl IntoIterator<Item = ToolEntrypoint>,
@@ -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::<Result<Vec<_>, _>>()?;
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
}
+2
View File
@@ -180,6 +180,7 @@ pub(crate) fn finalize_tool_install(
requirements: Vec<Requirement>,
constraints: Vec<Requirement>,
overrides: Vec<Requirement>,
excludes: Vec<PackageName>,
build_constraints: Vec<Requirement>,
printer: Printer,
) -> anyhow::Result<()> {
@@ -334,6 +335,7 @@ pub(crate) fn finalize_tool_install(
requirements,
constraints,
overrides,
excludes,
build_constraints,
python,
installed_entrypoints,
+4
View File
@@ -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<Requirement> =
operations::read_constraints(build_constraints, &client_builder)
@@ -753,6 +756,7 @@ pub(crate) async fn install(
requirements,
constraints,
overrides,
excludes,
build_constraints,
printer,
)?;
+3 -1
View File
@@ -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,
)?;
+60
View File
@@ -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".