diff --git a/Cargo.lock b/Cargo.lock index 30a8adc2524ff..113386f9aa9ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5575,6 +5575,7 @@ dependencies = [ "uv-platform-tags", "uv-pypi-types", "uv-python", + "uv-shell", "uv-version", ] diff --git a/crates/uv-virtualenv/Cargo.toml b/crates/uv-virtualenv/Cargo.toml index 959a17e203615..cf51a1c592c81 100644 --- a/crates/uv-virtualenv/Cargo.toml +++ b/crates/uv-virtualenv/Cargo.toml @@ -25,6 +25,7 @@ uv-platform-tags = { workspace = true } uv-pypi-types = { workspace = true } uv-python = { workspace = true } uv-version = { workspace = true } +uv-shell = { workspace = true } fs-err = { workspace = true } itertools = { workspace = true } diff --git a/crates/uv-virtualenv/src/virtualenv.rs b/crates/uv-virtualenv/src/virtualenv.rs index d11ecc1fb134e..d56f4576594a5 100644 --- a/crates/uv-virtualenv/src/virtualenv.rs +++ b/crates/uv-virtualenv/src/virtualenv.rs @@ -13,6 +13,7 @@ use tracing::debug; use uv_fs::{cachedir, Simplified, CWD}; use uv_pypi_types::Scheme; use uv_python::{Interpreter, VirtualEnvironment}; +use uv_shell::escape_posix_for_single_quotes; use uv_version::version; use crate::{Error, Prompt}; @@ -287,23 +288,20 @@ pub(crate) fn create( let virtual_env_dir = match (relocatable, name.to_owned()) { (true, "activate") => { - r#"'"$(dirname -- "$(dirname -- "$(realpath -- "$SCRIPT_PATH")")")"'"# + r#"'"$(dirname -- "$(dirname -- "$(realpath -- "$SCRIPT_PATH")")")"'"#.to_string() } - (true, "activate.bat") => r"%~dp0..", + (true, "activate.bat") => r"%~dp0..".to_string(), (true, "activate.fish") => { - r#"'"$(dirname -- "$(cd "$(dirname -- "$(status -f)")"; and pwd)")"'"# + r#"'"$(dirname -- "$(cd "$(dirname -- "$(status -f)")"; and pwd)")"'"#.to_string() } // Note: // * relocatable activate scripts appear not to be possible in csh and nu shell // * `activate.ps1` is already relocatable by default. - _ => { - // SAFETY: `unwrap` is guaranteed to succeed because `location` is an `Utf8PathBuf`. - location.simplified().to_str().unwrap() - } + _ => escape_posix_for_single_quotes(location.simplified().to_str().unwrap()), }; let activator = template - .replace("{{ VIRTUAL_ENV_DIR }}", virtual_env_dir) + .replace("{{ VIRTUAL_ENV_DIR }}", &virtual_env_dir) .replace("{{ BIN_NAME }}", bin_name) .replace( "{{ VIRTUAL_PROMPT }}", diff --git a/crates/uv/tests/it/venv.rs b/crates/uv/tests/it/venv.rs index 5cd54a971290f..7938d84a584d7 100644 --- a/crates/uv/tests/it/venv.rs +++ b/crates/uv/tests/it/venv.rs @@ -3,7 +3,6 @@ use assert_cmd::prelude::*; use assert_fs::prelude::*; use indoc::indoc; use predicates::prelude::*; - use uv_python::{PYTHON_VERSIONS_FILENAME, PYTHON_VERSION_FILENAME}; use uv_static::EnvVars; @@ -1087,3 +1086,57 @@ fn path_with_trailing_space_gives_proper_error() { ); // Note the extra trailing `/` in the snapshot is due to the filters, not the actual output. } + +/// Check that the activate script still works with the path contains an apostrophe. +#[test] +#[cfg(target_os = "linux")] +fn create_venv_apostrophe() { + use std::env; + use std::ffi::OsString; + use std::io::Write; + use std::process::Command; + use std::process::Stdio; + + let context = TestContext::new_with_versions(&["3.12"]); + + let venv_dir = context.temp_dir.join("Testing's"); + + uv_snapshot!(context.filters(), context.venv() + .arg(&venv_dir) + .arg("--python") + .arg("3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: Testing's + Activate with: source Testing's/[BIN]/activate + "### + ); + + // One of them should be commonly available on a linux developer machine, if not, we have to + // extend the fallbacks. + let shell = env::var_os("SHELL").unwrap_or(OsString::from("bash")); + let mut child = Command::new(shell) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .current_dir(&venv_dir) + .spawn() + .expect("Failed to spawn shell script"); + + let mut stdin = child.stdin.take().expect("Failed to open stdin"); + std::thread::spawn(move || { + stdin + .write_all(". bin/activate && python -c 'import sys; print(sys.prefix)'".as_bytes()) + .expect("Failed to write to stdin"); + }); + + let output = child.wait_with_output().expect("Failed to read stdout"); + + assert!(output.status.success(), "{output:?}"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout.trim(), venv_dir.to_string_lossy()); +}