diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18c9113ca..034688e43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -718,6 +718,46 @@ jobs: run: | ./uv pip install -v anyio + integration-test-free-threaded-windows: + timeout-minutes: 10 + needs: build-binary-windows + name: "integration test | free-threaded on windows" + runs-on: windows-latest + env: + # Avoid debug build stack overflows. + UV_STACK_SIZE: 2000000 + + steps: + - name: "Download binary" + uses: actions/download-artifact@v4 + with: + name: uv-windows-${{ github.sha }} + + - name: "Install free-threaded Python via uv" + run: | + ./uv python install -v 3.13t + + - name: "Create a virtual environment" + run: | + ./uv venv -p 3.13t --python-preference only-managed + + - name: "Check version" + run: | + .venv/Scripts/python --version + + - name: "Check is free-threaded" + run: | + .venv/Scripts/python -c "import sys; exit(1) if sys._is_gil_enabled() else exit(0)" + + - name: "Check install" + run: | + ./uv pip install -v anyio + + - name: "Check uv run" + run: | + ./uv run python -c "" + ./uv run -p 3.13t python -c "" + integration-test-pypy-linux: timeout-minutes: 10 needs: build-binary-linux diff --git a/crates/uv-fs/src/lib.rs b/crates/uv-fs/src/lib.rs index 3ed23f66e..a7007f342 100644 --- a/crates/uv-fs/src/lib.rs +++ b/crates/uv-fs/src/lib.rs @@ -104,6 +104,26 @@ pub fn remove_symlink(path: impl AsRef) -> std::io::Result<()> { fs_err::remove_file(path.as_ref()) } +/// Create a symlink at `dst` pointing to `src` or, on Windows, copy `src` to `dst`. +/// +/// This function should only be used for files. If targeting a directory, use [`replace_symlink`] +/// instead; it will use a junction on Windows, which is more performant. +pub fn symlink_copy_fallback_file( + src: impl AsRef, + dst: impl AsRef, +) -> std::io::Result<()> { + #[cfg(windows)] + { + fs_err::copy(src.as_ref(), dst.as_ref())?; + } + #[cfg(unix)] + { + std::os::unix::fs::symlink(src.as_ref(), dst.as_ref())?; + } + + Ok(()) +} + #[cfg(windows)] pub fn remove_symlink(path: impl AsRef) -> std::io::Result<()> { match junction::delete(dunce::simplified(path.as_ref())) { diff --git a/crates/uv-python/src/installation.rs b/crates/uv-python/src/installation.rs index 8aec1fc7f..b0a326bad 100644 --- a/crates/uv-python/src/installation.rs +++ b/crates/uv-python/src/installation.rs @@ -142,6 +142,7 @@ impl PythonInstallation { let installed = ManagedPythonInstallation::new(path)?; installed.ensure_externally_managed()?; + installed.ensure_canonical_executables()?; Ok(Self { source: PythonSource::Managed, diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index 5c077079d..98f8dccdf 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -7,7 +7,7 @@ use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::str::FromStr; use thiserror::Error; -use tracing::warn; +use tracing::{debug, warn}; use uv_state::{StateBucket, StateStore}; @@ -44,6 +44,15 @@ pub enum Error { #[source] err: io::Error, }, + #[error("Missing expected Python executable at {}", _0.user_display())] + MissingExecutable(PathBuf), + #[error("Failed to create canonical Python executable at {} from {}", to.user_display(), from.user_display())] + CanonicalizeExecutable { + from: PathBuf, + to: PathBuf, + #[source] + err: io::Error, + }, #[error("Failed to read Python installation directory: {0}", dir.user_display())] ReadError { dir: PathBuf, @@ -323,6 +332,48 @@ impl ManagedPythonInstallation { } } + /// Ensure the environment contains the canonical Python executable names. + pub fn ensure_canonical_executables(&self) -> Result<(), Error> { + let python = self.executable(); + + // Workaround for python-build-standalone v20241016 which is missing the standard + // `python.exe` executable in free-threaded distributions on Windows. + // + // See https://github.com/astral-sh/uv/issues/8298 + if !python.try_exists()? { + match self.key.variant { + PythonVariant::Default => return Err(Error::MissingExecutable(python.clone())), + PythonVariant::Freethreaded => { + // This is the alternative executable name for the freethreaded variant + let python_in_dist = self.python_dir().join(format!( + "python{}.{}t{}", + self.key.major, + self.key.minor, + std::env::consts::EXE_SUFFIX + )); + debug!( + "Creating link {} -> {}", + python.user_display(), + python_in_dist.user_display() + ); + uv_fs::symlink_copy_fallback_file(&python_in_dist, &python).map_err(|err| { + if err.kind() == io::ErrorKind::NotFound { + Error::MissingExecutable(python_in_dist.clone()) + } else { + Error::CanonicalizeExecutable { + from: python_in_dist, + to: python, + err, + } + } + })?; + } + } + } + + Ok(()) + } + /// Ensure the environment is marked as externally managed with the /// standard `EXTERNALLY-MANAGED` file. pub fn ensure_externally_managed(&self) -> Result<(), Error> { diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index b6df4af36..31cafbea6 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -167,6 +167,7 @@ pub(crate) async fn install( // Ensure the installations have externally managed markers let managed = ManagedPythonInstallation::new(path.clone())?; managed.ensure_externally_managed()?; + managed.ensure_canonical_executables()?; } Err(err) => { errors.push((key, err));