uv/crates/uv-python/src/virtualenv.rs
Pavel Dikov cb47aed9de
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`
2024-07-29 00:10:11 +00:00

187 lines
5.9 KiB
Rust

use std::{
env, io,
path::{Path, PathBuf},
};
use fs_err as fs;
use pypi_types::Scheme;
use thiserror::Error;
/// The layout of a virtual environment.
#[derive(Debug)]
pub struct VirtualEnvironment {
/// The absolute path to the root of the virtualenv, e.g., `/path/to/.venv`.
pub root: PathBuf,
/// The path to the Python interpreter inside the virtualenv, e.g., `.venv/bin/python`
/// (Unix, Python 3.11).
pub executable: PathBuf,
/// The [`Scheme`] paths for the virtualenv, as returned by (e.g.) `sysconfig.get_paths()`.
pub scheme: Scheme,
}
/// A parsed `pyvenv.cfg`
#[derive(Debug, Clone)]
pub struct PyVenvConfiguration {
/// If the virtualenv package was used to create the virtual environment.
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)]
pub enum Error {
#[error(transparent)]
Io(#[from] io::Error),
#[error("Broken virtualenv `{0}`: `pyvenv.cfg` is missing")]
MissingPyVenvCfg(PathBuf),
#[error("Broken virtualenv `{0}`: `pyvenv.cfg` could not be parsed")]
ParsePyVenvCfg(PathBuf, #[source] io::Error),
}
/// Locate an active virtual environment by inspecting environment variables.
///
/// Supports `VIRTUAL_ENV`.
pub(crate) fn virtualenv_from_env() -> Option<PathBuf> {
if let Some(dir) = env::var_os("VIRTUAL_ENV").filter(|value| !value.is_empty()) {
return Some(PathBuf::from(dir));
}
None
}
/// Locate an active conda environment by inspecting environment variables.
///
/// Supports `CONDA_PREFIX`.
pub(crate) fn conda_prefix_from_env() -> Option<PathBuf> {
if let Some(dir) = env::var_os("CONDA_PREFIX").filter(|value| !value.is_empty()) {
return Some(PathBuf::from(dir));
}
None
}
/// Locate a virtual environment by searching the file system.
///
/// Searches for a `.venv` directory in the current or any parent directory. If the current
/// directory is itself a virtual environment (or a subdirectory of a virtual environment), the
/// containing virtual environment is returned.
pub(crate) fn virtualenv_from_working_dir() -> Result<Option<PathBuf>, Error> {
let current_dir = crate::current_dir()?;
for dir in current_dir.ancestors() {
// If we're _within_ a virtualenv, return it.
if dir.join("pyvenv.cfg").is_file() {
return Ok(Some(dir.to_path_buf()));
}
// Otherwise, search for a `.venv` directory.
let dot_venv = dir.join(".venv");
if dot_venv.is_dir() {
if !dot_venv.join("pyvenv.cfg").is_file() {
return Err(Error::MissingPyVenvCfg(dot_venv));
}
return Ok(Some(dot_venv));
}
}
Ok(None)
}
/// Returns the path to the `python` executable inside a virtual environment.
pub(crate) fn virtualenv_python_executable(venv: impl AsRef<Path>) -> PathBuf {
let venv = venv.as_ref();
if cfg!(windows) {
// Search for `python.exe` in the `Scripts` directory.
let default_executable = venv.join("Scripts").join("python.exe");
if default_executable.exists() {
return default_executable;
}
// Apparently, Python installed via msys2 on Windows _might_ produce a POSIX-like layout.
// See: https://github.com/PyO3/maturin/issues/1108
let executable = venv.join("bin").join("python.exe");
if executable.exists() {
return executable;
}
// Fallback for Conda environments.
let executable = venv.join("python.exe");
if executable.exists() {
return executable;
}
// If none of these exist, return the standard location
default_executable
} else {
// Check for both `python3` over `python`, preferring the more specific one
let default_executable = venv.join("bin").join("python3");
if default_executable.exists() {
return default_executable;
}
let executable = venv.join("bin").join("python");
if executable.exists() {
return executable;
}
// If none of these exist, return the standard location
default_executable
}
}
impl PyVenvConfiguration {
/// Parse a `pyvenv.cfg` file into a [`PyVenvConfiguration`].
pub fn parse(cfg: impl AsRef<Path>) -> Result<Self, Error> {
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
// first equals sign.
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 {
continue;
};
match key.trim() {
"virtualenv" => {
virtualenv = true;
}
"uv" => {
uv = true;
}
"relocatable" => {
relocatable = value.trim().to_lowercase() == "true";
}
_ => {}
}
}
Ok(Self {
virtualenv,
uv,
relocatable,
})
}
/// Returns true if the virtual environment was created with the `virtualenv` package.
pub fn is_virtualenv(&self) -> bool {
self.virtualenv
}
/// Returns true if the virtual environment was created with the uv package.
pub fn is_uv(&self) -> bool {
self.uv
}
/// Returns true if the virtual environment is relocatable.
pub fn is_relocatable(&self) -> bool {
self.relocatable
}
}