Add a command to append uv's binary directory to PATH (#4975)

## Summary

I'll open follow-up tickets for Windows support.

Closes https://github.com/astral-sh/uv/issues/4953.

## Test Plan

```
❯ cargo run tool install flask
Resolved 7 packages in 353ms
Prepared 7 packages in 392ms
Installed 7 packages in 17ms
 + blinker==1.8.2
 + click==8.1.7
 + flask==3.0.3
 + itsdangerous==2.2.0
 + jinja2==3.1.4
 + markupsafe==2.1.5
 + werkzeug==3.0.3
Installed 1 executable: flask
warning: /Users/crmarsh/.local/bin is not on your PATH. To use installed tools, run:
  export PATH="/Users/crmarsh/.local/bin:$PATH"
```

Then:

```
❯ which flask
flask not found
```

Then:

```
❯ cargo run tool ensurepath
warning: `uv tool ensurepath` is experimental and may change without warning.
Updated configuration file: /Users/crmarsh/workspace/puffin/bar
Restart your shell for the changes to take effect.
```

Then:
```
❯ which flask
/Users/crmarsh/.local/bin/flask
```
This commit is contained in:
Charlie Marsh 2024-07-12 18:09:34 -04:00 committed by GitHub
parent a4e9b63d5b
commit a4cb21e081
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 280 additions and 67 deletions

1
Cargo.lock generated
View file

@ -4456,6 +4456,7 @@ dependencies = [
"flate2",
"fs-err",
"futures",
"home",
"ignore",
"indexmap",
"indicatif",

View file

@ -86,6 +86,7 @@ fs2 = { version = "0.4.3" }
futures = { version = "0.3.30" }
glob = { version = "0.3.1" }
hex = { version = "0.4.3" }
home = { version = "0.5.9" }
html-escape = { version = "0.2.13" }
http = { version = "1.1.0" }
indexmap = { version = "2.2.5" }

View file

@ -2041,6 +2041,9 @@ pub enum ToolCommand {
List(ToolListArgs),
/// Uninstall a tool.
Uninstall(ToolUninstallArgs),
/// Ensure that the tool executable directory is on `PATH`.
#[command(alias = "ensurepath")]
UpdateShell,
/// Show the tools directory.
Dir,
}

View file

@ -50,6 +50,7 @@ clap = { workspace = true, features = ["derive", "string", "wrap_help"] }
flate2 = { workspace = true, default-features = false }
fs-err = { workspace = true, features = ["tokio"] }
futures = { workspace = true }
home = { workspace = true }
indexmap = { workspace = true }
indicatif = { workspace = true }
itertools = { workspace = true }

View file

@ -38,6 +38,7 @@ pub(crate) use tool::list::list as tool_list;
pub(crate) use tool::run::run as tool_run;
pub(crate) use tool::run::ToolRunCommand;
pub(crate) use tool::uninstall::uninstall as tool_uninstall;
pub(crate) use tool::update_shell::update_shell as tool_update_shell;
use uv_cache::Cache;
use uv_fs::Simplified;
use uv_git::GitResolver;

View file

@ -265,7 +265,6 @@ pub(crate) async fn install(
};
// Find a suitable path to install into
// TODO(zanieb): Warn if this directory is not on the PATH
let executable_directory = find_executable_directory()?;
fs_err::create_dir_all(&executable_directory)
.context("Failed to create executable directory")?;
@ -375,74 +374,36 @@ 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()
);
if !Shell::contains_path(&executable_directory) {
if let Some(shell) = Shell::from_env() {
if let Some(command) = shell.prepend_path(&executable_directory) {
if shell.configuration_files().is_empty() {
warn_user!(
"{} is not on your PATH. To use installed tools, run {}.",
executable_directory.simplified_display().cyan(),
command.green()
);
} else {
warn_user!(
"{} is not on your PATH. To use installed tools, run {} or {}.",
executable_directory.simplified_display().cyan(),
command.green(),
"uv tool update-shell".green()
);
}
} else {
warn_user!(
"{} is not on your PATH. To use installed tools, add the directory to your PATH.",
executable_directory.simplified_display().cyan(),
);
}
} else {
warn_user!(
"`{dir}` is not on your PATH. To use installed tools, add the directory to your PATH",
"{} is not on your PATH. To use installed tools, add the directory to your PATH.",
executable_directory.simplified_display().cyan(),
);
}
}
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
}

View file

@ -4,3 +4,4 @@ pub(crate) mod install;
pub(crate) mod list;
pub(crate) mod run;
pub(crate) mod uninstall;
pub(crate) mod update_shell;

View file

@ -0,0 +1,120 @@
use std::fmt::Write;
use anyhow::Result;
use owo_colors::OwoColorize;
use tokio::io::AsyncWriteExt;
use tracing::debug;
use uv_configuration::PreviewMode;
use uv_fs::Simplified;
use uv_tool::find_executable_directory;
use uv_warnings::warn_user_once;
use crate::commands::ExitStatus;
use crate::printer::Printer;
use crate::shell::Shell;
/// Ensure that the executable directory is in PATH.
pub(crate) async fn update_shell(preview: PreviewMode, printer: Printer) -> Result<ExitStatus> {
if preview.is_disabled() {
warn_user_once!("`uv tool update-shell` is experimental and may change without warning.");
}
let executable_directory = find_executable_directory()?;
debug!(
"Ensuring that the executable directory is in PATH: {}",
executable_directory.simplified_display()
);
if Shell::contains_path(&executable_directory) {
writeln!(
printer.stderr(),
"Executable directory {} is already in PATH",
executable_directory.simplified_display().cyan()
)?;
Ok(ExitStatus::Success)
} else {
// Determine the current shell.
let Some(shell) = Shell::from_env() else {
return Err(anyhow::anyhow!("The executable directory {} is not in PATH, but the current shell could not be determined.", executable_directory.simplified_display().cyan()));
};
// Look up the configuration files (e.g., `.bashrc`, `.zshrc`) for the shell.
let files = shell.configuration_files();
if files.is_empty() {
return Err(anyhow::anyhow!("The executable directory {} is not in PATH, but updating {shell} is currently unsupported.", executable_directory.simplified_display().cyan()));
}
// Prepare the command (e.g., `export PATH="$HOME/.cargo/bin:$PATH"`).
let Some(command) = shell.prepend_path(&executable_directory) else {
return Err(anyhow::anyhow!("The executable directory {} is not in PATH, but the necessary command to update {shell} could not be determined.", executable_directory.simplified_display().cyan()));
};
// Update each file, as necessary.
let mut updated = false;
for file in files {
// Search for the command in the file, to avoid redundant updates.
match fs_err::tokio::read_to_string(&file).await {
Ok(contents) => {
if contents.contains(&command) {
debug!(
"Skipping already-updated configuration file: {}",
file.simplified_display()
);
continue;
}
// Append the command to the file.
fs_err::tokio::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&file)
.await?
.write_all(format!("{contents}\n# uv\n{command}\n").as_bytes())
.await?;
writeln!(
printer.stderr(),
"Updated configuration file: {}",
file.simplified_display().cyan()
)?;
updated = true;
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
// Ensure that the directory containing the file exists.
if let Some(parent) = file.parent() {
fs_err::tokio::create_dir_all(&parent).await?;
}
// Append the command to the file.
fs_err::tokio::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&file)
.await?
.write_all(format!("# uv\n{command}\n").as_bytes())
.await?;
writeln!(
printer.stderr(),
"Created configuration file: {}",
file.simplified_display().cyan()
)?;
updated = true;
}
Err(err) => {
return Err(err.into());
}
}
}
if updated {
writeln!(printer.stderr(), "Restart your shell to apply changes.")?;
Ok(ExitStatus::Success)
} else {
Err(anyhow::anyhow!("The executable directory {} is not in PATH, but the {shell} configuration files are already up-to-date.", executable_directory.simplified_display().cyan()))
}
}
}

View file

@ -681,6 +681,12 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
commands::tool_uninstall(args.name, globals.preview, printer).await
}
Commands::Tool(ToolNamespace {
command: ToolCommand::UpdateShell,
}) => {
commands::tool_update_shell(globals.preview, printer).await?;
Ok(ExitStatus::Success)
}
Commands::Tool(ToolNamespace {
command: ToolCommand::Dir,
}) => {

View file

@ -1,4 +1,5 @@
use std::path::Path;
use std::path::{Path, PathBuf};
use uv_fs::Simplified;
/// Shells for which virtualenv activation scripts are available.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@ -70,8 +71,100 @@ impl Shell {
pub(crate) fn from_shell_path(path: impl AsRef<Path>) -> Option<Shell> {
parse_shell_from_path(path.as_ref())
}
/// Return the configuration files that should be modified to append to a shell's `PATH`.
pub(crate) fn configuration_files(self) -> Vec<PathBuf> {
let Some(home_dir) = home::home_dir() else {
return vec![];
};
match self {
Shell::Bash => {
// On Bash, we need to update both `.bashrc` and `.bash_profile`. The former is
// sourced for non-login shells, and the latter is sourced for login shells. If
// `.profile` is present, we prefer it over `.bash_profile`, to match the behavior
// of the system shell.
vec![
home_dir.join(".bashrc"),
if home_dir.join(".profile").is_file() {
home_dir.join(".profile")
} else {
home_dir.join(".bash_profile")
},
]
}
Shell::Zsh => {
// On Zsh, we only need to update `.zshrc`. This file is sourced for both login and
// non-login shells.
vec![home_dir.join(".zshrc")]
}
Shell::Fish => {
// On Fish, we only need to update `config.fish`. This file is sourced for both
// login and non-login shells.
vec![home_dir.join(".config/fish/config.fish")]
}
Shell::Csh => {
// On Csh, we need to update both `.cshrc` and `.login`, like Bash.
vec![home_dir.join(".cshrc"), home_dir.join(".login")]
}
// TODO(charlie): Add support for Nushell, PowerShell, and Cmd.
Shell::Nushell => vec![],
Shell::Powershell => vec![],
Shell::Cmd => vec![],
}
}
/// Returns `true` if the given path is on the `PATH` in this shell.
pub(crate) fn contains_path(path: &Path) -> bool {
std::env::var_os("PATH")
.as_ref()
.iter()
.flat_map(std::env::split_paths)
.any(|p| same_file::is_same_file(path, p).unwrap_or(false))
}
/// Returns the command necessary to prepend a directory to the `PATH` in this shell.
pub(crate) fn prepend_path(self, path: &Path) -> Option<String> {
match self {
Shell::Nushell => None,
Shell::Bash | Shell::Zsh => Some(format!(
"export PATH=\"{}:$PATH\"",
backslash_escape(&path.simplified_display().to_string()),
)),
Shell::Fish => Some(format!(
"fish_add_path \"{}\"",
backslash_escape(&path.simplified_display().to_string()),
)),
Shell::Csh => Some(format!(
"setenv PATH \"{}:$PATH\"",
backslash_escape(&path.simplified_display().to_string()),
)),
Shell::Powershell => Some(format!(
"$env:PATH = \"{};$env:PATH\"",
backtick_escape(&path.simplified_display().to_string()),
)),
Shell::Cmd => Some(format!(
"set PATH=\"{};%PATH%\"",
backslash_escape(&path.simplified_display().to_string()),
)),
}
}
}
impl std::fmt::Display for Shell {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Shell::Bash => write!(f, "Bash"),
Shell::Fish => write!(f, "Fish"),
Shell::Powershell => write!(f, "PowerShell"),
Shell::Cmd => write!(f, "Command Prompt"),
Shell::Zsh => write!(f, "Zsh"),
Shell::Nushell => write!(f, "Nushell"),
Shell::Csh => write!(f, "Csh"),
}
}
}
/// Parse the shell from the name of the shell executable.
fn parse_shell_from_path(path: &Path) -> Option<Shell> {
let name = path.file_stem()?.to_str()?;
match name {
@ -83,3 +176,29 @@ fn parse_shell_from_path(path: &Path) -> Option<Shell> {
_ => None,
}
}
/// 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
}

View file

@ -1543,7 +1543,6 @@ fn tool_install_warn_path() {
+ 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"
warning: [TEMP_DIR]/bin is not on your PATH. To use installed tools, run export PATH="[TEMP_DIR]/bin:$PATH" or uv tool update-shell.
"###);
}