Skip to content

Commit

Permalink
Add uv tool install --force
Browse files Browse the repository at this point in the history
  • Loading branch information
zanieb committed Jun 25, 2024
1 parent da24e57 commit e8685a2
Show file tree
Hide file tree
Showing 5 changed files with 90 additions and 10 deletions.
9 changes: 8 additions & 1 deletion crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ use clap::{Args, Parser, Subcommand};
use distribution_types::{FlatIndexLocation, IndexUrl};
use uv_cache::CacheArgs;
use uv_configuration::{
ConfigSettingEntry, IndexStrategy, KeyringProviderType, PackageNameSpecifier, TargetTriple,
ConfigSettingEntry, IndexStrategy, KeyringProviderType, PackageNameSpecifier,
TargetTriple,
};
use uv_normalize::{ExtraName, PackageName};
use uv_resolver::{AnnotationStyle, ExcludeNewer, PreReleaseMode, ResolutionMode};
Expand Down Expand Up @@ -1889,6 +1890,12 @@ pub struct ToolInstallArgs {
#[command(flatten)]
pub refresh: RefreshArgs,

/// Force installation of the tool.
///
/// Will replace any existing entrypoints with the same name in the executable directory.
#[arg(long)]
pub force: bool,

/// The Python interpreter to use to build the run environment.
///
/// By default, `uv` uses the virtual environment in the current working directory or any parent
Expand Down
59 changes: 52 additions & 7 deletions crates/uv/src/commands/tool/install.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::ffi::OsString;
use std::fmt::Write;
use std::str::FromStr;

Expand Down Expand Up @@ -29,6 +30,7 @@ pub(crate) async fn install(
python: Option<String>,
from: Option<String>,
with: Vec<String>,
force: bool,
settings: ResolverInstallerSettings,
_isolated: bool,
preview: PreviewMode,
Expand All @@ -45,10 +47,14 @@ pub(crate) async fn install(

let installed_tools = InstalledTools::from_settings()?;

// TODO(zanieb): Allow replacing an existing tool
// TODO(zanieb): Automatically replace an existing tool if the request differs
if installed_tools.find_tool_entry(&name)?.is_some() {
writeln!(printer.stderr(), "Tool `{name}` is already installed.")?;
return Ok(ExitStatus::Failure);
if force {
debug!("Replacing existing tool due to `--force` flag.");
} else {
writeln!(printer.stderr(), "Tool `{name}` is already installed.")?;
return Ok(ExitStatus::Failure);
}
}

// TODO(zanieb): Figure out the interface here, do we infer the name or do we match the `run --from` interface?
Expand Down Expand Up @@ -112,18 +118,57 @@ pub(crate) async fn install(
fs_err::create_dir_all(&executable_directory)
.context("Failed to create executable directory")?;

debug!("Installing into {}", executable_directory.user_display());

let entrypoints = entrypoint_paths(
&environment,
installed_dist.name(),
installed_dist.version(),
None,
)?;

// Determine the entry points targets
let targets = entrypoints
.into_iter()
.map(|(name, path)| {
let target = executable_directory.join(
path.file_name()
.map(std::borrow::ToOwned::to_owned)
.unwrap_or_else(|| OsString::from(name.clone())),
);
(name, path, target)
})
.collect::<Vec<_>>();

// Check if they exist, before installing
let mut existing_targets = targets
.iter()
.filter(|(_, _, target)| target.exists())
.peekable();
if force {
for (name, _, target) in existing_targets {
debug!("Removing existing install of `{name}`");
fs_err::remove_file(target)?;
}
} else if existing_targets.peek().is_some() {
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::<Vec<_>>();
let (s, exists) = if existing_targets.len() == 1 {
("", "exists")
} else {
("s", "exist")
};
bail!(
"Entry point{s} for tool already {exists}: {} (use `--force` to overwrite)",
existing_targets.join(", ")
)
}

// TODO(zanieb): Handle the case where there are no entrypoints
// TODO(zanieb): Better error when an entry point exists, check if they all are don't exist first
for (name, path) in entrypoints {
let target = executable_directory.join(path.file_name().unwrap());
debug!("Installing {name} to {}", target.user_display());
for (name, path, target) in targets {
debug!("Installing `{name}`");
replace_symlink(&path, &target).context("Failed to install entrypoint")?;
}

Expand Down
1 change: 1 addition & 0 deletions crates/uv/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,7 @@ async fn run() -> Result<ExitStatus> {
args.python,
args.from,
args.with,
args.force,
args.settings,
globals.isolated,
globals.preview,
Expand Down
3 changes: 3 additions & 0 deletions crates/uv/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ pub(crate) struct ToolInstallSettings {
pub(crate) python: Option<String>,
pub(crate) refresh: Refresh,
pub(crate) settings: ResolverInstallerSettings,
pub(crate) force: bool,
}

impl ToolInstallSettings {
Expand All @@ -252,6 +253,7 @@ impl ToolInstallSettings {
from,
with,
installer,
force,
build,
refresh,
python,
Expand All @@ -262,6 +264,7 @@ impl ToolInstallSettings {
from,
with,
python,
force,
refresh: Refresh::from(refresh),
settings: ResolverInstallerSettings::combine(
resolver_installer_options(installer, build),
Expand Down
28 changes: 26 additions & 2 deletions crates/uv/tests/tool_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,8 @@ fn tool_install_entry_point_exists() {
.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
success: false
exit_code: 2
----- stdout -----
----- stderr -----
Expand All @@ -242,6 +242,7 @@ fn tool_install_entry_point_exists() {
+ 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
Expand All @@ -258,6 +259,29 @@ fn tool_install_entry_point_exists() {
assert_snapshot!(fs_err::read_to_string(bin_dir.join("black")).unwrap(), @"");

});

// Test when multiple entry points exist
bin_dir.child("blackd").touch().unwrap();
uv_snapshot!(context.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: false
exit_code: 2
----- stdout -----
----- stderr -----
warning: `uv tool install` is experimental and may change without warning.
Resolved 6 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 points for tool already exist: blackd, black (use `--force` to overwrite)
"###);
}

/// Test `uv tool install` when the bin directory is inferred from `$HOME`
Expand Down

0 comments on commit e8685a2

Please sign in to comment.