Skip to content

Commit a5bae91

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

File tree

8 files changed

+149
-22
lines changed

8 files changed

+149
-22
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -701,7 +701,7 @@ jobs:
701701
702702
- name: "Install free-threaded Python via uv"
703703
run: |
704-
./uv python install 3.13t
704+
./uv python install -v 3.13t
705705
./uv venv -p 3.13t --python-preference only-managed
706706
707707
- name: "Check version"
@@ -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: 94 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,43 @@ 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 => "python",
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 and versions
293+
let variant = if cfg!(unix) {
294+
self.key.variant.suffix()
295+
} else {
296+
""
297+
};
298+
let version = if cfg!(unix) {
299+
match self.implementation() {
300+
ImplementationName::CPython => self.key.major.to_string(),
301+
ImplementationName::PyPy => format!("{}.{}", self.key.major, self.key.minor),
302+
ImplementationName::GraalPy => {
303+
unreachable!("Managed installations of GraalPy are not supported")
304+
}
305+
}
306+
} else {
307+
String::new()
308+
};
309+
310+
let name = format!(
311+
"{implementation}{version}{variant}{exe}",
312+
exe = std::env::consts::EXE_SUFFIX
313+
);
314+
275315
if cfg!(windows) {
276-
self.python_dir().join("python.exe")
316+
self.python_dir().join(name)
277317
} else if cfg!(unix) {
278-
self.python_dir().join("bin").join("python3")
318+
self.python_dir().join("bin").join(name)
279319
} else {
280320
unimplemented!("Only Windows and Unix systems are supported.")
281321
}
@@ -361,12 +401,33 @@ impl ManagedPythonInstallation {
361401
Error::MissingExecutable(python_in_dist.clone())
362402
} else {
363403
Error::CanonicalizeExecutable {
364-
from: python_in_dist,
404+
from: python_in_dist.clone(),
365405
to: python,
366406
err,
367407
}
368408
}
369409
})?;
410+
let major = self.python_dir().join(format!(
411+
"python{}t{}",
412+
self.key.major,
413+
std::env::consts::EXE_SUFFIX
414+
));
415+
debug!(
416+
"Creating link {} -> {}",
417+
major.user_display(),
418+
python_in_dist.user_display()
419+
);
420+
uv_fs::symlink_copy_fallback_file(&python_in_dist, &major).map_err(|err| {
421+
if err.kind() == io::ErrorKind::NotFound {
422+
Error::MissingExecutable(python_in_dist.clone())
423+
} else {
424+
Error::CanonicalizeExecutable {
425+
from: python_in_dist.clone(),
426+
to: major,
427+
err,
428+
}
429+
}
430+
})?;
370431
}
371432
}
372433
}
@@ -381,10 +442,7 @@ impl ManagedPythonInstallation {
381442
let stdlib = if matches!(self.key.os, Os(target_lexicon::OperatingSystem::Windows)) {
382443
self.python_dir().join("Lib")
383444
} else {
384-
let lib_suffix = match self.key.variant {
385-
PythonVariant::Default => "",
386-
PythonVariant::Freethreaded => "t",
387-
};
445+
let lib_suffix = self.key.variant.suffix();
388446
let python = if matches!(
389447
self.key.implementation,
390448
LenientImplementationName::Known(ImplementationName::PyPy)
@@ -401,6 +459,34 @@ impl ManagedPythonInstallation {
401459

402460
Ok(())
403461
}
462+
463+
/// Create a link to the Python executable in the `bin` directory.
464+
pub fn create_bin_link(&self) -> Result<PathBuf, Error> {
465+
let python = self.executable();
466+
let bin = uv_dirs::user_executable_directory(Some(EnvVars::UV_PYTHON_BIN_DIR))
467+
.ok_or(Error::NoExecutableDirectory)?;
468+
469+
// TODO(zanieb): Add support for a "default" which
470+
let python_in_bin = bin.join(format!(
471+
"python{maj}.{min}{var}{exe}",
472+
maj = self.key.major,
473+
min = self.key.minor,
474+
var = self.key.variant.suffix(),
475+
exe = std::env::consts::EXE_SUFFIX
476+
));
477+
478+
match uv_fs::symlink_copy_fallback_file(&python, &python_in_bin) {
479+
Ok(()) => Ok(python_in_bin),
480+
Err(err) if err.kind() == io::ErrorKind::NotFound => {
481+
Err(Error::MissingExecutable(python.clone()))
482+
}
483+
Err(err) => Err(Error::LinkExecutable {
484+
from: python,
485+
to: python_in_bin,
486+
err,
487+
}),
488+
}
489+
}
404490
}
405491

406492
/// 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)