diff --git a/Cargo.lock b/Cargo.lock index 18d0531a6..ae51e1cf6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4475,6 +4475,7 @@ dependencies = [ "regex", "reqwest", "rustc-hash 2.0.0", + "same-file", "serde", "serde_json", "textwrap", diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index 491b93f76..ce1f33894 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -50,14 +50,15 @@ clap = { workspace = true, features = ["derive", "string", "wrap_help"] } flate2 = { workspace = true, default-features = false } fs-err = { workspace = true, features = ["tokio"] } futures = { workspace = true } -indicatif = { workspace = true } indexmap = { workspace = true } +indicatif = { workspace = true } itertools = { workspace = true } miette = { workspace = true, features = ["fancy"] } owo-colors = { workspace = true } rayon = { workspace = true } regex = { workspace = true } rustc-hash = { workspace = true } +same-file = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } textwrap = { workspace = true } diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index b1a8651a9..44438e157 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -23,7 +23,7 @@ use uv_python::{ }; use uv_requirements::RequirementsSpecification; use uv_tool::{entrypoint_paths, find_executable_directory, InstalledTools, Tool, ToolEntrypoint}; -use uv_warnings::warn_user_once; +use uv_warnings::{warn_user, warn_user_once}; use crate::commands::project::{resolve_environment, sync_environment, update_environment}; use crate::commands::reporters::PythonDownloadReporter; @@ -31,6 +31,7 @@ use crate::commands::tool::common::resolve_requirements; use crate::commands::{ExitStatus, SharedState}; use crate::printer::Printer; use crate::settings::ResolverInstallerSettings; +use crate::shell::Shell; /// Install a tool. pub(crate) async fn install( @@ -373,5 +374,75 @@ pub(crate) async fn install( ); installed_tools.add_tool_receipt(&from.name, tool)?; + // If the executable directory isn't on the user's PATH, warn. + if !std::env::var_os("PATH") + .as_ref() + .iter() + .flat_map(std::env::split_paths) + .any(|path| same_file::is_same_file(&executable_directory, path).unwrap_or(false)) + { + let dir = executable_directory.simplified_display(); + let export = match Shell::from_env() { + None => None, + Some(Shell::Nushell) => None, + Some(Shell::Bash | Shell::Zsh) => Some(format!( + "export PATH=\"{}:$PATH\"", + backslash_escape(&dir.to_string()), + )), + Some(Shell::Fish) => Some(format!( + "fish_add_path \"{}\"", + backslash_escape(&dir.to_string()), + )), + Some(Shell::Csh) => Some(format!( + "setenv PATH \"{}:$PATH\"", + backslash_escape(&dir.to_string()), + )), + Some(Shell::Powershell) => Some(format!( + "$env:PATH = \"{};$env:PATH\"", + backtick_escape(&dir.to_string()), + )), + Some(Shell::Cmd) => Some(format!( + "set PATH=\"{};%PATH%\"", + backslash_escape(&dir.to_string()), + )), + }; + if let Some(export) = export { + warn_user!( + "`{dir}` is not on your PATH. To use installed tools, run:\n {}", + export.green() + ); + } else { + warn_user!( + "`{dir}` is not on your PATH. To use installed tools, add the directory to your PATH", + ); + } + } + Ok(ExitStatus::Success) } + +/// Escape a string for use in a shell command by inserting backslashes. +fn backslash_escape(s: &str) -> String { + let mut escaped = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\\' | '"' => escaped.push('\\'), + _ => {} + } + escaped.push(c); + } + escaped +} + +/// Escape a string for use in a `PowerShell` command by inserting backticks. +fn backtick_escape(s: &str) -> String { + let mut escaped = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\\' | '"' | '$' => escaped.push('`'), + _ => {} + } + escaped.push(c); + } + escaped +} diff --git a/crates/uv/tests/tool_install.rs b/crates/uv/tests/tool_install.rs index 7ee1f36bf..798f500b3 100644 --- a/crates/uv/tests/tool_install.rs +++ b/crates/uv/tests/tool_install.rs @@ -26,7 +26,8 @@ fn tool_install() { 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###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -101,7 +102,8 @@ fn tool_install() { uv_snapshot!(context.filters(), context.tool_install() .arg("flask") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -179,7 +181,8 @@ fn tool_install_version() { uv_snapshot!(context.filters(), context.tool_install() .arg("black==24.2.0") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -264,7 +267,8 @@ fn tool_install_from() { .arg("--from") .arg("black==24.2.0") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -289,7 +293,8 @@ fn tool_install_from() { .arg("--from") .arg("flask==24.2.0") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: false exit_code: 2 ----- stdout ----- @@ -305,7 +310,8 @@ fn tool_install_from() { .arg("--from") .arg("black==24.3.0") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: false exit_code: 2 ----- stdout ----- @@ -329,7 +335,8 @@ fn tool_install_already_installed() { 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###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -394,7 +401,8 @@ fn tool_install_already_installed() { 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###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: false exit_code: 1 ----- stdout ----- @@ -429,7 +437,8 @@ fn tool_install_already_installed() { .arg("black") .arg("--reinstall") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -461,7 +470,8 @@ fn tool_install_already_installed() { .arg("--reinstall-package") .arg("black") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -483,7 +493,8 @@ fn tool_install_already_installed() { .arg("--reinstall-package") .arg("click") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -515,7 +526,8 @@ fn tool_install_entry_point_exists() { 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###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: false exit_code: 2 ----- stdout ----- @@ -554,7 +566,8 @@ fn tool_install_entry_point_exists() { .arg("black") .arg("--reinstall") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: false exit_code: 2 ----- stdout ----- @@ -594,7 +607,8 @@ fn tool_install_entry_point_exists() { 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###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: false exit_code: 2 ----- stdout ----- @@ -617,7 +631,8 @@ fn tool_install_entry_point_exists() { .arg("black") .arg("--force") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -642,7 +657,8 @@ fn tool_install_entry_point_exists() { .arg("black") .arg("--force") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -658,7 +674,8 @@ fn tool_install_entry_point_exists() { 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###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: false exit_code: 1 ----- stdout ----- @@ -675,7 +692,8 @@ fn tool_install_entry_point_exists() { .arg("black") .arg("--reinstall") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -776,6 +794,10 @@ fn tool_install_home() { .env( "XDG_DATA_HOME", context.home_dir.child(".local").child("share").as_os_str(), + ) + .env( + "PATH", + context.home_dir.child(".local").child("bin").as_os_str(), ); uv_snapshot!(context.filters(), cmd, @r###" success: true @@ -808,12 +830,14 @@ fn tool_install_xdg_data_home() { let context = TestContext::new("3.12").with_filtered_exe_suffix(); let tool_dir = context.temp_dir.child("tools"); let data_home = context.temp_dir.child("data/home"); + let bin_dir = context.temp_dir.child("data/bin"); // 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###" + .env("XDG_DATA_HOME", data_home.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -849,7 +873,8 @@ fn tool_install_xdg_bin_home() { 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###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -883,7 +908,8 @@ fn tool_install_no_entrypoints() { uv_snapshot!(context.filters(), context.tool_install() .arg("iniconfig") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: false exit_code: 2 ----- stdout ----- @@ -909,7 +935,8 @@ fn tool_install_unnamed_package() { uv_snapshot!(context.filters(), context.tool_install() .arg("https://files.pythonhosted.org/packages/0f/89/294c9a6b6c75a08da55e9d05321d0707e9418735e3062b12ef0f54c33474/black-24.4.2-py3-none-any.whl") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -995,7 +1022,8 @@ fn tool_install_unnamed_conflict() { .arg("--from") .arg("https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: false exit_code: 2 ----- stdout ----- @@ -1019,7 +1047,8 @@ fn tool_install_unnamed_from() { .arg("--from") .arg("https://files.pythonhosted.org/packages/0f/89/294c9a6b6c75a08da55e9d05321d0707e9418735e3062b12ef0f54c33474/black-24.4.2-py3-none-any.whl") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -1104,7 +1133,8 @@ fn tool_install_unnamed_with() { .arg("--with") .arg("https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -1193,7 +1223,8 @@ fn tool_install_upgrade() { uv_snapshot!(context.filters(), context.tool_install() .arg("black==24.1.1") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -1231,7 +1262,8 @@ fn tool_install_upgrade() { 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###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -1261,7 +1293,8 @@ fn tool_install_upgrade() { .arg("--with") .arg("iniconfig @ https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -1298,7 +1331,8 @@ fn tool_install_upgrade() { .arg("black") .arg("--upgrade") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -1345,7 +1379,8 @@ fn tool_install_python_request() { .arg("3.12") .arg("black") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -1370,7 +1405,8 @@ fn tool_install_python_request() { .arg("3.12") .arg("black") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: false exit_code: 1 ----- stdout ----- @@ -1386,7 +1422,8 @@ fn tool_install_python_request() { .arg("3.11") .arg("black") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -1420,7 +1457,8 @@ fn tool_install_preserve_environment() { uv_snapshot!(context.filters(), context.tool_install() .arg("black==24.1.1") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout ----- @@ -1445,7 +1483,8 @@ fn tool_install_preserve_environment() { .arg("--with") .arg("packaging==0.0.1") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: false exit_code: 2 ----- stdout ----- @@ -1460,7 +1499,8 @@ fn tool_install_preserve_environment() { uv_snapshot!(context.filters(), context.tool_install() .arg("black==24.1.1") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: false exit_code: 1 ----- stdout ----- @@ -1470,3 +1510,40 @@ fn tool_install_preserve_environment() { `black==24.1.1` is already installed "###); } + +/// Test warning when the binary directory is not on the user's PATH. +#[test] +#[cfg(unix)] +fn tool_install_warn_path() { + let context = TestContext::new("3.12") + .with_filtered_counts() + .with_filtered_exe_suffix(); + 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==24.1.1") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env_remove("PATH"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning. + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + black==24.1.1 + + click==8.1.7 + + mypy-extensions==1.0.0 + + packaging==24.0 + + pathspec==0.12.1 + + platformdirs==4.2.0 + Installed 2 executables: black, blackd + warning: `[TEMP_DIR]/bin` is not on your PATH. To use installed tools, run: + export PATH="[TEMP_DIR]/bin:$PATH" + "###); +} diff --git a/crates/uv/tests/tool_uninstall.rs b/crates/uv/tests/tool_uninstall.rs index b63c72438..64ef34127 100644 --- a/crates/uv/tests/tool_uninstall.rs +++ b/crates/uv/tests/tool_uninstall.rs @@ -51,7 +51,8 @@ fn tool_uninstall() { uv_snapshot!(context.filters(), context.tool_install() .arg("black==24.2.0") .env("UV_TOOL_DIR", tool_dir.as_os_str()) - .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" success: true exit_code: 0 ----- stdout -----