From fa2b0f86898845e8889e08449e22ce2210f1734b Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 30 Sep 2025 21:24:29 -0400 Subject: [PATCH 01/14] =?UTF-8?q?Surface=20pinned-version=20hint=20when=20?= =?UTF-8?q?`uv=20tool=20upgrade`=20can=E2=80=99t=20move=20the=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/uv/src/commands/tool/upgrade.rs | 92 ++++++++++++++++++++++---- crates/uv/tests/it/tool_upgrade.rs | 50 ++++++++++++++ 2 files changed, 129 insertions(+), 13 deletions(-) diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 7958316317e04..865f888715a1a 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -9,9 +9,10 @@ use tracing::{debug, trace}; use uv_cache::Cache; use uv_client::BaseClientBuilder; use uv_configuration::{Concurrency, Constraints, DryRun, TargetTriple}; -use uv_distribution_types::{ExtraBuildRequires, Requirement}; +use uv_distribution_types::{ExtraBuildRequires, Requirement, RequirementSource}; use uv_fs::CWD; use uv_normalize::PackageName; +use uv_pep440::{Operator, Version}; use uv_preview::Preview; use uv_python::{ EnvironmentPreference, Interpreter, PythonDownloads, PythonInstallation, PythonPreference, @@ -19,7 +20,7 @@ use uv_python::{ }; use uv_requirements::RequirementsSpecification; use uv_settings::{Combine, PythonInstallMirrors, ResolverInstallerOptions, ToolOptions}; -use uv_tool::InstalledTools; +use uv_tool::{InstalledTools, Tool}; use uv_warnings::write_error_chain; use uv_workspace::WorkspaceCache; @@ -114,6 +115,9 @@ pub(crate) async fn upgrade( // Determine whether we applied any upgrades. let mut did_upgrade_environment = vec![]; + // Track tools that could not be upgraded due to pinned versions. + let mut pinned = Vec::new(); + let mut errors = Vec::new(); for (name, constraints) in &names { debug!("Upgrading tool: `{name}`"); @@ -135,14 +139,26 @@ pub(crate) async fn upgrade( .await; match result { - Ok(UpgradeOutcome::UpgradeEnvironment) => { - did_upgrade_environment.push(name); - } - Ok(UpgradeOutcome::UpgradeDependencies | UpgradeOutcome::UpgradeTool) => { - did_upgrade_tool.push(name); - } - Ok(UpgradeOutcome::NoOp) => { - debug!("Upgrading `{name}` was a no-op"); + Ok((outcome, pinned_version)) => { + if let Some(version) = pinned_version.as_ref() { + debug!("`{name}` remains pinned to version {version}; skipping tool upgrade"); + } + + match outcome { + UpgradeOutcome::UpgradeEnvironment => { + did_upgrade_environment.push(name); + } + UpgradeOutcome::UpgradeDependencies | UpgradeOutcome::UpgradeTool => { + did_upgrade_tool.push(name); + } + UpgradeOutcome::NoOp => { + debug!("Upgrading `{name}` was a no-op"); + } + } + + if let Some(version) = pinned_version { + pinned.push((name.clone(), version)); + } } Err(err) => { errors.push((name, err)); @@ -187,9 +203,24 @@ pub(crate) async fn upgrade( } } + for (name, version) in pinned { + let name_str = name.to_string(); + + let version_str = version.to_string(); + + let reinstall_command = format!("uv tool install {}@latest", name_str); + + writeln!( + printer.stderr(), + "hint: `{}` is pinned to `{}` (installed with an exact version pin); reinstall with `{}` to pick up a newer release.", + name_str.cyan(), + version_str.magenta(), + reinstall_command.green(), + )?; + } + Ok(ExitStatus::Success) } - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum UpgradeOutcome { /// The tool itself was upgraded. @@ -217,7 +248,7 @@ async fn upgrade_tool( installer_metadata: bool, concurrency: Concurrency, preview: Preview, -) -> Result { +) -> Result<(UpgradeOutcome, Option)> { // Ensure the tool is installed. let existing_tool_receipt = match installed_tools.get_tool_receipt(name) { Ok(Some(receipt)) => receipt, @@ -398,5 +429,40 @@ async fn upgrade_tool( )?; } - Ok(outcome) + let pinned_version = match outcome { + UpgradeOutcome::UpgradeTool | UpgradeOutcome::UpgradeEnvironment => None, + UpgradeOutcome::UpgradeDependencies | UpgradeOutcome::NoOp => { + pinned_requirement_version(&existing_tool_receipt, name) + } + }; + + Ok((outcome, pinned_version)) +} + +fn pinned_requirement_version(tool: &Tool, name: &PackageName) -> Option { + pinned_version_from(tool.requirements(), name) + .or_else(|| pinned_version_from(tool.constraints(), name)) +} + +fn pinned_version_from(requirements: &[Requirement], name: &PackageName) -> Option { + requirements + .iter() + .filter(|requirement| requirement.name == *name) + .find_map(|requirement| match &requirement.source { + RequirementSource::Registry { specifier, .. } => { + let mut specifiers = specifier.iter(); + + let first = specifiers.next()?; + + if specifiers.next().is_some() { + return None; + } + + match first.operator() { + Operator::Equal | Operator::ExactEqual => Some(first.version().clone()), + _ => None, + } + } + _ => None, + }) } diff --git a/crates/uv/tests/it/tool_upgrade.rs b/crates/uv/tests/it/tool_upgrade.rs index a7e1f82e9898c..189ab846f0a5e 100644 --- a/crates/uv/tests/it/tool_upgrade.rs +++ b/crates/uv/tests/it/tool_upgrade.rs @@ -215,6 +215,56 @@ fn tool_upgrade_multiple_names() { "###); } +#[test] +fn tool_upgrade_pinned_hint() { + let context = TestContext::new("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"); + + // Install a specific version of `babel` so the receipt records an exact pin. + uv_snapshot!(context.filters(), context.tool_install() + .arg("babel==2.6.0") + .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()), @r###" + 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 + + pytz==2018.5 + Installed 1 executable: pybabel + "###); + + // Attempt to upgrade `babel`; it should remain pinned and emit a hint explaining why. + 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()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Modified babel environment + - pytz==2018.5 + + pytz==2024.1 + hint: `babel` is pinned to `2.6.0` (installed with an exact version pin); reinstall with `uv tool install babel@latest` to pick up a newer release. + "###); +} + #[test] fn tool_upgrade_all() { let context = TestContext::new("3.12") From fae19a587607e7acc89349c55ecefc9a3943719f Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 30 Sep 2025 21:28:00 -0400 Subject: [PATCH 02/14] Appease clippy --- crates/uv/src/commands/tool/upgrade.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 865f888715a1a..3b8692416ab55 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -208,7 +208,7 @@ pub(crate) async fn upgrade( let version_str = version.to_string(); - let reinstall_command = format!("uv tool install {}@latest", name_str); + let reinstall_command = format!("uv tool install {name_str}@latest"); writeln!( printer.stderr(), From 842e1edf6703f1d5b438c04ac86eef06436059c8 Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 30 Sep 2025 21:38:20 -0400 Subject: [PATCH 03/14] Update test --- crates/uv/tests/it/tool_upgrade.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/uv/tests/it/tool_upgrade.rs b/crates/uv/tests/it/tool_upgrade.rs index 189ab846f0a5e..9695bec749325 100644 --- a/crates/uv/tests/it/tool_upgrade.rs +++ b/crates/uv/tests/it/tool_upgrade.rs @@ -733,6 +733,7 @@ fn tool_upgrade_with() { Modified babel environment - pytz==2018.5 + pytz==2024.1 + hint: `babel` is pinned to `2.6.0` (installed with an exact version pin); reinstall with `uv tool install babel@latest` to pick up a newer release. "###); } From 3486bfd6e02a472df1c72f72851e514edfe6b512 Mon Sep 17 00:00:00 2001 From: Liam Date: Wed, 1 Oct 2025 16:20:31 -0400 Subject: [PATCH 04/14] Tweak copy --- crates/uv/src/commands/tool/upgrade.rs | 2 +- crates/uv/tests/it/tool_upgrade.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 3b8692416ab55..c3cfeadfd008d 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -212,7 +212,7 @@ pub(crate) async fn upgrade( writeln!( printer.stderr(), - "hint: `{}` is pinned to `{}` (installed with an exact version pin); reinstall with `{}` to pick up a newer release.", + "hint: `{}` is pinned to `{}` (installed with an exact version pin); reinstall with `{}` to upgrade to a new version.", name_str.cyan(), version_str.magenta(), reinstall_command.green(), diff --git a/crates/uv/tests/it/tool_upgrade.rs b/crates/uv/tests/it/tool_upgrade.rs index 9695bec749325..51f33f6597c6c 100644 --- a/crates/uv/tests/it/tool_upgrade.rs +++ b/crates/uv/tests/it/tool_upgrade.rs @@ -261,7 +261,7 @@ fn tool_upgrade_pinned_hint() { Modified babel environment - pytz==2018.5 + pytz==2024.1 - hint: `babel` is pinned to `2.6.0` (installed with an exact version pin); reinstall with `uv tool install babel@latest` to pick up a newer release. + hint: `babel` is pinned to `2.6.0` (installed with an exact version pin); reinstall with `uv tool install babel@latest` to upgrade to a new version. "###); } @@ -733,7 +733,7 @@ fn tool_upgrade_with() { Modified babel environment - pytz==2018.5 + pytz==2024.1 - hint: `babel` is pinned to `2.6.0` (installed with an exact version pin); reinstall with `uv tool install babel@latest` to pick up a newer release. + hint: `babel` is pinned to `2.6.0` (installed with an exact version pin); reinstall with `uv tool install babel@latest` to upgrade to a new version. "###); } From adcfbbde1bd8bd239691236c4999c7f4b1550b90 Mon Sep 17 00:00:00 2001 From: Liam Date: Wed, 1 Oct 2025 16:20:57 -0400 Subject: [PATCH 05/14] Remove whitespace --- crates/uv/src/commands/tool/upgrade.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index c3cfeadfd008d..3c2fe37752db4 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -205,9 +205,7 @@ pub(crate) async fn upgrade( for (name, version) in pinned { let name_str = name.to_string(); - let version_str = version.to_string(); - let reinstall_command = format!("uv tool install {name_str}@latest"); writeln!( From 50345c27507c1fd381d808d2f4dbfc40c8ff045b Mon Sep 17 00:00:00 2001 From: Liam Date: Wed, 1 Oct 2025 16:30:00 -0400 Subject: [PATCH 06/14] Add version onto `UpgradeOutcome` variants --- crates/uv/src/commands/tool/upgrade.rs | 43 ++++++++++++++++---------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 3c2fe37752db4..795f371e3e43e 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -139,7 +139,9 @@ pub(crate) async fn upgrade( .await; match result { - Ok((outcome, pinned_version)) => { + Ok(outcome) => { + let pinned_version = outcome.pinned_version().cloned(); + if let Some(version) = pinned_version.as_ref() { debug!("`{name}` remains pinned to version {version}; skipping tool upgrade"); } @@ -148,10 +150,10 @@ pub(crate) async fn upgrade( UpgradeOutcome::UpgradeEnvironment => { did_upgrade_environment.push(name); } - UpgradeOutcome::UpgradeDependencies | UpgradeOutcome::UpgradeTool => { + UpgradeOutcome::UpgradeTool | UpgradeOutcome::UpgradeDependencies { .. } => { did_upgrade_tool.push(name); } - UpgradeOutcome::NoOp => { + UpgradeOutcome::NoOp { .. } => { debug!("Upgrading `{name}` was a no-op"); } } @@ -219,16 +221,26 @@ pub(crate) async fn upgrade( Ok(ExitStatus::Success) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] enum UpgradeOutcome { /// The tool itself was upgraded. UpgradeTool, /// The tool's dependencies were upgraded, but the tool itself was unchanged. - UpgradeDependencies, + UpgradeDependencies { pinned_version: Option }, /// The tool's environment was upgraded. UpgradeEnvironment, /// The tool was already up-to-date. - NoOp, + NoOp { pinned_version: Option }, +} + +impl UpgradeOutcome { + fn pinned_version(&self) -> Option<&Version> { + match self { + UpgradeOutcome::UpgradeDependencies { pinned_version } + | UpgradeOutcome::NoOp { pinned_version } => pinned_version.as_ref(), + UpgradeOutcome::UpgradeTool | UpgradeOutcome::UpgradeEnvironment => None, + } + } } /// Upgrade a specific tool. @@ -246,7 +258,7 @@ async fn upgrade_tool( installer_metadata: bool, concurrency: Concurrency, preview: Preview, -) -> Result<(UpgradeOutcome, Option)> { +) -> Result { // Ensure the tool is installed. let existing_tool_receipt = match installed_tools.get_tool_receipt(name) { Ok(Some(receipt)) => receipt, @@ -388,9 +400,13 @@ async fn upgrade_tool( let outcome = if changelog.includes(name) { UpgradeOutcome::UpgradeTool } else if changelog.is_empty() { - UpgradeOutcome::NoOp + UpgradeOutcome::NoOp { + pinned_version: pinned_requirement_version(&existing_tool_receipt, name), + } } else { - UpgradeOutcome::UpgradeDependencies + UpgradeOutcome::UpgradeDependencies { + pinned_version: pinned_requirement_version(&existing_tool_receipt, name), + } }; (environment, outcome) @@ -427,14 +443,7 @@ async fn upgrade_tool( )?; } - let pinned_version = match outcome { - UpgradeOutcome::UpgradeTool | UpgradeOutcome::UpgradeEnvironment => None, - UpgradeOutcome::UpgradeDependencies | UpgradeOutcome::NoOp => { - pinned_requirement_version(&existing_tool_receipt, name) - } - }; - - Ok((outcome, pinned_version)) + Ok(outcome) } fn pinned_requirement_version(tool: &Tool, name: &PackageName) -> Option { From 5eaaa645e2da752cb0fe113848da5026a39c58e6 Mon Sep 17 00:00:00 2001 From: Liam Date: Wed, 1 Oct 2025 16:45:14 -0400 Subject: [PATCH 07/14] Lift out a general `UpgradeReason` --- crates/uv/src/commands/tool/upgrade.rs | 94 +++++++++++++++----------- 1 file changed, 53 insertions(+), 41 deletions(-) diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 795f371e3e43e..b0c9c77c13e7f 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -115,8 +115,8 @@ pub(crate) async fn upgrade( // Determine whether we applied any upgrades. let mut did_upgrade_environment = vec![]; - // Track tools that could not be upgraded due to pinned versions. - let mut pinned = Vec::new(); + // Collect reasons why upgrades were skipped or altered. + let mut collected_reasons: Vec<(PackageName, UpgradeReason)> = Vec::new(); let mut errors = Vec::new(); for (name, constraints) in &names { @@ -139,27 +139,21 @@ pub(crate) async fn upgrade( .await; match result { - Ok(outcome) => { - let pinned_version = outcome.pinned_version().cloned(); - - if let Some(version) = pinned_version.as_ref() { - debug!("`{name}` remains pinned to version {version}; skipping tool upgrade"); - } - - match outcome { + Ok(report) => { + match report.outcome { UpgradeOutcome::UpgradeEnvironment => { did_upgrade_environment.push(name); } - UpgradeOutcome::UpgradeTool | UpgradeOutcome::UpgradeDependencies { .. } => { + UpgradeOutcome::UpgradeTool | UpgradeOutcome::UpgradeDependencies => { did_upgrade_tool.push(name); } - UpgradeOutcome::NoOp { .. } => { + UpgradeOutcome::NoOp => { debug!("Upgrading `{name}` was a no-op"); } } - if let Some(version) = pinned_version { - pinned.push((name.clone(), version)); + if let Some(reason) = report.reason.clone() { + collected_reasons.push((name.clone(), reason)); } } Err(err) => { @@ -205,18 +199,8 @@ pub(crate) async fn upgrade( } } - for (name, version) in pinned { - let name_str = name.to_string(); - let version_str = version.to_string(); - let reinstall_command = format!("uv tool install {name_str}@latest"); - - writeln!( - printer.stderr(), - "hint: `{}` is pinned to `{}` (installed with an exact version pin); reinstall with `{}` to upgrade to a new version.", - name_str.cyan(), - version_str.magenta(), - reinstall_command.green(), - )?; + for (name, reason) in collected_reasons { + reason.print(&name, printer)?; } Ok(ExitStatus::Success) @@ -226,20 +210,44 @@ enum UpgradeOutcome { /// The tool itself was upgraded. UpgradeTool, /// The tool's dependencies were upgraded, but the tool itself was unchanged. - UpgradeDependencies { pinned_version: Option }, + UpgradeDependencies, /// The tool's environment was upgraded. UpgradeEnvironment, /// The tool was already up-to-date. - NoOp { pinned_version: Option }, + NoOp, } -impl UpgradeOutcome { - fn pinned_version(&self) -> Option<&Version> { +#[derive(Debug, Clone, PartialEq, Eq)] +struct UpgradeReport { + outcome: UpgradeOutcome, + reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum UpgradeReason { + /// The tool remains pinned to an exact version, so an upgrade was skipped. + PinnedVersion { version: Version }, +} + +impl UpgradeReason { + fn print(&self, name: &PackageName, printer: Printer) -> Result<()> { match self { - UpgradeOutcome::UpgradeDependencies { pinned_version } - | UpgradeOutcome::NoOp { pinned_version } => pinned_version.as_ref(), - UpgradeOutcome::UpgradeTool | UpgradeOutcome::UpgradeEnvironment => None, + Self::PinnedVersion { version } => { + let name_str = name.to_string(); + let version_str = version.to_string(); + let reinstall_command = format!("uv tool install {name_str}@latest"); + + writeln!( + printer.stderr(), + "hint: `{}` is pinned to `{}` (installed with an exact version pin); reinstall with `{}` to upgrade to a new version.", + name_str.cyan(), + version_str.magenta(), + reinstall_command.green(), + )?; + } } + + Ok(()) } } @@ -258,7 +266,7 @@ async fn upgrade_tool( installer_metadata: bool, concurrency: Concurrency, preview: Preview, -) -> Result { +) -> Result { // Ensure the tool is installed. let existing_tool_receipt = match installed_tools.get_tool_receipt(name) { Ok(Some(receipt)) => receipt, @@ -400,13 +408,9 @@ async fn upgrade_tool( let outcome = if changelog.includes(name) { UpgradeOutcome::UpgradeTool } else if changelog.is_empty() { - UpgradeOutcome::NoOp { - pinned_version: pinned_requirement_version(&existing_tool_receipt, name), - } + UpgradeOutcome::NoOp } else { - UpgradeOutcome::UpgradeDependencies { - pinned_version: pinned_requirement_version(&existing_tool_receipt, name), - } + UpgradeOutcome::UpgradeDependencies }; (environment, outcome) @@ -443,7 +447,15 @@ async fn upgrade_tool( )?; } - Ok(outcome) + let reason = match &outcome { + UpgradeOutcome::UpgradeDependencies | UpgradeOutcome::NoOp => { + pinned_requirement_version(&existing_tool_receipt, name) + .map(|version| UpgradeReason::PinnedVersion { version }) + } + UpgradeOutcome::UpgradeTool | UpgradeOutcome::UpgradeEnvironment => None, + }; + + Ok(UpgradeReport { outcome, reason }) } fn pinned_requirement_version(tool: &Tool, name: &PackageName) -> Option { From 43cbde2454543d9bf6edacf10a34e2c3219f5f60 Mon Sep 17 00:00:00 2001 From: Liam Date: Fri, 3 Oct 2025 12:06:21 -0400 Subject: [PATCH 08/14] Rename `UpgradeReason` -> `UpgradeConstraint` --- crates/uv/src/commands/tool/upgrade.rs | 36 ++++++++++++++------------ 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index b0c9c77c13e7f..7a2c6cf9b8520 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -115,8 +115,8 @@ pub(crate) async fn upgrade( // Determine whether we applied any upgrades. let mut did_upgrade_environment = vec![]; - // Collect reasons why upgrades were skipped or altered. - let mut collected_reasons: Vec<(PackageName, UpgradeReason)> = Vec::new(); + // Constraints that caused upgrades to be skipped or altered. + let mut collected_constraints: Vec<(PackageName, UpgradeConstraint)> = Vec::new(); let mut errors = Vec::new(); for (name, constraints) in &names { @@ -152,8 +152,8 @@ pub(crate) async fn upgrade( } } - if let Some(reason) = report.reason.clone() { - collected_reasons.push((name.clone(), reason)); + if let Some(constraint) = report.constraint.clone() { + collected_constraints.push((name.clone(), constraint)); } } Err(err) => { @@ -199,12 +199,13 @@ pub(crate) async fn upgrade( } } - for (name, reason) in collected_reasons { - reason.print(&name, printer)?; + for (name, constraint) in collected_constraints { + constraint.print(&name, printer)?; } Ok(ExitStatus::Success) } + #[derive(Debug, Clone, PartialEq, Eq)] enum UpgradeOutcome { /// The tool itself was upgraded. @@ -218,18 +219,18 @@ enum UpgradeOutcome { } #[derive(Debug, Clone, PartialEq, Eq)] -struct UpgradeReport { - outcome: UpgradeOutcome, - reason: Option, +enum UpgradeConstraint { + /// The tool remains pinned to an exact version, so an upgrade was skipped. + PinnedVersion { version: Version }, } #[derive(Debug, Clone, PartialEq, Eq)] -enum UpgradeReason { - /// The tool remains pinned to an exact version, so an upgrade was skipped. - PinnedVersion { version: Version }, +struct UpgradeReport { + outcome: UpgradeOutcome, + constraint: Option, } -impl UpgradeReason { +impl UpgradeConstraint { fn print(&self, name: &PackageName, printer: Printer) -> Result<()> { match self { Self::PinnedVersion { version } => { @@ -447,15 +448,18 @@ async fn upgrade_tool( )?; } - let reason = match &outcome { + let constraint = match &outcome { UpgradeOutcome::UpgradeDependencies | UpgradeOutcome::NoOp => { pinned_requirement_version(&existing_tool_receipt, name) - .map(|version| UpgradeReason::PinnedVersion { version }) + .map(|version| UpgradeConstraint::PinnedVersion { version }) } UpgradeOutcome::UpgradeTool | UpgradeOutcome::UpgradeEnvironment => None, }; - Ok(UpgradeReport { outcome, reason }) + Ok(UpgradeReport { + outcome, + constraint, + }) } fn pinned_requirement_version(tool: &Tool, name: &PackageName) -> Option { From 2caecb85a734661458c919b6938c13d2be3b9dfe Mon Sep 17 00:00:00 2001 From: Liam Date: Fri, 3 Oct 2025 12:16:02 -0400 Subject: [PATCH 09/14] Move up impl block --- crates/uv/src/commands/tool/upgrade.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 7a2c6cf9b8520..36808eaeb2e64 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -224,12 +224,6 @@ enum UpgradeConstraint { PinnedVersion { version: Version }, } -#[derive(Debug, Clone, PartialEq, Eq)] -struct UpgradeReport { - outcome: UpgradeOutcome, - constraint: Option, -} - impl UpgradeConstraint { fn print(&self, name: &PackageName, printer: Printer) -> Result<()> { match self { @@ -252,6 +246,12 @@ impl UpgradeConstraint { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct UpgradeReport { + outcome: UpgradeOutcome, + constraint: Option, +} + /// Upgrade a specific tool. async fn upgrade_tool( name: &PackageName, From 3164f92e4bcee31df9d357a98a42facd2367e4a1 Mon Sep 17 00:00:00 2001 From: Liam Date: Fri, 3 Oct 2025 12:27:23 -0400 Subject: [PATCH 10/14] Add `Copy` back to `UpgradeOutcome` --- crates/uv/src/commands/tool/upgrade.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 36808eaeb2e64..513aad0464a4d 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -206,7 +206,7 @@ pub(crate) async fn upgrade( Ok(ExitStatus::Success) } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] enum UpgradeOutcome { /// The tool itself was upgraded. UpgradeTool, From 315faa94333ce11938b4d238290b301ed0d58e14 Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 7 Oct 2025 10:53:23 -0400 Subject: [PATCH 11/14] Go through all specifiers --- crates/uv/src/commands/tool/upgrade.rs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 513aad0464a4d..c211e12be765b 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -473,18 +473,12 @@ fn pinned_version_from(requirements: &[Requirement], name: &PackageName) -> Opti .filter(|requirement| requirement.name == *name) .find_map(|requirement| match &requirement.source { RequirementSource::Registry { specifier, .. } => { - let mut specifiers = specifier.iter(); - - let first = specifiers.next()?; - - if specifiers.next().is_some() { - return None; - } - - match first.operator() { - Operator::Equal | Operator::ExactEqual => Some(first.version().clone()), - _ => None, - } + specifier + .iter() + .find_map(|specifier| match specifier.operator() { + Operator::Equal | Operator::ExactEqual => Some(specifier.version().clone()), + _ => None, + }) } _ => None, }) From 58c33cec159a6b98d434190ec298a293eadf67d6 Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 7 Oct 2025 10:55:10 -0400 Subject: [PATCH 12/14] Inline + shadow existing --- crates/uv/src/commands/tool/upgrade.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index c211e12be765b..2d5fde1ad164b 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -228,15 +228,14 @@ impl UpgradeConstraint { fn print(&self, name: &PackageName, printer: Printer) -> Result<()> { match self { Self::PinnedVersion { version } => { - let name_str = name.to_string(); - let version_str = version.to_string(); - let reinstall_command = format!("uv tool install {name_str}@latest"); + let name = name.to_string(); + let reinstall_command = format!("uv tool install {name}@latest"); writeln!( printer.stderr(), "hint: `{}` is pinned to `{}` (installed with an exact version pin); reinstall with `{}` to upgrade to a new version.", - name_str.cyan(), - version_str.magenta(), + name.cyan(), + version.to_string().magenta(), reinstall_command.green(), )?; } From e7e32a7eae27847bbee5529ad0b3de354c984e7d Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 7 Oct 2025 10:58:39 -0400 Subject: [PATCH 13/14] Add space before constraints --- crates/uv/src/commands/tool/upgrade.rs | 4 ++++ crates/uv/tests/it/tool_upgrade.rs | 2 ++ 2 files changed, 6 insertions(+) diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 2d5fde1ad164b..fe1cfa09e0c11 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -199,6 +199,10 @@ pub(crate) async fn upgrade( } } + if !collected_constraints.is_empty() { + writeln!(printer.stderr())?; + } + for (name, constraint) in collected_constraints { constraint.print(&name, printer)?; } diff --git a/crates/uv/tests/it/tool_upgrade.rs b/crates/uv/tests/it/tool_upgrade.rs index 51f33f6597c6c..73f680fcd0007 100644 --- a/crates/uv/tests/it/tool_upgrade.rs +++ b/crates/uv/tests/it/tool_upgrade.rs @@ -261,6 +261,7 @@ fn tool_upgrade_pinned_hint() { Modified babel environment - pytz==2018.5 + pytz==2024.1 + hint: `babel` is pinned to `2.6.0` (installed with an exact version pin); reinstall with `uv tool install babel@latest` to upgrade to a new version. "###); } @@ -733,6 +734,7 @@ fn tool_upgrade_with() { Modified babel environment - pytz==2018.5 + pytz==2024.1 + hint: `babel` is pinned to `2.6.0` (installed with an exact version pin); reinstall with `uv tool install babel@latest` to upgrade to a new version. "###); } From 5d8133e17a0a18447aaecfe67b3935c01bf65551 Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 7 Oct 2025 11:06:02 -0400 Subject: [PATCH 14/14] Add additional test for mixed constraints --- crates/uv/tests/it/tool_upgrade.rs | 52 ++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/uv/tests/it/tool_upgrade.rs b/crates/uv/tests/it/tool_upgrade.rs index 73f680fcd0007..d3a5d6da1bed1 100644 --- a/crates/uv/tests/it/tool_upgrade.rs +++ b/crates/uv/tests/it/tool_upgrade.rs @@ -266,6 +266,58 @@ fn tool_upgrade_pinned_hint() { "###); } +#[test] +fn tool_upgrade_pinned_hint_with_mixed_constraint() { + let context = TestContext::new("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"); + + // Install a specific version of `babel` with an additional constraint to ensure the requirement + // contains multiple specifiers while still including an exact pin. + uv_snapshot!(context.filters(), context.tool_install() + .arg("babel>=2.0,==2.6.0") + .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()), @r###" + 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 + + pytz==2018.5 + Installed 1 executable: pybabel + "###); + + // Attempt to upgrade `babel`; it should remain pinned and emit a hint explaining why. + 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()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Modified babel environment + - pytz==2018.5 + + pytz==2024.1 + + hint: `babel` is pinned to `2.6.0` (installed with an exact version pin); reinstall with `uv tool install babel@latest` to upgrade to a new version. + "###); +} + #[test] fn tool_upgrade_all() { let context = TestContext::new("3.12")