Skip to content

Commit 6bd04ee

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

File tree

8 files changed

+143
-22
lines changed

8 files changed

+143
-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: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,27 @@ 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+
},
63+
#[error("Failed to create directory for Python executable link at {}", to.user_display())]
64+
ExecutableDirectory {
65+
to: PathBuf,
66+
#[source]
67+
err: io::Error,
68+
},
5669
#[error("Failed to read Python installation directory: {0}", dir.user_display())]
5770
ReadError {
5871
dir: PathBuf,
5972
#[source]
6073
err: io::Error,
6174
},
75+
#[error("Failed to find a directory to install executables into")]
76+
NoExecutableDirectory,
6277
#[error("Failed to read managed Python directory name: {0}")]
6378
NameError(String),
6479
#[error(transparent)]
@@ -270,12 +285,47 @@ impl ManagedPythonInstallation {
270285
Ok(Self { path, key })
271286
}
272287

273-
/// The path to this toolchain's Python executable.
288+
/// The path to this managed installation's Python executable.
274289
pub fn executable(&self) -> PathBuf {
290+
let implementation = match self.implementation() {
291+
ImplementationName::CPython => "python",
292+
ImplementationName::PyPy => "pypy",
293+
ImplementationName::GraalPy => {
294+
unreachable!("Managed installations of GraalPy are not supported")
295+
}
296+
};
297+
298+
// On Windows, the executable is just `python.exe` even for alternative variants
299+
let variant = if cfg!(unix) {
300+
self.key.variant.suffix()
301+
} else {
302+
""
303+
};
304+
305+
// PyPy uses a full version number, even on Windows.
306+
let version = match self.implementation() {
307+
ImplementationName::CPython => {
308+
if cfg!(unix) {
309+
self.key.major.to_string()
310+
} else {
311+
String::new()
312+
}
313+
}
314+
ImplementationName::PyPy => format!("{}.{}", self.key.major, self.key.minor),
315+
ImplementationName::GraalPy => {
316+
unreachable!("Managed installations of GraalPy are not supported")
317+
}
318+
};
319+
320+
let name = format!(
321+
"{implementation}{version}{variant}{exe}",
322+
exe = std::env::consts::EXE_SUFFIX
323+
);
324+
275325
if cfg!(windows) {
276-
self.python_dir().join("python.exe")
326+
self.python_dir().join(name)
277327
} else if cfg!(unix) {
278-
self.python_dir().join("bin").join("python3")
328+
self.python_dir().join("bin").join(name)
279329
} else {
280330
unimplemented!("Only Windows and Unix systems are supported.")
281331
}
@@ -361,7 +411,7 @@ impl ManagedPythonInstallation {
361411
Error::MissingExecutable(python_in_dist.clone())
362412
} else {
363413
Error::CanonicalizeExecutable {
364-
from: python_in_dist,
414+
from: python_in_dist.clone(),
365415
to: python,
366416
err,
367417
}
@@ -381,10 +431,7 @@ impl ManagedPythonInstallation {
381431
let stdlib = if matches!(self.key.os, Os(target_lexicon::OperatingSystem::Windows)) {
382432
self.python_dir().join("Lib")
383433
} else {
384-
let lib_suffix = match self.key.variant {
385-
PythonVariant::Default => "",
386-
PythonVariant::Freethreaded => "t",
387-
};
434+
let lib_suffix = self.key.variant.suffix();
388435
let python = if matches!(
389436
self.key.implementation,
390437
LenientImplementationName::Known(ImplementationName::PyPy)
@@ -401,6 +448,39 @@ impl ManagedPythonInstallation {
401448

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

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