diff --git a/crates/install-wheel-rs/src/lib.rs b/crates/install-wheel-rs/src/lib.rs index 0873d4bc52cc..8a6f487630fc 100644 --- a/crates/install-wheel-rs/src/lib.rs +++ b/crates/install-wheel-rs/src/lib.rs @@ -1,7 +1,6 @@ //! Takes a wheel and installs it into a venv. use std::io; - use std::path::PathBuf; use platform_info::PlatformInfoError; diff --git a/crates/install-wheel-rs/src/linker.rs b/crates/install-wheel-rs/src/linker.rs index 7d71f198ddde..9b72be62e49b 100644 --- a/crates/install-wheel-rs/src/linker.rs +++ b/crates/install-wheel-rs/src/linker.rs @@ -37,6 +37,7 @@ pub struct Locks(Mutex>>>); #[instrument(skip_all, fields(wheel = %filename))] pub fn install_wheel( layout: &Layout, + relocatable: bool, wheel: impl AsRef, filename: &WheelFilename, direct_url: Option<&DirectUrl>, @@ -97,8 +98,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, + relocatable, + site_packages, + &console_scripts, + &mut record, + false, + )?; + write_script_entrypoints( + layout, + relocatable, + site_packages, + &gui_scripts, + &mut record, + true, + )?; } // 2.a Unpacked archive includes distribution-1.0.dist-info/ and (if there is data) distribution-1.0.data/. @@ -108,6 +123,7 @@ pub fn install_wheel( debug!(?name, "Installing data"); install_data( layout, + relocatable, site_packages, &data_dir, &name, diff --git a/crates/install-wheel-rs/src/wheel.rs b/crates/install-wheel-rs/src/wheel.rs index a41f4f2fe041..820ef49e2ceb 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, 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(' ') || relocatable { + let prefix = if 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' '''"); } } @@ -272,6 +279,7 @@ fn entrypoint_path(entrypoint: &Script, layout: &Layout) -> PathBuf { /// Create the wrapper scripts in the bin folder of the venv for launching console scripts. pub(crate) fn write_script_entrypoints( layout: &Layout, + relocatable: bool, site_packages: &Path, entrypoints: &[Script], record: &mut Vec, @@ -293,9 +301,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_relocatable_executable(launcher_executable, layout, relocatable)?; let launcher_python_script = get_script_launcher( entrypoint, - &format_shebang(&launcher_executable, &layout.os_name), + &format_shebang(&launcher_executable, &layout.os_name, relocatable), ); // If necessary, wrap the launcher script in a Windows launcher binary. @@ -432,11 +442,12 @@ pub(crate) fn move_folder_recorded( Ok(()) } -/// Installs a single script (not an entrypoint) +/// Installs a single script (not an entrypoint). /// -/// Has to deal with both binaries files (just move) and scripts (rewrite the shebang if applicable) +/// Has to deal with both binaries files (just move) and scripts (rewrite the shebang if applicable). fn install_script( layout: &Layout, + relocatable: bool, site_packages: &Path, record: &mut [RecordEntry], file: &DirEntry, @@ -494,7 +505,19 @@ 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; 1]; + 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); + let executable = get_relocatable_executable(executable, layout, relocatable)?; + let start = format_shebang(&executable, &layout.os_name, relocatable) .as_bytes() .to_vec(); @@ -555,6 +578,7 @@ fn install_script( #[instrument(skip_all)] pub(crate) fn install_data( layout: &Layout, + relocatable: bool, site_packages: &Path, data_dir: &Path, dist_name: &PackageName, @@ -598,7 +622,7 @@ pub(crate) fn install_data( initialized = true; } - install_script(layout, site_packages, record, &file)?; + install_script(layout, relocatable, site_packages, record, &file)?; } } Some("headers") => { @@ -682,6 +706,31 @@ pub(crate) fn extra_dist_info( Ok(()) } +/// Get the path to the Python executable for the [`Layout`], based on whether the wheel should +/// be relocatable. +/// +/// Returns `sys.executable` if the wheel is not relocatable; otherwise, returns a path relative +/// to the scripts directory. +pub(crate) fn get_relocatable_executable( + executable: PathBuf, + layout: &Layout, + relocatable: bool, +) -> Result { + Ok(if relocatable { + pathdiff::diff_paths(&executable, &layout.scheme.scripts).ok_or_else(|| { + Error::Io(io::Error::new( + io::ErrorKind::Other, + format!( + "Could not find relative path for: {}", + executable.simplified_display() + ), + )) + })? + } else { + executable + }) +} + /// Reads the record file /// pub fn read_record_file(record: &mut impl Read) -> Result, Error> { @@ -845,33 +894,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] 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 5ed95ece65b2..2aae67a06aea 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1743,6 +1743,22 @@ 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 without + /// invalidating its associated entrypoint and activation scripts. + /// + /// 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. + /// + /// As a result of making the environment relocatable (by way of writing relative, rather than + /// absolute paths), the entrypoints and scripts themselves will _not_ be relocatable. In other + /// words, copying those entrypoints and scripts to a location outside the environment will not + /// work, as they reference paths relative to the environment itself. + #[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 1e3b7a02a559..fb67dbe20113 100644 --- a/crates/uv-installer/src/installer.rs +++ b/crates/uv-installer/src/installer.rs @@ -85,8 +85,16 @@ impl<'a> Installer<'a> { let (tx, rx) = oneshot::channel(); let layout = venv.interpreter().layout(); + let relocatable = venv.relocatable(); rayon::spawn(move || { - let result = install(wheels, layout, installer_name, link_mode, reporter); + let result = install( + wheels, + layout, + installer_name, + link_mode, + reporter, + relocatable, + ); tx.send(result).unwrap(); }); @@ -112,6 +120,7 @@ impl<'a> Installer<'a> { self.installer_name, self.link_mode, self.reporter, + self.venv.relocatable(), ) } } @@ -124,11 +133,13 @@ fn install( installer_name: Option, link_mode: LinkMode, reporter: Option>, + relocatable: bool, ) -> Result> { let locks = install_wheel_rs::linker::Locks::default(); wheels.par_iter().try_for_each(|wheel| { install_wheel_rs::linker::install_wheel( &layout, + relocatable, wheel.path(), wheel.filename(), wheel diff --git a/crates/uv-python/src/environment.rs b/crates/uv-python/src/environment.rs index a7fa009e8494..3449fc5e7532 100644 --- a/crates/uv-python/src/environment.rs +++ b/crates/uv-python/src/environment.rs @@ -162,6 +162,11 @@ impl PythonEnvironment { Ok(PyVenvConfiguration::parse(self.0.root.join("pyvenv.cfg"))?) } + /// Returns `true` if the environment is "relocatable". + pub fn relocatable(&self) -> bool { + self.cfg().is_ok_and(|cfg| cfg.is_relocatable()) + } + /// Returns the location of the Python executable. pub fn python_executable(&self) -> &Path { self.0.interpreter.sys_executable() 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 5f336523204d..bd1e0eb1d524 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -593,6 +593,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 6198f1e0c6e8..4861af55fcba 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1407,6 +1407,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, } @@ -1422,6 +1423,7 @@ impl VenvSettings { name, prompt, system_site_packages, + relocatable, index_args, index_strategy, keyring_provider, @@ -1436,6 +1438,7 @@ impl VenvSettings { name, prompt, system_site_packages, + relocatable, settings: PipSettings::combine( PipOptions { python, diff --git a/crates/uv/tests/pip_install.rs b/crates/uv/tests/pip_install.rs index 6fd35d3fb8d4..d7f424f6a5a8 100644 --- a/crates/uv/tests/pip_install.rs +++ b/crates/uv/tests/pip_install.rs @@ -6,8 +6,10 @@ use anyhow::Result; use assert_cmd::prelude::*; use assert_fs::prelude::*; use base64::{prelude::BASE64_STANDARD as base64, Engine}; +use fs_err as fs; use indoc::indoc; use itertools::Itertools; +use predicates::prelude::predicate; use url::Url; use common::{uv_snapshot, TestContext}; @@ -6188,3 +6190,57 @@ fn unmanaged() -> Result<()> { Ok(()) } + +#[test] +fn install_relocatable() -> Result<()> { + let context = TestContext::new("3.12"); + + // Remake the venv as relocatable + context + .venv() + .arg(context.venv.as_os_str()) + .arg("--python") + .arg("3.12") + .arg("--relocatable") + .assert() + .success(); + + // Install a package with a hello-world console script entrypoint. + // (we use black_editable because it's convenient, but we don't actually install it as editable) + context + .pip_install() + .arg( + context + .workspace_root + .join("scripts/packages/black_editable"), + ) + .assert() + .success(); + + // Script should run correctly in-situ. + let script_path = if cfg!(windows) { + context.venv.child(r"Scripts\black.exe") + } else { + context.venv.child("bin/black") + }; + Command::new(script_path.as_os_str()) + .assert() + .success() + .stdout(predicate::str::contains("Hello world!")); + + // Relocate the venv, and see if it still works. + let new_venv_path = context.venv.with_file_name("relocated"); + fs::rename(context.venv, new_venv_path.clone())?; + + let script_path = if cfg!(windows) { + new_venv_path.join(r"Scripts\black.exe") + } else { + new_venv_path.join("bin/black") + }; + Command::new(script_path.as_os_str()) + .assert() + .success() + .stdout(predicate::str::contains("Hello world!")); + + Ok(()) +} 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.