Skip to content

Commit a214315

Browse files
committed
Use trampolines for Python executables on Windows
1 parent 656c3b3 commit a214315

File tree

6 files changed

+192
-23
lines changed

6 files changed

+192
-23
lines changed

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

+130-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,138 @@ 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+
#[allow(clippy::cast_possible_wrap)]
47+
pub fn try_from_path(path: &Path) -> Result<Option<Self>, Error> {
48+
let mut file = File::open(path)?;
49+
50+
let Some(kind) = LauncherKind::try_from_file(&mut file)? else {
51+
return Ok(None);
52+
};
53+
54+
// Seek to the start of the path length.
55+
let Ok(_) = file.seek(io::SeekFrom::End(
56+
-((MAGIC_NUMBER_SIZE + PATH_LENGTH_SIZE) as i64),
57+
)) else {
58+
return Err(Error::InvalidLauncher(
59+
"Unable to seek to the start of the path length".to_string(),
60+
));
61+
};
62+
63+
let mut buffer = [0; PATH_LENGTH_SIZE];
64+
file.read_exact(&mut buffer)
65+
.map_err(|err| Error::InvalidLauncherRead("path length".to_string(), err))?;
66+
67+
let path_length = {
68+
let raw_length = u32::from_le_bytes(buffer);
69+
70+
if raw_length > MAX_PATH_LENGTH {
71+
return Err(Error::InvalidLauncher(format!(
72+
"Only paths with a length up to 32KBs are supported but the Python executable path has a length of {raw_length}"
73+
)));
74+
}
75+
76+
// SAFETY: Above we guarantee the length is less than 32KB
77+
raw_length as usize
78+
};
79+
80+
let Ok(_) = file.seek(io::SeekFrom::End(
81+
-((MAGIC_NUMBER_SIZE + PATH_LENGTH_SIZE + path_length) as i64),
82+
)) else {
83+
return Err(Error::InvalidLauncher(
84+
"Unable to seek to the start of the path".to_string(),
85+
));
86+
};
87+
88+
let mut buffer = vec![0u8; path_length];
89+
file.read_exact(&mut buffer)
90+
.map_err(|err| Error::InvalidLauncherRead("executable path".to_string(), err))?;
91+
92+
let path = PathBuf::from(String::from_utf8(buffer).map_err(|_| {
93+
Error::InvalidLauncher("Python executable path was not valid UTF-8".to_string())
94+
})?);
95+
96+
Ok(Some(Self {
97+
kind,
98+
python_path: path,
99+
}))
100+
}
101+
}
102+
33103
/// The kind of trampoline launcher to create.
34104
///
35105
/// See [`uv-trampoline::bounce::TrampolineKind`].
36-
enum LauncherKind {
106+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107+
pub enum LauncherKind {
37108
/// The trampoline should execute itself, it's a zipped Python script.
38109
Script,
39110
/// The trampoline should just execute Python, it's a proxy Python executable.
40111
Python,
41112
}
42113

43114
impl LauncherKind {
44-
const fn magic_number(&self) -> &'static [u8; 4] {
115+
const fn magic_number(self) -> &'static [u8; 4] {
45116
match self {
46117
Self::Script => b"UVSC",
47118
Self::Python => b"UVPY",
48119
}
49120
}
121+
122+
/// Read a [`LauncherKind`] from 4 byte buffer.
123+
///
124+
/// If the buffer does not contain a matching magic number, `None` is returned.
125+
fn try_from_bytes(bytes: [u8; MAGIC_NUMBER_SIZE]) -> Option<Self> {
126+
if &bytes == Self::Script.magic_number() {
127+
return Some(Self::Script);
128+
}
129+
if &bytes == Self::Python.magic_number() {
130+
return Some(Self::Python);
131+
}
132+
None
133+
}
134+
135+
/// Read a [`LauncherKind`] from a file handle.
136+
///
137+
/// This will mutate the file handle, seeking to the end of the file.
138+
///
139+
/// If the file cannot be read, an [`io::Error`] is returned. If the path is not a launcher,
140+
/// `None` is returned.
141+
#[allow(clippy::cast_possible_wrap)]
142+
pub fn try_from_file(file: &mut File) -> Result<Option<Self>, Error> {
143+
let mut buffer = [0; MAGIC_NUMBER_SIZE];
144+
145+
// If the file is less than four bytes, it's not a launcher.
146+
let Ok(_) = file.seek(io::SeekFrom::End(-(MAGIC_NUMBER_SIZE as i64))) else {
147+
return Ok(None);
148+
};
149+
150+
file.read_exact(&mut buffer)
151+
.map_err(|err| Error::InvalidLauncherRead("magic number".to_string(), err))?;
152+
153+
Ok(Self::try_from_bytes(buffer))
154+
}
50155
}
51156

52157
/// Note: The caller is responsible for adding the path of the wheel we're installing.
53158
#[derive(Error, Debug)]
54159
pub enum Error {
160+
#[error(transparent)]
161+
Io(#[from] io::Error),
162+
#[error("Invalid launcher: {0}")]
163+
InvalidLauncher(String),
164+
#[error("Failed to read launcher {0}")]
165+
InvalidLauncherRead(String, #[source] io::Error),
55166
#[error(
56167
"Unable to create Windows launcher for: {0} (only x86_64, x86, and arm64 are supported)"
57168
)]
@@ -192,7 +303,7 @@ mod test {
192303

193304
use which::which;
194305

195-
use super::{windows_python_launcher, windows_script_launcher};
306+
use super::{windows_python_launcher, windows_script_launcher, Launcher, LauncherKind};
196307

197308
#[test]
198309
#[cfg(all(windows, target_arch = "x86", feature = "production"))]
@@ -340,6 +451,13 @@ if __name__ == "__main__":
340451
.stdout(stdout_predicate)
341452
.stderr(stderr_predicate);
342453

454+
let launcher = Launcher::try_from_path(console_bin_path.path())
455+
.expect("We should succeed at reading the launcher")
456+
.expect("The launcher should be valid");
457+
458+
assert!(launcher.kind == LauncherKind::Script);
459+
assert!(launcher.python_path == python_executable_path);
460+
343461
Ok(())
344462
}
345463

@@ -371,6 +489,13 @@ if __name__ == "__main__":
371489
.success()
372490
.stdout("Hello from Python Launcher\r\n");
373491

492+
let launcher = Launcher::try_from_path(console_bin_path.path())
493+
.expect("We should succeed at reading the launcher")
494+
.expect("The launcher should be valid");
495+
496+
assert!(launcher.kind == LauncherKind::Python);
497+
assert!(launcher.python_path == python_executable_path);
498+
374499
Ok(())
375500
}
376501

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(),

0 commit comments

Comments
 (0)