diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 7958316317e04..fe1cfa09e0c11 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![]; + // 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 { debug!("Upgrading tool: `{name}`"); @@ -135,14 +139,22 @@ 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(report) => { + match report.outcome { + UpgradeOutcome::UpgradeEnvironment => { + did_upgrade_environment.push(name); + } + UpgradeOutcome::UpgradeTool | UpgradeOutcome::UpgradeDependencies => { + did_upgrade_tool.push(name); + } + UpgradeOutcome::NoOp => { + debug!("Upgrading `{name}` was a no-op"); + } + } + + if let Some(constraint) = report.constraint.clone() { + collected_constraints.push((name.clone(), constraint)); + } } Err(err) => { errors.push((name, err)); @@ -187,6 +199,14 @@ pub(crate) async fn upgrade( } } + if !collected_constraints.is_empty() { + writeln!(printer.stderr())?; + } + + for (name, constraint) in collected_constraints { + constraint.print(&name, printer)?; + } + Ok(ExitStatus::Success) } @@ -202,6 +222,39 @@ enum UpgradeOutcome { NoOp, } +#[derive(Debug, Clone, PartialEq, Eq)] +enum UpgradeConstraint { + /// The tool remains pinned to an exact version, so an upgrade was skipped. + PinnedVersion { version: Version }, +} + +impl UpgradeConstraint { + fn print(&self, name: &PackageName, printer: Printer) -> Result<()> { + match self { + Self::PinnedVersion { version } => { + 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.cyan(), + version.to_string().magenta(), + reinstall_command.green(), + )?; + } + } + + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct UpgradeReport { + outcome: UpgradeOutcome, + constraint: Option, +} + /// Upgrade a specific tool. async fn upgrade_tool( name: &PackageName, @@ -217,7 +270,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, @@ -398,5 +451,38 @@ async fn upgrade_tool( )?; } - Ok(outcome) + let constraint = match &outcome { + UpgradeOutcome::UpgradeDependencies | UpgradeOutcome::NoOp => { + pinned_requirement_version(&existing_tool_receipt, name) + .map(|version| UpgradeConstraint::PinnedVersion { version }) + } + UpgradeOutcome::UpgradeTool | UpgradeOutcome::UpgradeEnvironment => None, + }; + + Ok(UpgradeReport { + outcome, + constraint, + }) +} + +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, .. } => { + specifier + .iter() + .find_map(|specifier| match specifier.operator() { + Operator::Equal | Operator::ExactEqual => Some(specifier.version().clone()), + _ => None, + }) + } + _ => None, + }) } diff --git a/crates/uv/tests/it/tool_upgrade.rs b/crates/uv/tests/it/tool_upgrade.rs index a7e1f82e9898c..d3a5d6da1bed1 100644 --- a/crates/uv/tests/it/tool_upgrade.rs +++ b/crates/uv/tests/it/tool_upgrade.rs @@ -215,6 +215,109 @@ 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 upgrade to a new version. + "###); +} + +#[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") @@ -683,6 +786,8 @@ 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. "###); }