diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index d95971923..c94437c7b 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -10,7 +10,9 @@ use tracing::debug; use uv_cache::Cache; use uv_client::Connectivity; use uv_configuration::{Concurrency, PreviewMode}; -use uv_fs::{replace_symlink, Simplified}; +#[cfg(unix)] +use uv_fs::replace_symlink; +use uv_fs::Simplified; use uv_installer::SitePackages; use uv_requirements::RequirementsSource; use uv_tool::{entrypoint_paths, find_executable_directory, InstalledTools, Tool}; @@ -122,7 +124,10 @@ pub(crate) async fn install( for (name, path) in entrypoints { let target = executable_directory.join(path.file_name().unwrap()); debug!("Installing {name} to {}", target.user_display()); + #[cfg(unix)] replace_symlink(&path, &target).context("Failed to install entrypoint")?; + #[cfg(windows)] + fs_err::copy(&path, &target).context("Failed to install entrypoint")?; } debug!("Adding `{name}` to {}", path.user_display()); diff --git a/crates/uv/tests/common/mod.rs b/crates/uv/tests/common/mod.rs index 15396962a..d0dda7bf0 100644 --- a/crates/uv/tests/common/mod.rs +++ b/crates/uv/tests/common/mod.rs @@ -362,6 +362,26 @@ impl TestContext { command } + /// Create a `uv tool install` command with options shared across scenarios. + pub fn tool_install(&self) -> std::process::Command { + let mut command = self.tool_install_without_exclude_newer(); + command.arg("--exclude-newer").arg(EXCLUDE_NEWER); + command + } + + /// Create a `uv tool install` command with no `--exclude-newer` option. + /// + /// One should avoid using this in tests to the extent possible because + /// it can result in tests failing when the index state changes. Therefore, + /// if you use this, there should be some other kind of mitigation in place. + /// For example, pinning package versions. + pub fn tool_install_without_exclude_newer(&self) -> std::process::Command { + let mut command = std::process::Command::new(get_bin()); + command.arg("tool").arg("install"); + self.add_shared_args(&mut command); + command + } + /// Create a `uv add` command for the given requirements. pub fn add(&self, reqs: &[&str]) -> Command { let mut command = Command::new(get_bin()); @@ -641,6 +661,7 @@ pub fn python_toolchains_for_versions( #[derive(Debug, Copy, Clone)] pub enum WindowsFilters { + CachedPlatform, Platform, Universal, } @@ -721,6 +742,10 @@ pub fn run_and_format>( WindowsFilters::Platform => { ["Resolved", "Prepared", "Installed", "Uninstalled"].iter() } + // When cached, "Prepared" should not change. + WindowsFilters::CachedPlatform => { + ["Resolved", "Installed", "Uninstalled"].iter() + } WindowsFilters::Universal => { ["Prepared", "Installed", "Uninstalled"].iter() } @@ -814,6 +839,12 @@ macro_rules! uv_snapshot { ::insta::assert_snapshot!(snapshot, @$snapshot); output }}; + ($filters:expr, cached_windows_filters=true, $spawnable:expr, @$snapshot:literal) => {{ + // Take a reference for backwards compatibility with the vec-expecting insta filters. + let (snapshot, output) = $crate::common::run_and_format($spawnable, &$filters, function_name!(), Some($crate::common::WindowsFilters::CachedPlatform)); + ::insta::assert_snapshot!(snapshot, @$snapshot); + output + }}; } /// diff --git a/crates/uv/tests/tool_install.rs b/crates/uv/tests/tool_install.rs new file mode 100644 index 000000000..77520ebff --- /dev/null +++ b/crates/uv/tests/tool_install.rs @@ -0,0 +1,406 @@ +#![cfg(all(feature = "python", feature = "pypi"))] + +use std::process::Command; + +use assert_fs::{ + assert::PathAssert, + fixture::{FileTouch, PathChild}, +}; +use common::{uv_snapshot, TestContext}; +use insta::assert_snapshot; +use predicates::prelude::predicate; + +mod common; + +/// Test installing a tool with `uv tool install` +#[test] +fn tool_install() { + let context = TestContext::new("3.12"); + 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") + .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 6 packages in [TIME] + Prepared 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 + "###); + + tool_dir.child("black").assert(predicate::path::is_dir()); + tool_dir + .child("tools.toml") + .assert(predicate::path::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!({ + filters => context.filters(), + }, { + // Should run black in the virtual environment + assert_snapshot!(fs_err::read_to_string(executable).unwrap(), @r###" + #![TEMP_DIR]/tools/black/bin/python + # -*- coding: utf-8 -*- + import re + import sys + from black import patched_main + if __name__ == "__main__": + sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0]) + sys.exit(patched_main()) + "###); + + }); + + insta::with_settings!({ + filters => context.filters(), + }, { + // We should have a tool entry + assert_snapshot!(fs_err::read_to_string(tool_dir.join("tools.toml")).unwrap(), @r###" + [tools] + black = { requirements = ["black"] } + "###); + }); + + uv_snapshot!(context.filters(), Command::new("black").arg("--version").env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + black, 24.3.0 (compiled: yes) + Python (CPython) 3.12.[X] + + ----- stderr ----- + "###); + + // Install another tool + uv_snapshot!(context.filters(), cached_windows_filters=true, context.tool_install() + .arg("flask") + .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 7 packages in [TIME] + Prepared 6 packages in [TIME] + Installed 7 packages in [TIME] + + blinker==1.7.0 + + click==8.1.7 + + flask==3.0.2 + + itsdangerous==2.1.2 + + jinja2==3.1.3 + + markupsafe==2.1.5 + + werkzeug==3.0.1 + "###); + + tool_dir.child("flask").assert(predicate::path::is_dir()); + assert!(bin_dir + .child(format!("flask{}", std::env::consts::EXE_SUFFIX)) + .exists()); + + #[cfg(not(windows))] + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(fs_err::read_to_string(bin_dir.join("flask")).unwrap(), @r###" + #![TEMP_DIR]/tools/flask/bin/python + # -*- coding: utf-8 -*- + import re + import sys + from flask.cli import main + if __name__ == "__main__": + sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0]) + sys.exit(main()) + "###); + + }); + + uv_snapshot!(context.filters(), Command::new("flask").arg("--version").env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.12.[X] + Flask 3.0.2 + Werkzeug 3.0.1 + + ----- stderr ----- + "###); + + insta::with_settings!({ + filters => context.filters(), + }, { + // We should have an additional tool entry + assert_snapshot!(fs_err::read_to_string(tool_dir.join("tools.toml")).unwrap(), @r###" + [tools] + black = { requirements = ["black"] } + flask = { requirements = ["flask"] } + "###); + }); +} + +/// Test installing a tool twice with `uv tool install` +#[test] +fn tool_install_twice() { + let context = TestContext::new("3.12"); + 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") + .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 6 packages in [TIME] + Prepared 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 + "###); + + tool_dir.child("black").assert(predicate::path::is_dir()); + tool_dir + .child("tools.toml") + .assert(predicate::path::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!({ + filters => context.filters(), + }, { + // Should run black in the virtual environment + assert_snapshot!(fs_err::read_to_string(executable).unwrap(), @r###" + #![TEMP_DIR]/tools/black/bin/python + # -*- coding: utf-8 -*- + import re + import sys + from black import patched_main + if __name__ == "__main__": + sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0]) + sys.exit(patched_main()) + "###); + + }); + + insta::with_settings!({ + filters => context.filters(), + }, { + // We should have a tool entry + assert_snapshot!(fs_err::read_to_string(tool_dir.join("tools.toml")).unwrap(), @r###" + [tools] + black = { requirements = ["black"] } + "###); + }); + + // Install `black` again + 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: 1 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning. + Tool `black` is already installed. + "###); + + tool_dir.child("black").assert(predicate::path::is_dir()); + bin_dir + .child(format!("black{}", std::env::consts::EXE_SUFFIX)) + .assert(predicate::path::exists()); + + insta::with_settings!({ + filters => context.filters(), + }, { + // We should not have an additional tool entry + assert_snapshot!(fs_err::read_to_string(tool_dir.join("tools.toml")).unwrap(), @r###" + [tools] + black = { requirements = ["black"] } + "###); + }); +} + +/// Test installing a tool when its entry pint already exists +#[test] +fn tool_install_entry_point_exists() { + let context = TestContext::new("3.12"); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + let executable = bin_dir.child(format!("black{}", std::env::consts::EXE_SUFFIX)); + executable.touch().unwrap(); + + // Install `black` + 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: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning. + Resolved 6 packages in [TIME] + Prepared 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 + "###); + + // TODO(zanieb): We happily overwrite entry points by default right now + // https://github.com/astral-sh/uv/pull/4501 should resolve this + + // 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(bin_dir.join("black")).unwrap(), @""); + + // }); +} + +/// Test `uv tool install` when the bin directory is inferred from `$HOME` +/// +/// Only tested on Linux right now because it's not clear how to change the %USERPROFILE% on Windows +#[cfg(unix)] +#[test] +fn tool_install_home() { + let context = TestContext::new("3.12"); + let tool_dir = context.temp_dir.child("tools"); + + // Install `black` + uv_snapshot!(context.filters(), context.tool_install().arg("black").env("UV_TOOL_DIR", tool_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning. + Resolved 6 packages in [TIME] + Prepared 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 + "###); + + context + .home_dir + .child(format!(".local/bin/black{}", std::env::consts::EXE_SUFFIX)) + .assert(predicate::path::exists()); +} + +/// Test `uv tool install` when the bin directory is inferred from `$XDG_DATA_HOME` +#[test] +fn tool_install_xdg_data_home() { + let context = TestContext::new("3.12"); + let tool_dir = context.temp_dir.child("tools"); + let data_home = context.temp_dir.child("data/home"); + + // Install `black` + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_DATA_HOME", data_home.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning. + Resolved 6 packages in [TIME] + Prepared 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 + "###); + + context + .temp_dir + .child(format!("data/bin/black{}", std::env::consts::EXE_SUFFIX)) + .assert(predicate::path::exists()); +} + +/// Test `uv tool install` when the bin directory is set by `$XDG_BIN_HOME` +#[test] +fn tool_install_xdg_bin_home() { + let context = TestContext::new("3.12"); + 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") + .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 6 packages in [TIME] + Prepared 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 + "###); + + bin_dir + .child(format!("black{}", std::env::consts::EXE_SUFFIX)) + .assert(predicate::path::exists()); +}