Skip to content

Commit d9fdcd2

Browse files
committed
Use trampolines for Python executables on Windows
1 parent 222cf26 commit d9fdcd2

File tree

9 files changed

+224
-32
lines changed

9 files changed

+224
-32
lines changed

.github/workflows/ci.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ jobs:
297297
# See https://github.com/astral-sh/uv/issues/6940
298298
UV_LINK_MODE: copy
299299
run: |
300-
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
300+
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
301301
302302
- name: "Smoke test"
303303
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-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

+54-14
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::{self as fs, File};
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::{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())]
@@ -485,16 +489,52 @@ impl ManagedPythonInstallation {
485489
err,
486490
})?;
487491

488-
match uv_fs::symlink_copy_fallback_file(&python, target) {
489-
Ok(()) => Ok(()),
490-
Err(err) if err.kind() == io::ErrorKind::NotFound => {
491-
Err(Error::MissingExecutable(python.clone()))
492+
if cfg!(unix) {
493+
match uv_fs::symlink_copy_fallback_file(&python, target) {
494+
Ok(()) => Ok(()),
495+
Err(err) if err.kind() == io::ErrorKind::NotFound => {
496+
Err(Error::MissingExecutable(python.clone()))
497+
}
498+
Err(err) => Err(Error::LinkExecutable {
499+
from: python,
500+
to: target.to_path_buf(),
501+
err,
502+
}),
503+
}
504+
} else if cfg!(windows) {
505+
// TODO(zanieb): Install GUI launchers as well
506+
let launcher = windows_python_launcher(&python, false)?;
507+
match File::create(target)?.write_all(launcher.as_ref()) {
508+
Ok(()) => Ok(()),
509+
Err(err) if err.kind() == io::ErrorKind::NotFound => {
510+
Err(Error::MissingExecutable(python.clone()))
511+
}
512+
Err(err) => Err(Error::LinkExecutable {
513+
from: python,
514+
to: target.to_path_buf(),
515+
err,
516+
}),
517+
}
518+
} else {
519+
unimplemented!("Only Windows and Unix systems are supported.")
520+
}
521+
}
522+
523+
/// Returns `true` if the path is a link to this installation's binary, e.g., as created by
524+
/// [`ManagedPythonInstallation::create_bin_link`].
525+
pub fn is_bin_link(&self, path: &Path) -> bool {
526+
if cfg!(unix) {
527+
is_same_file(path, self.executable()).unwrap_or_default()
528+
} else if cfg!(windows) {
529+
let Some(launcher) = Launcher::try_from_path(path).unwrap_or_default() else {
530+
return false;
531+
};
532+
if !matches!(launcher.kind, uv_trampoline_builder::LauncherKind::Python) {
533+
return false;
492534
}
493-
Err(err) => Err(Error::LinkExecutable {
494-
from: python,
495-
to: target.to_path_buf(),
496-
err,
497-
}),
535+
launcher.python_path == self.executable()
536+
} else {
537+
unreachable!("Only Windows and Unix are supported")
498538
}
499539
}
500540
}

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

crates/uv-trampoline-builder/src/lib.rs

+152-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
use std::io::{Cursor, Write};
2-
use std::path::Path;
1+
use std::io::{self, Cursor, Read, Seek, Write};
2+
use std::path::{Path, PathBuf};
33

4+
use fs_err::File;
45
use thiserror::Error;
56
use uv_fs::Simplified;
67
use zip::write::FileOptions;
@@ -30,28 +31,160 @@ const LAUNCHER_AARCH64_GUI: &[u8] =
3031
const LAUNCHER_AARCH64_CONSOLE: &[u8] =
3132
include_bytes!("../../uv-trampoline/trampolines/uv-trampoline-aarch64-console.exe");
3233

34+
// See `uv-trampoline::bounce`. These numbers must match.
35+
const PATH_LENGTH_SIZE: usize = size_of::<u32>();
36+
const MAX_PATH_LENGTH: u32 = 32 * 1024;
37+
const MAGIC_NUMBER_SIZE: usize = 4;
38+
39+
#[derive(Debug)]
40+
pub struct Launcher {
41+
pub kind: LauncherKind,
42+
pub python_path: PathBuf,
43+
}
44+
45+
impl Launcher {
46+
/// Read [`Launcher`] metadata from a trampoline executable file.
47+
///
48+
/// Returns `Ok(None)` if the file is not a trampoline executable.
49+
/// Returns `Err` if the file looks like a trampoline executable but is formatted incorrectly.
50+
///
51+
/// Expects the following metadata to be at the end of the file:
52+
///
53+
/// ```text
54+
/// - file path (no greater than 32KB)
55+
/// - file path length (u32)
56+
/// - magic number(4 bytes)
57+
/// ```
58+
///
59+
/// This should only be used on Windows, but should just return `Ok(None)` on other platforms.
60+
///
61+
/// This is an implementation of [`uv-trampoline::bounce::read_trampoline_metadata`] that
62+
/// returns errors instead of panicking. Unlike the utility there, we don't assume that the
63+
/// file we are reading is a trampoline.
64+
#[allow(clippy::cast_possible_wrap)]
65+
pub fn try_from_path(path: &Path) -> Result<Option<Self>, Error> {
66+
let mut file = File::open(path)?;
67+
68+
// Read the magic number
69+
let Some(kind) = LauncherKind::try_from_file(&mut file)? else {
70+
return Ok(None);
71+
};
72+
73+
// Seek to the start of the path length.
74+
let Ok(_) = file.seek(io::SeekFrom::End(
75+
-((MAGIC_NUMBER_SIZE + PATH_LENGTH_SIZE) as i64),
76+
)) else {
77+
return Err(Error::InvalidLauncher(
78+
"Unable to seek to the start of the path length".to_string(),
79+
));
80+
};
81+
82+
// Read the path length
83+
let mut buffer = [0; PATH_LENGTH_SIZE];
84+
file.read_exact(&mut buffer)
85+
.map_err(|err| Error::InvalidLauncherRead("path length".to_string(), err))?;
86+
87+
let path_length = {
88+
let raw_length = u32::from_le_bytes(buffer);
89+
90+
if raw_length > MAX_PATH_LENGTH {
91+
return Err(Error::InvalidLauncher(format!(
92+
"Only paths with a length up to 32KBs are supported but the Python executable path has a length of {raw_length}"
93+
)));
94+
}
95+
96+
// SAFETY: Above we guarantee the length is less than 32KB
97+
raw_length as usize
98+
};
99+
100+
// Seek to the start of the path
101+
let Ok(_) = file.seek(io::SeekFrom::End(
102+
-((MAGIC_NUMBER_SIZE + PATH_LENGTH_SIZE + path_length) as i64),
103+
)) else {
104+
return Err(Error::InvalidLauncher(
105+
"Unable to seek to the start of the path".to_string(),
106+
));
107+
};
108+
109+
// Read the path
110+
let mut buffer = vec![0u8; path_length];
111+
file.read_exact(&mut buffer)
112+
.map_err(|err| Error::InvalidLauncherRead("executable path".to_string(), err))?;
113+
114+
let path = PathBuf::from(String::from_utf8(buffer).map_err(|_| {
115+
Error::InvalidLauncher("Python executable path was not valid UTF-8".to_string())
116+
})?);
117+
118+
Ok(Some(Self {
119+
kind,
120+
python_path: path,
121+
}))
122+
}
123+
}
124+
33125
/// The kind of trampoline launcher to create.
34126
///
35127
/// See [`uv-trampoline::bounce::TrampolineKind`].
36-
enum LauncherKind {
128+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129+
pub enum LauncherKind {
37130
/// The trampoline should execute itself, it's a zipped Python script.
38131
Script,
39132
/// The trampoline should just execute Python, it's a proxy Python executable.
40133
Python,
41134
}
42135

43136
impl LauncherKind {
44-
const fn magic_number(&self) -> &'static [u8; 4] {
137+
/// Return the magic number for this [`LauncherKind`].
138+
const fn magic_number(self) -> &'static [u8; 4] {
45139
match self {
46140
Self::Script => b"UVSC",
47141
Self::Python => b"UVPY",
48142
}
49143
}
144+
145+
/// Read a [`LauncherKind`] from 4 byte buffer.
146+
///
147+
/// If the buffer does not contain a matching magic number, `None` is returned.
148+
fn try_from_bytes(bytes: [u8; MAGIC_NUMBER_SIZE]) -> Option<Self> {
149+
if &bytes == Self::Script.magic_number() {
150+
return Some(Self::Script);
151+
}
152+
if &bytes == Self::Python.magic_number() {
153+
return Some(Self::Python);
154+
}
155+
None
156+
}
157+
158+
/// Read a [`LauncherKind`] from a file handle, based on the magic number.
159+
///
160+
/// This will mutate the file handle, seeking to the end of the file.
161+
///
162+
/// If the file cannot be read, an [`io::Error`] is returned. If the path is not a launcher,
163+
/// `None` is returned.
164+
#[allow(clippy::cast_possible_wrap)]
165+
pub fn try_from_file(file: &mut File) -> Result<Option<Self>, Error> {
166+
// If the file is less than four bytes, it's not a launcher.
167+
let Ok(_) = file.seek(io::SeekFrom::End(-(MAGIC_NUMBER_SIZE as i64))) else {
168+
return Ok(None);
169+
};
170+
171+
let mut buffer = [0; MAGIC_NUMBER_SIZE];
172+
file.read_exact(&mut buffer)
173+
.map_err(|err| Error::InvalidLauncherRead("magic number".to_string(), err))?;
174+
175+
Ok(Self::try_from_bytes(buffer))
176+
}
50177
}
51178

52179
/// Note: The caller is responsible for adding the path of the wheel we're installing.
53180
#[derive(Error, Debug)]
54181
pub enum Error {
182+
#[error(transparent)]
183+
Io(#[from] io::Error),
184+
#[error("Invalid launcher: {0}")]
185+
InvalidLauncher(String),
186+
#[error("Failed to read launcher {0}")]
187+
InvalidLauncherRead(String, #[source] io::Error),
55188
#[error(
56189
"Unable to create Windows launcher for: {0} (only x86_64, x86, and arm64 are supported)"
57190
)]
@@ -192,7 +325,7 @@ mod test {
192325

193326
use which::which;
194327

195-
use super::{windows_python_launcher, windows_script_launcher};
328+
use super::{windows_python_launcher, windows_script_launcher, Launcher, LauncherKind};
196329

197330
#[test]
198331
#[cfg(all(windows, target_arch = "x86", feature = "production"))]
@@ -340,6 +473,13 @@ if __name__ == "__main__":
340473
.stdout(stdout_predicate)
341474
.stderr(stderr_predicate);
342475

476+
let launcher = Launcher::try_from_path(console_bin_path.path())
477+
.expect("We should succeed at reading the launcher")
478+
.expect("The launcher should be valid");
479+
480+
assert!(launcher.kind == LauncherKind::Script);
481+
assert!(launcher.python_path == python_executable_path);
482+
343483
Ok(())
344484
}
345485

@@ -371,6 +511,13 @@ if __name__ == "__main__":
371511
.success()
372512
.stdout("Hello from Python Launcher\r\n");
373513

514+
let launcher = Launcher::try_from_path(console_bin_path.path())
515+
.expect("We should succeed at reading the launcher")
516+
.expect("The launcher should be valid");
517+
518+
assert!(launcher.kind == LauncherKind::Python);
519+
assert!(launcher.python_path == python_executable_path);
520+
374521
Ok(())
375522
}
376523

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

+4-4
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ use futures::StreamExt;
77
use itertools::Itertools;
88
use owo_colors::OwoColorize;
99

10-
use same_file::is_same_file;
1110
use tracing::{debug, warn};
1211
use uv_fs::Simplified;
1312
use uv_python::downloads::PythonDownloadRequest;
@@ -149,9 +148,9 @@ async fn do_uninstall(
149148
})
150149
// Only include Python executables that match the installations
151150
.filter(|path| {
152-
matching_installations.iter().any(|installation| {
153-
is_same_file(path, installation.executable()).unwrap_or_default()
154-
})
151+
matching_installations
152+
.iter()
153+
.any(|installation| installation.is_bin_link(path.as_path()))
155154
})
156155
.collect::<BTreeSet<_>>();
157156

@@ -218,6 +217,7 @@ async fn do_uninstall(
218217
.sorted_unstable_by(|a, b| a.key.cmp(&b.key).then_with(|| a.kind.cmp(&b.kind)))
219218
{
220219
match event.kind {
220+
// TODO(zanieb): Track removed executables and report them all here
221221
ChangeEventKind::Removed => {
222222
writeln!(
223223
printer.stderr(),

crates/uv/tests/it/common/mod.rs

+9-1
Original file line numberDiff line numberDiff line change
@@ -663,7 +663,15 @@ impl TestContext {
663663
.arg("python")
664664
.arg("install")
665665
.env(EnvVars::UV_PYTHON_INSTALL_DIR, managed)
666-
.env(EnvVars::UV_PYTHON_BIN_DIR, bin)
666+
.env(EnvVars::UV_PYTHON_BIN_DIR, bin.as_os_str())
667+
.env(
668+
EnvVars::PATH,
669+
std::env::join_paths(
670+
std::iter::once(bin)
671+
.chain(std::env::split_paths(&env::var("PATH").unwrap_or_default())),
672+
)
673+
.unwrap(),
674+
)
667675
.current_dir(&self.temp_dir);
668676
command
669677
}

0 commit comments

Comments
 (0)