From fbbe0d9223c16adc2f54eca6282eafb97875c7d1 Mon Sep 17 00:00:00 2001 From: Pavel Dikov Date: Sat, 27 Jul 2024 19:42:38 +0100 Subject: [PATCH] feat(venv): add relocatable flag Adds a `--relocatable` CLI arg to `uv venv`. This flag does two things: * ensures that the associated activation scripts do not rely on a hardcoded absolute path to the virtual environment (to the extent possible; `.csh` and `.nu` left as-is) * persists a `relocatable` flag in `pyvenv.cfg`. The flag in `pyvenv.cfg` in turn instructs the wheel `Installer` to create script entrypoints in a relocatable way (use `exec` trick + `dirname $0` on POSIX; use relative path to `python[w].exe` on Windows). Fixes: #3863 --- crates/install-wheel-rs/src/linker.rs | 20 +++- crates/install-wheel-rs/src/wheel.rs | 102 ++++++++++++++---- crates/uv-build/src/lib.rs | 1 + crates/uv-cli/src/lib.rs | 11 ++ crates/uv-installer/src/installer.rs | 13 ++- crates/uv-python/src/virtualenv.rs | 19 +++- crates/uv-tool/src/lib.rs | 1 + .../uv-virtualenv/src/activator/activate.bat | 2 +- crates/uv-virtualenv/src/lib.rs | 2 + crates/uv-virtualenv/src/virtualenv.rs | 34 +++++- crates/uv/src/commands/project/environment.rs | 1 + crates/uv/src/commands/project/mod.rs | 1 + crates/uv/src/commands/project/run.rs | 1 + crates/uv/src/commands/venv.rs | 8 ++ crates/uv/src/lib.rs | 1 + crates/uv/src/settings.rs | 3 + crates/uv/tests/venv.rs | 49 +++++++++ 17 files changed, 237 insertions(+), 32 deletions(-) diff --git a/crates/install-wheel-rs/src/linker.rs b/crates/install-wheel-rs/src/linker.rs index 5eb48195ce21..6b00489c4544 100644 --- a/crates/install-wheel-rs/src/linker.rs +++ b/crates/install-wheel-rs/src/linker.rs @@ -46,6 +46,7 @@ pub fn install_wheel( installer: Option<&str>, link_mode: LinkMode, locks: &Locks, + is_relocatable: bool, ) -> Result<(), Error> { let dist_info_prefix = find_dist_info(&wheel)?; let metadata = dist_info_metadata(&dist_info_prefix, &wheel)?; @@ -101,8 +102,22 @@ pub fn install_wheel( debug!(name, "Writing entrypoints"); fs_err::create_dir_all(&layout.scheme.scripts)?; - write_script_entrypoints(layout, site_packages, &console_scripts, &mut record, false)?; - write_script_entrypoints(layout, site_packages, &gui_scripts, &mut record, true)?; + write_script_entrypoints( + layout, + site_packages, + &console_scripts, + &mut record, + false, + is_relocatable, + )?; + write_script_entrypoints( + layout, + site_packages, + &gui_scripts, + &mut record, + true, + is_relocatable, + )?; } // 2.a Unpacked archive includes distribution-1.0.dist-info/ and (if there is data) distribution-1.0.data/. @@ -118,6 +133,7 @@ pub fn install_wheel( &console_scripts, &gui_scripts, &mut record, + is_relocatable, )?; // 2.c If applicable, update scripts starting with #!python to point to the correct interpreter. // Script are unsupported through data diff --git a/crates/install-wheel-rs/src/wheel.rs b/crates/install-wheel-rs/src/wheel.rs index f53bf8f6ec3f..dc52775480b7 100644 --- a/crates/install-wheel-rs/src/wheel.rs +++ b/crates/install-wheel-rs/src/wheel.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::io::{BufRead, BufReader, Cursor, Read, Write}; +use std::io::{BufRead, BufReader, Cursor, Read, Seek, Write}; use std::path::{Path, PathBuf}; use std::{env, io}; @@ -128,7 +128,7 @@ fn copy_and_hash(reader: &mut impl Read, writer: &mut impl Write) -> io::Result< /// executable. /// /// See: -fn format_shebang(executable: impl AsRef, os_name: &str) -> String { +fn format_shebang(executable: impl AsRef, os_name: &str, is_relocatable: bool) -> String { // Convert the executable to a simplified path. let executable = executable.as_ref().simplified_display().to_string(); @@ -139,11 +139,18 @@ fn format_shebang(executable: impl AsRef, os_name: &str) -> String { let shebang_length = 2 + executable.len() + 1; // If the shebang is too long, or contains spaces, wrap it in `/bin/sh`. - if shebang_length > 127 || executable.contains(' ') { + // Same applies for relocatable scripts (executable is relative to script dir, hence `dirname` trick) + // (note: the Windows trampoline binaries natively support relative paths to executable) + if shebang_length > 127 || executable.contains(' ') || is_relocatable { + let prefix = if is_relocatable { + r#""$(CDPATH= cd -- "$(dirname -- "$0")" && echo "$PWD")""# + } else { + "" + }; // Like Python's `shlex.quote`: // > Use single quotes, and put single quotes into double quotes // > The string $'b is then quoted as '$'"'"'b' - let executable = format!("'{}'", executable.replace('\'', r#"'"'"'"#)); + let executable = format!("{}'{}'", prefix, executable.replace('\'', r#"'"'"'"#)); return format!("#!/bin/sh\n'''exec' {executable} \"$0\" \"$@\"\n' '''"); } } @@ -235,9 +242,9 @@ pub(crate) fn windows_script_launcher( /// Returns a [`PathBuf`] to `python[w].exe` for script execution. /// /// -fn get_script_executable(python_executable: &Path, is_gui: bool) -> PathBuf { +fn get_script_executable(python_executable: &Path, is_gui: bool, is_relocatable: bool) -> PathBuf { // Only check for pythonw.exe on Windows - if cfg!(windows) && is_gui { + let script_executable = if cfg!(windows) && is_gui { python_executable .file_name() .map(|name| { @@ -248,6 +255,14 @@ fn get_script_executable(python_executable: &Path, is_gui: bool) -> PathBuf { .unwrap_or_else(|| python_executable.to_path_buf()) } else { python_executable.to_path_buf() + }; + if is_relocatable { + script_executable + .file_name() + .map(PathBuf::from) + .unwrap_or_else(|| script_executable) + } else { + script_executable } } @@ -276,6 +291,7 @@ pub(crate) fn write_script_entrypoints( entrypoints: &[Script], record: &mut Vec, is_gui: bool, + is_relocatable: bool, ) -> Result<(), Error> { for entrypoint in entrypoints { let entrypoint_absolute = entrypoint_path(entrypoint, layout); @@ -292,10 +308,11 @@ pub(crate) fn write_script_entrypoints( })?; // Generate the launcher script. - let launcher_executable = get_script_executable(&layout.sys_executable, is_gui); + let launcher_executable = + get_script_executable(&layout.sys_executable, is_gui, is_relocatable); let launcher_python_script = get_script_launcher( entrypoint, - &format_shebang(&launcher_executable, &layout.os_name), + &format_shebang(&launcher_executable, &layout.os_name, is_relocatable), ); // If necessary, wrap the launcher script in a Windows launcher binary. @@ -440,6 +457,7 @@ fn install_script( site_packages: &Path, record: &mut [RecordEntry], file: &DirEntry, + is_relocatable: bool, ) -> Result<(), Error> { let file_type = file.file_type()?; @@ -494,7 +512,18 @@ fn install_script( let mut start = vec![0; placeholder_python.len()]; script.read_exact(&mut start)?; let size_and_encoded_hash = if start == placeholder_python { - let start = format_shebang(&layout.sys_executable, &layout.os_name) + let is_gui = { + let mut buf = vec![0]; + script.read_exact(&mut buf)?; + if buf == b"w" { + true + } else { + script.seek_relative(-1)?; + false + } + }; + let executable = get_script_executable(&layout.sys_executable, is_gui, is_relocatable); + let start = format_shebang(&executable, &layout.os_name, is_relocatable) .as_bytes() .to_vec(); @@ -561,6 +590,7 @@ pub(crate) fn install_data( console_scripts: &[Script], gui_scripts: &[Script], record: &mut [RecordEntry], + is_relocatable: bool, ) -> Result<(), Error> { for entry in fs::read_dir(data_dir)? { let entry = entry?; @@ -598,7 +628,7 @@ pub(crate) fn install_data( initialized = true; } - install_script(layout, site_packages, record, &file)?; + install_script(layout, site_packages, record, &file, is_relocatable)?; } } Some("headers") => { @@ -888,33 +918,47 @@ mod test { // By default, use a simple shebang. let executable = Path::new("/usr/bin/python3"); let os_name = "posix"; - assert_eq!(format_shebang(executable, os_name), "#!/usr/bin/python3"); + assert_eq!( + format_shebang(executable, os_name, false), + "#!/usr/bin/python3" + ); // If the path contains spaces, we should use the `exec` trick. let executable = Path::new("/usr/bin/path to python3"); let os_name = "posix"; assert_eq!( - format_shebang(executable, os_name), + format_shebang(executable, os_name, false), "#!/bin/sh\n'''exec' '/usr/bin/path to python3' \"$0\" \"$@\"\n' '''" ); + // And if we want a relocatable script, we should use the `exec` trick with `dirname`. + let executable = Path::new("python3"); + let os_name = "posix"; + assert_eq!( + format_shebang(executable, os_name, true), + "#!/bin/sh\n'''exec' \"$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && echo \"$PWD\")\"'python3' \"$0\" \"$@\"\n' '''" + ); + // Except on Windows... let executable = Path::new("/usr/bin/path to python3"); let os_name = "nt"; assert_eq!( - format_shebang(executable, os_name), + format_shebang(executable, os_name, false), "#!/usr/bin/path to python3" ); // Quotes, however, are ok. let executable = Path::new("/usr/bin/'python3'"); let os_name = "posix"; - assert_eq!(format_shebang(executable, os_name), "#!/usr/bin/'python3'"); + assert_eq!( + format_shebang(executable, os_name, false), + "#!/usr/bin/'python3'" + ); // If the path is too long, we should not use the `exec` trick. let executable = Path::new("/usr/bin/path/to/a/very/long/executable/executable/executable/executable/executable/executable/executable/executable/name/python3"); let os_name = "posix"; - assert_eq!(format_shebang(executable, os_name), "#!/bin/sh\n'''exec' '/usr/bin/path/to/a/very/long/executable/executable/executable/executable/executable/executable/executable/executable/name/python3' \"$0\" \"$@\"\n' '''"); + assert_eq!(format_shebang(executable, os_name, false), "#!/bin/sh\n'''exec' '/usr/bin/path/to/a/very/long/executable/executable/executable/executable/executable/executable/executable/executable/name/python3' \"$0\" \"$@\"\n' '''"); } #[test] @@ -1005,13 +1049,13 @@ mod test { python_exe.write_str("")?; pythonw_exe.write_str("")?; - let script_path = get_script_executable(&python_exe, true); + let script_path = get_script_executable(&python_exe, true, false); #[cfg(windows)] assert_eq!(script_path, pythonw_exe.to_path_buf()); #[cfg(not(windows))] assert_eq!(script_path, python_exe.to_path_buf()); - let script_path = get_script_executable(&python_exe, false); + let script_path = get_script_executable(&python_exe, false, false); assert_eq!(script_path, python_exe.to_path_buf()); // Test without adjacent pythonw.exe @@ -1019,10 +1063,10 @@ mod test { let python_exe = temp_dir.child("python.exe"); python_exe.write_str("")?; - let script_path = get_script_executable(&python_exe, true); + let script_path = get_script_executable(&python_exe, true, false); assert_eq!(script_path, python_exe.to_path_buf()); - let script_path = get_script_executable(&python_exe, false); + let script_path = get_script_executable(&python_exe, false, false); assert_eq!(script_path, python_exe.to_path_buf()); // Test with overridden python.exe and pythonw.exe @@ -1036,15 +1080,31 @@ mod test { dot_python_exe.write_str("")?; dot_pythonw_exe.write_str("")?; - let script_path = get_script_executable(&dot_python_exe, true); + let script_path = get_script_executable(&dot_python_exe, true, false); #[cfg(windows)] assert_eq!(script_path, dot_pythonw_exe.to_path_buf()); #[cfg(not(windows))] assert_eq!(script_path, dot_python_exe.to_path_buf()); - let script_path = get_script_executable(&dot_python_exe, false); + let script_path = get_script_executable(&dot_python_exe, false, false); assert_eq!(script_path, dot_python_exe.to_path_buf()); + // Test with relocatable executable. + let temp_dir = assert_fs::TempDir::new()?; + let python_exe = temp_dir.child("python.exe"); + let pythonw_exe = temp_dir.child("pythonw.exe"); + python_exe.write_str("")?; + pythonw_exe.write_str("")?; + + let script_path = get_script_executable(&python_exe, true, true); + #[cfg(windows)] + assert_eq!(script_path, Path::new("pythonw.exe").to_path_buf()); + #[cfg(not(windows))] + assert_eq!(script_path, Path::new("python.exe").to_path_buf()); + + let script_path = get_script_executable(&python_exe, false, true); + assert_eq!(script_path, Path::new("python.exe").to_path_buf()); + Ok(()) } } diff --git a/crates/uv-build/src/lib.rs b/crates/uv-build/src/lib.rs index d629b19c0ea7..fe84509388a7 100644 --- a/crates/uv-build/src/lib.rs +++ b/crates/uv-build/src/lib.rs @@ -442,6 +442,7 @@ impl SourceBuild { uv_virtualenv::Prompt::None, false, false, + false, )?, BuildIsolation::Shared(venv) => venv.clone(), }; diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 27b680952041..64466f823b62 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1743,6 +1743,17 @@ pub struct VenvArgs { #[arg(long)] pub system_site_packages: bool, + /// Make the virtual environment relocatable. + /// + /// A relocatable virtual environment can be moved around and redistributed with its + /// associated entrypoint and activation scripts functioning as usual. + /// + /// Note that this can only be guaranteed for standard `console_scripts` and `gui_scripts`. + /// Other scripts may be adjusted if they ship with a generic `#!python[w]` shebang, + /// and binaries are left as-is. + #[arg(long)] + pub relocatable: bool, + #[command(flatten)] pub index_args: IndexArgs, diff --git a/crates/uv-installer/src/installer.rs b/crates/uv-installer/src/installer.rs index 008b8d32543b..f4ff0d9d9b82 100644 --- a/crates/uv-installer/src/installer.rs +++ b/crates/uv-installer/src/installer.rs @@ -63,9 +63,17 @@ impl<'a> Installer<'a> { installer_name, } = self; let layout = venv.interpreter().layout(); + let is_relocatable = venv.cfg().is_ok_and(|cfg| cfg.is_relocatable()); rayon::spawn(move || { - let result = install(wheels, layout, installer_name, link_mode, reporter); + let result = install( + wheels, + layout, + installer_name, + link_mode, + reporter, + is_relocatable, + ); tx.send(result).unwrap(); }); @@ -83,6 +91,7 @@ impl<'a> Installer<'a> { self.installer_name, self.link_mode, self.reporter, + self.venv.cfg().is_ok_and(|cfg| cfg.is_relocatable()), ) } } @@ -95,6 +104,7 @@ fn install( installer_name: Option, link_mode: LinkMode, reporter: Option>, + is_relocatable: bool, ) -> Result> { let locks = install_wheel_rs::linker::Locks::default(); wheels.par_iter().try_for_each(|wheel| { @@ -111,6 +121,7 @@ fn install( installer_name.as_deref(), link_mode, &locks, + is_relocatable, ) .with_context(|| format!("Failed to install: {} ({wheel})", wheel.filename()))?; diff --git a/crates/uv-python/src/virtualenv.rs b/crates/uv-python/src/virtualenv.rs index ed9a862a7cd0..d18265f8ae0b 100644 --- a/crates/uv-python/src/virtualenv.rs +++ b/crates/uv-python/src/virtualenv.rs @@ -28,6 +28,8 @@ pub struct PyVenvConfiguration { pub(crate) virtualenv: bool, /// If the uv package was used to create the virtual environment. pub(crate) uv: bool, + /// Is the virtual environment relocatable? + pub(crate) relocatable: bool, } #[derive(Debug, Error)] @@ -136,6 +138,7 @@ impl PyVenvConfiguration { pub fn parse(cfg: impl AsRef) -> Result { let mut virtualenv = false; let mut uv = false; + let mut relocatable = false; // Per https://snarky.ca/how-virtual-environments-work/, the `pyvenv.cfg` file is not a // valid INI file, and is instead expected to be parsed by partitioning each line on the @@ -143,7 +146,7 @@ impl PyVenvConfiguration { let content = fs::read_to_string(&cfg) .map_err(|err| Error::ParsePyVenvCfg(cfg.as_ref().to_path_buf(), err))?; for line in content.lines() { - let Some((key, _value)) = line.split_once('=') else { + let Some((key, value)) = line.split_once('=') else { continue; }; match key.trim() { @@ -153,11 +156,18 @@ impl PyVenvConfiguration { "uv" => { uv = true; } + "relocatable" => { + relocatable = value.trim().to_lowercase() == "true"; + } _ => {} } } - Ok(Self { virtualenv, uv }) + Ok(Self { + virtualenv, + uv, + relocatable, + }) } /// Returns true if the virtual environment was created with the `virtualenv` package. @@ -169,4 +179,9 @@ impl PyVenvConfiguration { pub fn is_uv(&self) -> bool { self.uv } + + /// Returns true if the virtual environment is relocatable. + pub fn is_relocatable(&self) -> bool { + self.relocatable + } } diff --git a/crates/uv-tool/src/lib.rs b/crates/uv-tool/src/lib.rs index b54bb06f3e41..c330fd3bd422 100644 --- a/crates/uv-tool/src/lib.rs +++ b/crates/uv-tool/src/lib.rs @@ -258,6 +258,7 @@ impl InstalledTools { uv_virtualenv::Prompt::None, false, false, + false, )?; Ok(venv) diff --git a/crates/uv-virtualenv/src/activator/activate.bat b/crates/uv-virtualenv/src/activator/activate.bat index 584627c957c6..ba3621d3d5b5 100644 --- a/crates/uv-virtualenv/src/activator/activate.bat +++ b/crates/uv-virtualenv/src/activator/activate.bat @@ -19,7 +19,7 @@ @REM OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION @REM WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -@set "VIRTUAL_ENV={{ VIRTUAL_ENV_DIR }}" +@for %%i in ("{{ VIRTUAL_ENV_DIR }}") do @set "VIRTUAL_ENV=%%~fi" @set "VIRTUAL_ENV_PROMPT={{ VIRTUAL_PROMPT }}" @if NOT DEFINED VIRTUAL_ENV_PROMPT ( diff --git a/crates/uv-virtualenv/src/lib.rs b/crates/uv-virtualenv/src/lib.rs index 6983284d6814..30e8ed4de9eb 100644 --- a/crates/uv-virtualenv/src/lib.rs +++ b/crates/uv-virtualenv/src/lib.rs @@ -52,6 +52,7 @@ pub fn create_venv( prompt: Prompt, system_site_packages: bool, allow_existing: bool, + relocatable: bool, ) -> Result { // Create the virtualenv at the given location. let virtualenv = virtualenv::create( @@ -60,6 +61,7 @@ pub fn create_venv( prompt, system_site_packages, allow_existing, + relocatable, )?; // Create the corresponding `PythonEnvironment`. diff --git a/crates/uv-virtualenv/src/virtualenv.rs b/crates/uv-virtualenv/src/virtualenv.rs index 09348e8e52c1..1fe4f50e6c4a 100644 --- a/crates/uv-virtualenv/src/virtualenv.rs +++ b/crates/uv-virtualenv/src/virtualenv.rs @@ -50,6 +50,7 @@ pub(crate) fn create( prompt: Prompt, system_site_packages: bool, allow_existing: bool, + relocatable: bool, ) -> Result { // Determine the base Python executable; that is, the Python executable that should be // considered the "base" for the virtual environment. This is typically the Python executable @@ -294,12 +295,27 @@ pub(crate) fn create( .map(|path| path.simplified().to_str().unwrap().replace('\\', "\\\\")) .join(path_sep); - let activator = template - .replace( - "{{ VIRTUAL_ENV_DIR }}", + let virtual_env_dir = match (relocatable, name.to_owned()) { + (true, "activate") => { + // Extremely verbose, but should cover all major POSIX shells, + // as well as platforms where `readlink` does not implement `-f`. + r#"'"$(dirname -- "$(CDPATH= cd -- "$(dirname -- ${BASH_SOURCE[0]:-${(%):-%x}})" && echo "$PWD")")"'"# + } + (true, "activate.bat") => r"%~dp0..", + (true, "activate.fish") => { + r#"'"$(dirname -- "$(cd "$(dirname -- "$(status -f)")"; and pwd)")"'"# + } + // 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(), - ) + location.simplified().to_str().unwrap() + } + }; + + let activator = template + .replace("{{ VIRTUAL_ENV_DIR }}", virtual_env_dir) .replace("{{ BIN_NAME }}", bin_name) .replace( "{{ VIRTUAL_PROMPT }}", @@ -335,6 +351,14 @@ pub(crate) fn create( "false".to_string() }, ), + ( + "relocatable".to_string(), + if relocatable { + "true".to_string() + } else { + "false".to_string() + }, + ), ]; if let Some(prompt) = prompt { diff --git a/crates/uv/src/commands/project/environment.rs b/crates/uv/src/commands/project/environment.rs index d6789c5dcb8b..484bea8d04d8 100644 --- a/crates/uv/src/commands/project/environment.rs +++ b/crates/uv/src/commands/project/environment.rs @@ -132,6 +132,7 @@ impl CachedEnvironment { uv_virtualenv::Prompt::None, false, false, + false, )?; let venv = sync_environment( diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 300aa7c80b76..8ba42c0f6709 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -313,6 +313,7 @@ pub(crate) async fn get_or_init_environment( uv_virtualenv::Prompt::None, false, false, + false, )?) } } diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 76117e368eb1..92b9bf4b811a 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -385,6 +385,7 @@ pub(crate) async fn run( uv_virtualenv::Prompt::None, false, false, + false, )?; match spec { diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index b4abb9f1645d..bc9ba04963c8 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -28,6 +28,7 @@ use uv_python::{ use uv_resolver::{ExcludeNewer, FlatIndex}; use uv_shell::Shell; use uv_types::{BuildContext, BuildIsolation, HashStrategy}; +use uv_warnings::warn_user_once; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::{pip, ExitStatus, SharedState}; @@ -54,6 +55,7 @@ pub(crate) async fn venv( preview: PreviewMode, cache: &Cache, printer: Printer, + relocatable: bool, ) -> Result { match venv_impl( path, @@ -74,6 +76,7 @@ pub(crate) async fn venv( native_tls, cache, printer, + relocatable, ) .await { @@ -125,6 +128,7 @@ async fn venv_impl( native_tls: bool, cache: &Cache, printer: Printer, + relocatable: bool, ) -> miette::Result { let client_builder = BaseClientBuilder::default() .connectivity(connectivity) @@ -138,6 +142,9 @@ async fn venv_impl( if preview.is_enabled() && interpreter_request.is_none() { interpreter_request = request_from_version_file().await.into_diagnostic()?; } + if preview.is_disabled() && relocatable { + warn_user_once!("`--relocatable` is experimental and may change without warning"); + } // Locate the Python interpreter to use in the environment let python = PythonInstallation::find_or_fetch( @@ -192,6 +199,7 @@ async fn venv_impl( prompt, system_site_packages, allow_existing, + relocatable, ) .map_err(VenvError::Creation)?; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index c5ca7eef3956..6a9dbf82e9a9 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -594,6 +594,7 @@ async fn run(cli: Cli) -> Result { globals.preview, &cache, printer, + args.relocatable, ) .await } diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index cf4e7f510287..72d392de3378 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1403,6 +1403,7 @@ pub(crate) struct VenvSettings { pub(crate) name: PathBuf, pub(crate) prompt: Option, pub(crate) system_site_packages: bool, + pub(crate) relocatable: bool, pub(crate) settings: PipSettings, } @@ -1418,6 +1419,7 @@ impl VenvSettings { name, prompt, system_site_packages, + relocatable, index_args, index_strategy, keyring_provider, @@ -1432,6 +1434,7 @@ impl VenvSettings { name, prompt, system_site_packages, + relocatable, settings: PipSettings::combine( PipOptions { python, diff --git a/crates/uv/tests/venv.rs b/crates/uv/tests/venv.rs index 4f509bf67ef7..1e3c1deef4a1 100644 --- a/crates/uv/tests/venv.rs +++ b/crates/uv/tests/venv.rs @@ -577,6 +577,55 @@ fn verify_pyvenv_cfg() { let version = env!("CARGO_PKG_VERSION").to_string(); let search_string = format!("uv = {version}"); pyvenv_cfg.assert(predicates::str::contains(search_string)); + + // Not relocatable by default. + pyvenv_cfg.assert(predicates::str::contains("relocatable = false")); +} + +#[test] +fn verify_pyvenv_cfg_relocatable() { + let context = TestContext::new("3.12"); + + // Create a virtual environment at `.venv`. + context + .venv() + .arg(context.venv.as_os_str()) + .arg("--python") + .arg("3.12") + .arg("--relocatable") + .assert() + .success(); + + let pyvenv_cfg = context.venv.child("pyvenv.cfg"); + + context.venv.assert(predicates::path::is_dir()); + + // Check pyvenv.cfg exists + pyvenv_cfg.assert(predicates::path::is_file()); + + // Relocatable flag is set. + pyvenv_cfg.assert(predicates::str::contains("relocatable = true")); + + // Activate scripts contain the relocatable boilerplate + let scripts = if cfg!(windows) { + context.venv.child("Scripts") + } else { + context.venv.child("bin") + }; + + let activate_sh = scripts.child("activate"); + activate_sh.assert(predicates::path::is_file()); + activate_sh.assert(predicates::str::contains(r#"VIRTUAL_ENV=''"$(dirname -- "$(CDPATH= cd -- "$(dirname -- ${BASH_SOURCE[0]:-${(%):-%x}})" && echo "$PWD")")"''"#)); + + let activate_bat = scripts.child("activate.bat"); + activate_bat.assert(predicates::path::is_file()); + activate_bat.assert(predicates::str::contains( + r#"@for %%i in ("%~dp0..") do @set "VIRTUAL_ENV=%%~fi""#, + )); + + let activate_fish = scripts.child("activate.fish"); + activate_fish.assert(predicates::path::is_file()); + activate_fish.assert(predicates::str::contains(r#"set -gx VIRTUAL_ENV ''"$(dirname -- "$(cd "$(dirname -- "$(status -f)")"; and pwd)")"''"#)); } /// Ensure that a nested virtual environment uses the same `home` directory as the parent.