Add test cases for uv tool install (#4509)

Adds test cases for functionality in #4492.

Includes #4520 which was needed to pass CI.
This commit is contained in:
Zanie Blue 2024-06-26 15:51:32 -04:00 committed by GitHub
parent fe13ea39f0
commit dc408146ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 443 additions and 1 deletions

View file

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

View file

@ -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<T: AsRef<str>>(
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
}};
}
/// <https://stackoverflow.com/a/31749071/3549270>

View file

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