diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 65ac2ad0a..142ff4bea 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2555,6 +2555,8 @@ pub enum ToolCommand { Uvx(ToolRunArgs), /// Install a tool. Install(ToolInstallArgs), + /// Upgrade a tool. + Upgrade(ToolUpgradeArgs), /// List installed tools. List(ToolListArgs), /// Uninstall a tool. @@ -2712,6 +2714,27 @@ pub struct ToolUninstallArgs { pub all: bool, } +#[derive(Args)] +#[allow(clippy::struct_excessive_bools)] +pub struct ToolUpgradeArgs { + /// The name of the tool to upgrade. + #[arg(required = true)] + pub name: Option, + + /// Upgrade all tools. + #[arg(long, conflicts_with("name"))] + pub all: bool, + + #[command(flatten)] + pub installer: ResolverInstallerArgs, + + #[command(flatten)] + pub build: BuildArgs, + + #[command(flatten)] + pub refresh: RefreshArgs, +} + #[derive(Args)] #[allow(clippy::struct_excessive_bools)] pub struct PythonNamespace { diff --git a/crates/uv-tool/src/tool.rs b/crates/uv-tool/src/tool.rs index b641a06a7..39f0667a9 100644 --- a/crates/uv-tool/src/tool.rs +++ b/crates/uv-tool/src/tool.rs @@ -170,6 +170,10 @@ impl Tool { pub fn requirements(&self) -> &[Requirement] { &self.requirements } + + pub fn python(&self) -> &Option { + &self.python + } } impl ToolEntrypoint { diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index 3ab3f20a1..3cb05608b 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -40,6 +40,7 @@ pub(crate) use tool::run::run as tool_run; pub(crate) use tool::run::ToolRunCommand; pub(crate) use tool::uninstall::uninstall as tool_uninstall; pub(crate) use tool::update_shell::update_shell as tool_update_shell; +pub(crate) use tool::upgrade::upgrade as tool_upgrade; use uv_cache::Cache; use uv_fs::Simplified; use uv_git::GitResolver; diff --git a/crates/uv/src/commands/tool/common.rs b/crates/uv/src/commands/tool/common.rs index d8dc76225..a56496d9a 100644 --- a/crates/uv/src/commands/tool/common.rs +++ b/crates/uv/src/commands/tool/common.rs @@ -1,6 +1,25 @@ +use std::fmt::Write; +use std::{collections::BTreeSet, ffi::OsString}; + +use anyhow::{bail, Context}; +use itertools::Itertools; +use owo_colors::OwoColorize; +use tracing::{debug, warn}; + use distribution_types::{InstalledDist, Name}; +use pep508_rs::PackageName; +use pypi_types::Requirement; +#[cfg(unix)] +use uv_fs::replace_symlink; +use uv_fs::Simplified; use uv_installer::SitePackages; -use uv_tool::entrypoint_paths; +use uv_python::PythonEnvironment; +use uv_shell::Shell; +use uv_tool::{entrypoint_paths, find_executable_directory, InstalledTools, Tool, ToolEntrypoint}; +use uv_warnings::warn_user; + +use crate::commands::ExitStatus; +use crate::printer::Printer; /// Return all packages which contain an executable with the given name. pub(super) fn matching_packages(name: &str, site_packages: &SitePackages) -> Vec { @@ -23,3 +42,235 @@ pub(super) fn matching_packages(name: &str, site_packages: &SitePackages) -> Vec }) .collect() } + +/// Remove any entrypoints attached to the [`Tool`]. +pub(crate) fn remove_entrypoints(tool: &Tool) { + for executable in tool + .entrypoints() + .iter() + .map(|entrypoint| &entrypoint.install_path) + { + debug!("Removing executable: `{}`", executable.simplified_display()); + if let Err(err) = fs_err::remove_file(executable) { + warn!( + "Failed to remove executable: `{}`: {err}", + executable.simplified_display() + ); + } + } +} + +/// Represents the action to be performed on executables: update or install. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(crate) enum InstallAction { + Update, + Install, +} + +/// Installs tool executables for a given package and handles any conflicts. +pub(crate) fn install_executables( + environment: &PythonEnvironment, + name: &PackageName, + installed_tools: &InstalledTools, + printer: Printer, + force: bool, + python: Option, + requirements: Vec, + action: InstallAction, +) -> anyhow::Result { + let site_packages = SitePackages::from_environment(environment)?; + let installed = site_packages.get_packages(name); + let Some(installed_dist) = installed.first().copied() else { + bail!("Expected at least one requirement") + }; + + // Find a suitable path to install into + let executable_directory = find_executable_directory()?; + fs_err::create_dir_all(&executable_directory) + .context("Failed to create executable directory")?; + + debug!( + "Installing tool executables into: {}", + executable_directory.user_display() + ); + + let entry_points = entrypoint_paths( + &site_packages, + installed_dist.name(), + installed_dist.version(), + )?; + + // Determine the entry points targets + // Use a sorted collection for deterministic output + let target_entry_points = entry_points + .into_iter() + .map(|(name, source_path)| { + let target_path = executable_directory.join( + source_path + .file_name() + .map(std::borrow::ToOwned::to_owned) + .unwrap_or_else(|| OsString::from(name.clone())), + ); + (name, source_path, target_path) + }) + .collect::>(); + + if target_entry_points.is_empty() { + writeln!( + printer.stdout(), + "No executables are provided by `{from}`", + from = name.cyan() + )?; + + hint_executable_from_dependency(name, &site_packages, printer)?; + + // Clean up the environment we just created. + installed_tools.remove_environment(name)?; + + return Ok(ExitStatus::Failure); + } + + // Check if they exist, before installing + let mut existing_entry_points = target_entry_points + .iter() + .filter(|(_, _, target_path)| target_path.exists()) + .peekable(); + + // Ignore any existing entrypoints if the user passed `--force`, or the existing recept was + // broken. + if force { + for (name, _, target) in existing_entry_points { + debug!("Removing existing executable: `{name}`"); + fs_err::remove_file(target)?; + } + } else if existing_entry_points.peek().is_some() { + // Clean up the environment we just created + installed_tools.remove_environment(name)?; + + let existing_entry_points = existing_entry_points + // SAFETY: We know the target has a filename because we just constructed it above + .map(|(_, _, target)| target.file_name().unwrap().to_string_lossy()) + .collect::>(); + let (s, exists) = if existing_entry_points.len() == 1 { + ("", "exists") + } else { + ("s", "exist") + }; + bail!( + "Executable{s} already {exists}: {} (use `--force` to overwrite)", + existing_entry_points + .iter() + .map(|name| name.bold()) + .join(", ") + ) + } + + for (name, source_path, target_path) in &target_entry_points { + debug!("Installing executable: `{name}`"); + #[cfg(unix)] + replace_symlink(source_path, target_path).context("Failed to install executable")?; + #[cfg(windows)] + fs_err::copy(source_path, target_path).context("Failed to install entrypoint")?; + } + + let s = if target_entry_points.len() == 1 { + "" + } else { + "s" + }; + let install_message = match action { + InstallAction::Install => "Installed", + InstallAction::Update => "Updated", + }; + writeln!( + printer.stderr(), + "{install_message} {} executable{s}: {}", + target_entry_points.len(), + target_entry_points + .iter() + .map(|(name, _, _)| name.bold()) + .join(", ") + )?; + + debug!("Adding receipt for tool `{}`", name); + let tool = Tool::new( + requirements.into_iter().collect(), + python, + target_entry_points + .into_iter() + .map(|(name, _, target_path)| ToolEntrypoint::new(name, target_path)), + ); + installed_tools.add_tool_receipt(name, tool)?; + + // If the executable directory isn't on the user's PATH, warn. + if !Shell::contains_path(&executable_directory) { + if let Some(shell) = Shell::from_env() { + if let Some(command) = shell.prepend_path(&executable_directory) { + if shell.configuration_files().is_empty() { + warn_user!( + "`{}` is not on your PATH. To use installed tools, run `{}`.", + executable_directory.simplified_display().cyan(), + command.green() + ); + } else { + warn_user!( + "`{}` is not on your PATH. To use installed tools, run `{}` or `{}`.", + executable_directory.simplified_display().cyan(), + command.green(), + "uv tool update-shell".green() + ); + } + } else { + warn_user!( + "`{}` is not on your PATH. To use installed tools, add the directory to your PATH.", + executable_directory.simplified_display().cyan(), + ); + } + } else { + warn_user!( + "`{}` is not on your PATH. To use installed tools, add the directory to your PATH.", + executable_directory.simplified_display().cyan(), + ); + } + } + Ok(ExitStatus::Success) +} + +/// Displays a hint if an executable matching the package name can be found in a dependency of the package. +fn hint_executable_from_dependency( + name: &PackageName, + site_packages: &SitePackages, + printer: Printer, +) -> anyhow::Result<()> { + let packages = matching_packages(name.as_ref(), site_packages); + match packages.as_slice() { + [] => {} + [package] => { + let command = format!("uv tool install {}", package.name()); + writeln!( + printer.stdout(), + "However, an executable with the name `{}` is available via dependency `{}`.\nDid you mean `{}`?", + name.cyan(), + package.name().cyan(), + command.bold(), + )?; + } + packages => { + writeln!( + printer.stdout(), + "However, an executable with the name `{}` is available via the following dependencies::", + name.cyan(), + )?; + + for package in packages { + writeln!(printer.stdout(), "- {}", package.name().cyan())?; + } + writeln!( + printer.stdout(), + "Did you mean to install one of them instead?" + )?; + } + } + + Ok(()) +} diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 117be3707..ffe963428 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -1,38 +1,30 @@ -use std::collections::BTreeSet; -use std::ffi::OsString; use std::fmt::Write; use std::str::FromStr; -use anyhow::{bail, Context, Result}; -use itertools::Itertools; +use anyhow::{bail, Result}; +use distribution_types::UnresolvedRequirementSpecification; use owo_colors::OwoColorize; -use tracing::{debug, warn}; +use tracing::debug; -use distribution_types::{Name, UnresolvedRequirementSpecification}; -use pypi_types::Requirement; use uv_cache::Cache; use uv_client::{BaseClientBuilder, Connectivity}; use uv_configuration::{Concurrency, PreviewMode}; -#[cfg(unix)] -use uv_fs::replace_symlink; -use uv_fs::Simplified; -use uv_installer::SitePackages; use uv_normalize::PackageName; use uv_python::{ EnvironmentPreference, PythonFetch, PythonInstallation, PythonPreference, PythonRequest, }; use uv_requirements::{RequirementsSource, RequirementsSpecification}; -use uv_shell::Shell; -use uv_tool::{entrypoint_paths, find_executable_directory, InstalledTools, Tool, ToolEntrypoint}; +use uv_tool::InstalledTools; use uv_warnings::{warn_user, warn_user_once}; -use crate::commands::reporters::PythonDownloadReporter; - use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger}; + +use crate::commands::tool::common::remove_entrypoints; use crate::commands::{ project::{resolve_environment, resolve_names, sync_environment, update_environment}, - tool::common::matching_packages, + tool::common::InstallAction, }; +use crate::commands::{reporters::PythonDownloadReporter, tool::common::install_executables}; use crate::commands::{ExitStatus, SharedState}; use crate::printer::Printer; use crate::settings::ResolverInstallerSettings; @@ -337,213 +329,14 @@ pub(crate) async fn install( .await? }; - let site_packages = SitePackages::from_environment(&environment)?; - let installed = site_packages.get_packages(&from.name); - let Some(installed_dist) = installed.first().copied() else { - bail!("Expected at least one requirement") - }; - - // Find a suitable path to install into - let executable_directory = find_executable_directory()?; - fs_err::create_dir_all(&executable_directory) - .context("Failed to create executable directory")?; - - debug!( - "Installing tool executables into: {}", - executable_directory.user_display() - ); - - let entry_points = entrypoint_paths( - &site_packages, - installed_dist.name(), - installed_dist.version(), - )?; - - // Determine the entry points targets - // Use a sorted collection for deterministic output - let target_entry_points = entry_points - .into_iter() - .map(|(name, source_path)| { - let target_path = executable_directory.join( - source_path - .file_name() - .map(std::borrow::ToOwned::to_owned) - .unwrap_or_else(|| OsString::from(name.clone())), - ); - (name, source_path, target_path) - }) - .collect::>(); - - if target_entry_points.is_empty() { - writeln!( - printer.stdout(), - "No executables are provided by `{from}`", - from = from.name.cyan() - )?; - - hint_executable_from_dependency(&from, &site_packages, printer)?; - - // Clean up the environment we just created. - installed_tools.remove_environment(&from.name)?; - - return Ok(ExitStatus::Failure); - } - - // Check if they exist, before installing - let mut existing_entry_points = target_entry_points - .iter() - .filter(|(_, _, target_path)| target_path.exists()) - .peekable(); - - // Ignore any existing entrypoints if the user passed `--force`, or the existing recept was - // broken. - if force || invalid_tool_receipt { - for (name, _, target) in existing_entry_points { - debug!("Removing existing executable: `{name}`"); - fs_err::remove_file(target)?; - } - } else if existing_entry_points.peek().is_some() { - // Clean up the environment we just created - installed_tools.remove_environment(&from.name)?; - - let existing_entry_points = existing_entry_points - // SAFETY: We know the target has a filename because we just constructed it above - .map(|(_, _, target)| target.file_name().unwrap().to_string_lossy()) - .collect::>(); - let (s, exists) = if existing_entry_points.len() == 1 { - ("", "exists") - } else { - ("s", "exist") - }; - bail!( - "Executable{s} already {exists}: {} (use `--force` to overwrite)", - existing_entry_points - .iter() - .map(|name| name.bold()) - .join(", ") - ) - } - - for (name, source_path, target_path) in &target_entry_points { - debug!("Installing executable: `{name}`"); - #[cfg(unix)] - replace_symlink(source_path, target_path).context("Failed to install executable")?; - #[cfg(windows)] - fs_err::copy(source_path, target_path).context("Failed to install entrypoint")?; - } - - let s = if target_entry_points.len() == 1 { - "" - } else { - "s" - }; - writeln!( - printer.stderr(), - "Installed {} executable{s}: {}", - target_entry_points.len(), - target_entry_points - .iter() - .map(|(name, _, _)| name.bold()) - .join(", ") - )?; - - debug!("Adding receipt for tool `{}`", from.name); - let tool = Tool::new( - requirements.into_iter().collect(), + install_executables( + &environment, + &from.name, + &installed_tools, + printer, + force || invalid_tool_receipt, python, - target_entry_points - .into_iter() - .map(|(name, _, target_path)| ToolEntrypoint::new(name, target_path)), - ); - installed_tools.add_tool_receipt(&from.name, tool)?; - - // If the executable directory isn't on the user's PATH, warn. - if !Shell::contains_path(&executable_directory) { - if let Some(shell) = Shell::from_env() { - if let Some(command) = shell.prepend_path(&executable_directory) { - if shell.configuration_files().is_empty() { - warn_user!( - "`{}` is not on your PATH. To use installed tools, run `{}`.", - executable_directory.simplified_display().cyan(), - command.green() - ); - } else { - warn_user!( - "`{}` is not on your PATH. To use installed tools, run `{}` or `{}`.", - executable_directory.simplified_display().cyan(), - command.green(), - "uv tool update-shell".green() - ); - } - } else { - warn_user!( - "`{}` is not on your PATH. To use installed tools, add the directory to your PATH.", - executable_directory.simplified_display().cyan(), - ); - } - } else { - warn_user!( - "`{}` is not on your PATH. To use installed tools, add the directory to your PATH.", - executable_directory.simplified_display().cyan(), - ); - } - } - - Ok(ExitStatus::Success) -} - -/// Remove any entrypoints attached to the [`Tool`]. -fn remove_entrypoints(tool: &Tool) { - for executable in tool - .entrypoints() - .iter() - .map(|entrypoint| &entrypoint.install_path) - { - debug!("Removing executable: `{}`", executable.simplified_display()); - if let Err(err) = fs_err::remove_file(executable) { - warn!( - "Failed to remove executable: `{}`: {err}", - executable.simplified_display() - ); - } - } -} - -/// Displays a hint if an executable matching the package name can be found in a dependency of the package. -fn hint_executable_from_dependency( - from: &Requirement, - site_packages: &SitePackages, - printer: Printer, -) -> Result<()> { - let packages = matching_packages(from.name.as_ref(), site_packages); - match packages.as_slice() { - [] => {} - [package] => { - let command = format!("uv tool install {}", package.name()); - writeln!( - printer.stdout(), - "However, an executable with the name `{}` is available via dependency `{}`.\nDid you mean `{}`?", - from.name.cyan(), - package.name().cyan(), - command.bold(), - )?; - } - packages => { - writeln!( - printer.stdout(), - "However, an executable with the name `{}` is available via the following dependencies::", - from.name.cyan(), - )?; - - for package in packages { - writeln!(printer.stdout(), "- {}", package.name().cyan())?; - } - writeln!( - printer.stdout(), - "Did you mean to install one of them instead?" - )?; - } - } - - Ok(()) + requirements, + InstallAction::Install, + ) } diff --git a/crates/uv/src/commands/tool/mod.rs b/crates/uv/src/commands/tool/mod.rs index 0f16b6c76..9fc990dcc 100644 --- a/crates/uv/src/commands/tool/mod.rs +++ b/crates/uv/src/commands/tool/mod.rs @@ -5,3 +5,4 @@ pub(crate) mod list; pub(crate) mod run; pub(crate) mod uninstall; pub(crate) mod update_shell; +pub(crate) mod upgrade; diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs new file mode 100644 index 000000000..cecc4ec5c --- /dev/null +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -0,0 +1,157 @@ +use std::{collections::BTreeSet, fmt::Write}; + +use anyhow::Result; +use owo_colors::OwoColorize; +use tracing::debug; + +use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger}; +use crate::commands::project::update_environment; +use crate::commands::tool::common::{remove_entrypoints, InstallAction}; +use crate::commands::{tool::common::install_executables, ExitStatus, SharedState}; +use crate::printer::Printer; +use crate::settings::ResolverInstallerSettings; +use uv_cache::Cache; +use uv_client::Connectivity; +use uv_configuration::{Concurrency, PreviewMode, Upgrade}; +use uv_normalize::PackageName; +use uv_requirements::RequirementsSpecification; +use uv_tool::InstalledTools; +use uv_warnings::warn_user_once; + +/// Upgrade a tool. +pub(crate) async fn upgrade( + name: Option, + connectivity: Connectivity, + settings: ResolverInstallerSettings, + concurrency: Concurrency, + native_tls: bool, + cache: &Cache, + preview: PreviewMode, + printer: Printer, +) -> Result { + if preview.is_disabled() { + warn_user_once!("`uv tool upgrade` is experimental and may change without warning"); + } + + // Force upgrades. + let settings = ResolverInstallerSettings { + upgrade: Upgrade::All, + ..settings + }; + + // Initialize any shared state. + let state = SharedState::default(); + + let installed_tools = InstalledTools::from_settings()?.init()?; + let _lock = installed_tools.acquire_lock()?; + + let names: BTreeSet = + name.map(|name| BTreeSet::from_iter([name])) + .unwrap_or_else(|| { + installed_tools + .tools() + .unwrap_or_default() + .into_iter() + .map(|(name, _)| name) + .collect() + }); + + if names.is_empty() { + writeln!(printer.stderr(), "Nothing to upgrade")?; + return Ok(ExitStatus::Success); + } + + for name in names { + debug!("Upgrading tool: `{name}`"); + + // Ensure the tool is installed. + let existing_tool_receipt = match installed_tools.get_tool_receipt(&name) { + Ok(Some(receipt)) => receipt, + Ok(None) => { + let install_command = format!("uv tool install {name}"); + writeln!( + printer.stderr(), + "`{}` is not installed; run `{}` to install", + name.cyan(), + install_command.green() + )?; + return Ok(ExitStatus::Failure); + } + Err(_) => { + let install_command = format!("uv tool install --force {name}"); + writeln!( + printer.stderr(), + "`{}` is missing a valid receipt; run `{}` to reinstall", + name.cyan(), + install_command.green() + )?; + return Ok(ExitStatus::Failure); + } + }; + + let existing_environment = match installed_tools.get_environment(&name, cache) { + Ok(Some(environment)) => environment, + Ok(None) => { + let install_command = format!("uv tool install {name}"); + writeln!( + printer.stderr(), + "`{}` is not installed; run `{}` to install", + name.cyan(), + install_command.green() + )?; + return Ok(ExitStatus::Failure); + } + Err(_) => { + let install_command = format!("uv tool install --force {name}"); + writeln!( + printer.stderr(), + "`{}` is missing a valid environment; run `{}` to reinstall", + name.cyan(), + install_command.green() + )?; + return Ok(ExitStatus::Failure); + } + }; + + // Resolve the requirements. + let requirements = existing_tool_receipt.requirements(); + let spec = RequirementsSpecification::from_requirements(requirements.to_vec()); + + // TODO(zanieb): Build the environment in the cache directory then copy into the tool directory. + // This lets us confirm the environment is valid before removing an existing install. However, + // entrypoints always contain an absolute path to the relevant Python interpreter, which would + // be invalidated by moving the environment. + let environment = update_environment( + existing_environment, + spec, + &settings, + &state, + Box::new(DefaultResolveLogger), + Box::new(DefaultInstallLogger), + preview, + connectivity, + concurrency, + native_tls, + cache, + printer, + ) + .await?; + + // At this point, we updated the existing environment, so we should remove any of its + // existing executables. + remove_entrypoints(&existing_tool_receipt); + + install_executables( + &environment, + &name, + &installed_tools, + printer, + true, + existing_tool_receipt.python().to_owned(), + requirements.to_vec(), + InstallAction::Update, + )?; + } + + Ok(ExitStatus::Success) +} diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 1a3bc3783..99e1d3783 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -796,6 +796,28 @@ async fn run(cli: Cli) -> Result { commands::tool_list(args.show_paths, globals.preview, &cache, printer).await } + Commands::Tool(ToolNamespace { + command: ToolCommand::Upgrade(args), + }) => { + // Resolve the settings from the command-line arguments and workspace configuration. + let args = settings::ToolUpgradeSettings::resolve(args, filesystem); + show_settings!(args); + + // Initialize the cache. + let cache = cache.init()?.with_refresh(args.refresh); + + commands::tool_upgrade( + args.name, + globals.connectivity, + args.settings, + Concurrency::default(), + globals.native_tls, + &cache, + globals.preview, + printer, + ) + .await + } Commands::Tool(ToolNamespace { command: ToolCommand::Uninstall(args), }) => { diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 22b1844d5..0a97d961a 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -9,7 +9,10 @@ use install_wheel_rs::linker::LinkMode; use pep508_rs::{ExtraName, RequirementOrigin}; use pypi_types::Requirement; use uv_cache::{CacheArgs, Refresh}; -use uv_cli::options::{flag, resolver_installer_options, resolver_options}; +use uv_cli::{ + options::{flag, resolver_installer_options, resolver_options}, + ToolUpgradeArgs, +}; use uv_cli::{ AddArgs, ColorChoice, Commands, ExternalCommand, GlobalArgs, InitArgs, ListFormat, LockArgs, Maybe, PipCheckArgs, PipCompileArgs, PipFreezeArgs, PipInstallArgs, PipListArgs, PipShowArgs, @@ -375,6 +378,42 @@ impl ToolListSettings { } } +/// The resolved settings to use for a `tool upgrade` invocation. +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone)] +pub(crate) struct ToolUpgradeSettings { + pub(crate) name: Option, + pub(crate) settings: ResolverInstallerSettings, + pub(crate) refresh: Refresh, +} + +impl ToolUpgradeSettings { + /// Resolve the [`ToolUpgradeSettings`] from the CLI and filesystem configuration. + #[allow(clippy::needless_pass_by_value)] + pub(crate) fn resolve(args: ToolUpgradeArgs, filesystem: Option) -> Self { + let ToolUpgradeArgs { + name, + all, + mut installer, + build, + refresh, + } = args; + + if !installer.upgrade && installer.upgrade_package.is_empty() { + installer.upgrade = true; + } + + Self { + name: name.filter(|_| !all), + settings: ResolverInstallerSettings::combine( + resolver_installer_options(installer, build), + filesystem, + ), + refresh: Refresh::from(refresh), + } + } +} + /// The resolved settings to use for a `tool uninstall` invocation. #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] diff --git a/crates/uv/tests/common/mod.rs b/crates/uv/tests/common/mod.rs index 85b658ee9..a29abd1fd 100644 --- a/crates/uv/tests/common/mod.rs +++ b/crates/uv/tests/common/mod.rs @@ -504,6 +504,14 @@ impl TestContext { command } + /// Create a `uv upgrade run` command with options shared across scenarios. + pub fn tool_upgrade(&self) -> Command { + let mut command = Command::new(get_bin()); + command.arg("tool").arg("upgrade"); + self.add_shared_args(&mut command); + command + } + /// Create a `uv tool install` command with options shared across scenarios. pub fn tool_install(&self) -> Command { let mut command = self.tool_install_without_exclude_newer(); diff --git a/crates/uv/tests/tool_upgrade.rs b/crates/uv/tests/tool_upgrade.rs new file mode 100644 index 000000000..b07287cf1 --- /dev/null +++ b/crates/uv/tests/tool_upgrade.rs @@ -0,0 +1,227 @@ +#![cfg(all(feature = "python", feature = "pypi"))] + +use assert_fs::prelude::*; + +use common::{uv_snapshot, TestContext}; + +mod common; + +#[test] +fn test_tool_upgrade_name() { + 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 `black`. + uv_snapshot!(context.filters(), context.tool_install() + .arg("black>=23.1") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + black==24.3.0 + + click==8.1.7 + + mypy-extensions==1.0.0 + + packaging==24.0 + + pathspec==0.12.1 + + platformdirs==4.2.0 + Installed 2 executables: black, blackd + "###); + + // Upgrade `black`. This should be a no-op, since we have the latest version already. + uv_snapshot!(context.filters(), context.tool_upgrade() + .arg("black") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool upgrade` is experimental and may change without warning + Resolved [N] packages in [TIME] + Audited [N] packages in [TIME] + Updated 2 executables: black, blackd + "###); +} + +#[test] +fn test_tool_upgrade_all() { + 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 `black==23.1`. + uv_snapshot!(context.filters(), context.tool_install() + .arg("black==23.1") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + black==23.1.0 + + click==8.1.7 + + mypy-extensions==1.0.0 + + packaging==24.0 + + pathspec==0.12.1 + + platformdirs==4.2.0 + Installed 2 executables: black, blackd + "###); + + // Install `pytest==8.0`. + uv_snapshot!(context.filters(), context.tool_install() + .arg("pytest==8.0") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + iniconfig==2.0.0 + + packaging==24.0 + + pluggy==1.4.0 + + pytest==8.0.0 + Installed 2 executables: py.test, pytest + "###); + + // Upgrade all. This is a no-op, since we have the latest versions already. + uv_snapshot!(context.filters(), context.tool_upgrade() + .arg("--all") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool upgrade` is experimental and may change without warning + Resolved [N] packages in [TIME] + Audited [N] packages in [TIME] + Updated 2 executables: black, blackd + Resolved [N] packages in [TIME] + Audited [N] packages in [TIME] + Updated 2 executables: py.test, pytest + "###); +} + +#[test] +fn test_tool_upgrade_non_existing_package() { + 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"); + + // Attempt to upgrade `black`. + uv_snapshot!(context.filters(), context.tool_upgrade() + .arg("black") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool upgrade` is experimental and may change without warning + `black` is not installed; run `uv tool install black` to install + "###); + + // Attempt to upgrade all. + uv_snapshot!(context.filters(), context.tool_upgrade() + .arg("--all") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool upgrade` is experimental and may change without warning + Nothing to upgrade + "###); +} + +#[test] +fn test_tool_upgrade_settings() { + 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 `black` with `lowest-direct`. + uv_snapshot!(context.filters(), context.tool_install() + .arg("black>=23") + .arg("--resolution=lowest-direct") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + black==23.1.0 + + click==8.1.7 + + mypy-extensions==1.0.0 + + packaging==24.0 + + pathspec==0.12.1 + + platformdirs==4.2.0 + Installed 2 executables: black, blackd + "###); + + // Upgrade `black`. It should respect `lowest-direct`, but doesn't right now, so it's + // unintentionally upgraded. + uv_snapshot!(context.filters(), context.tool_upgrade() + .arg("black") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool upgrade` is experimental and may change without warning + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Uninstalled [N] packages in [TIME] + Installed [N] packages in [TIME] + - black==23.1.0 + + black==24.3.0 + Updated 2 executables: black, blackd + "###); +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index b418862f1..ce7dde9b1 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1796,6 +1796,8 @@ uv tool [OPTIONS]
uv tool install

Install a tool

+
uv tool upgrade

Upgrade a tool

+
uv tool list

List installed tools

uv tool uninstall

Uninstall a tool

@@ -2288,6 +2290,239 @@ uv tool install [OPTIONS]
+### uv tool upgrade + +Upgrade a tool + +

Usage

+ +``` +uv tool upgrade [OPTIONS] +``` + +

Arguments

+ +
NAME

The name of the tool to upgrade

+ +
+ +

Options

+ +
--all

Upgrade all tools

+ +
--cache-dir cache-dir

Path to the cache directory.

+ +

Defaults to $HOME/Library/Caches/uv on macOS, $XDG_CACHE_HOME/uv or $HOME/.cache/uv on Linux, and {FOLDERID_LocalAppData}\uv\cache on Windows.

+ +
--color color-choice

Control colors in output

+ +

[default: auto]

+

Possible values:

+ +
    +
  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • + +
  • always: Enables colored output regardless of the detected environment
  • + +
  • never: Disables colored output
  • +
+
--compile-bytecode

Compile Python files to bytecode after installation.

+ +

By default, uv does not compile Python (.py) files to bytecode (__pycache__/*.pyc); instead, compilation is performed lazily the first time a module is imported. For use-cases in which start time is critical, such as CLI applications and Docker containers, this option can be enabled to trade longer installation times for faster start times.

+ +

When enabled, uv will process the entire site-packages directory (including packages that are not being modified by the current operation) for consistency. Like pip, it will also ignore errors.

+ +
--config-file config-file

The path to a uv.toml file to use for configuration.

+ +

While uv configuration can be included in a pyproject.toml file, it is not allowed in this context.

+ +
--config-setting, -C config-setting

Settings to pass to the PEP 517 build backend, specified as KEY=VALUE pairs

+ +
--exclude-newer exclude-newer

Limit candidate packages to those that were uploaded prior to the given date.

+ +

Accepts both RFC 3339 timestamps (e.g., 2006-12-02T02:07:43Z) and UTC dates in the same format (e.g., 2006-12-02).

+ +
--extra-index-url extra-index-url

Extra URLs of package indexes to use, in addition to --index-url.

+ +

Accepts either a repository compliant with PEP 503 (the simple repository API), or a local directory laid out in the same format.

+ +

All indexes provided via this flag take priority over the index specified by --index-url (which defaults to PyPI). When multiple --extra-index-url flags are provided, earlier values take priority.

+ +
--find-links, -f find-links

Locations to search for candidate distributions, in addition to those found in the registry indexes.

+ +

If a path, the target must be a directory that contains packages as wheel files (.whl) or source distributions (.tar.gz or .zip) at the top level.

+ +

If a URL, the page must contain a flat list of links to package files adhering to the formats described above.

+ +
--help, -h

Display the concise help for this command

+ +
--index-strategy index-strategy

The strategy to use when resolving against multiple index URLs.

+ +

By default, uv will stop at the first index on which a given package is available, and limit resolutions to those present on that first index (first-match). This prevents "dependency confusion" attacks, whereby an attack can upload a malicious package under the same name to a secondary.

+ +

Possible values:

+ +
    +
  • first-index: Only use results from the first index that returns a match for a given package name
  • + +
  • unsafe-first-match: Search for every package name across all indexes, exhausting the versions from the first index before moving on to the next
  • + +
  • unsafe-best-match: Search for every package name across all indexes, preferring the "best" version found. If a package version is in multiple indexes, only look at the entry for the first index
  • +
+
--index-url, -i index-url

The URL of the Python package index (by default: <https://pypi.org/simple>).

+ +

Accepts either a repository compliant with PEP 503 (the simple repository API), or a local directory laid out in the same format.

+ +

The index given by this flag is given lower priority than all other indexes specified via the --extra-index-url flag.

+ +
--keyring-provider keyring-provider

Attempt to use keyring for authentication for index URLs.

+ +

At present, only --keyring-provider subprocess is supported, which configures uv to use the keyring CLI to handle authentication.

+ +

Defaults to disabled.

+ +

Possible values:

+ +
    +
  • disabled: Do not use keyring for credential lookup
  • + +
  • subprocess: Use the keyring command for credential lookup
  • +
+
--link-mode link-mode

The method to use when installing packages from the global cache.

+ +

Defaults to clone (also known as Copy-on-Write) on macOS, and hardlink on Linux and Windows.

+ +

Possible values:

+ +
    +
  • clone: Clone (i.e., copy-on-write) packages from the wheel into the site-packages directory
  • + +
  • copy: Copy packages from the wheel into the site-packages directory
  • + +
  • hardlink: Hard link packages from the wheel into the site-packages directory
  • + +
  • symlink: Symbolically link packages from the wheel into the site-packages directory
  • +
+
--native-tls

Whether to load TLS certificates from the platform’s native certificate store.

+ +

By default, uv loads certificates from the bundled webpki-roots crate. The webpki-roots are a reliable set of trust roots from Mozilla, and including them in uv improves portability and performance (especially on macOS).

+ +

However, in some cases, you may want to use the platform’s native certificate store, especially if you’re relying on a corporate trust root (e.g., for a mandatory proxy) that’s included in your system’s certificate store.

+ +
--no-binary

Don’t install pre-built wheels.

+ +

The given packages will be built and installed from source. The resolver will still use pre-built wheels to extract package metadata, if available.

+ +
--no-binary-package no-binary-package

Don’t install pre-built wheels for a specific package

+ +
--no-build

Don’t build source distributions.

+ +

When enabled, resolving will not run arbitrary Python code. The cached wheels of already-built source distributions will be reused, but operations that require building distributions will exit with an error.

+ +
--no-build-isolation

Disable isolation when building source distributions.

+ +

Assumes that build dependencies specified by PEP 518 are already installed.

+ +
--no-build-isolation-package no-build-isolation-package

Disable isolation when building source distributions for a specific package.

+ +

Assumes that the packages’ build dependencies specified by PEP 518 are already installed.

+ +
--no-build-package no-build-package

Don’t build source distributions for a specific package

+ +
--no-cache, -n

Avoid reading from or writing to the cache, instead using a temporary directory for the duration of the operation

+ +
--no-config

Avoid discovering configuration files (pyproject.toml, uv.toml).

+ +

Normally, configuration files are discovered in the current directory, parent directories, or user configuration directories.

+ +
--no-index

Ignore the registry index (e.g., PyPI), instead relying on direct URL dependencies and those provided via --find-links

+ +
--no-progress

Hide all progress outputs.

+ +

For example, spinners or progress bars.

+ +
--no-sources

Ignore the tool.uv.sources table when resolving dependencies. Used to lock against the standards-compliant, publishable package metadata, as opposed to using any local or Git sources

+ +
--offline

Disable network access.

+ +

When disabled, uv will only use locally cached data and locally available files.

+ +
--prerelease prerelease

The strategy to use when considering pre-release versions.

+ +

By default, uv will accept pre-releases for packages that only publish pre-releases, along with first-party requirements that contain an explicit pre-release marker in the declared specifiers (if-necessary-or-explicit).

+ +

Possible values:

+ +
    +
  • disallow: Disallow all pre-release versions
  • + +
  • allow: Allow all pre-release versions
  • + +
  • if-necessary: Allow pre-release versions if all versions of a package are pre-release
  • + +
  • explicit: Allow pre-release versions for first-party packages with explicit pre-release markers in their version requirements
  • + +
  • if-necessary-or-explicit: Allow pre-release versions if all versions of a package are pre-release, or if the package has an explicit pre-release marker in its version requirements
  • +
+
--python-fetch python-fetch

Whether to automatically download Python when required

+ +

Possible values:

+ +
    +
  • automatic: Automatically fetch managed Python installations when needed
  • + +
  • manual: Do not automatically fetch managed Python installations; require explicit installation
  • +
+
--python-preference python-preference

Whether to prefer uv-managed or system Python installations.

+ +

By default, uv prefers using Python versions it manages. However, it will use system Python installations if a uv-managed Python is not installed. This option allows prioritizing or ignoring system Python installations.

+ +

Possible values:

+ +
    +
  • only-managed: Only use managed Python installations; never use system Python installations
  • + +
  • managed: Prefer managed Python installations over system Python installations
  • + +
  • system: Prefer system Python installations over managed Python installations
  • + +
  • only-system: Only use system Python installations; never use managed Python installations
  • +
+
--quiet, -q

Do not print any output

+ +
--refresh

Refresh all cached data

+ +
--refresh-package refresh-package

Refresh cached data for a specific package

+ +
--reinstall

Reinstall all packages, regardless of whether they’re already installed. Implies --refresh

+ +
--reinstall-package reinstall-package

Reinstall a specific package, regardless of whether it’s already installed. Implies --refresh-package

+ +
--resolution resolution

The strategy to use when selecting between the different compatible versions for a given package requirement.

+ +

By default, uv will use the latest compatible version of each package (highest).

+ +

Possible values:

+ +
    +
  • highest: Resolve the highest compatible version of each package
  • + +
  • lowest: Resolve the lowest compatible version of each package
  • + +
  • lowest-direct: Resolve the lowest compatible version of any direct dependencies, and the highest compatible version of any transitive dependencies
  • +
+
--upgrade, -U

Allow package upgrades, ignoring pinned versions in any existing output file

+ +
--upgrade-package, -P upgrade-package

Allow upgrades for a specific package, ignoring pinned versions in any existing output file

+ +
--verbose, -v

Use verbose output.

+ +

You can configure fine-grained logging using the RUST_LOG environment variable. (<https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives>)

+ +
--version, -V

Display the uv version

+ +
+ ### uv tool list List installed tools