From 4dd36b799fc73429a91ce7b74e838833da932a98 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 30 Oct 2024 09:13:20 -0500 Subject: [PATCH] Install versioned Python executables into the bin directory during `uv python install` (#8458) Updates `uv python install` to link `python3.x` in the executable directory (i.e., `~/.local/bin`) to the the managed interpreter path. Includes - #8569 - #8571 Remaining work - #8663 - #8650 - Add an opt-out setting and flag - Update documentation --- .github/workflows/ci.yml | 4 +- Cargo.lock | 2 + crates/uv-cli/src/lib.rs | 38 ++++- crates/uv-python/Cargo.toml | 1 + crates/uv-python/src/discovery.rs | 17 +- crates/uv-python/src/installation.rs | 11 ++ crates/uv-python/src/managed.rs | 184 ++++++++++++++++----- crates/uv-static/src/env_vars.rs | 3 + crates/uv-tool/src/lib.rs | 2 +- crates/uv/Cargo.toml | 1 + crates/uv/src/commands/python/dir.rs | 24 ++- crates/uv/src/commands/python/install.rs | 130 ++++++++++++++- crates/uv/src/commands/python/uninstall.rs | 47 +++++- crates/uv/src/lib.rs | 9 +- crates/uv/src/settings.rs | 19 ++- crates/uv/tests/it/common/mod.rs | 4 + crates/uv/tests/it/help.rs | 12 +- crates/uv/tests/it/python_install.rs | 150 ++++++++++++++++- docs/reference/cli.md | 28 +++- 19 files changed, 597 insertions(+), 89 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 113b8a2f7..39261eba9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -702,7 +702,7 @@ jobs: - name: "Install free-threaded Python via uv" run: | - ./uv python install 3.13t + ./uv python install -v 3.13t ./uv venv -p 3.13t --python-preference only-managed - name: "Check version" @@ -774,7 +774,7 @@ jobs: run: chmod +x ./uv - name: "Install PyPy" - run: ./uv python install pypy3.9 + run: ./uv python install -v pypy3.9 - name: "Create a virtual environment" run: | diff --git a/Cargo.lock b/Cargo.lock index b14c3a431..7ac8ee866 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4208,6 +4208,7 @@ dependencies = [ "regex", "reqwest", "rustc-hash", + "same-file", "serde", "serde_json", "similar", @@ -5060,6 +5061,7 @@ dependencies = [ "uv-cache-info", "uv-cache-key", "uv-client", + "uv-dirs", "uv-distribution-filename", "uv-extract", "uv-fs", diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index c3498965f..63cf40c87 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3803,14 +3803,15 @@ pub enum PythonCommand { /// /// Multiple Python versions may be requested. /// - /// Supports CPython and PyPy. + /// Supports CPython and PyPy. CPython distributions are downloaded from the + /// `python-build-standalone` project. PyPy distributions are downloaded from `python.org`. /// - /// CPython distributions are downloaded from the `python-build-standalone` project. + /// Python versions are installed into the uv Python directory, which can be retrieved with `uv + /// python dir`. /// - /// Python versions are installed into the uv Python directory, which can be - /// retrieved with `uv python dir`. A `python` executable is not made - /// globally available, managed Python versions are only used in uv - /// commands or in active virtual environments. + /// A `python` executable is not made globally available, managed Python versions are only used + /// in uv commands or in active virtual environments. There is experimental support for + /// adding Python executables to the `PATH` — use the `--preview` flag to enable this behavior. /// /// See `uv help python` to view supported request formats. Install(PythonInstallArgs), @@ -3838,7 +3839,9 @@ pub enum PythonCommand { /// `%APPDATA%\uv\data\python` on Windows. /// /// The Python installation directory may be overridden with `$UV_PYTHON_INSTALL_DIR`. - Dir, + /// + /// To instead view the directory uv installs Python executables into, use the `--bin` flag. + Dir(PythonDirArgs), /// Uninstall Python versions. Uninstall(PythonUninstallArgs), @@ -3866,6 +3869,27 @@ pub struct PythonListArgs { pub only_installed: bool, } +#[derive(Args)] +#[allow(clippy::struct_excessive_bools)] +pub struct PythonDirArgs { + /// Show the directory into which `uv python` will install Python executables. + /// + /// Note this directory is only used when installing with preview mode enabled. + /// + /// By default, `uv python dir` shows the directory into which the Python distributions + /// themselves are installed, rather than the directory containing the linked executables. + /// + /// The Python executable directory is determined according to the XDG standard and is derived + /// from the following environment variables, in order of preference: + /// + /// - `$UV_PYTHON_BIN_DIR` + /// - `$XDG_BIN_HOME` + /// - `$XDG_DATA_HOME/../bin` + /// - `$HOME/.local/bin` + #[arg(long, verbatim_doc_comment)] + pub bin: bool, +} + #[derive(Args)] #[allow(clippy::struct_excessive_bools)] pub struct PythonInstallArgs { diff --git a/crates/uv-python/Cargo.toml b/crates/uv-python/Cargo.toml index 7dbb92d13..3811ff9d9 100644 --- a/crates/uv-python/Cargo.toml +++ b/crates/uv-python/Cargo.toml @@ -20,6 +20,7 @@ uv-cache = { workspace = true } uv-cache-info = { workspace = true } uv-cache-key = { workspace = true } uv-client = { workspace = true } +uv-dirs = { workspace = true } uv-distribution-filename = { workspace = true } uv-extract = { workspace = true } uv-fs = { workspace = true } diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index 0aa66dc71..4993f7868 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -1236,6 +1236,16 @@ impl PythonVariant { PythonVariant::Freethreaded => interpreter.gil_disabled(), } } + + /// Return the lib or executable suffix for the variant, e.g., `t` for `python3.13t`. + /// + /// Returns an empty string for the default Python variant. + pub fn suffix(self) -> &'static str { + match self { + Self::Default => "", + Self::Freethreaded => "t", + } + } } impl PythonRequest { /// Create a request from a string. @@ -1635,12 +1645,7 @@ impl std::fmt::Display for ExecutableName { if let Some(prerelease) = &self.prerelease { write!(f, "{prerelease}")?; } - match self.variant { - PythonVariant::Default => {} - PythonVariant::Freethreaded => { - f.write_str("t")?; - } - }; + f.write_str(self.variant.suffix())?; f.write_str(std::env::consts::EXE_SUFFIX)?; Ok(()) } diff --git a/crates/uv-python/src/installation.rs b/crates/uv-python/src/installation.rs index b9afa8e48..9faef3438 100644 --- a/crates/uv-python/src/installation.rs +++ b/crates/uv-python/src/installation.rs @@ -305,6 +305,17 @@ impl PythonInstallationKey { pub fn libc(&self) -> &Libc { &self.libc } + + /// Return a canonical name for a versioned executable. + pub fn versioned_executable_name(&self) -> String { + format!( + "python{maj}.{min}{var}{exe}", + maj = self.major, + min = self.minor, + var = self.variant.suffix(), + exe = std::env::consts::EXE_SUFFIX + ) + } } impl fmt::Display for PythonInstallationKey { diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index 98f8dccdf..751adbf4b 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -53,14 +53,31 @@ pub enum Error { #[source] err: io::Error, }, + #[error("Failed to create Python executable link at {} from {}", to.user_display(), from.user_display())] + LinkExecutable { + from: PathBuf, + to: PathBuf, + #[source] + err: io::Error, + }, + #[error("Failed to create directory for Python executable link at {}", to.user_display())] + ExecutableDirectory { + to: PathBuf, + #[source] + err: io::Error, + }, #[error("Failed to read Python installation directory: {0}", dir.user_display())] ReadError { dir: PathBuf, #[source] err: io::Error, }, + #[error("Failed to find a directory to install executables into")] + NoExecutableDirectory, #[error("Failed to read managed Python directory name: {0}")] NameError(String), + #[error("Failed to construct absolute path to managed Python directory: {}", _0.user_display())] + AbsolutePath(PathBuf, #[source] std::io::Error), #[error(transparent)] NameParseError(#[from] installation::PythonInstallationKeyError), #[error(transparent)] @@ -267,18 +284,78 @@ impl ManagedPythonInstallation { .ok_or(Error::NameError("not a valid string".to_string()))?, )?; + let path = std::path::absolute(&path).map_err(|err| Error::AbsolutePath(path, err))?; + Ok(Self { path, key }) } - /// The path to this toolchain's Python executable. + /// The path to this managed installation's Python executable. + /// + /// If the installation has multiple execututables i.e., `python`, `python3`, etc., this will + /// return the _canonical_ executable name which the other names link to. On Unix, this is + /// `python{major}.{minor}{variant}` and on Windows, this is `python{exe}`. pub fn executable(&self) -> PathBuf { - if cfg!(windows) { - self.python_dir().join("python.exe") + let implementation = match self.implementation() { + ImplementationName::CPython => "python", + ImplementationName::PyPy => "pypy", + ImplementationName::GraalPy => { + unreachable!("Managed installations of GraalPy are not supported") + } + }; + + let version = match self.implementation() { + ImplementationName::CPython => { + if cfg!(unix) { + format!("{}.{}", self.key.major, self.key.minor) + } else { + String::new() + } + } + // PyPy uses a full version number, even on Windows. + ImplementationName::PyPy => format!("{}.{}", self.key.major, self.key.minor), + ImplementationName::GraalPy => { + unreachable!("Managed installations of GraalPy are not supported") + } + }; + + // On Windows, the executable is just `python.exe` even for alternative variants + let variant = if cfg!(unix) { + self.key.variant.suffix() + } else { + "" + }; + + let name = format!( + "{implementation}{version}{variant}{exe}", + exe = std::env::consts::EXE_SUFFIX + ); + + let executable = if cfg!(windows) { + self.python_dir().join(name) } else if cfg!(unix) { - self.python_dir().join("bin").join("python3") + self.python_dir().join("bin").join(name) } else { unimplemented!("Only Windows and Unix systems are supported.") + }; + + // Workaround for python-build-standalone v20241016 which is missing the standard + // `python.exe` executable in free-threaded distributions on Windows. + // + // See https://github.com/astral-sh/uv/issues/8298 + if cfg!(windows) + && matches!(self.key.variant, PythonVariant::Freethreaded) + && !executable.exists() + { + // This is the alternative executable name for the freethreaded variant + return self.python_dir().join(format!( + "python{}.{}t{}", + self.key.major, + self.key.minor, + std::env::consts::EXE_SUFFIX + )); } + + executable } fn python_dir(&self) -> PathBuf { @@ -336,39 +413,38 @@ impl ManagedPythonInstallation { pub fn ensure_canonical_executables(&self) -> Result<(), Error> { let python = self.executable(); - // Workaround for python-build-standalone v20241016 which is missing the standard - // `python.exe` executable in free-threaded distributions on Windows. - // - // See https://github.com/astral-sh/uv/issues/8298 - if !python.try_exists()? { - match self.key.variant { - PythonVariant::Default => return Err(Error::MissingExecutable(python.clone())), - PythonVariant::Freethreaded => { - // This is the alternative executable name for the freethreaded variant - let python_in_dist = self.python_dir().join(format!( - "python{}.{}t{}", - self.key.major, - self.key.minor, - std::env::consts::EXE_SUFFIX - )); - debug!( - "Creating link {} -> {}", - python.user_display(), - python_in_dist.user_display() - ); - uv_fs::symlink_copy_fallback_file(&python_in_dist, &python).map_err(|err| { - if err.kind() == io::ErrorKind::NotFound { - Error::MissingExecutable(python_in_dist.clone()) - } else { - Error::CanonicalizeExecutable { - from: python_in_dist, - to: python, - err, - } - } - })?; - } + let canonical_names = &["python"]; + + for name in canonical_names { + let executable = + python.with_file_name(format!("{name}{exe}", exe = std::env::consts::EXE_SUFFIX)); + + // Do not attempt to perform same-file copies — this is fine on Unix but fails on + // Windows with a permission error instead of 'already exists' + if executable == python { + continue; } + + match uv_fs::symlink_copy_fallback_file(&python, &executable) { + Ok(()) => { + debug!( + "Created link {} -> {}", + executable.user_display(), + python.user_display(), + ); + } + Err(err) if err.kind() == io::ErrorKind::NotFound => { + return Err(Error::MissingExecutable(python.clone())) + } + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {} + Err(err) => { + return Err(Error::CanonicalizeExecutable { + from: executable, + to: python, + err, + }) + } + }; } Ok(()) @@ -381,10 +457,7 @@ impl ManagedPythonInstallation { let stdlib = if matches!(self.key.os, Os(target_lexicon::OperatingSystem::Windows)) { self.python_dir().join("Lib") } else { - let lib_suffix = match self.key.variant { - PythonVariant::Default => "", - PythonVariant::Freethreaded => "t", - }; + let lib_suffix = self.key.variant.suffix(); let python = if matches!( self.key.implementation, LenientImplementationName::Known(ImplementationName::PyPy) @@ -401,6 +474,31 @@ impl ManagedPythonInstallation { Ok(()) } + + /// Create a link to the Python executable in the given `bin` directory. + pub fn create_bin_link(&self, bin: &Path) -> Result { + let python = self.executable(); + + fs_err::create_dir_all(bin).map_err(|err| Error::ExecutableDirectory { + to: bin.to_path_buf(), + err, + })?; + + // TODO(zanieb): Add support for a "default" which + let python_in_bin = bin.join(self.key.versioned_executable_name()); + + match uv_fs::symlink_copy_fallback_file(&python, &python_in_bin) { + Ok(()) => Ok(python_in_bin), + Err(err) if err.kind() == io::ErrorKind::NotFound => { + Err(Error::MissingExecutable(python.clone())) + } + Err(err) => Err(Error::LinkExecutable { + from: python, + to: python_in_bin, + err, + }), + } + } } /// Generate a platform portion of a key from the environment. @@ -423,3 +521,9 @@ impl fmt::Display for ManagedPythonInstallation { ) } } + +/// Find the directory to install Python executables into. +pub fn python_executable_dir() -> Result { + uv_dirs::user_executable_directory(Some(EnvVars::UV_PYTHON_BIN_DIR)) + .ok_or(Error::NoExecutableDirectory) +} diff --git a/crates/uv-static/src/env_vars.rs b/crates/uv-static/src/env_vars.rs index dc5822b0c..0c7c6a48d 100644 --- a/crates/uv-static/src/env_vars.rs +++ b/crates/uv-static/src/env_vars.rs @@ -141,6 +141,9 @@ impl EnvVars { /// Specifies the path to the project virtual environment. pub const UV_PROJECT_ENVIRONMENT: &'static str = "UV_PROJECT_ENVIRONMENT"; + /// Specifies the directory to place links to installed, managed Python executables. + pub const UV_PYTHON_BIN_DIR: &'static str = "UV_PYTHON_BIN_DIR"; + /// Specifies the directory for storing managed Python installations. pub const UV_PYTHON_INSTALL_DIR: &'static str = "UV_PYTHON_INSTALL_DIR"; diff --git a/crates/uv-tool/src/lib.rs b/crates/uv-tool/src/lib.rs index 631d6c026..085793e56 100644 --- a/crates/uv-tool/src/lib.rs +++ b/crates/uv-tool/src/lib.rs @@ -42,7 +42,7 @@ pub enum Error { EntrypointRead(#[from] uv_install_wheel::Error), #[error("Failed to find dist-info directory `{0}` in environment at {1}")] DistInfoMissing(String, PathBuf), - #[error("Failed to find a directory for executables")] + #[error("Failed to find a directory to install executables into")] NoExecutableDirectory, #[error(transparent)] ToolName(#[from] InvalidNameError), diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index 7268fbf16..98f18afc6 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -78,6 +78,7 @@ rayon = { workspace = true } regex = { workspace = true } reqwest = { workspace = true } rustc-hash = { workspace = true } +same-file = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tempfile = { workspace = true } diff --git a/crates/uv/src/commands/python/dir.rs b/crates/uv/src/commands/python/dir.rs index d83668fe1..7c6a1b325 100644 --- a/crates/uv/src/commands/python/dir.rs +++ b/crates/uv/src/commands/python/dir.rs @@ -3,15 +3,21 @@ use anyhow::Context; use owo_colors::OwoColorize; use uv_fs::Simplified; -use uv_python::managed::ManagedPythonInstallations; +use uv_python::managed::{python_executable_dir, ManagedPythonInstallations}; + +/// Show the Python installation directory. +pub(crate) fn dir(bin: bool) -> anyhow::Result<()> { + if bin { + let bin = python_executable_dir()?; + println!("{}", bin.simplified_display().cyan()); + } else { + let installed_toolchains = ManagedPythonInstallations::from_settings() + .context("Failed to initialize toolchain settings")?; + println!( + "{}", + installed_toolchains.root().simplified_display().cyan() + ); + } -/// Show the toolchain directory. -pub(crate) fn dir() -> anyhow::Result<()> { - let installed_toolchains = ManagedPythonInstallations::from_settings() - .context("Failed to initialize toolchain settings")?; - println!( - "{}", - installed_toolchains.root().simplified_display().cyan() - ); Ok(()) } diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index 50d3a3079..ded7b004a 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -1,18 +1,27 @@ +use std::collections::BTreeSet; +use std::fmt::Write; +use std::io::ErrorKind; +use std::path::Path; + use anyhow::Result; use futures::stream::FuturesUnordered; use futures::StreamExt; use itertools::Itertools; use owo_colors::OwoColorize; use rustc_hash::FxHashSet; -use std::collections::BTreeSet; -use std::fmt::Write; -use std::path::Path; +use same_file::is_same_file; use tracing::debug; use uv_client::Connectivity; +use uv_configuration::PreviewMode; +use uv_fs::Simplified; use uv_python::downloads::{DownloadResult, ManagedPythonDownload, PythonDownloadRequest}; -use uv_python::managed::{ManagedPythonInstallation, ManagedPythonInstallations}; +use uv_python::managed::{ + python_executable_dir, ManagedPythonInstallation, ManagedPythonInstallations, +}; use uv_python::{PythonDownloads, PythonRequest, PythonVersionFile}; +use uv_shell::Shell; +use uv_warnings::warn_user; use crate::commands::python::{ChangeEvent, ChangeEventKind}; use crate::commands::reporters::PythonDownloadReporter; @@ -28,6 +37,7 @@ pub(crate) async fn install( native_tls: bool, connectivity: Connectivity, no_config: bool, + preview: PreviewMode, printer: Printer, ) -> Result { let start = std::time::Instant::now(); @@ -156,6 +166,12 @@ pub(crate) async fn install( }); } + let bin = if preview.is_enabled() { + Some(python_executable_dir()?) + } else { + None + }; + let mut installed = FxHashSet::default(); let mut errors = vec![]; while let Some((key, result)) = tasks.next().await { @@ -173,9 +189,46 @@ pub(crate) async fn install( let managed = ManagedPythonInstallation::new(path.clone())?; managed.ensure_externally_managed()?; managed.ensure_canonical_executables()?; + + if preview.is_enabled() && cfg!(unix) { + let bin = bin + .as_ref() + .expect("We should have a bin directory with preview enabled") + .as_path(); + match managed.create_bin_link(bin) { + Ok(executable) => { + debug!("Installed {} executable to {}", key, executable.display()); + } + Err(uv_python::managed::Error::LinkExecutable { from, to, err }) + if err.kind() == ErrorKind::AlreadyExists => + { + // TODO(zanieb): Add `--force` + if reinstall { + fs_err::remove_file(&to)?; + let executable = managed.create_bin_link(bin)?; + debug!( + "Replaced {} executable at {}", + key, + executable.user_display() + ); + } else { + if !is_same_file(&to, &from).unwrap_or_default() { + errors.push(( + key, + anyhow::anyhow!( + "Executable already exists at `{}`. Use `--reinstall` to force replacement.", + to.user_display() + ), + )); + } + } + } + Err(err) => return Err(err.into()), + } + } } Err(err) => { - errors.push((key, err)); + errors.push((key, anyhow::Error::new(err))); } } } @@ -232,16 +285,42 @@ pub(crate) async fn install( { match event.kind { ChangeEventKind::Added => { - writeln!(printer.stderr(), " {} {}", "+".green(), event.key.bold())?; + writeln!( + printer.stderr(), + " {} {} ({})", + "+".green(), + event.key.bold(), + event.key.versioned_executable_name() + )?; } ChangeEventKind::Removed => { - writeln!(printer.stderr(), " {} {}", "-".red(), event.key.bold())?; + writeln!( + printer.stderr(), + " {} {} ({})", + "-".red(), + event.key.bold(), + event.key.versioned_executable_name() + )?; } ChangeEventKind::Reinstalled => { - writeln!(printer.stderr(), " {} {}", "~".yellow(), event.key.bold(),)?; + writeln!( + printer.stderr(), + " {} {} ({})", + "~".yellow(), + event.key.bold(), + event.key.versioned_executable_name() + )?; } } } + + if preview.is_enabled() && cfg!(unix) { + let bin = bin + .as_ref() + .expect("We should have a bin directory with preview enabled") + .as_path(); + warn_if_not_on_path(bin); + } } if !errors.is_empty() { @@ -255,7 +334,7 @@ pub(crate) async fn install( "error".red().bold(), key.green() )?; - for err in anyhow::Error::new(err).chain() { + for err in err.chain() { writeln!( printer.stderr(), " {}: {}", @@ -269,3 +348,36 @@ pub(crate) async fn install( Ok(ExitStatus::Success) } + +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) { + if shell.configuration_files().is_empty() { + warn_user!( + "`{}` is not on your PATH. To use the installed Python executable, run `{}`.", + bin.simplified_display().cyan(), + command.green() + ); + } else { + // TODO(zanieb): Update when we add `uv python update-shell` to match `uv tool` + warn_user!( + "`{}` is not on your PATH. To use the installed Python executable, 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.", + bin.simplified_display().cyan(), + ); + } + } else { + warn_user!( + "`{}` is not on your PATH. To use the installed Python executable, add the directory to your PATH.", + bin.simplified_display().cyan(), + ); + } + } +} diff --git a/crates/uv/src/commands/python/uninstall.rs b/crates/uv/src/commands/python/uninstall.rs index cd83cecb4..f74085cf4 100644 --- a/crates/uv/src/commands/python/uninstall.rs +++ b/crates/uv/src/commands/python/uninstall.rs @@ -7,8 +7,11 @@ use futures::StreamExt; use itertools::Itertools; use owo_colors::OwoColorize; +use same_file::is_same_file; +use tracing::{debug, warn}; +use uv_fs::Simplified; use uv_python::downloads::PythonDownloadRequest; -use uv_python::managed::ManagedPythonInstallations; +use uv_python::managed::{python_executable_dir, ManagedPythonInstallations}; use uv_python::PythonRequest; use crate::commands::python::{ChangeEvent, ChangeEventKind}; @@ -121,6 +124,40 @@ async fn do_uninstall( return Ok(ExitStatus::Failure); } + // Collect files in a directory + let executables = python_executable_dir()? + .read_dir()? + .filter_map(|entry| match entry { + Ok(entry) => Some(entry), + Err(err) => { + warn!("Failed to read executable: {}", err); + None + } + }) + .filter(|entry| entry.file_type().is_ok_and(|file_type| !file_type.is_dir())) + .map(|entry| entry.path()) + // Only include files that match the expected Python executable names + // TODO(zanieb): This is a minor optimization to avoid opening more files, but we could + // leave broken links behind, i.e., if the user created them. + .filter(|path| { + matching_installations.iter().any(|installation| { + path.file_name().and_then(|name| name.to_str()) + == Some(&installation.key().versioned_executable_name()) + }) + }) + // Only include Python executables that match the installations + .filter(|path| { + matching_installations.iter().any(|installation| { + is_same_file(path, installation.executable()).unwrap_or_default() + }) + }) + .collect::>(); + + for executable in &executables { + fs_err::remove_file(executable)?; + debug!("Removed {}", executable.user_display()); + } + let mut tasks = FuturesUnordered::new(); for installation in &matching_installations { tasks.push(async { @@ -180,7 +217,13 @@ async fn do_uninstall( { match event.kind { ChangeEventKind::Removed => { - writeln!(printer.stderr(), " {} {}", "-".red(), event.key.bold())?; + writeln!( + printer.stderr(), + " {} {} ({})", + "-".red(), + event.key.bold(), + event.key.versioned_executable_name() + )?; } _ => unreachable!(), } diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index f5b362b65..7055a9bfb 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1057,6 +1057,7 @@ async fn run(mut cli: Cli) -> Result { globals.native_tls, globals.connectivity, cli.top_level.no_config, + globals.preview, printer, ) .await @@ -1111,9 +1112,13 @@ async fn run(mut cli: Cli) -> Result { .await } Commands::Python(PythonNamespace { - command: PythonCommand::Dir, + command: PythonCommand::Dir(args), }) => { - commands::python_dir()?; + // Resolve the settings from the command-line arguments and workspace configuration. + let args = settings::PythonDirSettings::resolve(args, filesystem); + show_settings!(args); + + commands::python_dir(args.bin)?; Ok(ExitStatus::Success) } Commands::Publish(args) => { diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index c520afe68..b4259fe6e 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -8,7 +8,7 @@ use url::Url; use uv_cache::{CacheArgs, Refresh}; use uv_cli::{ options::{flag, resolver_installer_options, resolver_options}, - AuthorFrom, BuildArgs, ExportArgs, PublishArgs, ToolUpgradeArgs, + AuthorFrom, BuildArgs, ExportArgs, PublishArgs, PythonDirArgs, ToolUpgradeArgs, }; use uv_cli::{ AddArgs, ColorChoice, ExternalCommand, GlobalArgs, InitArgs, ListFormat, LockArgs, Maybe, @@ -597,6 +597,23 @@ impl PythonListSettings { } } +/// The resolved settings to use for a `python dir` invocation. +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone)] +pub(crate) struct PythonDirSettings { + pub(crate) bin: bool, +} + +impl PythonDirSettings { + /// Resolve the [`PythonDirSettings`] from the CLI and filesystem configuration. + #[allow(clippy::needless_pass_by_value)] + pub(crate) fn resolve(args: PythonDirArgs, _filesystem: Option) -> Self { + let PythonDirArgs { bin } = args; + + Self { bin } + } +} + /// The resolved settings to use for a `python install` invocation. #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 6ae279044..166f0f600 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -653,11 +653,13 @@ impl TestContext { pub fn python_install(&self) -> Command { let mut command = self.new_command(); let managed = self.temp_dir.join("managed"); + let bin = self.temp_dir.join("bin"); self.add_shared_args(&mut command, true); command .arg("python") .arg("install") .env(EnvVars::UV_PYTHON_INSTALL_DIR, managed) + .env(EnvVars::UV_PYTHON_BIN_DIR, bin) .current_dir(&self.temp_dir); command } @@ -666,11 +668,13 @@ impl TestContext { pub fn python_uninstall(&self) -> Command { let mut command = self.new_command(); let managed = self.temp_dir.join("managed"); + let bin = self.temp_dir.join("bin"); self.add_shared_args(&mut command, true); command .arg("python") .arg("uninstall") .env(EnvVars::UV_PYTHON_INSTALL_DIR, managed) + .env(EnvVars::UV_PYTHON_BIN_DIR, bin) .current_dir(&self.temp_dir); command } diff --git a/crates/uv/tests/it/help.rs b/crates/uv/tests/it/help.rs index 3143aa43b..faf837a7a 100644 --- a/crates/uv/tests/it/help.rs +++ b/crates/uv/tests/it/help.rs @@ -411,13 +411,15 @@ fn help_subsubcommand() { Multiple Python versions may be requested. - Supports CPython and PyPy. - - CPython distributions are downloaded from the `python-build-standalone` project. + Supports CPython and PyPy. CPython distributions are downloaded from the `python-build-standalone` + project. PyPy distributions are downloaded from `python.org`. Python versions are installed into the uv Python directory, which can be retrieved with `uv python - dir`. A `python` executable is not made globally available, managed Python versions are only used in - uv commands or in active virtual environments. + dir`. + + A `python` executable is not made globally available, managed Python versions are only used in uv + commands or in active virtual environments. There is experimental support for adding Python + executables to the `PATH` — use the `--preview` flag to enable this behavior. See `uv help python` to view supported request formats. diff --git a/crates/uv/tests/it/python_install.rs b/crates/uv/tests/it/python_install.rs index 61700312a..835336168 100644 --- a/crates/uv/tests/it/python_install.rs +++ b/crates/uv/tests/it/python_install.rs @@ -1,3 +1,8 @@ +use std::process::Command; + +use assert_fs::{assert::PathAssert, prelude::PathChild}; +use predicates::prelude::predicate; + use crate::common::{uv_snapshot, TestContext}; #[test] @@ -16,6 +21,14 @@ fn python_install() { + cpython-3.13.0-[PLATFORM] "###); + let bin_python = context + .temp_dir + .child("bin") + .child(format!("python3.13{}", std::env::consts::EXE_SUFFIX)); + + // The executable should not be installed in the bin directory (requires preview) + bin_python.assert(predicate::path::missing()); + // Should be a no-op when already installed uv_snapshot!(context.filters(), context.python_install(), @r###" success: true @@ -39,6 +52,114 @@ fn python_install() { Found existing installation for Python 3.13: cpython-3.13.0-[PLATFORM] "###); + // You can opt-in to a reinstall + uv_snapshot!(context.filters(), context.python_install().arg("--reinstall"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Searching for Python installations + Found: cpython-3.13.0-[PLATFORM] + Installed Python 3.13.0 in [TIME] + ~ cpython-3.13.0-[PLATFORM] + "###); + + // Uninstallation requires an argument + uv_snapshot!(context.filters(), context.python_uninstall(), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: the following required arguments were not provided: + ... + + Usage: uv python uninstall ... + + For more information, try '--help'. + "###); + + uv_snapshot!(context.filters(), context.python_uninstall().arg("3.13"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Searching for Python versions matching: Python 3.13 + error: No such file or directory (os error 2) + "###); +} + +#[test] +fn python_install_preview() { + let context: TestContext = TestContext::new_with_versions(&[]).with_filtered_python_keys(); + + // Install the latest version + uv_snapshot!(context.filters(), context.python_install().arg("--preview"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Searching for Python installations + Installed Python 3.13.0 in [TIME] + + cpython-3.13.0-[PLATFORM] + warning: `[TEMP_DIR]/bin` is not on your PATH. To use the installed Python executable, run `export PATH="[TEMP_DIR]/bin:$PATH"`. + "###); + + let bin_python = context + .temp_dir + .child("bin") + .child(format!("python3.13{}", std::env::consts::EXE_SUFFIX)); + + // The executable should be installed in the bin directory + bin_python.assert(predicate::path::exists()); + + // On Unix, it should be a link + #[cfg(unix)] + bin_python.assert(predicate::path::is_symlink()); + + // The executable should "work" + uv_snapshot!(context.filters(), Command::new(bin_python.as_os_str()) + .arg("-c").arg("import subprocess; print('hello world')"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + hello world + + ----- stderr ----- + "###); + + // Should be a no-op when already installed + uv_snapshot!(context.filters(), context.python_install().arg("--preview"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Searching for Python installations + Found: cpython-3.13.0-[PLATFORM] + Python is already available. Use `uv python install ` to install a specific version. + "###); + + // You can opt-in to a reinstall + uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("--reinstall"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Searching for Python installations + Found: cpython-3.13.0-[PLATFORM] + Installed Python 3.13.0 in [TIME] + ~ cpython-3.13.0-[PLATFORM] + warning: `[TEMP_DIR]/bin` is not on your PATH. To use the installed Python executable, run `export PATH="[TEMP_DIR]/bin:$PATH"`. + "###); + + // The executable should still be present in the bin directory + bin_python.assert(predicate::path::exists()); + // Uninstallation requires an argument uv_snapshot!(context.filters(), context.python_uninstall(), @r###" success: false @@ -64,6 +185,9 @@ fn python_install() { Uninstalled Python 3.13.0 in [TIME] - cpython-3.13.0-[PLATFORM] "###); + + // The executable should be removed + bin_python.assert(predicate::path::missing()); } #[test] @@ -71,7 +195,7 @@ fn python_install_freethreaded() { let context: TestContext = TestContext::new_with_versions(&[]).with_filtered_python_keys(); // Install the latest version - uv_snapshot!(context.filters(), context.python_install().arg("3.13t"), @r###" + uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.13t"), @r###" success: true exit_code: 0 ----- stdout ----- @@ -80,6 +204,30 @@ fn python_install_freethreaded() { Searching for Python versions matching: Python 3.13t Installed Python 3.13.0 in [TIME] + cpython-3.13.0+freethreaded-[PLATFORM] + warning: `[TEMP_DIR]/bin` is not on your PATH. To use the installed Python executable, run `export PATH="[TEMP_DIR]/bin:$PATH"`. + "###); + + let bin_python = context + .temp_dir + .child("bin") + .child(format!("python3.13t{}", std::env::consts::EXE_SUFFIX)); + + // The executable should be installed in the bin directory + bin_python.assert(predicate::path::exists()); + + // On Unix, it should be a link + #[cfg(unix)] + bin_python.assert(predicate::path::is_symlink()); + + // The executable should "work" + uv_snapshot!(context.filters(), Command::new(bin_python.as_os_str()) + .arg("-c").arg("import subprocess; print('hello world')"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + hello world + + ----- stderr ----- "###); // Should be distinct from 3.13 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a32e7ede4..ae89996eb 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -4302,11 +4302,11 @@ Download and install Python versions. Multiple Python versions may be requested. -Supports CPython and PyPy. +Supports CPython and PyPy. CPython distributions are downloaded from the `python-build-standalone` project. PyPy distributions are downloaded from `python.org`. -CPython distributions are downloaded from the `python-build-standalone` project. +Python versions are installed into the uv Python directory, which can be retrieved with `uv python dir`. -Python versions are installed into the uv Python directory, which can be retrieved with `uv python dir`. A `python` executable is not made globally available, managed Python versions are only used in uv commands or in active virtual environments. +A `python` executable is not made globally available, managed Python versions are only used in uv commands or in active virtual environments. There is experimental support for adding Python executables to the `PATH` — use the `--preview` flag to enable this behavior. See `uv help python` to view supported request formats. @@ -4693,6 +4693,8 @@ By default, Python installations are stored in the uv data directory at `$XDG_DA The Python installation directory may be overridden with `$UV_PYTHON_INSTALL_DIR`. +To instead view the directory uv installs Python executables into, use the `--bin` flag. +

Usage

``` @@ -4701,7 +4703,25 @@ uv python dir [OPTIONS]

Options

-
--cache-dir cache-dir

Path to the cache directory.

+
--bin

Show the directory into which uv python will install Python executables.

+ +

Note this directory is only used when installing with preview mode enabled.

+ +

By default, uv python dir shows the directory into which the Python distributions themselves are installed, rather than the directory containing the linked executables.

+ +

The Python executable directory is determined according to the XDG standard and is derived from the following environment variables, in order of preference:

+ +
    +
  • $UV_PYTHON_BIN_DIR
  • + +
  • $XDG_BIN_HOME
  • + +
  • $XDG_DATA_HOME/../bin
  • + +
  • $HOME/.local/bin
  • +
+ +
--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.