Add support for --reinstall and --reinstall-package in uv tool install (#4504)

Adds support for `--reinstall` and `--reinstall-package` to `uv tool
install`. These are already available via the installer settings, we
just respect them now.

`--reinstall` implies a recreation of the environment and reinstallation
of the entry points.
`--reinstall-package` will only update a subset of the environment. If
the target package is the one with the entry points, we'll reinstall the
entry points. Otherwise, the entry points are not changed.
This commit is contained in:
Zanie Blue 2024-06-26 16:23:34 -04:00 committed by GitHub
parent 747ab0d9f7
commit b44c47fdab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 221 additions and 42 deletions

1
Cargo.lock generated
View file

@ -5027,6 +5027,7 @@ dependencies = [
"toml",
"toml_edit",
"tracing",
"uv-cache",
"uv-fs",
"uv-state",
"uv-toolchain",

View file

@ -21,6 +21,7 @@ uv-virtualenv = { workspace = true }
uv-toolchain = { workspace = true }
install-wheel-rs = { workspace = true }
pep440_rs = { workspace = true }
uv-cache = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }

View file

@ -8,6 +8,7 @@ use std::io::{self, Write};
use std::path::{Path, PathBuf};
use thiserror::Error;
use tracing::debug;
use uv_cache::Cache;
use uv_fs::{LockedFile, Simplified};
use uv_toolchain::{Interpreter, PythonEnvironment};
@ -21,9 +22,9 @@ pub enum Error {
#[error(transparent)]
IO(#[from] io::Error),
// TODO(zanieb): Improve the error handling here
#[error("Failed to update `tools.toml` at {0}")]
#[error("Failed to update `tools.toml` metadata at {0}")]
TomlEdit(PathBuf, #[source] tools_toml::Error),
#[error("Failed to read `tools.toml` at {0}")]
#[error("Failed to read `tools.toml` metadata at {0}")]
TomlRead(PathBuf, #[source] Box<toml::de::Error>),
#[error(transparent)]
VirtualEnvError(#[from] uv_virtualenv::Error),
@ -33,6 +34,8 @@ pub enum Error {
DistInfoMissing(String, PathBuf),
#[error("Failed to find a directory for executables")]
NoExecutableDirectory,
#[error(transparent)]
EnvironmentError(#[from] uv_toolchain::Error),
}
/// A collection of uv-managed tools installed on the current system.
@ -121,16 +124,26 @@ impl InstalledTools {
Ok(())
}
pub fn create_environment(
pub fn environment(
&self,
name: &str,
remove_existing: bool,
interpreter: Interpreter,
cache: &Cache,
) -> Result<PythonEnvironment, Error> {
let _lock = self.acquire_lock();
let environment_path = self.root.join(name);
if !remove_existing && environment_path.exists() {
debug!(
"Using existing environment for tool `{name}` at `{}`.",
environment_path.user_display()
);
return Ok(PythonEnvironment::from_root(environment_path, cache)?);
}
debug!(
"Creating environment for tool `{name}` at {}.",
"Creating environment for tool `{name}` at `{}`.",
environment_path.user_display()
);

View file

@ -9,10 +9,10 @@ use itertools::Itertools;
use pep508_rs::Requirement;
use pypi_types::VerbatimParsedUrl;
use tracing::debug;
use tracing::{debug, trace};
use uv_cache::Cache;
use uv_client::Connectivity;
use uv_configuration::{Concurrency, PreviewMode};
use uv_configuration::{Concurrency, PreviewMode, Reinstall};
#[cfg(unix)]
use uv_fs::replace_symlink;
use uv_fs::Simplified;
@ -47,29 +47,44 @@ pub(crate) async fn install(
if preview.is_disabled() {
warn_user_once!("`uv tool install` is experimental and may change without warning.");
}
let from = from.unwrap_or(name.clone());
let installed_tools = InstalledTools::from_settings()?;
// TODO(zanieb): Figure out the interface here, do we infer the name or do we match the `run --from` interface?
let from = Requirement::<VerbatimParsedUrl>::from_str(&from)?;
let existing_tool_entry = installed_tools.find_tool_entry(&name)?;
// TODO(zanieb): Automatically replace an existing tool if the request differs
if installed_tools.find_tool_entry(&name)?.is_some() {
let reinstall_entry_points = if existing_tool_entry.is_some() {
if force {
debug!("Replacing existing tool due to `--force` flag.");
false
} else {
writeln!(printer.stderr(), "Tool `{name}` is already installed.")?;
return Ok(ExitStatus::Failure);
match settings.reinstall {
Reinstall::All => {
debug!("Replacing existing tool due to `--reinstall` flag.");
true
}
// Do not replace the entry points unless the tool is explicitly requested
Reinstall::Packages(ref packages) => packages.contains(&from.name),
// If not reinstalling... then we're done
Reinstall::None => {
writeln!(printer.stderr(), "Tool `{name}` is already installed")?;
return Ok(ExitStatus::Failure);
}
}
}
}
} else {
false
};
// TODO(zanieb): Figure out the interface here, do we infer the name or do we match the `run --from` interface?
let from = from.unwrap_or(name.clone());
let requirements = [Requirement::from_str(&from)]
let requirements = [Requirement::from_str(from.name.as_ref())]
.into_iter()
.chain(with.iter().map(|name| Requirement::from_str(name)))
.collect::<Result<Vec<Requirement<VerbatimParsedUrl>>, _>>()?;
// TODO(zanieb): Duplicative with the above parsing but needed for `update_environment`
let requirements_sources = [RequirementsSource::from_package(from.clone())]
let requirements_sources = [RequirementsSource::from_package(from.name.to_string())]
.into_iter()
.chain(with.into_iter().map(RequirementsSource::from_package))
.collect::<Vec<_>>();
@ -93,7 +108,13 @@ pub(crate) async fn install(
// 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
let environment = installed_tools.create_environment(&name, interpreter)?;
let environment = installed_tools.environment(
&name,
// Do not remove the existing environment if we're reinstalling a subset of packages
!matches!(settings.reinstall, Reinstall::Packages(_)),
interpreter,
cache,
)?;
// Install the ephemeral requirements.
let environment = update_environment(
@ -115,13 +136,23 @@ pub(crate) async fn install(
bail!("Expected at least one requirement")
};
// Exit early if we're not supposed to be reinstalling entry points
// e.g. `--reinstall-package` was used for some dependency
if existing_tool_entry.is_some() && !reinstall_entry_points {
writeln!(printer.stderr(), "Updated environment for tool `{name}`")?;
return Ok(ExitStatus::Success);
}
// Find a suitable path to install into
// TODO(zanieb): Warn if this directory is not on the PATH
let executable_directory = find_executable_directory()?;
fs_err::create_dir_all(&executable_directory)
.context("Failed to create executable directory")?;
debug!("Installing into {}", executable_directory.user_display());
debug!(
"Installing tool entry points into {}",
executable_directory.user_display()
);
let entrypoints = entrypoint_paths(
&environment,
@ -130,6 +161,7 @@ pub(crate) async fn install(
)?;
// Determine the entry points targets
// Use a sorted collection for deterministic output
let targets = entrypoints
.into_iter()
.map(|(name, path)| {
@ -140,16 +172,19 @@ pub(crate) async fn install(
);
(name, path, target)
})
.collect::<Vec<_>>();
.collect::<BTreeSet<_>>();
// Check if they exist, before installing
let mut existing_targets = targets
.iter()
.filter(|(_, _, target)| target.exists())
.peekable();
if force {
// Note we use `reinstall_entry_points` here instead of `reinstall`; requesting reinstall
// will _not_ remove existing entry points when they are not managed by uv.
if force || reinstall_entry_points {
for (name, _, target) in existing_targets {
debug!("Removing existing install of `{name}`");
debug!("Removing existing entry point `{name}`");
fs_err::remove_file(target)?;
}
} else if existing_targets.peek().is_some() {
@ -159,7 +194,7 @@ pub(crate) async fn install(
let existing_targets = existing_targets
// SAFETY: We know the target has a filename because we just constructed it above
.map(|(_, _, target)| target.file_name().unwrap().to_string_lossy())
.collect::<BTreeSet<_>>();
.collect::<Vec<_>>();
let (s, exists) = if existing_targets.len() == 1 {
("", "exists")
} else {
@ -172,15 +207,23 @@ pub(crate) async fn install(
}
// TODO(zanieb): Handle the case where there are no entrypoints
for (name, path, target) in targets {
for (name, path, target) in &targets {
debug!("Installing `{name}`");
#[cfg(unix)]
replace_symlink(&path, &target).context("Failed to install entrypoint")?;
replace_symlink(path, target).context("Failed to install entrypoint")?;
#[cfg(windows)]
fs_err::copy(&path, &target).context("Failed to install entrypoint")?;
fs_err::copy(path, target).context("Failed to install entrypoint")?;
}
writeln!(
printer.stdout(),
"Installed: {}",
targets.iter().map(|(name, _, _)| name).join(", ")
)?;
debug!("Adding `{name}` to {}", path.user_display());
trace!(
"Tracking installed tool `{name}` in tool metadata at `{}`",
path.user_display()
);
let installed_tools = installed_tools.init()?;
installed_tools.add_tool_entry(&name, &tool)?;

View file

@ -27,6 +27,7 @@ fn tool_install() {
success: true
exit_code: 0
----- stdout -----
Installed: black, blackd
----- stderr -----
warning: `uv tool install` is experimental and may change without warning.
@ -96,6 +97,7 @@ fn tool_install() {
success: true
exit_code: 0
----- stdout -----
Installed: flask
----- stderr -----
warning: `uv tool install` is experimental and may change without warning.
@ -156,25 +158,33 @@ fn tool_install() {
});
}
/// Test installing a tool twice with `uv tool install`
/// Test installing and reinstalling an already installed tool
#[test]
fn tool_install_twice() {
fn tool_install_already_installed() {
let context = TestContext::new("3.12");
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");
// Drop resolved counts, they differ on Windows and are not relevant here
let filters = context
.filters()
.into_iter()
.chain([("Resolved [0-9] packages", "Resolved [COUNT] packages")])
.collect::<Vec<_>>();
// Install `black`
uv_snapshot!(context.filters(), context.tool_install()
uv_snapshot!(filters, context.tool_install()
.arg("black")
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
Installed: black, blackd
----- stderr -----
warning: `uv tool install` is experimental and may change without warning.
Resolved 6 packages in [TIME]
Resolved [COUNT] packages in [TIME]
Prepared 6 packages in [TIME]
Installed 6 packages in [TIME]
+ black==24.3.0
@ -223,7 +233,7 @@ fn tool_install_twice() {
});
// Install `black` again
uv_snapshot!(context.filters(), context.tool_install()
uv_snapshot!(filters, context.tool_install()
.arg("black")
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###"
@ -233,7 +243,7 @@ fn tool_install_twice() {
----- stderr -----
warning: `uv tool install` is experimental and may change without warning.
Tool `black` is already installed.
Tool `black` is already installed
"###);
tool_dir.child("black").assert(predicate::path::is_dir());
@ -250,6 +260,74 @@ fn tool_install_twice() {
black = { requirements = ["black"] }
"###);
});
// Install `black` again with the `--reinstall` flag
// We should recreate the entire environment and reinstall the entry points
uv_snapshot!(filters, context.tool_install()
.arg("black")
.arg("--reinstall")
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
Installed: black, blackd
----- stderr -----
warning: `uv tool install` is experimental and may change without warning.
Resolved [COUNT] packages in [TIME]
Installed 6 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
"###);
// Install `black` again with `--reinstall-package` for `black`
// We should reinstall `black` in the environment and reinstall the entry points
uv_snapshot!(filters, context.tool_install()
.arg("black")
.arg("--reinstall-package")
.arg("black")
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
Installed: black, blackd
----- stderr -----
warning: `uv tool install` is experimental and may change without warning.
Resolved [COUNT] packages in [TIME]
Uninstalled 1 package in [TIME]
Installed 1 package in [TIME]
- black==24.3.0
+ black==24.3.0
"###);
// Install `black` again with `--reinstall-package` for a dependency
// We should reinstall `click` in the environment but not reinstall the entry points
uv_snapshot!(filters, context.tool_install()
.arg("black")
.arg("--reinstall-package")
.arg("click")
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", 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 [COUNT] packages in [TIME]
Uninstalled 1 package in [TIME]
Installed 1 package in [TIME]
- click==8.1.7
+ click==8.1.7
Updated environment for tool `black`
"###);
}
/// Test installing a tool when its entry point already exists
@ -262,14 +340,18 @@ fn tool_install_entry_point_exists() {
let executable = bin_dir.child(format!("black{}", std::env::consts::EXE_SUFFIX));
executable.touch().unwrap();
// Drop executable suffixes for cross-platform snapshtos
// Drop executable suffixes for cross-platform snapshots
// Drop resolved counts, they differ on Windows and are not relevant here
let filters = context
.filters()
.into_iter()
.chain([(std::env::consts::EXE_SUFFIX, "")])
.chain([
(std::env::consts::EXE_SUFFIX, ""),
("Resolved [0-9] packages", "Resolved [COUNT] packages"),
])
.collect::<Vec<_>>();
// Install `black`
// Attempt to install `black`
uv_snapshot!(filters, context.tool_install()
.arg("black")
.env("UV_TOOL_DIR", tool_dir.as_os_str())
@ -280,7 +362,7 @@ fn tool_install_entry_point_exists() {
----- stderr -----
warning: `uv tool install` is experimental and may change without warning.
Resolved 6 packages in [TIME]
Resolved [COUNT] packages in [TIME]
Prepared 6 packages in [TIME]
Installed 6 packages in [TIME]
+ black==24.3.0
@ -302,7 +384,45 @@ fn tool_install_entry_point_exists() {
filters => context.filters(),
}, {
// Nor should we change the `black` entry point that exists
assert_snapshot!(fs_err::read_to_string(bin_dir.join(format!("black{}", std::env::consts::EXE_SUFFIX))).unwrap(), @"");
assert_snapshot!(fs_err::read_to_string(&executable).unwrap(), @"");
});
// Attempt to install `black` with the `--reinstall` flag
// Should have no effect
uv_snapshot!(filters, context.tool_install()
.arg("black")
.arg("--reinstall")
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
warning: `uv tool install` is experimental and may change without warning.
Resolved [COUNT] packages in [TIME]
Installed 6 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
error: Entry point for tool already exists: black (use `--force` to overwrite)
"###);
// We should not create a virtual environment
assert!(!tool_dir.child("black").exists());
// We should not write a tools entry
assert!(!tool_dir.join("tools.toml").exists());
insta::with_settings!({
filters => context.filters(),
}, {
// Nor should we change the `black` entry point that exists
assert_snapshot!(fs_err::read_to_string(&executable).unwrap(), @"");
});
@ -321,7 +441,7 @@ fn tool_install_entry_point_exists() {
----- stderr -----
warning: `uv tool install` is experimental and may change without warning.
Resolved 6 packages in [TIME]
Resolved [COUNT] packages in [TIME]
Installed 6 packages in [TIME]
+ black==24.3.0
+ click==8.1.7
@ -333,7 +453,7 @@ fn tool_install_entry_point_exists() {
"###);
// Install `black` with `--force`
uv_snapshot!(context.filters(), context.tool_install()
uv_snapshot!(filters, context.tool_install()
.arg("black")
.arg("--force")
.env("UV_TOOL_DIR", tool_dir.as_os_str())
@ -341,10 +461,11 @@ fn tool_install_entry_point_exists() {
success: true
exit_code: 0
----- stdout -----
Installed: black, blackd
----- stderr -----
warning: `uv tool install` is experimental and may change without warning.
Resolved 6 packages in [TIME]
Resolved [COUNT] packages in [TIME]
Installed 6 packages in [TIME]
+ black==24.3.0
+ click==8.1.7
@ -366,9 +487,6 @@ fn tool_install_entry_point_exists() {
"###);
});
let executable = bin_dir.child(format!("black{}", std::env::consts::EXE_SUFFIX));
assert!(executable.exists());
// On Windows, we can't snapshot an executable file.
#[cfg(not(windows))]
insta::with_settings!({
@ -423,6 +541,7 @@ fn tool_install_home() {
success: true
exit_code: 0
----- stdout -----
Installed: black, blackd
----- stderr -----
warning: `uv tool install` is experimental and may change without warning.
@ -458,6 +577,7 @@ fn tool_install_xdg_data_home() {
success: true
exit_code: 0
----- stdout -----
Installed: black, blackd
----- stderr -----
warning: `uv tool install` is experimental and may change without warning.
@ -493,6 +613,7 @@ fn tool_install_xdg_bin_home() {
success: true
exit_code: 0
----- stdout -----
Installed: black, blackd
----- stderr -----
warning: `uv tool install` is experimental and may change without warning.