Skip to content

Commit

Permalink
Install versioned Python executables into the bin directory during `u…
Browse files Browse the repository at this point in the history
…v python install`
  • Loading branch information
zanieb committed Oct 22, 2024
1 parent 5a15e45 commit a5bae91
Show file tree
Hide file tree
Showing 8 changed files with 149 additions and 22 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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: |
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/uv-python/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
17 changes: 11 additions & 6 deletions crates/uv-python/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(())
}
Expand Down
102 changes: 94 additions & 8 deletions crates/uv-python/src/managed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -270,12 +279,43 @@ 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 and versions
let variant = if cfg!(unix) {
self.key.variant.suffix()
} else {
""
};
let version = if cfg!(unix) {
match self.implementation() {
ImplementationName::CPython => self.key.major.to_string(),
ImplementationName::PyPy => format!("{}.{}", self.key.major, self.key.minor),
ImplementationName::GraalPy => {
unreachable!("Managed installations of GraalPy are not supported")
}
}
} else {
String::new()
};

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.")
}
Expand Down Expand Up @@ -361,12 +401,33 @@ impl ManagedPythonInstallation {
Error::MissingExecutable(python_in_dist.clone())
} else {
Error::CanonicalizeExecutable {
from: python_in_dist,
from: python_in_dist.clone(),
to: python,
err,
}
}
})?;
let major = self.python_dir().join(format!(
"python{}t{}",
self.key.major,
std::env::consts::EXE_SUFFIX
));
debug!(
"Creating link {} -> {}",
major.user_display(),
python_in_dist.user_display()
);
uv_fs::symlink_copy_fallback_file(&python_in_dist, &major).map_err(|err| {
if err.kind() == io::ErrorKind::NotFound {
Error::MissingExecutable(python_in_dist.clone())
} else {
Error::CanonicalizeExecutable {
from: python_in_dist.clone(),
to: major,
err,
}
}
})?;
}
}
}
Expand All @@ -381,10 +442,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)
Expand All @@ -401,6 +459,34 @@ impl ManagedPythonInstallation {

Ok(())
}

/// Create a link to the Python executable in the `bin` directory.
pub fn create_bin_link(&self) -> Result<PathBuf, Error> {
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.
Expand Down
3 changes: 3 additions & 0 deletions crates/uv-static/src/env_vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
2 changes: 1 addition & 1 deletion crates/uv-tool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
41 changes: 36 additions & 5 deletions crates/uv/src/commands/python/install.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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)));
}
}
}
Expand Down Expand Up @@ -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(),
" {}: {}",
Expand Down

0 comments on commit a5bae91

Please sign in to comment.