mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 13:25:00 +00:00
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:
parent
fe13ea39f0
commit
dc408146ac
3 changed files with 443 additions and 1 deletions
|
@ -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());
|
||||
|
|
|
@ -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>
|
||||
|
|
406
crates/uv/tests/tool_install.rs
Normal file
406
crates/uv/tests/tool_install.rs
Normal 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());
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue