Add a --python flag to allow installation into arbitrary Python interpreters (#2000)

## Summary

This PR adds a `--python` flag that allows users to provide a specific
Python interpreter into which `uv` should install packages. This would
replace the `VIRTUAL_ENV=` workaround that folks have been using to
install into arbitrary, system environments, while _also_ actually being
correct for installing into non-virtual environments, where the bin and
site-packages paths can differ.

The approach taken here is to use `sysconfig.get_paths()` to get the
correct paths from the interpreter, and then use those for determining
the `bin` and `site-packages` directories, rather than constructing them
based on hard-coded expectations for each platform.

Closes https://github.com/astral-sh/uv/issues/1396.

Closes https://github.com/astral-sh/uv/issues/1779.

Closes https://github.com/astral-sh/uv/issues/1988.

## Test Plan

- Verified that, on my Windows machine, I was able to install `requests`
into a global environment with: `cargo run pip install requests --python
'C:\\Users\\crmarsh\\AppData\\Local\\Programs\\Python\\Python3.12\\python.exe`,
then `python` and `import requests`.
- Verified that, on macOS, I was able to install `requests` into a
global environment installed via Homebrew with: `cargo run pip install
requests --python $(which python3.8)`.
This commit is contained in:
Charlie Marsh 2024-02-27 21:10:29 -05:00 committed by GitHub
parent 72a5ebada3
commit 10175143d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 450 additions and 238 deletions

View file

@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize};
use tracing::{debug, instrument, warn};
use cache_key::digest;
use install_wheel_rs::Layout;
use pep440_rs::Version;
use pep508_rs::MarkerEnvironment;
use platform_host::Platform;
@ -24,18 +25,19 @@ use crate::{find_requested_python, Error, PythonVersion};
/// A Python executable and its associated platform markers.
#[derive(Debug, Clone)]
pub struct Interpreter {
pub(crate) platform: PythonPlatform,
pub(crate) markers: Box<MarkerEnvironment>,
pub(crate) base_exec_prefix: PathBuf,
pub(crate) base_prefix: PathBuf,
pub(crate) stdlib: PathBuf,
pub(crate) sys_executable: PathBuf,
platform: PythonPlatform,
markers: Box<MarkerEnvironment>,
sysconfig_paths: SysconfigPaths,
prefix: PathBuf,
base_exec_prefix: PathBuf,
base_prefix: PathBuf,
sys_executable: PathBuf,
tags: OnceCell<Tags>,
}
impl Interpreter {
/// Detect the interpreter info for the given Python executable.
pub fn query(executable: &Path, platform: &Platform, cache: &Cache) -> Result<Self, Error> {
pub fn query(executable: &Path, platform: Platform, cache: &Cache) -> Result<Self, Error> {
let info = InterpreterInfo::query_cached(executable, cache)?;
debug_assert!(
@ -45,41 +47,65 @@ impl Interpreter {
);
Ok(Self {
platform: PythonPlatform(platform.to_owned()),
platform: PythonPlatform(platform),
markers: Box::new(info.markers),
sysconfig_paths: info.sysconfig_paths,
prefix: info.prefix,
base_exec_prefix: info.base_exec_prefix,
base_prefix: info.base_prefix,
stdlib: info.stdlib,
sys_executable: info.sys_executable,
tags: OnceCell::new(),
})
}
// TODO(konstin): Find a better way mocking the fields
pub fn artificial(
platform: Platform,
markers: MarkerEnvironment,
base_exec_prefix: PathBuf,
base_prefix: PathBuf,
sys_executable: PathBuf,
stdlib: PathBuf,
) -> Self {
pub fn artificial(platform: Platform, markers: MarkerEnvironment) -> Self {
Self {
platform: PythonPlatform(platform),
markers: Box::new(markers),
base_exec_prefix,
base_prefix,
stdlib,
sys_executable,
sysconfig_paths: SysconfigPaths {
stdlib: PathBuf::from("/dev/null"),
platstdlib: PathBuf::from("/dev/null"),
purelib: PathBuf::from("/dev/null"),
platlib: PathBuf::from("/dev/null"),
include: PathBuf::from("/dev/null"),
platinclude: PathBuf::from("/dev/null"),
scripts: PathBuf::from("/dev/null"),
data: PathBuf::from("/dev/null"),
},
prefix: PathBuf::from("/dev/null"),
base_exec_prefix: PathBuf::from("/dev/null"),
base_prefix: PathBuf::from("/dev/null"),
sys_executable: PathBuf::from("/dev/null"),
tags: OnceCell::new(),
}
}
/// Return a new [`Interpreter`] with the given base prefix.
/// Return a new [`Interpreter`] with the given virtual environment root.
#[must_use]
pub(crate) fn with_base_prefix(self, base_prefix: PathBuf) -> Self {
pub(crate) fn with_venv_root(self, venv_root: PathBuf) -> Self {
Self {
base_prefix,
// Given that we know `venv_root` is a virtualenv, and not an arbitrary Python
// interpreter, we can safely assume that the platform is the same as the host
// platform. Further, we can safely assume that the paths follow a predictable
// structure, which allows us to avoid querying the interpreter for the `sysconfig`
// paths.
sysconfig_paths: SysconfigPaths {
purelib: self
.platform
.venv_site_packages(&venv_root, self.python_tuple()),
platlib: self
.platform
.venv_site_packages(&venv_root, self.python_tuple()),
platstdlib: self
.platform
.venv_platstdlib_dir(&venv_root, self.python_tuple()),
scripts: self.platform.venv_scripts_dir(&venv_root),
data: self.platform.venv_data_dir(&venv_root),
..self.sysconfig_paths
},
sys_executable: self.platform.venv_python(&venv_root),
prefix: venv_root,
..self
}
}
@ -168,7 +194,7 @@ impl Interpreter {
let python_platform = PythonPlatform::from(platform.to_owned());
if let Some(venv) = detect_virtual_env(&python_platform)? {
let executable = python_platform.venv_python(venv);
let interpreter = Self::query(&executable, &python_platform.0, cache)?;
let interpreter = Self::query(&executable, python_platform.0, cache)?;
if version_matches(&interpreter) {
return Ok(Some(interpreter));
@ -234,6 +260,29 @@ impl Interpreter {
})
}
/// Returns `true` if the environment is a PEP 405-compliant virtual environment.
///
/// See: <https://github.com/pypa/pip/blob/0ad4c94be74cc24874c6feb5bb3c2152c398a18e/src/pip/_internal/utils/virtualenv.py#L14>
pub fn is_virtualenv(&self) -> bool {
self.prefix != self.base_prefix
}
/// Returns `true` if the environment is externally managed.
///
/// See: <https://packaging.python.org/en/latest/specifications/externally-managed-environments/>
pub fn externally_managed(&self) -> bool {
// Per the spec, a virtual environment is never externally managed.
if self.is_virtualenv() {
return false;
}
// Per the spec, the existence of the file is the only requirement.
self.sysconfig_paths
.stdlib
.join("EXTERNALLY-MANAGED")
.is_file()
}
/// Returns the Python version.
#[inline]
pub const fn python_version(&self) -> &Version {
@ -280,32 +329,103 @@ impl Interpreter {
(self.implementation_major(), self.implementation_minor())
}
/// Returns the implementation name (e.g., `CPython` or `PyPy`).
pub fn implementation_name(&self) -> &str {
&self.markers.implementation_name
}
/// Return the `sys.base_exec_prefix` path for this Python interpreter.
pub fn base_exec_prefix(&self) -> &Path {
&self.base_exec_prefix
}
/// Return the `sys.base_prefix` path for this Python interpreter.
pub fn base_prefix(&self) -> &Path {
&self.base_prefix
}
/// `sysconfig.get_path("stdlib")`
pub fn stdlib(&self) -> &Path {
&self.stdlib
}
/// Return the `sys.executable` path for this Python interpreter.
pub fn sys_executable(&self) -> &Path {
&self.sys_executable
}
/// Return the `purelib` path for this Python interpreter, as returned by `sysconfig.get_paths()`.
pub fn purelib(&self) -> &Path {
&self.sysconfig_paths.purelib
}
/// Return the `platlib` path for this Python interpreter, as returned by `sysconfig.get_paths()`.
pub fn platlib(&self) -> &Path {
&self.sysconfig_paths.platlib
}
/// Return the `scripts` path for this Python interpreter, as returned by `sysconfig.get_paths()`.
pub fn scripts(&self) -> &Path {
&self.sysconfig_paths.scripts
}
/// Return the `data` path for this Python interpreter, as returned by `sysconfig.get_paths()`.
pub fn data(&self) -> &Path {
&self.sysconfig_paths.data
}
/// Return the `include` path for this Python interpreter, as returned by `sysconfig.get_paths()`.
pub fn include(&self) -> &Path {
&self.sysconfig_paths.include
}
/// Return the `stdlib` path for this Python interpreter, as returned by `sysconfig.get_paths()`.
pub fn stdlib(&self) -> &Path {
&self.sysconfig_paths.stdlib
}
/// Return the [`Layout`] environment used to install wheels into this interpreter.
pub fn layout(&self) -> Layout {
Layout {
python_version: self.python_tuple(),
sys_executable: self.sys_executable().to_path_buf(),
purelib: self.purelib().to_path_buf(),
platlib: self.platlib().to_path_buf(),
scripts: self.scripts().to_path_buf(),
data: self.data().to_path_buf(),
include: if self.is_virtualenv() {
// If the interpreter is a venv, then the `include` directory has a different structure.
// See: https://github.com/pypa/pip/blob/0ad4c94be74cc24874c6feb5bb3c2152c398a18e/src/pip/_internal/locations/_sysconfig.py#L172
self.prefix.join("include").join("site").join(format!(
"python{}.{}",
self.python_major(),
self.python_minor()
))
} else {
self.include().to_path_buf()
},
}
}
}
/// The installation paths returned by `sysconfig.get_paths()`.
///
/// See: <https://docs.python.org/3.12/library/sysconfig.html#installation-paths>
#[derive(Debug, Deserialize, Serialize, Clone)]
struct SysconfigPaths {
stdlib: PathBuf,
platstdlib: PathBuf,
purelib: PathBuf,
platlib: PathBuf,
include: PathBuf,
platinclude: PathBuf,
scripts: PathBuf,
data: PathBuf,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub(crate) struct InterpreterInfo {
pub(crate) markers: MarkerEnvironment,
pub(crate) base_exec_prefix: PathBuf,
pub(crate) base_prefix: PathBuf,
pub(crate) stdlib: PathBuf,
pub(crate) sys_executable: PathBuf,
struct InterpreterInfo {
markers: MarkerEnvironment,
sysconfig_paths: SysconfigPaths,
prefix: PathBuf,
base_exec_prefix: PathBuf,
base_prefix: PathBuf,
sys_executable: PathBuf,
}
impl InterpreterInfo {
@ -499,8 +619,18 @@ mod tests {
},
"base_exec_prefix": "/home/ferris/.pyenv/versions/3.12.0",
"base_prefix": "/home/ferris/.pyenv/versions/3.12.0",
"stdlib": "/usr/lib/python3.12",
"sys_executable": "/home/ferris/projects/uv/.venv/bin/python"
"prefix": "/home/ferris/projects/uv/.venv",
"sys_executable": "/home/ferris/projects/uv/.venv/bin/python",
"sysconfig_paths": {
"data": "/home/ferris/.pyenv/versions/3.12.0",
"include": "/home/ferris/.pyenv/versions/3.12.0/include",
"platinclude": "/home/ferris/.pyenv/versions/3.12.0/include",
"platlib": "/home/ferris/.pyenv/versions/3.12.0/lib/python3.12/site-packages",
"purelib": "/home/ferris/.pyenv/versions/3.12.0/lib/python3.12/site-packages",
"scripts": "/home/ferris/.pyenv/versions/3.12.0/bin",
"stdlib": "/home/ferris/.pyenv/versions/3.12.0/lib/python3.12",
"platstdlib": "/home/ferris/.pyenv/versions/3.12.0/lib/python3.12"
}
}
"##};
@ -520,7 +650,8 @@ mod tests {
std::os::unix::fs::PermissionsExt::from_mode(0o770),
)
.unwrap();
let interpreter = Interpreter::query(&mocked_interpreter, &platform, &cache).unwrap();
let interpreter =
Interpreter::query(&mocked_interpreter, platform.clone(), &cache).unwrap();
assert_eq!(
interpreter.markers.python_version.version,
Version::from_str("3.12").unwrap()
@ -533,7 +664,8 @@ mod tests {
"##, json.replace("3.12", "3.13")},
)
.unwrap();
let interpreter = Interpreter::query(&mocked_interpreter, &platform, &cache).unwrap();
let interpreter =
Interpreter::query(&mocked_interpreter, platform.clone(), &cache).unwrap();
assert_eq!(
interpreter.markers.python_version.version,
Version::from_str("3.13").unwrap()