Skip to content

Commit 8d3408f

Browse files
authored
Add support for installing versioned Python executables on Windows (#8663)
Incorporating #8637 into #8458 - Adds `python-managed` feature selection to Windows CI for `python install` tests - Adds trampoline sniffing utilities to `uv-trampoline-builder` - Uses a trampoline to install Python executables into the `PATH` on Windows
1 parent f5a7d70 commit 8d3408f

File tree

11 files changed

+245
-44
lines changed

11 files changed

+245
-44
lines changed

.github/workflows/ci.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ jobs:
296296
# See https://github.com/astral-sh/uv/issues/6940
297297
UV_LINK_MODE: copy
298298
run: |
299-
cargo nextest run --no-default-features --features python,pypi --workspace --status-level skip --failure-output immediate-final --no-fail-fast -j 20 --final-status-level slow
299+
cargo nextest run --no-default-features --features python,pypi,python-managed --workspace --status-level skip --failure-output immediate-final --no-fail-fast -j 20 --final-status-level slow
300300
301301
- name: "Smoke test"
302302
working-directory: ${{ env.UV_WORKSPACE }}

Cargo.lock

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

crates/uv-fs/src/lib.rs

+7-6
Original file line numberDiff line numberDiff line change
@@ -104,21 +104,22 @@ pub fn remove_symlink(path: impl AsRef<Path>) -> std::io::Result<()> {
104104
fs_err::remove_file(path.as_ref())
105105
}
106106

107-
/// Create a symlink at `dst` pointing to `src` or, on Windows, copy `src` to `dst`.
107+
/// Create a symlink at `dst` pointing to `src` on Unix or copy `src` to `dst` on Windows
108+
///
109+
/// This does not replace an existing symlink or file at `dst`.
110+
///
111+
/// This does not fallback to copying on Unix.
108112
///
109113
/// This function should only be used for files. If targeting a directory, use [`replace_symlink`]
110114
/// instead; it will use a junction on Windows, which is more performant.
111-
pub fn symlink_copy_fallback_file(
112-
src: impl AsRef<Path>,
113-
dst: impl AsRef<Path>,
114-
) -> std::io::Result<()> {
115+
pub fn symlink_or_copy_file(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
115116
#[cfg(windows)]
116117
{
117118
fs_err::copy(src.as_ref(), dst.as_ref())?;
118119
}
119120
#[cfg(unix)]
120121
{
121-
std::os::unix::fs::symlink(src.as_ref(), dst.as_ref())?;
122+
fs_err::os::unix::fs::symlink(src.as_ref(), dst.as_ref())?;
122123
}
123124

124125
Ok(())

crates/uv-python/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ uv-platform-tags = { workspace = true }
3131
uv-pypi-types = { workspace = true }
3232
uv-state = { workspace = true }
3333
uv-static = { workspace = true }
34+
uv-trampoline-builder = { workspace = true }
3435
uv-warnings = { workspace = true }
3536

3637
anyhow = { workspace = true }

crates/uv-python/src/managed.rs

+62-19
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
use core::fmt;
2-
use fs_err as fs;
3-
use itertools::Itertools;
42
use std::cmp::Reverse;
53
use std::ffi::OsStr;
64
use std::io::{self, Write};
75
use std::path::{Path, PathBuf};
86
use std::str::FromStr;
7+
8+
use fs_err as fs;
9+
use itertools::Itertools;
10+
use same_file::is_same_file;
911
use thiserror::Error;
1012
use tracing::{debug, warn};
1113

14+
use uv_fs::{symlink_or_copy_file, LockedFile, Simplified};
1215
use uv_state::{StateBucket, StateStore};
16+
use uv_static::EnvVars;
17+
use uv_trampoline_builder::{windows_python_launcher, Launcher};
1318

1419
use crate::downloads::Error as DownloadError;
1520
use crate::implementation::{
@@ -21,9 +26,6 @@ use crate::platform::Error as PlatformError;
2126
use crate::platform::{Arch, Libc, Os};
2227
use crate::python_version::PythonVersion;
2328
use crate::{PythonRequest, PythonVariant};
24-
use uv_fs::{LockedFile, Simplified};
25-
use uv_static::EnvVars;
26-
2729
#[derive(Error, Debug)]
2830
pub enum Error {
2931
#[error(transparent)]
@@ -74,6 +76,8 @@ pub enum Error {
7476
},
7577
#[error("Failed to find a directory to install executables into")]
7678
NoExecutableDirectory,
79+
#[error(transparent)]
80+
LauncherError(#[from] uv_trampoline_builder::Error),
7781
#[error("Failed to read managed Python directory name: {0}")]
7882
NameError(String),
7983
#[error("Failed to construct absolute path to managed Python directory: {}", _0.user_display())]
@@ -425,7 +429,7 @@ impl ManagedPythonInstallation {
425429
continue;
426430
}
427431

428-
match uv_fs::symlink_copy_fallback_file(&python, &executable) {
432+
match uv_fs::symlink_or_copy_file(&python, &executable) {
429433
Ok(()) => {
430434
debug!(
431435
"Created link {} -> {}",
@@ -475,28 +479,67 @@ impl ManagedPythonInstallation {
475479
Ok(())
476480
}
477481

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> {
482+
/// Create a link to the managed Python executable.
483+
///
484+
/// If the file already exists at the target path, an error will be returned.
485+
pub fn create_bin_link(&self, target: &Path) -> Result<(), Error> {
480486
let python = self.executable();
481487

488+
let bin = target.parent().ok_or(Error::NoExecutableDirectory)?;
482489
fs_err::create_dir_all(bin).map_err(|err| Error::ExecutableDirectory {
483490
to: bin.to_path_buf(),
484491
err,
485492
})?;
486493

487-
// TODO(zanieb): Add support for a "default" which
488-
let python_in_bin = bin.join(self.key.versioned_executable_name());
494+
if cfg!(unix) {
495+
// Note this will never copy on Unix — we use it here to allow compilation on Windows
496+
match symlink_or_copy_file(&python, target) {
497+
Ok(()) => Ok(()),
498+
Err(err) if err.kind() == io::ErrorKind::NotFound => {
499+
Err(Error::MissingExecutable(python.clone()))
500+
}
501+
Err(err) => Err(Error::LinkExecutable {
502+
from: python,
503+
to: target.to_path_buf(),
504+
err,
505+
}),
506+
}
507+
} else if cfg!(windows) {
508+
// TODO(zanieb): Install GUI launchers as well
509+
let launcher = windows_python_launcher(&python, false)?;
510+
511+
// OK to use `std::fs` here, `fs_err` does not support `File::create_new` and we attach
512+
// error context anyway
513+
#[allow(clippy::disallowed_types)]
514+
{
515+
std::fs::File::create_new(target)
516+
.and_then(|mut file| file.write_all(launcher.as_ref()))
517+
.map_err(|err| Error::LinkExecutable {
518+
from: python,
519+
to: target.to_path_buf(),
520+
err,
521+
})
522+
}
523+
} else {
524+
unimplemented!("Only Windows and Unix systems are supported.")
525+
}
526+
}
489527

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()))
528+
/// Returns `true` if the path is a link to this installation's binary, e.g., as created by
529+
/// [`ManagedPythonInstallation::create_bin_link`].
530+
pub fn is_bin_link(&self, path: &Path) -> bool {
531+
if cfg!(unix) {
532+
is_same_file(path, self.executable()).unwrap_or_default()
533+
} else if cfg!(windows) {
534+
let Some(launcher) = Launcher::try_from_path(path).unwrap_or_default() else {
535+
return false;
536+
};
537+
if !matches!(launcher.kind, uv_trampoline_builder::LauncherKind::Python) {
538+
return false;
494539
}
495-
Err(err) => Err(Error::LinkExecutable {
496-
from: python,
497-
to: python_in_bin,
498-
err,
499-
}),
540+
launcher.python_path == self.executable()
541+
} else {
542+
unreachable!("Only Windows and Unix are supported")
500543
}
501544
}
502545
}

crates/uv-trampoline-builder/Cargo.toml

+2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ workspace = true
2323

2424
[dependencies]
2525
uv-fs = { workspace = true }
26+
27+
fs-err = {workspace = true }
2628
thiserror = { workspace = true }
2729
zip = { workspace = true }
2830

0 commit comments

Comments
 (0)