Add `uv tool upgrade` command (#5197)

## Summary

Resolves #5188. Most of the changes involve creating a new function in
`tool/common.rs` to contain the common functionality previously found in
`tool/install.rs`.

## Test Plan

`cargo test`

```console
❯ ./target/debug/uv tool upgrade black
warning: `uv tool upgrade` is experimental and may change without warning.
Resolved 6 packages in 25ms
Uninstalled 1 package in 3ms
Installed 1 package in 19ms
 - black==23.1.0
 + black==24.4.2
Installed 2 executables: black, blackd
```
This commit is contained in:
Ahmed Ilyas 2024-08-08 22:48:14 +02:00 committed by GitHub
parent bf0497e652
commit cbc3274848
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 987 additions and 226 deletions

View File

@ -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<PackageName>,
/// 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 {

View File

@ -170,6 +170,10 @@ impl Tool {
pub fn requirements(&self) -> &[Requirement] {
&self.requirements
}
pub fn python(&self) -> &Option<String> {
&self.python
}
}
impl ToolEntrypoint {

View File

@ -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;

View File

@ -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<InstalledDist> {
@ -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<String>,
requirements: Vec<Requirement>,
action: InstallAction,
) -> anyhow::Result<ExitStatus> {
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::<BTreeSet<_>>();
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::<Vec<_>>();
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(())
}

View File

@ -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::<BTreeSet<_>>();
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::<Vec<_>>();
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,
)
}

View File

@ -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;

View File

@ -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<PackageName>,
connectivity: Connectivity,
settings: ResolverInstallerSettings,
concurrency: Concurrency,
native_tls: bool,
cache: &Cache,
preview: PreviewMode,
printer: Printer,
) -> Result<ExitStatus> {
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<PackageName> =
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)
}

View File

@ -796,6 +796,28 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
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),
}) => {

View File

@ -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<PackageName>,
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<FilesystemOptions>) -> 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)]

View File

@ -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();

View File

@ -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
"###);
}

View File

@ -1796,6 +1796,8 @@ uv tool [OPTIONS] <COMMAND>
</dd>
<dt><a href="#uv-tool-install"><code>uv tool install</code></a></dt><dd><p>Install a tool</p>
</dd>
<dt><a href="#uv-tool-upgrade"><code>uv tool upgrade</code></a></dt><dd><p>Upgrade a tool</p>
</dd>
<dt><a href="#uv-tool-list"><code>uv tool list</code></a></dt><dd><p>List installed tools</p>
</dd>
<dt><a href="#uv-tool-uninstall"><code>uv tool uninstall</code></a></dt><dd><p>Uninstall a tool</p>
@ -2288,6 +2290,239 @@ uv tool install [OPTIONS] <PACKAGE>
</dd></dl>
### uv tool upgrade
Upgrade a tool
<h3 class="cli-reference">Usage</h3>
```
uv tool upgrade [OPTIONS] <NAME>
```
<h3 class="cli-reference">Arguments</h3>
<dl class="cli-reference"><dt><code>NAME</code></dt><dd><p>The name of the tool to upgrade</p>
</dd></dl>
<h3 class="cli-reference">Options</h3>
<dl class="cli-reference"><dt><code>--all</code></dt><dd><p>Upgrade all tools</p>
</dd><dt><code>--cache-dir</code> <i>cache-dir</i></dt><dd><p>Path to the cache directory.</p>
<p>Defaults to <code>$HOME/Library/Caches/uv</code> on macOS, <code>$XDG_CACHE_HOME/uv</code> or <code>$HOME/.cache/uv</code> on Linux, and <code>{FOLDERID_LocalAppData}\uv\cache</code> on Windows.</p>
</dd><dt><code>--color</code> <i>color-choice</i></dt><dd><p>Control colors in output</p>
<p>[default: auto]</p>
<p>Possible values:</p>
<ul>
<li><code>auto</code>: Enables colored output only when the output is going to a terminal or TTY with support</li>
<li><code>always</code>: Enables colored output regardless of the detected environment</li>
<li><code>never</code>: Disables colored output</li>
</ul>
</dd><dt><code>--compile-bytecode</code></dt><dd><p>Compile Python files to bytecode after installation.</p>
<p>By default, uv does not compile Python (<code>.py</code>) files to bytecode (<code>__pycache__/*.pyc</code>); 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.</p>
<p>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.</p>
</dd><dt><code>--config-file</code> <i>config-file</i></dt><dd><p>The path to a <code>uv.toml</code> file to use for configuration.</p>
<p>While uv configuration can be included in a <code>pyproject.toml</code> file, it is not allowed in this context.</p>
</dd><dt><code>--config-setting</code>, <code>-C</code> <i>config-setting</i></dt><dd><p>Settings to pass to the PEP 517 build backend, specified as <code>KEY=VALUE</code> pairs</p>
</dd><dt><code>--exclude-newer</code> <i>exclude-newer</i></dt><dd><p>Limit candidate packages to those that were uploaded prior to the given date.</p>
<p>Accepts both RFC 3339 timestamps (e.g., <code>2006-12-02T02:07:43Z</code>) and UTC dates in the same format (e.g., <code>2006-12-02</code>).</p>
</dd><dt><code>--extra-index-url</code> <i>extra-index-url</i></dt><dd><p>Extra URLs of package indexes to use, in addition to <code>--index-url</code>.</p>
<p>Accepts either a repository compliant with PEP 503 (the simple repository API), or a local directory laid out in the same format.</p>
<p>All indexes provided via this flag take priority over the index specified by <code>--index-url</code> (which defaults to PyPI). When multiple <code>--extra-index-url</code> flags are provided, earlier values take priority.</p>
</dd><dt><code>--find-links</code>, <code>-f</code> <i>find-links</i></dt><dd><p>Locations to search for candidate distributions, in addition to those found in the registry indexes.</p>
<p>If a path, the target must be a directory that contains packages as wheel files (<code>.whl</code>) or source distributions (<code>.tar.gz</code> or <code>.zip</code>) at the top level.</p>
<p>If a URL, the page must contain a flat list of links to package files adhering to the formats described above.</p>
</dd><dt><code>--help</code>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>
</dd><dt><code>--index-strategy</code> <i>index-strategy</i></dt><dd><p>The strategy to use when resolving against multiple index URLs.</p>
<p>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 (<code>first-match</code>). This prevents &quot;dependency confusion&quot; attacks, whereby an attack can upload a malicious package under the same name to a secondary.</p>
<p>Possible values:</p>
<ul>
<li><code>first-index</code>: Only use results from the first index that returns a match for a given package name</li>
<li><code>unsafe-first-match</code>: Search for every package name across all indexes, exhausting the versions from the first index before moving on to the next</li>
<li><code>unsafe-best-match</code>: Search for every package name across all indexes, preferring the &quot;best&quot; version found. If a package version is in multiple indexes, only look at the entry for the first index</li>
</ul>
</dd><dt><code>--index-url</code>, <code>-i</code> <i>index-url</i></dt><dd><p>The URL of the Python package index (by default: &lt;https://pypi.org/simple&gt;).</p>
<p>Accepts either a repository compliant with PEP 503 (the simple repository API), or a local directory laid out in the same format.</p>
<p>The index given by this flag is given lower priority than all other indexes specified via the <code>--extra-index-url</code> flag.</p>
</dd><dt><code>--keyring-provider</code> <i>keyring-provider</i></dt><dd><p>Attempt to use <code>keyring</code> for authentication for index URLs.</p>
<p>At present, only <code>--keyring-provider subprocess</code> is supported, which configures uv to use the <code>keyring</code> CLI to handle authentication.</p>
<p>Defaults to <code>disabled</code>.</p>
<p>Possible values:</p>
<ul>
<li><code>disabled</code>: Do not use keyring for credential lookup</li>
<li><code>subprocess</code>: Use the <code>keyring</code> command for credential lookup</li>
</ul>
</dd><dt><code>--link-mode</code> <i>link-mode</i></dt><dd><p>The method to use when installing packages from the global cache.</p>
<p>Defaults to <code>clone</code> (also known as Copy-on-Write) on macOS, and <code>hardlink</code> on Linux and Windows.</p>
<p>Possible values:</p>
<ul>
<li><code>clone</code>: Clone (i.e., copy-on-write) packages from the wheel into the <code>site-packages</code> directory</li>
<li><code>copy</code>: Copy packages from the wheel into the <code>site-packages</code> directory</li>
<li><code>hardlink</code>: Hard link packages from the wheel into the <code>site-packages</code> directory</li>
<li><code>symlink</code>: Symbolically link packages from the wheel into the <code>site-packages</code> directory</li>
</ul>
</dd><dt><code>--native-tls</code></dt><dd><p>Whether to load TLS certificates from the platform&#8217;s native certificate store.</p>
<p>By default, uv loads certificates from the bundled <code>webpki-roots</code> crate. The <code>webpki-roots</code> are a reliable set of trust roots from Mozilla, and including them in uv improves portability and performance (especially on macOS).</p>
<p>However, in some cases, you may want to use the platform&#8217;s native certificate store, especially if you&#8217;re relying on a corporate trust root (e.g., for a mandatory proxy) that&#8217;s included in your system&#8217;s certificate store.</p>
</dd><dt><code>--no-binary</code></dt><dd><p>Don&#8217;t install pre-built wheels.</p>
<p>The given packages will be built and installed from source. The resolver will still use pre-built wheels to extract package metadata, if available.</p>
</dd><dt><code>--no-binary-package</code> <i>no-binary-package</i></dt><dd><p>Don&#8217;t install pre-built wheels for a specific package</p>
</dd><dt><code>--no-build</code></dt><dd><p>Don&#8217;t build source distributions.</p>
<p>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.</p>
</dd><dt><code>--no-build-isolation</code></dt><dd><p>Disable isolation when building source distributions.</p>
<p>Assumes that build dependencies specified by PEP 518 are already installed.</p>
</dd><dt><code>--no-build-isolation-package</code> <i>no-build-isolation-package</i></dt><dd><p>Disable isolation when building source distributions for a specific package.</p>
<p>Assumes that the packages&#8217; build dependencies specified by PEP 518 are already installed.</p>
</dd><dt><code>--no-build-package</code> <i>no-build-package</i></dt><dd><p>Don&#8217;t build source distributions for a specific package</p>
</dd><dt><code>--no-cache</code>, <code>-n</code></dt><dd><p>Avoid reading from or writing to the cache, instead using a temporary directory for the duration of the operation</p>
</dd><dt><code>--no-config</code></dt><dd><p>Avoid discovering configuration files (<code>pyproject.toml</code>, <code>uv.toml</code>).</p>
<p>Normally, configuration files are discovered in the current directory, parent directories, or user configuration directories.</p>
</dd><dt><code>--no-index</code></dt><dd><p>Ignore the registry index (e.g., PyPI), instead relying on direct URL dependencies and those provided via <code>--find-links</code></p>
</dd><dt><code>--no-progress</code></dt><dd><p>Hide all progress outputs.</p>
<p>For example, spinners or progress bars.</p>
</dd><dt><code>--no-sources</code></dt><dd><p>Ignore the <code>tool.uv.sources</code> table when resolving dependencies. Used to lock against the standards-compliant, publishable package metadata, as opposed to using any local or Git sources</p>
</dd><dt><code>--offline</code></dt><dd><p>Disable network access.</p>
<p>When disabled, uv will only use locally cached data and locally available files.</p>
</dd><dt><code>--prerelease</code> <i>prerelease</i></dt><dd><p>The strategy to use when considering pre-release versions.</p>
<p>By default, uv will accept pre-releases for packages that <em>only</em> publish pre-releases, along with first-party requirements that contain an explicit pre-release marker in the declared specifiers (<code>if-necessary-or-explicit</code>).</p>
<p>Possible values:</p>
<ul>
<li><code>disallow</code>: Disallow all pre-release versions</li>
<li><code>allow</code>: Allow all pre-release versions</li>
<li><code>if-necessary</code>: Allow pre-release versions if all versions of a package are pre-release</li>
<li><code>explicit</code>: Allow pre-release versions for first-party packages with explicit pre-release markers in their version requirements</li>
<li><code>if-necessary-or-explicit</code>: 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</li>
</ul>
</dd><dt><code>--python-fetch</code> <i>python-fetch</i></dt><dd><p>Whether to automatically download Python when required</p>
<p>Possible values:</p>
<ul>
<li><code>automatic</code>: Automatically fetch managed Python installations when needed</li>
<li><code>manual</code>: Do not automatically fetch managed Python installations; require explicit installation</li>
</ul>
</dd><dt><code>--python-preference</code> <i>python-preference</i></dt><dd><p>Whether to prefer uv-managed or system Python installations.</p>
<p>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.</p>
<p>Possible values:</p>
<ul>
<li><code>only-managed</code>: Only use managed Python installations; never use system Python installations</li>
<li><code>managed</code>: Prefer managed Python installations over system Python installations</li>
<li><code>system</code>: Prefer system Python installations over managed Python installations</li>
<li><code>only-system</code>: Only use system Python installations; never use managed Python installations</li>
</ul>
</dd><dt><code>--quiet</code>, <code>-q</code></dt><dd><p>Do not print any output</p>
</dd><dt><code>--refresh</code></dt><dd><p>Refresh all cached data</p>
</dd><dt><code>--refresh-package</code> <i>refresh-package</i></dt><dd><p>Refresh cached data for a specific package</p>
</dd><dt><code>--reinstall</code></dt><dd><p>Reinstall all packages, regardless of whether they&#8217;re already installed. Implies <code>--refresh</code></p>
</dd><dt><code>--reinstall-package</code> <i>reinstall-package</i></dt><dd><p>Reinstall a specific package, regardless of whether it&#8217;s already installed. Implies <code>--refresh-package</code></p>
</dd><dt><code>--resolution</code> <i>resolution</i></dt><dd><p>The strategy to use when selecting between the different compatible versions for a given package requirement.</p>
<p>By default, uv will use the latest compatible version of each package (<code>highest</code>).</p>
<p>Possible values:</p>
<ul>
<li><code>highest</code>: Resolve the highest compatible version of each package</li>
<li><code>lowest</code>: Resolve the lowest compatible version of each package</li>
<li><code>lowest-direct</code>: Resolve the lowest compatible version of any direct dependencies, and the highest compatible version of any transitive dependencies</li>
</ul>
</dd><dt><code>--upgrade</code>, <code>-U</code></dt><dd><p>Allow package upgrades, ignoring pinned versions in any existing output file</p>
</dd><dt><code>--upgrade-package</code>, <code>-P</code> <i>upgrade-package</i></dt><dd><p>Allow upgrades for a specific package, ignoring pinned versions in any existing output file</p>
</dd><dt><code>--verbose</code>, <code>-v</code></dt><dd><p>Use verbose output.</p>
<p>You can configure fine-grained logging using the <code>RUST_LOG</code> environment variable. (&lt;https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives&gt;)</p>
</dd><dt><code>--version</code>, <code>-V</code></dt><dd><p>Display the uv version</p>
</dd></dl>
### uv tool list
List installed tools