diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index 6d715bd05..307eede29 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -1002,6 +1002,39 @@ pub fn create_link_to_executable(link: &Path, executable: &Path) -> Result<(), E } } +/// Create or replace a link to a managed Python executable. +/// +/// If a file already exists at the link path, it will be atomically replaced. +/// +/// See [`create_link_to_executable`] for a variant that errors if the link already exists. +pub fn replace_link_to_executable(link: &Path, executable: &Path) -> Result<(), Error> { + let link_parent = link.parent().ok_or(Error::NoExecutableDirectory)?; + fs_err::create_dir_all(link_parent).map_err(|err| Error::ExecutableDirectory { + to: link_parent.to_path_buf(), + err, + })?; + + if cfg!(unix) { + replace_symlink(executable, link).map_err(|err| Error::LinkExecutable { + from: executable.to_path_buf(), + to: link.to_path_buf(), + err, + }) + } else if cfg!(windows) { + use uv_trampoline_builder::windows_python_launcher; + + let launcher = windows_python_launcher(executable, false)?; + + uv_fs::write_atomic_sync(link, &*launcher).map_err(|err| Error::LinkExecutable { + from: executable.to_path_buf(), + to: link.to_path_buf(), + err, + }) + } else { + unimplemented!("Only Windows and Unix are supported.") + } +} + // TODO(zanieb): Only used in tests now. /// Generate a platform portion of a key from the environment. pub fn platform_key_from_env() -> Result { diff --git a/crates/uv-virtualenv/src/virtualenv.rs b/crates/uv-virtualenv/src/virtualenv.rs index 18b356f35..68bec538e 100644 --- a/crates/uv-virtualenv/src/virtualenv.rs +++ b/crates/uv-virtualenv/src/virtualenv.rs @@ -16,7 +16,7 @@ use uv_fs::{CWD, Simplified, cachedir}; use uv_platform_tags::Os; use uv_pypi_types::Scheme; use uv_python::managed::{ - ManagedPythonInstallation, PythonMinorVersionLink, create_link_to_executable, + ManagedPythonInstallation, PythonMinorVersionLink, replace_link_to_executable, }; use uv_python::{Interpreter, VirtualEnvironment}; use uv_shell::escape_posix_for_single_quotes; @@ -298,24 +298,24 @@ pub(crate) fn create( if cfg!(windows) { if using_minor_version_link { let target = scripts.join(WindowsExecutable::Python.exe(interpreter)); - create_link_to_executable(target.as_path(), &executable_target) + replace_link_to_executable(target.as_path(), &executable_target) .map_err(Error::Python)?; let targetw = scripts.join(WindowsExecutable::Pythonw.exe(interpreter)); - create_link_to_executable(targetw.as_path(), &executable_target) + replace_link_to_executable(targetw.as_path(), &executable_target) .map_err(Error::Python)?; if interpreter.gil_disabled() { let targett = scripts.join(WindowsExecutable::PythonMajorMinort.exe(interpreter)); - create_link_to_executable(targett.as_path(), &executable_target) + replace_link_to_executable(targett.as_path(), &executable_target) .map_err(Error::Python)?; let targetwt = scripts.join(WindowsExecutable::PythonwMajorMinort.exe(interpreter)); - create_link_to_executable(targetwt.as_path(), &executable_target) + replace_link_to_executable(targetwt.as_path(), &executable_target) .map_err(Error::Python)?; } } else if matches!(interpreter.platform().os(), Os::Pyodide { .. }) { // For Pyodide, link only `python.exe`. // This should not be copied as `python.exe` is a wrapper that launches Pyodide. let target = scripts.join(WindowsExecutable::Python.exe(interpreter)); - create_link_to_executable(target.as_path(), &executable_target) + replace_link_to_executable(target.as_path(), &executable_target) .map_err(Error::Python)?; } else { // Always copy `python.exe`. diff --git a/crates/uv/tests/it/python_install.rs b/crates/uv/tests/it/python_install.rs index 94d5ba78e..275befb98 100644 --- a/crates/uv/tests/it/python_install.rs +++ b/crates/uv/tests/it/python_install.rs @@ -2913,6 +2913,58 @@ fn python_install_emulated_windows_x86_on_x64() { "); } +// Creating a venv with `--allow-existing` over an existing managed venv should succeed. +// +// Regression test for . +#[test] +fn install_managed_venv_allow_existing() { + let context = uv_test::test_context_with_versions!(&[]) + .with_filtered_python_keys() + .with_filtered_exe_suffix() + .with_filtered_latest_python_versions() + .with_managed_python_dirs() + .with_python_download_cache() + .with_filtered_python_install_bin(); + + // Install a managed Python version. + uv_snapshot!(context.filters(), context.python_install().arg("3.13"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed Python 3.13.[LATEST] in [TIME] + + cpython-3.13.[LATEST]-[PLATFORM] (python3.13) + "); + + // Create a virtual environment using the managed installation. + uv_snapshot!(context.filters(), context.venv().arg("-p").arg("3.13") + .arg(context.venv.as_os_str()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.13.[LATEST] + Creating virtual environment at: .venv + Activate with: source .venv/[BIN]/activate + "); + + // Create the venv again with `--allow-existing` — this should not fail. + uv_snapshot!(context.filters(), context.venv().arg("-p").arg("3.13") + .arg("--allow-existing") + .arg(context.venv.as_os_str()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.13.[LATEST] + Creating virtual environment at: .venv + Activate with: source .venv/[BIN]/activate + "); +} + // A virtual environment should track the latest patch version installed. #[test] fn install_transparent_patch_upgrade_uv_venv() {