From b18878149c9059100d07b1b3da6a9bb14d5f287f Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Tue, 22 Oct 2024 08:01:56 -0500 Subject: [PATCH] Install versioned Python executables into the bin directory during `uv python install` --- .github/workflows/ci.yml | 4 +- Cargo.lock | 1 + crates/uv-python/Cargo.toml | 1 + crates/uv-python/src/discovery.rs | 17 +++-- crates/uv-python/src/managed.rs | 85 +++++++++++++++++++++--- crates/uv-static/src/env_vars.rs | 3 + crates/uv-tool/src/lib.rs | 2 +- crates/uv/src/commands/python/install.rs | 41 ++++++++++-- 8 files changed, 132 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b67f802f5ae..933ca4f9afc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -701,7 +701,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" @@ -773,7 +773,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 b31ea8c17702..bad3185cdb22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5016,6 +5016,7 @@ dependencies = [ "uv-cache-info", "uv-cache-key", "uv-client", + "uv-dirs", "uv-distribution-filename", "uv-extract", "uv-fs", diff --git a/crates/uv-python/Cargo.toml b/crates/uv-python/Cargo.toml index 361d3fa7da68..3221c98cb8f5 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 aee120ab25fa..ece1269ee026 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -1250,6 +1250,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. @@ -1651,12 +1661,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/managed.rs b/crates/uv-python/src/managed.rs index 98f8dccdf277..6014d8c273b5 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -53,12 +53,21 @@ 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 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(transparent)] @@ -270,12 +279,47 @@ impl ManagedPythonInstallation { Ok(Self { path, key }) } - /// The path to this toolchain's Python executable. + /// The path to this managed installation's Python executable. pub fn executable(&self) -> PathBuf { + let implementation = match self.implementation() { + ImplementationName::CPython => "python", + ImplementationName::PyPy => "pypy", + 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 { + "" + }; + + // PyPy uses a full version number, even on Windows. + let version = match self.implementation() { + ImplementationName::CPython => { + if cfg!(unix) { + self.key.major.to_string() + } else { + String::new() + } + } + ImplementationName::PyPy => format!("{}.{}", self.key.major, self.key.minor), + ImplementationName::GraalPy => { + unreachable!("Managed installations of GraalPy are not supported") + } + }; + + let name = format!( + "{implementation}{version}{variant}{exe}", + exe = std::env::consts::EXE_SUFFIX + ); + if cfg!(windows) { - self.python_dir().join("python.exe") + 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.") } @@ -361,7 +405,7 @@ impl ManagedPythonInstallation { Error::MissingExecutable(python_in_dist.clone()) } else { Error::CanonicalizeExecutable { - from: python_in_dist, + from: python_in_dist.clone(), to: python, err, } @@ -381,10 +425,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 +442,34 @@ impl ManagedPythonInstallation { Ok(()) } + + /// Create a link to the Python executable in the `bin` directory. + pub fn create_bin_link(&self) -> Result { + let python = self.executable(); + let bin = uv_dirs::user_executable_directory(Some(EnvVars::UV_PYTHON_BIN_DIR)) + .ok_or(Error::NoExecutableDirectory)?; + + // TODO(zanieb): Add support for a "default" which + let python_in_bin = bin.join(format!( + "python{maj}.{min}{var}{exe}", + maj = self.key.major, + min = self.key.minor, + var = self.key.variant.suffix(), + exe = std::env::consts::EXE_SUFFIX + )); + + 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. diff --git a/crates/uv-static/src/env_vars.rs b/crates/uv-static/src/env_vars.rs index 590c054297cf..5c5504fe2ec6 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 45eebb4add12..24be27dc2d91 100644 --- a/crates/uv-tool/src/lib.rs +++ b/crates/uv-tool/src/lib.rs @@ -41,7 +41,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/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index 601694359783..7deb65e91a05 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -1,15 +1,18 @@ +use std::collections::BTreeSet; +use std::fmt::Write; +use std::io::ErrorKind; +use std::path::Path; + use anyhow::Result; use fs_err as fs; use futures::stream::FuturesUnordered; use futures::StreamExt; use itertools::Itertools; use owo_colors::OwoColorize; -use std::collections::BTreeSet; -use std::fmt::Write; -use std::path::Path; use tracing::debug; use uv_client::Connectivity; +use uv_fs::Simplified; use uv_python::downloads::{DownloadResult, ManagedPythonDownload, PythonDownloadRequest}; use uv_python::managed::{ManagedPythonInstallation, ManagedPythonInstallations}; use uv_python::{PythonDownloads, PythonRequest, PythonVersionFile}; @@ -168,9 +171,37 @@ pub(crate) async fn install( let managed = ManagedPythonInstallation::new(path.clone())?; managed.ensure_externally_managed()?; managed.ensure_canonical_executables()?; + match managed.create_bin_link() { + 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::remove_file(&to)?; + let executable = managed.create_bin_link()?; + debug!( + "Replaced {} executable at {}", + key, + executable.user_display() + ); + } else { + 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))); } } } @@ -234,7 +265,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(), " {}: {}",