Skip to content

Commit cae1480

Browse files
committed
Patch Python executable name for Windows free-threaded builds
1 parent 3fd69b4 commit cae1480

File tree

5 files changed

+114
-1
lines changed

5 files changed

+114
-1
lines changed

.github/workflows/ci.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,46 @@ jobs:
718718
run: |
719719
./uv pip install -v anyio
720720
721+
integration-test-free-threaded-windows:
722+
timeout-minutes: 10
723+
needs: build-binary-windows
724+
name: "integration test | free-threaded on windows"
725+
runs-on: windows-latest
726+
env:
727+
# Avoid debug build stack overflows.
728+
UV_STACK_SIZE: 2000000
729+
730+
steps:
731+
- name: "Download binary"
732+
uses: actions/download-artifact@v4
733+
with:
734+
name: uv-windows-${{ github.sha }}
735+
736+
- name: "Install free-threaded Python via uv"
737+
run: |
738+
./uv python install -v 3.13t
739+
740+
- name: "Create a virtual environment"
741+
run: |
742+
./uv venv -p 3.13t --python-preference only-managed
743+
744+
- name: "Check version"
745+
run: |
746+
.venv/Scripts/python --version
747+
748+
- name: "Check is free-threaded"
749+
run: |
750+
.venv/Scripts/python -c "import sys; exit(1) if sys._is_gil_enabled() else exit(0)"
751+
752+
- name: "Check install"
753+
run: |
754+
./uv pip install -v anyio
755+
756+
- name: "Check uv run"
757+
run: |
758+
./uv run python -c ""
759+
./uv run -p 3.13t python -c ""
760+
721761
integration-test-pypy-linux:
722762
timeout-minutes: 10
723763
needs: build-binary-linux

crates/uv-fs/src/lib.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,26 @@ 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`.
108+
///
109+
/// This function should only be used for files. If targeting a directory, use [`replace_symlink`]
110+
/// 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+
#[cfg(windows)]
116+
{
117+
fs_err::copy(src.as_ref(), dst.as_ref())?;
118+
}
119+
#[cfg(unix)]
120+
{
121+
std::os::unix::fs::symlink(src.as_ref(), dst.as_ref())?;
122+
}
123+
124+
Ok(())
125+
}
126+
107127
#[cfg(windows)]
108128
pub fn remove_symlink(path: impl AsRef<Path>) -> std::io::Result<()> {
109129
match junction::delete(dunce::simplified(path.as_ref())) {

crates/uv-python/src/installation.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ impl PythonInstallation {
142142

143143
let installed = ManagedPythonInstallation::new(path)?;
144144
installed.ensure_externally_managed()?;
145+
installed.ensure_canonical_executables()?;
145146

146147
Ok(Self {
147148
source: PythonSource::Managed,

crates/uv-python/src/managed.rs

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use std::io::{self, Write};
77
use std::path::{Path, PathBuf};
88
use std::str::FromStr;
99
use thiserror::Error;
10-
use tracing::warn;
10+
use tracing::{debug, warn};
1111

1212
use uv_state::{StateBucket, StateStore};
1313

@@ -44,6 +44,15 @@ pub enum Error {
4444
#[source]
4545
err: io::Error,
4646
},
47+
#[error("Missing expected Python executable at {}", _0.user_display())]
48+
MissingExecutable(PathBuf),
49+
#[error("Failed to create canonical Python executable at {} from {}", to.user_display(), from.user_display())]
50+
CanonicalizeExecutable {
51+
from: PathBuf,
52+
to: PathBuf,
53+
#[source]
54+
err: io::Error,
55+
},
4756
#[error("Failed to read Python installation directory: {0}", dir.user_display())]
4857
ReadError {
4958
dir: PathBuf,
@@ -323,6 +332,48 @@ impl ManagedPythonInstallation {
323332
}
324333
}
325334

335+
/// Ensure the environment contains the canonical Python executable names.
336+
pub fn ensure_canonical_executables(&self) -> Result<(), Error> {
337+
let python = self.executable();
338+
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+
));
354+
debug!(
355+
"Creating link {} -> {}",
356+
python.user_display(),
357+
python_in_dist.user_display()
358+
);
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+
})?;
370+
}
371+
}
372+
}
373+
374+
Ok(())
375+
}
376+
326377
/// Ensure the environment is marked as externally managed with the
327378
/// standard `EXTERNALLY-MANAGED` file.
328379
pub fn ensure_externally_managed(&self) -> Result<(), Error> {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ pub(crate) async fn install(
167167
// Ensure the installations have externally managed markers
168168
let managed = ManagedPythonInstallation::new(path.clone())?;
169169
managed.ensure_externally_managed()?;
170+
managed.ensure_canonical_executables()?;
170171
}
171172
Err(err) => {
172173
errors.push((key, err));

0 commit comments

Comments
 (0)