Skip to content

Commit 2021ad8

Browse files
committed
Install versioned Python executables into the bin directory during uv python install
1 parent a5bce0a commit 2021ad8

File tree

12 files changed

+352
-55
lines changed

12 files changed

+352
-55
lines changed

.github/workflows/ci.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -697,7 +697,7 @@ jobs:
697697
698698
- name: "Install free-threaded Python via uv"
699699
run: |
700-
./uv python install 3.13t
700+
./uv python install -v 3.13t
701701
./uv venv -p 3.13t --python-preference only-managed
702702
703703
- name: "Check version"
@@ -769,7 +769,7 @@ jobs:
769769
run: chmod +x ./uv
770770

771771
- name: "Install PyPy"
772-
run: ./uv python install pypy3.9
772+
run: ./uv python install -v pypy3.9
773773

774774
- name: "Create a virtual environment"
775775
run: |

Cargo.lock

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/uv-python/Cargo.toml

+1
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

+11-6
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/installation.rs

+11
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,17 @@ impl PythonInstallationKey {
305305
pub fn libc(&self) -> &Libc {
306306
&self.libc
307307
}
308+
309+
/// Return a canonical name for a versioned executable.
310+
pub fn versioned_executable_name(&self) -> String {
311+
format!(
312+
"python{maj}.{min}{var}{exe}",
313+
maj = self.major,
314+
min = self.minor,
315+
var = self.variant.suffix(),
316+
exe = std::env::consts::EXE_SUFFIX
317+
)
318+
}
308319
}
309320

310321
impl fmt::Display for PythonInstallationKey {

crates/uv-python/src/managed.rs

+141-37
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,31 @@ 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),
79+
#[error("Failed to construct absolute path to managed Python directory: {}", _0.user_display())]
80+
AbsolutePath(PathBuf, #[source] std::io::Error),
6481
#[error(transparent)]
6582
NameParseError(#[from] installation::PythonInstallationKeyError),
6683
#[error(transparent)]
@@ -267,18 +284,78 @@ impl ManagedPythonInstallation {
267284
.ok_or(Error::NameError("not a valid string".to_string()))?,
268285
)?;
269286

287+
let path = std::path::absolute(&path).map_err(|err| Error::AbsolutePath(path, err))?;
288+
270289
Ok(Self { path, key })
271290
}
272291

273-
/// The path to this toolchain's Python executable.
292+
/// The path to this managed installation's Python executable.
293+
///
294+
/// If the installation has multiple execututables i.e., `python`, `python3`, etc., this will
295+
/// return the _canonical_ executable name which the other names link to. On Unix, this is
296+
/// `python{major}.{minor}{variant}` and on Windows, this is `python{exe}`.
274297
pub fn executable(&self) -> PathBuf {
275-
if cfg!(windows) {
276-
self.python_dir().join("python.exe")
298+
let implementation = match self.implementation() {
299+
ImplementationName::CPython => "python",
300+
ImplementationName::PyPy => "pypy",
301+
ImplementationName::GraalPy => {
302+
unreachable!("Managed installations of GraalPy are not supported")
303+
}
304+
};
305+
306+
let version = match self.implementation() {
307+
ImplementationName::CPython => {
308+
if cfg!(unix) {
309+
format!("{}.{}", self.key.major, self.key.minor)
310+
} else {
311+
String::new()
312+
}
313+
}
314+
// PyPy uses a full version number, even on Windows.
315+
ImplementationName::PyPy => format!("{}.{}", self.key.major, self.key.minor),
316+
ImplementationName::GraalPy => {
317+
unreachable!("Managed installations of GraalPy are not supported")
318+
}
319+
};
320+
321+
// On Windows, the executable is just `python.exe` even for alternative variants
322+
let variant = if cfg!(unix) {
323+
self.key.variant.suffix()
324+
} else {
325+
""
326+
};
327+
328+
let name = format!(
329+
"{implementation}{version}{variant}{exe}",
330+
exe = std::env::consts::EXE_SUFFIX
331+
);
332+
333+
let executable = if cfg!(windows) {
334+
self.python_dir().join(name)
277335
} else if cfg!(unix) {
278-
self.python_dir().join("bin").join("python3")
336+
self.python_dir().join("bin").join(name)
279337
} else {
280338
unimplemented!("Only Windows and Unix systems are supported.")
339+
};
340+
341+
// Workaround for python-build-standalone v20241016 which is missing the standard
342+
// `python.exe` executable in free-threaded distributions on Windows.
343+
//
344+
// See https://github.com/astral-sh/uv/issues/8298
345+
if cfg!(windows)
346+
&& matches!(self.key.variant, PythonVariant::Freethreaded)
347+
&& !executable.exists()
348+
{
349+
// This is the alternative executable name for the freethreaded variant
350+
return self.python_dir().join(format!(
351+
"python{}.{}t{}",
352+
self.key.major,
353+
self.key.minor,
354+
std::env::consts::EXE_SUFFIX
355+
));
281356
}
357+
358+
executable
282359
}
283360

284361
fn python_dir(&self) -> PathBuf {
@@ -336,39 +413,38 @@ impl ManagedPythonInstallation {
336413
pub fn ensure_canonical_executables(&self) -> Result<(), Error> {
337414
let python = self.executable();
338415

339-
// Workaround for python-build-standalone v20241016 which is missing the standard
340-
// `python.exe` executable in free-threaded distributions on Windows.
341-
//
342-
// See https://github.com/astral-sh/uv/issues/8298
343-
if !python.try_exists()? {
344-
match self.key.variant {
345-
PythonVariant::Default => return Err(Error::MissingExecutable(python.clone())),
346-
PythonVariant::Freethreaded => {
347-
// This is the alternative executable name for the freethreaded variant
348-
let python_in_dist = self.python_dir().join(format!(
349-
"python{}.{}t{}",
350-
self.key.major,
351-
self.key.minor,
352-
std::env::consts::EXE_SUFFIX
353-
));
416+
let canonical_names = &["python"];
417+
418+
for name in canonical_names {
419+
let executable =
420+
python.with_file_name(format!("{name}{exe}", exe = std::env::consts::EXE_SUFFIX));
421+
422+
// Do not attempt to perform same-file copies — this is fine on Unix but fails on
423+
// Windows with a permission error instead of 'already exists'
424+
if executable == python {
425+
continue;
426+
}
427+
428+
match uv_fs::symlink_copy_fallback_file(&python, &executable) {
429+
Ok(()) => {
354430
debug!(
355-
"Creating link {} -> {}",
431+
"Created link {} -> {}",
432+
executable.user_display(),
356433
python.user_display(),
357-
python_in_dist.user_display()
358434
);
359-
uv_fs::symlink_copy_fallback_file(&python_in_dist, &python).map_err(|err| {
360-
if err.kind() == io::ErrorKind::NotFound {
361-
Error::MissingExecutable(python_in_dist.clone())
362-
} else {
363-
Error::CanonicalizeExecutable {
364-
from: python_in_dist,
365-
to: python,
366-
err,
367-
}
368-
}
369-
})?;
370435
}
371-
}
436+
Err(err) if err.kind() == io::ErrorKind::NotFound => {
437+
return Err(Error::MissingExecutable(python.clone()))
438+
}
439+
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
440+
Err(err) => {
441+
return Err(Error::CanonicalizeExecutable {
442+
from: executable,
443+
to: python,
444+
err,
445+
})
446+
}
447+
};
372448
}
373449

374450
Ok(())
@@ -381,10 +457,7 @@ impl ManagedPythonInstallation {
381457
let stdlib = if matches!(self.key.os, Os(target_lexicon::OperatingSystem::Windows)) {
382458
self.python_dir().join("Lib")
383459
} else {
384-
let lib_suffix = match self.key.variant {
385-
PythonVariant::Default => "",
386-
PythonVariant::Freethreaded => "t",
387-
};
460+
let lib_suffix = self.key.variant.suffix();
388461
let python = if matches!(
389462
self.key.implementation,
390463
LenientImplementationName::Known(ImplementationName::PyPy)
@@ -401,6 +474,31 @@ impl ManagedPythonInstallation {
401474

402475
Ok(())
403476
}
477+
478+
/// Create a link to the Python executable in the given `bin` directory.
479+
pub fn create_bin_link(&self, bin: &Path) -> Result<PathBuf, Error> {
480+
let python = self.executable();
481+
482+
fs_err::create_dir_all(bin).map_err(|err| Error::ExecutableDirectory {
483+
to: bin.to_path_buf(),
484+
err,
485+
})?;
486+
487+
// TODO(zanieb): Add support for a "default" which
488+
let python_in_bin = bin.join(self.key.versioned_executable_name());
489+
490+
match uv_fs::symlink_copy_fallback_file(&python, &python_in_bin) {
491+
Ok(()) => Ok(python_in_bin),
492+
Err(err) if err.kind() == io::ErrorKind::NotFound => {
493+
Err(Error::MissingExecutable(python.clone()))
494+
}
495+
Err(err) => Err(Error::LinkExecutable {
496+
from: python,
497+
to: python_in_bin,
498+
err,
499+
}),
500+
}
501+
}
404502
}
405503

406504
/// Generate a platform portion of a key from the environment.
@@ -423,3 +521,9 @@ impl fmt::Display for ManagedPythonInstallation {
423521
)
424522
}
425523
}
524+
525+
/// Find the directory to install Python executables into.
526+
pub fn python_executable_dir() -> Result<PathBuf, Error> {
527+
uv_dirs::user_executable_directory(Some(EnvVars::UV_PYTHON_BIN_DIR))
528+
.ok_or(Error::NoExecutableDirectory)
529+
}

crates/uv-static/src/env_vars.rs

+3
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

+1-1
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/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ rayon = { workspace = true }
7979
regex = { workspace = true }
8080
reqwest = { workspace = true }
8181
rustc-hash = { workspace = true }
82+
same-file = { workspace = true }
8283
serde = { workspace = true }
8384
serde_json = { workspace = true }
8485
tempfile = { workspace = true }

0 commit comments

Comments
 (0)