Skip to content

Commit dc70ac5

Browse files
committed
Install versioned Python executables into the bin directory during uv python install
1 parent 5a15e45 commit dc70ac5

File tree

8 files changed

+134
-21
lines changed

8 files changed

+134
-21
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -773,7 +773,7 @@ jobs:
773773
run: chmod +x ./uv
774774

775775
- name: "Install PyPy"
776-
run: ./uv python install pypy3.9
776+
run: ./uv python install -v pypy3.9
777777

778778
- name: "Create a virtual environment"
779779
run: |

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/uv-python/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ uv-cache = { workspace = true }
2020
uv-cache-info = { workspace = true }
2121
uv-cache-key = { workspace = true }
2222
uv-client = { workspace = true }
23+
uv-dirs = { workspace = true }
2324
uv-distribution-filename = { workspace = true }
2425
uv-extract = { workspace = true }
2526
uv-fs = { workspace = true }

crates/uv-python/src/discovery.rs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1250,6 +1250,16 @@ impl PythonVariant {
12501250
PythonVariant::Freethreaded => interpreter.gil_disabled(),
12511251
}
12521252
}
1253+
1254+
/// Return the lib or executable suffix for the variant, e.g., `t` for `python3.13t`.
1255+
///
1256+
/// Returns an empty string for the default Python variant.
1257+
pub fn suffix(self) -> &'static str {
1258+
match self {
1259+
Self::Default => "",
1260+
Self::Freethreaded => "t",
1261+
}
1262+
}
12531263
}
12541264
impl PythonRequest {
12551265
/// Create a request from a string.
@@ -1651,12 +1661,7 @@ impl std::fmt::Display for ExecutableName {
16511661
if let Some(prerelease) = &self.prerelease {
16521662
write!(f, "{prerelease}")?;
16531663
}
1654-
match self.variant {
1655-
PythonVariant::Default => {}
1656-
PythonVariant::Freethreaded => {
1657-
f.write_str("t")?;
1658-
}
1659-
};
1664+
f.write_str(self.variant.suffix())?;
16601665
f.write_str(std::env::consts::EXE_SUFFIX)?;
16611666
Ok(())
16621667
}

crates/uv-python/src/managed.rs

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,21 @@ pub enum Error {
5353
#[source]
5454
err: io::Error,
5555
},
56+
#[error("Failed to create Python executable link at {} from {}", to.user_display(), from.user_display())]
57+
LinkExecutable {
58+
from: PathBuf,
59+
to: PathBuf,
60+
#[source]
61+
err: io::Error,
62+
},
5663
#[error("Failed to read Python installation directory: {0}", dir.user_display())]
5764
ReadError {
5865
dir: PathBuf,
5966
#[source]
6067
err: io::Error,
6168
},
69+
#[error("Failed to find a directory to install executables into")]
70+
NoExecutableDirectory,
6271
#[error("Failed to read managed Python directory name: {0}")]
6372
NameError(String),
6473
#[error(transparent)]
@@ -270,12 +279,29 @@ impl ManagedPythonInstallation {
270279
Ok(Self { path, key })
271280
}
272281

273-
/// The path to this toolchain's Python executable.
282+
/// The path to this managed installation's Python executable.
274283
pub fn executable(&self) -> PathBuf {
284+
let implementation = match self.implementation() {
285+
ImplementationName::CPython => "python3",
286+
ImplementationName::PyPy => "pypy",
287+
ImplementationName::GraalPy => {
288+
unreachable!("Managed installations of GraalPy are not supported")
289+
}
290+
};
291+
292+
// On Windows, the executable is just `python.exe` even for alternative variants
293+
let variant = if cfg!(unix) {
294+
self.key.variant.suffix()
295+
} else {
296+
""
297+
};
298+
299+
let name = format!("{implementation}{variant}{}", std::env::consts::EXE_SUFFIX);
300+
275301
if cfg!(windows) {
276-
self.python_dir().join("python.exe")
302+
self.python_dir().join(name)
277303
} else if cfg!(unix) {
278-
self.python_dir().join("bin").join("python3")
304+
self.python_dir().join("bin").join(name)
279305
} else {
280306
unimplemented!("Only Windows and Unix systems are supported.")
281307
}
@@ -361,12 +387,33 @@ impl ManagedPythonInstallation {
361387
Error::MissingExecutable(python_in_dist.clone())
362388
} else {
363389
Error::CanonicalizeExecutable {
364-
from: python_in_dist,
390+
from: python_in_dist.clone(),
365391
to: python,
366392
err,
367393
}
368394
}
369395
})?;
396+
let major = self.python_dir().join(format!(
397+
"python{}t{}",
398+
self.key.major,
399+
std::env::consts::EXE_SUFFIX
400+
));
401+
debug!(
402+
"Creating link {} -> {}",
403+
major.user_display(),
404+
python_in_dist.user_display()
405+
);
406+
uv_fs::symlink_copy_fallback_file(&python_in_dist, &major).map_err(|err| {
407+
if err.kind() == io::ErrorKind::NotFound {
408+
Error::MissingExecutable(python_in_dist.clone())
409+
} else {
410+
Error::CanonicalizeExecutable {
411+
from: python_in_dist.clone(),
412+
to: major,
413+
err,
414+
}
415+
}
416+
})?;
370417
}
371418
}
372419
}
@@ -381,10 +428,7 @@ impl ManagedPythonInstallation {
381428
let stdlib = if matches!(self.key.os, Os(target_lexicon::OperatingSystem::Windows)) {
382429
self.python_dir().join("Lib")
383430
} else {
384-
let lib_suffix = match self.key.variant {
385-
PythonVariant::Default => "",
386-
PythonVariant::Freethreaded => "t",
387-
};
431+
let lib_suffix = self.key.variant.suffix();
388432
let python = if matches!(
389433
self.key.implementation,
390434
LenientImplementationName::Known(ImplementationName::PyPy)
@@ -401,6 +445,34 @@ impl ManagedPythonInstallation {
401445

402446
Ok(())
403447
}
448+
449+
/// Create a link to the Python executable in the `bin` directory.
450+
pub fn create_bin_link(&self) -> Result<PathBuf, Error> {
451+
let python = self.executable();
452+
let bin = uv_dirs::user_executable_directory(Some(EnvVars::UV_PYTHON_BIN_DIR))
453+
.ok_or(Error::NoExecutableDirectory)?;
454+
455+
// TODO(zanieb): Add support for a "default" which
456+
let python_in_bin = bin.join(format!(
457+
"python{maj}.{min}{var}{exe}",
458+
maj = self.key.major,
459+
min = self.key.minor,
460+
var = self.key.variant.suffix(),
461+
exe = std::env::consts::EXE_SUFFIX
462+
));
463+
464+
match uv_fs::symlink_copy_fallback_file(&python, &python_in_bin) {
465+
Ok(()) => Ok(python_in_bin),
466+
Err(err) if err.kind() == io::ErrorKind::NotFound => {
467+
Err(Error::MissingExecutable(python.clone()))
468+
}
469+
Err(err) => Err(Error::LinkExecutable {
470+
from: python,
471+
to: python_in_bin,
472+
err,
473+
}),
474+
}
475+
}
404476
}
405477

406478
/// Generate a platform portion of a key from the environment.

crates/uv-static/src/env_vars.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ impl EnvVars {
141141
/// Specifies the path to the project virtual environment.
142142
pub const UV_PROJECT_ENVIRONMENT: &'static str = "UV_PROJECT_ENVIRONMENT";
143143

144+
/// Specifies the directory to place links to installed, managed Python executables.
145+
pub const UV_PYTHON_BIN_DIR: &'static str = "UV_PYTHON_BIN_DIR";
146+
144147
/// Specifies the directory for storing managed Python installations.
145148
pub const UV_PYTHON_INSTALL_DIR: &'static str = "UV_PYTHON_INSTALL_DIR";
146149

crates/uv-tool/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ pub enum Error {
4141
EntrypointRead(#[from] uv_install_wheel::Error),
4242
#[error("Failed to find dist-info directory `{0}` in environment at {1}")]
4343
DistInfoMissing(String, PathBuf),
44-
#[error("Failed to find a directory for executables")]
44+
#[error("Failed to find a directory to install executables into")]
4545
NoExecutableDirectory,
4646
#[error(transparent)]
4747
ToolName(#[from] InvalidNameError),

crates/uv/src/commands/python/install.rs

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1+
use std::collections::BTreeSet;
2+
use std::fmt::Write;
3+
use std::io::ErrorKind;
4+
use std::path::Path;
5+
16
use anyhow::Result;
27
use fs_err as fs;
38
use futures::stream::FuturesUnordered;
49
use futures::StreamExt;
510
use itertools::Itertools;
611
use owo_colors::OwoColorize;
7-
use std::collections::BTreeSet;
8-
use std::fmt::Write;
9-
use std::path::Path;
1012
use tracing::debug;
1113

1214
use uv_client::Connectivity;
15+
use uv_fs::Simplified;
1316
use uv_python::downloads::{DownloadResult, ManagedPythonDownload, PythonDownloadRequest};
1417
use uv_python::managed::{ManagedPythonInstallation, ManagedPythonInstallations};
1518
use uv_python::{PythonDownloads, PythonRequest, PythonVersionFile};
@@ -168,9 +171,37 @@ pub(crate) async fn install(
168171
let managed = ManagedPythonInstallation::new(path.clone())?;
169172
managed.ensure_externally_managed()?;
170173
managed.ensure_canonical_executables()?;
174+
match managed.create_bin_link() {
175+
Ok(executable) => {
176+
debug!("Installed {} executable to {}", key, executable.display());
177+
}
178+
Err(uv_python::managed::Error::LinkExecutable { from: _, to, err })
179+
if err.kind() == ErrorKind::AlreadyExists =>
180+
{
181+
// TODO(zanieb): Add `--force`
182+
if reinstall {
183+
fs::remove_file(&to)?;
184+
let executable = managed.create_bin_link()?;
185+
debug!(
186+
"Replaced {} executable at {}",
187+
key,
188+
executable.user_display()
189+
);
190+
} else {
191+
errors.push((
192+
key,
193+
anyhow::anyhow!(
194+
"Executable already exists at `{}`. Use `--reinstall` to force replacement.",
195+
to.user_display()
196+
),
197+
));
198+
}
199+
}
200+
Err(err) => return Err(err.into()),
201+
}
171202
}
172203
Err(err) => {
173-
errors.push((key, err));
204+
errors.push((key, anyhow::Error::new(err)));
174205
}
175206
}
176207
}
@@ -234,7 +265,7 @@ pub(crate) async fn install(
234265
"error".red().bold(),
235266
key.green()
236267
)?;
237-
for err in anyhow::Error::new(err).chain() {
268+
for err in err.chain() {
238269
writeln!(
239270
printer.stderr(),
240271
" {}: {}",

0 commit comments

Comments
 (0)