Skip to content

Commit 6d494bb

Browse files
committed
Use trampolines for Python executables on Windows
1 parent b52f229 commit 6d494bb

File tree

11 files changed

+238
-44
lines changed

11 files changed

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

+55-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::{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::{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,60 @@ 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+
pub fn create_bin_link(&self, target: &Path) -> Result<(), Error> {
480484
let python = self.executable();
481485

486+
let bin = target.parent().ok_or(Error::NoExecutableDirectory)?;
482487
fs_err::create_dir_all(bin).map_err(|err| Error::ExecutableDirectory {
483488
to: bin.to_path_buf(),
484489
err,
485490
})?;
486491

487-
// TODO(zanieb): Add support for a "default" which
488-
let python_in_bin = bin.join(self.key.versioned_executable_name());
492+
if cfg!(unix) {
493+
// Note this will never copy on Unix — we use it here to allow compilation on Windows
494+
match symlink_or_copy_file(&python, target) {
495+
Ok(()) => Ok(()),
496+
Err(err) if err.kind() == io::ErrorKind::NotFound => {
497+
Err(Error::MissingExecutable(python.clone()))
498+
}
499+
Err(err) => Err(Error::LinkExecutable {
500+
from: python,
501+
to: target.to_path_buf(),
502+
err,
503+
}),
504+
}
505+
} else if cfg!(windows) {
506+
// TODO(zanieb): Install GUI launchers as well
507+
let launcher = windows_python_launcher(&python, false)?;
508+
match File::create(target)?.write_all(launcher.as_ref()) {
509+
Ok(()) => Ok(()),
510+
Err(err) => Err(Error::LinkExecutable {
511+
from: python,
512+
to: target.to_path_buf(),
513+
err,
514+
}),
515+
}
516+
} else {
517+
unimplemented!("Only Windows and Unix systems are supported.")
518+
}
519+
}
489520

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

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

+150-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
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};
3+
use std::str::Utf8Error;
34

5+
use fs_err::File;
46
use thiserror::Error;
57
use uv_fs::Simplified;
68
use zip::write::FileOptions;
@@ -30,28 +32,157 @@ const LAUNCHER_AARCH64_GUI: &[u8] =
3032
const LAUNCHER_AARCH64_CONSOLE: &[u8] =
3133
include_bytes!("../../uv-trampoline/trampolines/uv-trampoline-aarch64-console.exe");
3234

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

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

52173
/// Note: The caller is responsible for adding the path of the wheel we're installing.
53174
#[derive(Error, Debug)]
54175
pub enum Error {
176+
#[error(transparent)]
177+
Io(#[from] io::Error),
178+
#[error("Only paths with a length up to 32KB are supported but found a length of {0} bytes")]
179+
InvalidPathLength(u32),
180+
#[error("Failed to parse executable path")]
181+
InvalidPath(#[source] Utf8Error),
182+
#[error("Failed to seek to {0} at offset {1}")]
183+
InvalidLauncherSeek(String, i64, #[source] io::Error),
184+
#[error("Failed to read launcher {0}")]
185+
InvalidLauncherRead(String, #[source] io::Error),
55186
#[error(
56187
"Unable to create Windows launcher for: {0} (only x86_64, x86, and arm64 are supported)"
57188
)]
@@ -192,7 +323,7 @@ mod test {
192323

193324
use which::which;
194325

195-
use super::{windows_python_launcher, windows_script_launcher};
326+
use super::{windows_python_launcher, windows_script_launcher, Launcher, LauncherKind};
196327

197328
#[test]
198329
#[cfg(all(windows, target_arch = "x86", feature = "production"))]
@@ -340,6 +471,13 @@ if __name__ == "__main__":
340471
.stdout(stdout_predicate)
341472
.stderr(stderr_predicate);
342473

474+
let launcher = Launcher::try_from_path(console_bin_path.path())
475+
.expect("We should succeed at reading the launcher")
476+
.expect("The launcher should be valid");
477+
478+
assert!(launcher.kind == LauncherKind::Script);
479+
assert!(launcher.python_path == python_executable_path);
480+
343481
Ok(())
344482
}
345483

@@ -371,6 +509,13 @@ if __name__ == "__main__":
371509
.success()
372510
.stdout("Hello from Python Launcher\r\n");
373511

512+
let launcher = Launcher::try_from_path(console_bin_path.path())
513+
.expect("We should succeed at reading the launcher")
514+
.expect("The launcher should be valid");
515+
516+
assert!(launcher.kind == LauncherKind::Python);
517+
assert!(launcher.python_path == python_executable_path);
518+
374519
Ok(())
375520
}
376521

0 commit comments

Comments
 (0)