mirror of https://github.com/astral-sh/uv
feat(venv): add relocatable flag (#5515)
## Summary 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 ## Test Plan * Relocatable console scripts covered as additional scenarios in existing test cases. * Integration testing of boilerplate generation in `venv`. * Manual testing of `uv venv` with and without `--relocatable`
This commit is contained in:
parent
3626d08cca
commit
cb47aed9de
|
|
@ -1,7 +1,6 @@
|
||||||
//! Takes a wheel and installs it into a venv.
|
//! Takes a wheel and installs it into a venv.
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use platform_info::PlatformInfoError;
|
use platform_info::PlatformInfoError;
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ pub struct Locks(Mutex<FxHashMap<PathBuf, Arc<Mutex<()>>>>);
|
||||||
#[instrument(skip_all, fields(wheel = %filename))]
|
#[instrument(skip_all, fields(wheel = %filename))]
|
||||||
pub fn install_wheel(
|
pub fn install_wheel(
|
||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
|
relocatable: bool,
|
||||||
wheel: impl AsRef<Path>,
|
wheel: impl AsRef<Path>,
|
||||||
filename: &WheelFilename,
|
filename: &WheelFilename,
|
||||||
direct_url: Option<&DirectUrl>,
|
direct_url: Option<&DirectUrl>,
|
||||||
|
|
@ -97,8 +98,22 @@ pub fn install_wheel(
|
||||||
debug!(?name, "Writing entrypoints");
|
debug!(?name, "Writing entrypoints");
|
||||||
|
|
||||||
fs_err::create_dir_all(&layout.scheme.scripts)?;
|
fs_err::create_dir_all(&layout.scheme.scripts)?;
|
||||||
write_script_entrypoints(layout, site_packages, &console_scripts, &mut record, false)?;
|
write_script_entrypoints(
|
||||||
write_script_entrypoints(layout, site_packages, &gui_scripts, &mut record, true)?;
|
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/.
|
// 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");
|
debug!(?name, "Installing data");
|
||||||
install_data(
|
install_data(
|
||||||
layout,
|
layout,
|
||||||
|
relocatable,
|
||||||
site_packages,
|
site_packages,
|
||||||
&data_dir,
|
&data_dir,
|
||||||
&name,
|
&name,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use std::collections::HashMap;
|
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::path::{Path, PathBuf};
|
||||||
use std::{env, io};
|
use std::{env, io};
|
||||||
|
|
||||||
|
|
@ -128,7 +128,7 @@ fn copy_and_hash(reader: &mut impl Read, writer: &mut impl Write) -> io::Result<
|
||||||
/// executable.
|
/// executable.
|
||||||
///
|
///
|
||||||
/// See: <https://github.com/pypa/pip/blob/0ad4c94be74cc24874c6feb5bb3c2152c398a18e/src/pip/_vendor/distlib/scripts.py#L136-L165>
|
/// See: <https://github.com/pypa/pip/blob/0ad4c94be74cc24874c6feb5bb3c2152c398a18e/src/pip/_vendor/distlib/scripts.py#L136-L165>
|
||||||
fn format_shebang(executable: impl AsRef<Path>, os_name: &str) -> String {
|
fn format_shebang(executable: impl AsRef<Path>, os_name: &str, relocatable: bool) -> String {
|
||||||
// Convert the executable to a simplified path.
|
// Convert the executable to a simplified path.
|
||||||
let executable = executable.as_ref().simplified_display().to_string();
|
let executable = executable.as_ref().simplified_display().to_string();
|
||||||
|
|
||||||
|
|
@ -139,11 +139,18 @@ fn format_shebang(executable: impl AsRef<Path>, os_name: &str) -> String {
|
||||||
let shebang_length = 2 + executable.len() + 1;
|
let shebang_length = 2 + executable.len() + 1;
|
||||||
|
|
||||||
// If the shebang is too long, or contains spaces, wrap it in `/bin/sh`.
|
// 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`:
|
// Like Python's `shlex.quote`:
|
||||||
// > Use single quotes, and put single quotes into double quotes
|
// > Use single quotes, and put single quotes into double quotes
|
||||||
// > The string $'b is then quoted as '$'"'"'b'
|
// > 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' '''");
|
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.
|
/// Create the wrapper scripts in the bin folder of the venv for launching console scripts.
|
||||||
pub(crate) fn write_script_entrypoints(
|
pub(crate) fn write_script_entrypoints(
|
||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
|
relocatable: bool,
|
||||||
site_packages: &Path,
|
site_packages: &Path,
|
||||||
entrypoints: &[Script],
|
entrypoints: &[Script],
|
||||||
record: &mut Vec<RecordEntry>,
|
record: &mut Vec<RecordEntry>,
|
||||||
|
|
@ -293,9 +301,11 @@ pub(crate) fn write_script_entrypoints(
|
||||||
|
|
||||||
// Generate the launcher script.
|
// 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);
|
||||||
|
let launcher_executable =
|
||||||
|
get_relocatable_executable(launcher_executable, layout, relocatable)?;
|
||||||
let launcher_python_script = get_script_launcher(
|
let launcher_python_script = get_script_launcher(
|
||||||
entrypoint,
|
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.
|
// If necessary, wrap the launcher script in a Windows launcher binary.
|
||||||
|
|
@ -432,11 +442,12 @@ pub(crate) fn move_folder_recorded(
|
||||||
Ok(())
|
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(
|
fn install_script(
|
||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
|
relocatable: bool,
|
||||||
site_packages: &Path,
|
site_packages: &Path,
|
||||||
record: &mut [RecordEntry],
|
record: &mut [RecordEntry],
|
||||||
file: &DirEntry,
|
file: &DirEntry,
|
||||||
|
|
@ -494,7 +505,19 @@ fn install_script(
|
||||||
let mut start = vec![0; placeholder_python.len()];
|
let mut start = vec![0; placeholder_python.len()];
|
||||||
script.read_exact(&mut start)?;
|
script.read_exact(&mut start)?;
|
||||||
let size_and_encoded_hash = if start == placeholder_python {
|
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()
|
.as_bytes()
|
||||||
.to_vec();
|
.to_vec();
|
||||||
|
|
||||||
|
|
@ -555,6 +578,7 @@ fn install_script(
|
||||||
#[instrument(skip_all)]
|
#[instrument(skip_all)]
|
||||||
pub(crate) fn install_data(
|
pub(crate) fn install_data(
|
||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
|
relocatable: bool,
|
||||||
site_packages: &Path,
|
site_packages: &Path,
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
dist_name: &PackageName,
|
dist_name: &PackageName,
|
||||||
|
|
@ -598,7 +622,7 @@ pub(crate) fn install_data(
|
||||||
initialized = true;
|
initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
install_script(layout, site_packages, record, &file)?;
|
install_script(layout, relocatable, site_packages, record, &file)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("headers") => {
|
Some("headers") => {
|
||||||
|
|
@ -682,6 +706,31 @@ pub(crate) fn extra_dist_info(
|
||||||
Ok(())
|
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<PathBuf, Error> {
|
||||||
|
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
|
/// Reads the record file
|
||||||
/// <https://www.python.org/dev/peps/pep-0376/#record>
|
/// <https://www.python.org/dev/peps/pep-0376/#record>
|
||||||
pub fn read_record_file(record: &mut impl Read) -> Result<Vec<RecordEntry>, Error> {
|
pub fn read_record_file(record: &mut impl Read) -> Result<Vec<RecordEntry>, Error> {
|
||||||
|
|
@ -845,33 +894,47 @@ mod test {
|
||||||
// By default, use a simple shebang.
|
// By default, use a simple shebang.
|
||||||
let executable = Path::new("/usr/bin/python3");
|
let executable = Path::new("/usr/bin/python3");
|
||||||
let os_name = "posix";
|
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.
|
// If the path contains spaces, we should use the `exec` trick.
|
||||||
let executable = Path::new("/usr/bin/path to python3");
|
let executable = Path::new("/usr/bin/path to python3");
|
||||||
let os_name = "posix";
|
let os_name = "posix";
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_shebang(executable, os_name),
|
format_shebang(executable, os_name, false),
|
||||||
"#!/bin/sh\n'''exec' '/usr/bin/path to python3' \"$0\" \"$@\"\n' '''"
|
"#!/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...
|
// Except on Windows...
|
||||||
let executable = Path::new("/usr/bin/path to python3");
|
let executable = Path::new("/usr/bin/path to python3");
|
||||||
let os_name = "nt";
|
let os_name = "nt";
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_shebang(executable, os_name),
|
format_shebang(executable, os_name, false),
|
||||||
"#!/usr/bin/path to python3"
|
"#!/usr/bin/path to python3"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Quotes, however, are ok.
|
// Quotes, however, are ok.
|
||||||
let executable = Path::new("/usr/bin/'python3'");
|
let executable = Path::new("/usr/bin/'python3'");
|
||||||
let os_name = "posix";
|
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.
|
// 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 executable = Path::new("/usr/bin/path/to/a/very/long/executable/executable/executable/executable/executable/executable/executable/executable/name/python3");
|
||||||
let os_name = "posix";
|
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]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -442,6 +442,7 @@ impl SourceBuild {
|
||||||
uv_virtualenv::Prompt::None,
|
uv_virtualenv::Prompt::None,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
)?,
|
)?,
|
||||||
BuildIsolation::Shared(venv) => venv.clone(),
|
BuildIsolation::Shared(venv) => venv.clone(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1743,6 +1743,22 @@ pub struct VenvArgs {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub system_site_packages: bool,
|
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)]
|
#[command(flatten)]
|
||||||
pub index_args: IndexArgs,
|
pub index_args: IndexArgs,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,8 +85,16 @@ impl<'a> Installer<'a> {
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
|
|
||||||
let layout = venv.interpreter().layout();
|
let layout = venv.interpreter().layout();
|
||||||
|
let relocatable = venv.relocatable();
|
||||||
rayon::spawn(move || {
|
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();
|
tx.send(result).unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -112,6 +120,7 @@ impl<'a> Installer<'a> {
|
||||||
self.installer_name,
|
self.installer_name,
|
||||||
self.link_mode,
|
self.link_mode,
|
||||||
self.reporter,
|
self.reporter,
|
||||||
|
self.venv.relocatable(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -124,11 +133,13 @@ fn install(
|
||||||
installer_name: Option<String>,
|
installer_name: Option<String>,
|
||||||
link_mode: LinkMode,
|
link_mode: LinkMode,
|
||||||
reporter: Option<Box<dyn Reporter>>,
|
reporter: Option<Box<dyn Reporter>>,
|
||||||
|
relocatable: bool,
|
||||||
) -> Result<Vec<CachedDist>> {
|
) -> Result<Vec<CachedDist>> {
|
||||||
let locks = install_wheel_rs::linker::Locks::default();
|
let locks = install_wheel_rs::linker::Locks::default();
|
||||||
wheels.par_iter().try_for_each(|wheel| {
|
wheels.par_iter().try_for_each(|wheel| {
|
||||||
install_wheel_rs::linker::install_wheel(
|
install_wheel_rs::linker::install_wheel(
|
||||||
&layout,
|
&layout,
|
||||||
|
relocatable,
|
||||||
wheel.path(),
|
wheel.path(),
|
||||||
wheel.filename(),
|
wheel.filename(),
|
||||||
wheel
|
wheel
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,11 @@ impl PythonEnvironment {
|
||||||
Ok(PyVenvConfiguration::parse(self.0.root.join("pyvenv.cfg"))?)
|
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.
|
/// Returns the location of the Python executable.
|
||||||
pub fn python_executable(&self) -> &Path {
|
pub fn python_executable(&self) -> &Path {
|
||||||
self.0.interpreter.sys_executable()
|
self.0.interpreter.sys_executable()
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ pub struct PyVenvConfiguration {
|
||||||
pub(crate) virtualenv: bool,
|
pub(crate) virtualenv: bool,
|
||||||
/// If the uv package was used to create the virtual environment.
|
/// If the uv package was used to create the virtual environment.
|
||||||
pub(crate) uv: bool,
|
pub(crate) uv: bool,
|
||||||
|
/// Is the virtual environment relocatable?
|
||||||
|
pub(crate) relocatable: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
|
|
@ -136,6 +138,7 @@ impl PyVenvConfiguration {
|
||||||
pub fn parse(cfg: impl AsRef<Path>) -> Result<Self, Error> {
|
pub fn parse(cfg: impl AsRef<Path>) -> Result<Self, Error> {
|
||||||
let mut virtualenv = false;
|
let mut virtualenv = false;
|
||||||
let mut uv = false;
|
let mut uv = false;
|
||||||
|
let mut relocatable = false;
|
||||||
|
|
||||||
// Per https://snarky.ca/how-virtual-environments-work/, the `pyvenv.cfg` file is not a
|
// 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
|
// 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)
|
let content = fs::read_to_string(&cfg)
|
||||||
.map_err(|err| Error::ParsePyVenvCfg(cfg.as_ref().to_path_buf(), err))?;
|
.map_err(|err| Error::ParsePyVenvCfg(cfg.as_ref().to_path_buf(), err))?;
|
||||||
for line in content.lines() {
|
for line in content.lines() {
|
||||||
let Some((key, _value)) = line.split_once('=') else {
|
let Some((key, value)) = line.split_once('=') else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
match key.trim() {
|
match key.trim() {
|
||||||
|
|
@ -153,11 +156,18 @@ impl PyVenvConfiguration {
|
||||||
"uv" => {
|
"uv" => {
|
||||||
uv = true;
|
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.
|
/// Returns true if the virtual environment was created with the `virtualenv` package.
|
||||||
|
|
@ -169,4 +179,9 @@ impl PyVenvConfiguration {
|
||||||
pub fn is_uv(&self) -> bool {
|
pub fn is_uv(&self) -> bool {
|
||||||
self.uv
|
self.uv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true if the virtual environment is relocatable.
|
||||||
|
pub fn is_relocatable(&self) -> bool {
|
||||||
|
self.relocatable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,7 @@ impl InstalledTools {
|
||||||
uv_virtualenv::Prompt::None,
|
uv_virtualenv::Prompt::None,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(venv)
|
Ok(venv)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
@REM OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
@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.
|
@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 }}"
|
@set "VIRTUAL_ENV_PROMPT={{ VIRTUAL_PROMPT }}"
|
||||||
@if NOT DEFINED VIRTUAL_ENV_PROMPT (
|
@if NOT DEFINED VIRTUAL_ENV_PROMPT (
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ pub fn create_venv(
|
||||||
prompt: Prompt,
|
prompt: Prompt,
|
||||||
system_site_packages: bool,
|
system_site_packages: bool,
|
||||||
allow_existing: bool,
|
allow_existing: bool,
|
||||||
|
relocatable: bool,
|
||||||
) -> Result<PythonEnvironment, Error> {
|
) -> Result<PythonEnvironment, Error> {
|
||||||
// Create the virtualenv at the given location.
|
// Create the virtualenv at the given location.
|
||||||
let virtualenv = virtualenv::create(
|
let virtualenv = virtualenv::create(
|
||||||
|
|
@ -60,6 +61,7 @@ pub fn create_venv(
|
||||||
prompt,
|
prompt,
|
||||||
system_site_packages,
|
system_site_packages,
|
||||||
allow_existing,
|
allow_existing,
|
||||||
|
relocatable,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Create the corresponding `PythonEnvironment`.
|
// Create the corresponding `PythonEnvironment`.
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ pub(crate) fn create(
|
||||||
prompt: Prompt,
|
prompt: Prompt,
|
||||||
system_site_packages: bool,
|
system_site_packages: bool,
|
||||||
allow_existing: bool,
|
allow_existing: bool,
|
||||||
|
relocatable: bool,
|
||||||
) -> Result<VirtualEnvironment, Error> {
|
) -> Result<VirtualEnvironment, Error> {
|
||||||
// Determine the base Python executable; that is, the Python executable that should be
|
// 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
|
// 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('\\', "\\\\"))
|
.map(|path| path.simplified().to_str().unwrap().replace('\\', "\\\\"))
|
||||||
.join(path_sep);
|
.join(path_sep);
|
||||||
|
|
||||||
let activator = template
|
let virtual_env_dir = match (relocatable, name.to_owned()) {
|
||||||
.replace(
|
(true, "activate") => {
|
||||||
"{{ VIRTUAL_ENV_DIR }}",
|
// 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`.
|
// 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("{{ BIN_NAME }}", bin_name)
|
||||||
.replace(
|
.replace(
|
||||||
"{{ VIRTUAL_PROMPT }}",
|
"{{ VIRTUAL_PROMPT }}",
|
||||||
|
|
@ -335,6 +351,14 @@ pub(crate) fn create(
|
||||||
"false".to_string()
|
"false".to_string()
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"relocatable".to_string(),
|
||||||
|
if relocatable {
|
||||||
|
"true".to_string()
|
||||||
|
} else {
|
||||||
|
"false".to_string()
|
||||||
|
},
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
if let Some(prompt) = prompt {
|
if let Some(prompt) = prompt {
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,7 @@ impl CachedEnvironment {
|
||||||
uv_virtualenv::Prompt::None,
|
uv_virtualenv::Prompt::None,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let venv = sync_environment(
|
let venv = sync_environment(
|
||||||
|
|
|
||||||
|
|
@ -313,6 +313,7 @@ pub(crate) async fn get_or_init_environment(
|
||||||
uv_virtualenv::Prompt::None,
|
uv_virtualenv::Prompt::None,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
)?)
|
)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,7 @@ pub(crate) async fn run(
|
||||||
uv_virtualenv::Prompt::None,
|
uv_virtualenv::Prompt::None,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
match spec {
|
match spec {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ use uv_python::{
|
||||||
use uv_resolver::{ExcludeNewer, FlatIndex};
|
use uv_resolver::{ExcludeNewer, FlatIndex};
|
||||||
use uv_shell::Shell;
|
use uv_shell::Shell;
|
||||||
use uv_types::{BuildContext, BuildIsolation, HashStrategy};
|
use uv_types::{BuildContext, BuildIsolation, HashStrategy};
|
||||||
|
use uv_warnings::warn_user_once;
|
||||||
|
|
||||||
use crate::commands::reporters::PythonDownloadReporter;
|
use crate::commands::reporters::PythonDownloadReporter;
|
||||||
use crate::commands::{pip, ExitStatus, SharedState};
|
use crate::commands::{pip, ExitStatus, SharedState};
|
||||||
|
|
@ -54,6 +55,7 @@ pub(crate) async fn venv(
|
||||||
preview: PreviewMode,
|
preview: PreviewMode,
|
||||||
cache: &Cache,
|
cache: &Cache,
|
||||||
printer: Printer,
|
printer: Printer,
|
||||||
|
relocatable: bool,
|
||||||
) -> Result<ExitStatus> {
|
) -> Result<ExitStatus> {
|
||||||
match venv_impl(
|
match venv_impl(
|
||||||
path,
|
path,
|
||||||
|
|
@ -74,6 +76,7 @@ pub(crate) async fn venv(
|
||||||
native_tls,
|
native_tls,
|
||||||
cache,
|
cache,
|
||||||
printer,
|
printer,
|
||||||
|
relocatable,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|
@ -125,6 +128,7 @@ async fn venv_impl(
|
||||||
native_tls: bool,
|
native_tls: bool,
|
||||||
cache: &Cache,
|
cache: &Cache,
|
||||||
printer: Printer,
|
printer: Printer,
|
||||||
|
relocatable: bool,
|
||||||
) -> miette::Result<ExitStatus> {
|
) -> miette::Result<ExitStatus> {
|
||||||
let client_builder = BaseClientBuilder::default()
|
let client_builder = BaseClientBuilder::default()
|
||||||
.connectivity(connectivity)
|
.connectivity(connectivity)
|
||||||
|
|
@ -138,6 +142,9 @@ async fn venv_impl(
|
||||||
if preview.is_enabled() && interpreter_request.is_none() {
|
if preview.is_enabled() && interpreter_request.is_none() {
|
||||||
interpreter_request = request_from_version_file().await.into_diagnostic()?;
|
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
|
// Locate the Python interpreter to use in the environment
|
||||||
let python = PythonInstallation::find_or_fetch(
|
let python = PythonInstallation::find_or_fetch(
|
||||||
|
|
@ -192,6 +199,7 @@ async fn venv_impl(
|
||||||
prompt,
|
prompt,
|
||||||
system_site_packages,
|
system_site_packages,
|
||||||
allow_existing,
|
allow_existing,
|
||||||
|
relocatable,
|
||||||
)
|
)
|
||||||
.map_err(VenvError::Creation)?;
|
.map_err(VenvError::Creation)?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -593,6 +593,7 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
|
||||||
globals.preview,
|
globals.preview,
|
||||||
&cache,
|
&cache,
|
||||||
printer,
|
printer,
|
||||||
|
args.relocatable,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1407,6 +1407,7 @@ pub(crate) struct VenvSettings {
|
||||||
pub(crate) name: PathBuf,
|
pub(crate) name: PathBuf,
|
||||||
pub(crate) prompt: Option<String>,
|
pub(crate) prompt: Option<String>,
|
||||||
pub(crate) system_site_packages: bool,
|
pub(crate) system_site_packages: bool,
|
||||||
|
pub(crate) relocatable: bool,
|
||||||
pub(crate) settings: PipSettings,
|
pub(crate) settings: PipSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1422,6 +1423,7 @@ impl VenvSettings {
|
||||||
name,
|
name,
|
||||||
prompt,
|
prompt,
|
||||||
system_site_packages,
|
system_site_packages,
|
||||||
|
relocatable,
|
||||||
index_args,
|
index_args,
|
||||||
index_strategy,
|
index_strategy,
|
||||||
keyring_provider,
|
keyring_provider,
|
||||||
|
|
@ -1436,6 +1438,7 @@ impl VenvSettings {
|
||||||
name,
|
name,
|
||||||
prompt,
|
prompt,
|
||||||
system_site_packages,
|
system_site_packages,
|
||||||
|
relocatable,
|
||||||
settings: PipSettings::combine(
|
settings: PipSettings::combine(
|
||||||
PipOptions {
|
PipOptions {
|
||||||
python,
|
python,
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@ use anyhow::Result;
|
||||||
use assert_cmd::prelude::*;
|
use assert_cmd::prelude::*;
|
||||||
use assert_fs::prelude::*;
|
use assert_fs::prelude::*;
|
||||||
use base64::{prelude::BASE64_STANDARD as base64, Engine};
|
use base64::{prelude::BASE64_STANDARD as base64, Engine};
|
||||||
|
use fs_err as fs;
|
||||||
use indoc::indoc;
|
use indoc::indoc;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
|
use predicates::prelude::predicate;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use common::{uv_snapshot, TestContext};
|
use common::{uv_snapshot, TestContext};
|
||||||
|
|
@ -6188,3 +6190,57 @@ fn unmanaged() -> Result<()> {
|
||||||
|
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -577,6 +577,55 @@ fn verify_pyvenv_cfg() {
|
||||||
let version = env!("CARGO_PKG_VERSION").to_string();
|
let version = env!("CARGO_PKG_VERSION").to_string();
|
||||||
let search_string = format!("uv = {version}");
|
let search_string = format!("uv = {version}");
|
||||||
pyvenv_cfg.assert(predicates::str::contains(search_string));
|
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.
|
/// Ensure that a nested virtual environment uses the same `home` directory as the parent.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue