Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4856,6 +4856,19 @@ pub enum PythonCommand {

/// Uninstall Python versions.
Uninstall(PythonUninstallArgs),

/// Ensure that the Python executable directory is on the `PATH`.
///
/// If the Python executable directory is not present on the `PATH`, uv will attempt to add it to
/// the relevant shell configuration files.
///
/// If the shell configuration files already include a blurb to add the executable directory to
/// the path, but the directory is not present on the `PATH`, uv will exit with an error.
///
/// The Python executable directory is determined according to the XDG standard and can be
/// retrieved with `uv python dir --bin`.
#[command(alias = "ensurepath")]
UpdateShell,
}

#[derive(Args)]
Expand Down
1 change: 1 addition & 0 deletions crates/uv/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub(crate) use python::install::install as python_install;
pub(crate) use python::list::list as python_list;
pub(crate) use python::pin::pin as python_pin;
pub(crate) use python::uninstall::uninstall as python_uninstall;
pub(crate) use python::update_shell::update_shell as python_update_shell;
#[cfg(feature = "self-update")]
pub(crate) use self_update::self_update;
pub(crate) use tool::dir::dir as tool_dir;
Expand Down
23 changes: 16 additions & 7 deletions crates/uv/src/commands/python/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -908,20 +908,29 @@ fn warn_if_not_on_path(bin: &Path) {
if !Shell::contains_path(bin) {
if let Some(shell) = Shell::from_env() {
if let Some(command) = shell.prepend_path(bin) {
warn_user!(
"`{}` is not on your PATH. To use the installed Python executable, run `{}`.",
bin.simplified_display().cyan(),
command.green(),
);
if shell.supports_update() {
warn_user!(
"`{}` is not on your PATH. To use installed Python executables, run `{}` or `{}`.",
bin.simplified_display().cyan(),
command.green(),
"uv python update-shell".green()
);
} else {
warn_user!(
"`{}` is not on your PATH. To use installed Python executables, run `{}`.",
bin.simplified_display().cyan(),
command.green()
);
}
} else {
warn_user!(
"`{}` is not on your PATH. To use the installed Python executable, add the directory to your PATH.",
"`{}` is not on your PATH. To use installed Python executables, add the directory to your PATH.",
bin.simplified_display().cyan(),
);
}
} else {
warn_user!(
"`{}` is not on your PATH. To use the installed Python executable, add the directory to your PATH.",
"`{}` is not on your PATH. To use installed Python executables, add the directory to your PATH.",
bin.simplified_display().cyan(),
);
}
Expand Down
1 change: 1 addition & 0 deletions crates/uv/src/commands/python/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub(crate) mod install;
pub(crate) mod list;
pub(crate) mod pin;
pub(crate) mod uninstall;
pub(crate) mod update_shell;

#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)]
pub(super) enum ChangeEventKind {
Expand Down
153 changes: 153 additions & 0 deletions crates/uv/src/commands/python/update_shell.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
#![cfg_attr(windows, allow(unreachable_code))]

use std::fmt::Write;

use anyhow::Result;
use owo_colors::OwoColorize;
use tokio::io::AsyncWriteExt;
use tracing::debug;

use uv_fs::Simplified;
use uv_python::managed::python_executable_dir;
use uv_shell::Shell;

use crate::commands::ExitStatus;
use crate::printer::Printer;

/// Ensure that the executable directory is in PATH.
pub(crate) async fn update_shell(printer: Printer) -> Result<ExitStatus> {
let executable_directory = python_executable_dir()?;
debug!(
"Ensuring that the executable directory is in PATH: {}",
executable_directory.simplified_display()
);

#[cfg(windows)]
{
if uv_shell::windows::prepend_path(&executable_directory)? {
writeln!(
printer.stderr(),
"Updated PATH to include executable directory {}",
executable_directory.simplified_display().cyan()
)?;
writeln!(printer.stderr(), "Restart your shell to apply changes")?;
} else {
writeln!(
printer.stderr(),
"Executable directory {} is already in PATH",
executable_directory.simplified_display().cyan()
)?;
}

return Ok(ExitStatus::Success);
}

if Shell::contains_path(&executable_directory) {
writeln!(
printer.stderr(),
"Executable directory {} is already in PATH",
executable_directory.simplified_display().cyan()
)?;
return Ok(ExitStatus::Success);
}

// 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
.lines()
.map(str::trim)
.filter(|line| !line.starts_with('#'))
.any(|line| line.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()
))
}
}
6 changes: 6 additions & 0 deletions crates/uv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1533,6 +1533,12 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
commands::python_dir(args.bin)?;
Ok(ExitStatus::Success)
}
Commands::Python(PythonNamespace {
command: PythonCommand::UpdateShell,
}) => {
commands::python_update_shell(printer).await?;
Ok(ExitStatus::Success)
}
Commands::Publish(args) => {
show_settings!(args);

Expand Down
35 changes: 19 additions & 16 deletions crates/uv/tests/it/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,14 +290,15 @@ fn help_subcommand() {
Usage: uv python [OPTIONS] <COMMAND>

Commands:
list List the available Python installations
install Download and install Python versions
upgrade Upgrade installed Python versions to the latest supported patch release (requires the
`--preview` flag)
find Search for a Python installation
pin Pin to a specific Python version
dir Show the uv Python installation directory
uninstall Uninstall Python versions
list List the available Python installations
install Download and install Python versions
upgrade Upgrade installed Python versions to the latest supported patch release (requires
the `--preview` flag)
find Search for a Python installation
pin Pin to a specific Python version
dir Show the uv Python installation directory
uninstall Uninstall Python versions
update-shell Ensure that the Python executable directory is on the `PATH`

Cache options:
-n, --no-cache
Expand Down Expand Up @@ -719,14 +720,15 @@ fn help_flag_subcommand() {
Usage: uv python [OPTIONS] <COMMAND>

Commands:
list List the available Python installations
install Download and install Python versions
upgrade Upgrade installed Python versions to the latest supported patch release (requires the
`--preview` flag)
find Search for a Python installation
pin Pin to a specific Python version
dir Show the uv Python installation directory
uninstall Uninstall Python versions
list List the available Python installations
install Download and install Python versions
upgrade Upgrade installed Python versions to the latest supported patch release (requires
the `--preview` flag)
find Search for a Python installation
pin Pin to a specific Python version
dir Show the uv Python installation directory
uninstall Uninstall Python versions
update-shell Ensure that the Python executable directory is on the `PATH`

Cache options:
-n, --no-cache Avoid reading from or writing to the cache, instead using a temporary
Expand Down Expand Up @@ -924,6 +926,7 @@ fn help_unknown_subsubcommand() {
pin
dir
uninstall
update-shell
");
}

Expand Down
Loading
Loading