Skip to content

Commit 6772cf8

Browse files
Preserve directory-level standalone build symlinks (#9723)
## Summary This PR improves our "don't fully resolve symlinks" behavior for `python-build-standalone` builds based on learnings from astral-sh/python-build-standalone#380 (comment). Specifically, we can now robustly detect whether a target executable will lead to a valid `prefix` or not, and iteratively resolve symlinks until we find a valid target executable. ## Test Plan ### Direct symlink to `python` Correctly resolves to the symlink target, rather than the symlink itself. ``` ❯ ln -s /Users/crmarsh/.local/share/uv/python/cpython-3.12.6-macos-aarch64-none/bin/python foo ❯ cargo run venv --python ./foo ❯ cat .venv/pyvenv.cfg home = /Users/crmarsh/.local/share/uv/python/cpython-3.12.6-macos-aarch64-none/bin implementation = CPython uv = 0.5.7 version_info = 3.12.6 include-system-site-packages = false prompt = uv ❯ .venv/bin/python -c "import sys" ``` ### Symlink to the Python installation Correctly does _not_ resolve the symlink. ``` ❯ ln -s /Users/crmarsh/.local/share/uv/python/cpython-3.12.6-macos-aarch64-none bar ❯ cargo run venv --python ./bar ❯ cat .venv/pyvenv.cfg home = /Users/crmarsh/workspace/uv/bar/bin implementation = CPython uv = 0.5.7 version_info = 3.12.6 include-system-site-packages = false prompt = uv ❯ .venv/bin/python -c "import sys" ``` ### Direct symlink to `python` in a symlinked Python installation Correctly resolves the direct symlink, but not the symlink of the Python installation. ``` ❯ ln -s bar/bin/python baz ❯ cargo run venv --python ./baz ❯ cat .venv/pyvenv.cfg home = /Users/crmarsh/workspace/uv/bar/bin implementation = CPython uv = 0.5.7 version_info = 3.12.6 include-system-site-packages = false prompt = uv ❯ .venv/bin/python -c "import sys" ```
1 parent f6f9179 commit 6772cf8

File tree

1 file changed

+109
-12
lines changed

1 file changed

+109
-12
lines changed

crates/uv-virtualenv/src/virtualenv.rs

+109-12
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
//! Create a virtual environment.
22
3+
use std::borrow::Cow;
34
use std::env::consts::EXE_SUFFIX;
45
use std::io;
56
use std::io::{BufWriter, Write};
6-
use std::path::Path;
7+
use std::path::{Path, PathBuf};
78

89
use fs_err as fs;
910
use fs_err::File;
1011
use itertools::Itertools;
11-
use tracing::debug;
12+
use tracing::{debug, warn};
1213

1314
use uv_fs::{cachedir, Simplified, CWD};
1415
use uv_pypi_types::Scheme;
@@ -58,22 +59,39 @@ pub(crate) fn create(
5859
// considered the "base" for the virtual environment. This is typically the Python executable
5960
// from the [`Interpreter`]; however, if the interpreter is a virtual environment itself, then
6061
// the base Python executable is the Python executable of the interpreter's base interpreter.
62+
let base_executable = interpreter
63+
.sys_base_executable()
64+
.unwrap_or(interpreter.sys_executable());
6165
let base_python = if cfg!(unix) && interpreter.is_standalone() {
6266
// In `python-build-standalone`, a symlinked interpreter will return its own executable path
63-
// as `sys._base_executable`. Using the symlinked path as the base Python executable is
64-
// incorrect, since it will cause `home` to point to something that is _not_ a Python
65-
// installation.
67+
// as `sys._base_executable`. Using the symlinked path as the base Python executable can be
68+
// incorrect, since it could cause `home` to point to something that is _not_ a Python
69+
// installation. Specifically, if the interpreter _itself_ is symlinked to an arbitrary
70+
// location, we need to fully resolve it to the actual Python executable; however, if the
71+
// entire standalone interpreter is symlinked, then we can use the symlinked path.
6672
//
67-
// Instead, we want to fully resolve the symlink to the actual Python executable.
68-
uv_fs::canonicalize_executable(interpreter.sys_executable())?
73+
// We emulate CPython's `getpath.py` to ensure that the base executable results in a valid
74+
// Python prefix when converted into the `home` key for `pyvenv.cfg`.
75+
match find_base_python(
76+
base_executable,
77+
interpreter.python_major(),
78+
interpreter.python_minor(),
79+
) {
80+
Ok(path) => path,
81+
Err(err) => {
82+
warn!("Failed to find base Python executable: {err}");
83+
uv_fs::canonicalize_executable(base_executable)?
84+
}
85+
}
6986
} else {
70-
std::path::absolute(
71-
interpreter
72-
.sys_base_executable()
73-
.unwrap_or(interpreter.sys_executable()),
74-
)?
87+
std::path::absolute(base_executable)?
7588
};
7689

90+
debug!(
91+
"Using base executable for virtual environment: {}",
92+
base_python.display()
93+
);
94+
7795
// Validate the existing location.
7896
match location.metadata() {
7997
Ok(metadata) => {
@@ -610,3 +628,82 @@ fn copy_launcher_windows(
610628

611629
Err(Error::NotFound(base_python.user_display().to_string()))
612630
}
631+
632+
/// Find the Python executable that should be considered the "base" for a virtual environment.
633+
///
634+
/// Assumes that the provided executable is that of a standalone Python interpreter.
635+
///
636+
/// The strategy here mimics that of `getpath.py`: we search up the ancestor path to determine
637+
/// whether a given executable will convert into a valid Python prefix; if not, we resolve the
638+
/// symlink and try again.
639+
///
640+
/// This ensures that:
641+
///
642+
/// 1. We avoid using symlinks to arbitrary locations as the base Python executable. For example,
643+
/// if a user symlinks a Python _executable_ to `/Users/user/foo`, we want to avoid using
644+
/// `/Users/user` as `home`, since it's not a Python installation, and so the relevant libraries
645+
/// and headers won't be found when it's used as the executable directory.
646+
/// See: <https://github.com/python/cpython/blob/a03efb533a58fd13fb0cc7f4a5c02c8406a407bd/Modules/getpath.py#L367-L400>
647+
///
648+
/// 2. We use the "first" resolved symlink that _is_ a valid Python prefix, and thereby preserve
649+
/// symlinks. For example, if a user symlinks a Python _installation_ to `/Users/user/foo`, such
650+
/// that `/Users/user/foo/bin/python` is the resulting executable, we want to use `/Users/user/foo`
651+
/// as `home`, rather than resolving to the symlink target. Concretely, this allows users to
652+
/// symlink patch versions (like `cpython-3.12.6-macos-aarch64-none`) to minor version aliases
653+
/// (like `cpython-3.12-macos-aarch64-none`) and preserve those aliases in the resulting virtual
654+
/// environments.
655+
///
656+
/// See: <https://github.com/python/cpython/blob/a03efb533a58fd13fb0cc7f4a5c02c8406a407bd/Modules/getpath.py#L591-L594>
657+
fn find_base_python(executable: &Path, major: u8, minor: u8) -> Result<PathBuf, io::Error> {
658+
/// Determining whether `dir` is a valid Python prefix by searching for a "landmark".
659+
///
660+
/// See: <https://github.com/python/cpython/blob/a03efb533a58fd13fb0cc7f4a5c02c8406a407bd/Modules/getpath.py#L183>
661+
fn is_prefix(dir: &Path, major: u8, minor: u8) -> bool {
662+
if cfg!(windows) {
663+
dir.join("Lib").join("os.py").is_file()
664+
} else {
665+
dir.join("lib")
666+
.join(format!("python{major}.{minor}"))
667+
.join("os.py")
668+
.is_file()
669+
}
670+
}
671+
672+
let mut executable = Cow::Borrowed(executable);
673+
674+
loop {
675+
debug!(
676+
"Assessing Python executable as base candidate: {}",
677+
executable.display()
678+
);
679+
680+
// Determine whether this executable will produce a valid `home` for a virtual environment.
681+
for prefix in executable.ancestors() {
682+
if is_prefix(prefix, major, minor) {
683+
return Ok(executable.into_owned());
684+
}
685+
}
686+
687+
// If not, resolve the symlink.
688+
let resolved = fs_err::read_link(&executable)?;
689+
690+
// If the symlink is relative, resolve it relative to the executable.
691+
let resolved = if resolved.is_relative() {
692+
if let Some(parent) = executable.parent() {
693+
parent.join(resolved)
694+
} else {
695+
return Err(io::Error::new(
696+
io::ErrorKind::Other,
697+
"Symlink has no parent directory",
698+
));
699+
}
700+
} else {
701+
resolved
702+
};
703+
704+
// Normalize the resolved path.
705+
let resolved = uv_fs::normalize_absolute_path(&resolved)?;
706+
707+
executable = Cow::Owned(resolved);
708+
}
709+
}

0 commit comments

Comments
 (0)