From d5257202662773b6794b1b2de6c490dd1404d7b5 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Tue, 15 Jul 2025 13:47:02 -0500 Subject: [PATCH] Add `uv python update-shell` (#14627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #14296 This is the same as `uv tool update-shell` but handles the case where the Python bin directory is configured to a different path. ``` ❯ UV_PYTHON_BIN_DIR=/tmp/foo cargo run -q -- python install --preview 3.13.3 Installed Python 3.13.3 in 1.75s + cpython-3.13.3-macos-aarch64-none warning: `/tmp/foo` is not on your PATH. To use installed Python executables, run `export PATH="/tmp/foo:$PATH"` or `uv python update-shell`. ❯ UV_PYTHON_BIN_DIR=/tmp/foo cargo run -q -- python update-shell Created configuration file: /Users/zb/.zshenv Restart your shell to apply changes ❯ cat /Users/zb/.zshenv # uv export PATH="/tmp/foo:$PATH" ❯ UV_TOOL_BIN_DIR=/tmp/bar cargo run -q -- tool update-shell Updated configuration file: /Users/zb/.zshenv Restart your shell to apply changes ❯ cat /Users/zb/.zshenv # uv export PATH="/tmp/foo:$PATH" # uv export PATH="/tmp/bar:$PATH" ``` --- crates/uv-cli/src/lib.rs | 13 ++ crates/uv/src/commands/mod.rs | 1 + crates/uv/src/commands/python/install.rs | 23 ++- crates/uv/src/commands/python/mod.rs | 1 + crates/uv/src/commands/python/update_shell.rs | 153 ++++++++++++++++++ crates/uv/src/lib.rs | 6 + crates/uv/tests/it/help.rs | 35 ++-- docs/reference/cli.md | 65 ++++++++ 8 files changed, 274 insertions(+), 23 deletions(-) create mode 100644 crates/uv/src/commands/python/update_shell.rs diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 2efb30724..a846aec59 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -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)] diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index d1e647363..405aad955 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -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; diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index bbab7cbb1..feb0cf7c7 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -993,20 +993,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(), ); } diff --git a/crates/uv/src/commands/python/mod.rs b/crates/uv/src/commands/python/mod.rs index afc700d23..6f7a5c980 100644 --- a/crates/uv/src/commands/python/mod.rs +++ b/crates/uv/src/commands/python/mod.rs @@ -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 { diff --git a/crates/uv/src/commands/python/update_shell.rs b/crates/uv/src/commands/python/update_shell.rs new file mode 100644 index 000000000..18757ff9e --- /dev/null +++ b/crates/uv/src/commands/python/update_shell.rs @@ -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 { + 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() + )) + } +} diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index e6fea035f..384f48ac4 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1537,6 +1537,12 @@ async fn run(mut cli: Cli) -> Result { 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); diff --git a/crates/uv/tests/it/help.rs b/crates/uv/tests/it/help.rs index a557b0eff..39de4c6f9 100644 --- a/crates/uv/tests/it/help.rs +++ b/crates/uv/tests/it/help.rs @@ -290,14 +290,15 @@ fn help_subcommand() { Usage: uv python [OPTIONS] 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 @@ -725,14 +726,15 @@ fn help_flag_subcommand() { Usage: uv python [OPTIONS] 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 @@ -934,6 +936,7 @@ fn help_unknown_subsubcommand() { pin dir uninstall + update-shell "); } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f6bc028df..66c46ae0c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -2633,6 +2633,7 @@ uv python [OPTIONS]
uv python pin

Pin to a specific Python version

uv python dir

Show the uv Python installation directory

uv python uninstall

Uninstall Python versions

+
uv python update-shell

Ensure that the Python executable directory is on the PATH

### uv python list @@ -3206,6 +3207,70 @@ uv python uninstall [OPTIONS] ...

You can configure fine-grained logging using the RUST_LOG environment variable. (https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives)

+### uv python update-shell + +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`. + +

Usage

+ +``` +uv python update-shell [OPTIONS] +``` + +

Options

+ +
--allow-insecure-host, --trusted-host allow-insecure-host

Allow insecure connections to a host.

+

Can be provided multiple times.

+

Expects to receive either a hostname (e.g., localhost), a host-port pair (e.g., localhost:8080), or a URL (e.g., https://localhost).

+

WARNING: Hosts included in this list will not be verified against the system's certificate store. Only use --allow-insecure-host in a secure network with verified sources, as it bypasses SSL verification and could expose you to MITM attacks.

+

May also be set with the UV_INSECURE_HOST environment variable.

--cache-dir cache-dir

Path to the cache directory.

+

Defaults to $XDG_CACHE_HOME/uv or $HOME/.cache/uv on macOS and Linux, and %LOCALAPPDATA%\uv\cache on Windows.

+

To view the location of the cache directory, run uv cache dir.

+

May also be set with the UV_CACHE_DIR environment variable.

--color color-choice

Control the use of color in output.

+

By default, uv will automatically detect support for colors when writing to a terminal.

+

Possible values:

+
    +
  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • +
  • always: Enables colored output regardless of the detected environment
  • +
  • never: Disables colored output
  • +
--config-file config-file

The path to a uv.toml file to use for configuration.

+

While uv configuration can be included in a pyproject.toml file, it is not allowed in this context.

+

May also be set with the UV_CONFIG_FILE environment variable.

--directory directory

Change to the given directory prior to running the command.

+

Relative paths are resolved with the given directory as the base.

+

See --project to only change the project root directory.

+
--help, -h

Display the concise help for this command

+
--managed-python

Require use of uv-managed Python versions.

+

By default, uv prefers using Python versions it manages. However, it will use system Python versions if a uv-managed Python is not installed. This option disables use of system Python versions.

+

May also be set with the UV_MANAGED_PYTHON environment variable.

--native-tls

Whether to load TLS certificates from the platform's native certificate store.

+

By default, uv loads certificates from the bundled webpki-roots crate. The webpki-roots are a reliable set of trust roots from Mozilla, and including them in uv improves portability and performance (especially on macOS).

+

However, in some cases, you may want to use the platform's native certificate store, especially if you're relying on a corporate trust root (e.g., for a mandatory proxy) that's included in your system's certificate store.

+

May also be set with the UV_NATIVE_TLS environment variable.

--no-cache, --no-cache-dir, -n

Avoid reading from or writing to the cache, instead using a temporary directory for the duration of the operation

+

May also be set with the UV_NO_CACHE environment variable.

--no-config

Avoid discovering configuration files (pyproject.toml, uv.toml).

+

Normally, configuration files are discovered in the current directory, parent directories, or user configuration directories.

+

May also be set with the UV_NO_CONFIG environment variable.

--no-managed-python

Disable use of uv-managed Python versions.

+

Instead, uv will search for a suitable Python version on the system.

+

May also be set with the UV_NO_MANAGED_PYTHON environment variable.

--no-progress

Hide all progress outputs.

+

For example, spinners or progress bars.

+

May also be set with the UV_NO_PROGRESS environment variable.

--no-python-downloads

Disable automatic downloads of Python.

+
--offline

Disable network access.

+

When disabled, uv will only use locally cached data and locally available files.

+

May also be set with the UV_OFFLINE environment variable.

--project project

Run the command within the given project directory.

+

All pyproject.toml, uv.toml, and .python-version files will be discovered by walking up the directory tree from the project root, as will the project's virtual environment (.venv).

+

Other command-line arguments (such as relative paths) will be resolved relative to the current working directory.

+

See --directory to change the working directory entirely.

+

This setting has no effect when used in the uv pip interface.

+

May also be set with the UV_PROJECT environment variable.

--quiet, -q

Use quiet output.

+

Repeating this option, e.g., -qq, will enable a silent mode in which uv will write no output to stdout.

+
--verbose, -v

Use verbose output.

+

You can configure fine-grained logging using the RUST_LOG environment variable. (https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives)

+
+ ## uv pip Manage Python packages with a pip-compatible interface