Avoid resolving symbolic links when querying Python interpreters (#11083)

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

This brings the `PythonEnvironment::from_root` behavior in-line with the
rest of uv Python discovery behavior (and in-line with pip). It's not
clear why we were canonicalizing the path in the first place here.
This commit is contained in:
Zanie Blue 2025-01-30 10:10:33 -06:00 committed by GitHub
parent 586bab32b9
commit d281f49103
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 105 additions and 12 deletions

View file

@ -4,6 +4,7 @@ use std::env;
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tracing::debug;
use uv_cache::Cache;
use uv_cache_key::cache_digest;
use uv_fs::{LockedFile, Simplified};
@ -161,9 +162,13 @@ impl PythonEnvironment {
///
/// N.B. This function also works for system Python environments and users depend on this.
pub fn from_root(root: impl AsRef<Path>, cache: &Cache) -> Result<Self, Error> {
let venv = match fs_err::canonicalize(root.as_ref()) {
Ok(venv) => venv,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
debug!(
"Checking for Python environment at `{}`",
root.as_ref().user_display()
);
match root.as_ref().try_exists() {
Ok(true) => {}
Ok(false) => {
return Err(Error::MissingEnvironment(EnvironmentNotFound {
preference: EnvironmentPreference::Any,
request: PythonRequest::Directory(root.as_ref().to_owned()),
@ -172,30 +177,35 @@ impl PythonEnvironment {
Err(err) => return Err(Error::Discovery(err.into())),
};
if venv.is_file() {
if root.as_ref().is_file() {
return Err(InvalidEnvironment {
path: venv,
path: root.as_ref().to_path_buf(),
kind: InvalidEnvironmentKind::NotDirectory,
}
.into());
}
if venv.read_dir().is_ok_and(|mut dir| dir.next().is_none()) {
if root
.as_ref()
.read_dir()
.is_ok_and(|mut dir| dir.next().is_none())
{
return Err(InvalidEnvironment {
path: venv,
path: root.as_ref().to_path_buf(),
kind: InvalidEnvironmentKind::Empty,
}
.into());
}
let executable = virtualenv_python_executable(&venv);
// Note we do not canonicalize the root path or the executable path, this is important
// because the path the interpreter is invoked at can determine the value of
// `sys.executable`.
let executable = virtualenv_python_executable(&root);
// Check if the executable exists before querying so we can provide a more specific error
// Note we intentionally don't require a resolved link to exist here, we're just trying to
// tell if this _looks_ like a Python environment.
// If we can't find an executable, exit before querying to provide a better error.
if !(executable.is_symlink() || executable.is_file()) {
return Err(InvalidEnvironment {
path: venv,
path: root.as_ref().to_path_buf(),
kind: InvalidEnvironmentKind::MissingExecutable(executable.clone()),
}
.into());

View file

@ -14646,6 +14646,7 @@ fn lock_explicit_default_index() -> Result<()> {
DEBUG Found workspace root: `[TEMP_DIR]/`
DEBUG Adding current workspace member: `[TEMP_DIR]/`
DEBUG Using Python request `>=3.12` from `requires-python` metadata
DEBUG Checking for Python environment at `.venv`
DEBUG The virtual environment's Python version satisfies `>=3.12`
DEBUG Using request timeout of [TIME]
DEBUG Found static `pyproject.toml` for: project @ file://[TEMP_DIR]/

View file

@ -3349,6 +3349,88 @@ fn run_gui_script_explicit_unix() -> Result<()> {
Ok(())
}
#[test]
#[cfg(unix)]
fn run_linked_environment_path() -> Result<()> {
use anyhow::Ok;
let context = TestContext::new("3.12")
.with_filtered_virtualenv_bin()
.with_filtered_python_names();
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["black"]
"#,
)?;
// Create a link from `target` -> virtual environment
fs_err::os::unix::fs::symlink(&context.venv, context.temp_dir.child("target"))?;
// Running `uv sync` should use the environment at `target``
uv_snapshot!(context.filters(), context.sync()
.env(EnvVars::UV_PROJECT_ENVIRONMENT, "target"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 8 packages in [TIME]
Prepared 6 packages in [TIME]
Installed 6 packages in [TIME]
+ black==24.3.0
+ click==8.1.7
+ mypy-extensions==1.0.0
+ packaging==24.0
+ pathspec==0.12.1
+ platformdirs==4.2.0
"###);
// `sys.prefix` and `sys.executable` should be from the `target` directory
uv_snapshot!(context.filters(), context.run()
.env_remove("VIRTUAL_ENV") // Ignore the test context's active virtual environment
.env(EnvVars::UV_PROJECT_ENVIRONMENT, "target")
.arg("python").arg("-c").arg("import sys; print(sys.prefix); print(sys.executable)"), @r###"
success: true
exit_code: 0
----- stdout -----
[TEMP_DIR]/target
[TEMP_DIR]/target/[BIN]/python
----- stderr -----
Resolved 8 packages in [TIME]
Audited 6 packages in [TIME]
"###);
// And, similarly, the entrypoint should use `target`
let black_entrypoint = context.read("target/bin/black");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
black_entrypoint, @r###"
#![TEMP_DIR]/target/[BIN]/python
# -*- coding: utf-8 -*-
import sys
from black import patched_main
if __name__ == "__main__":
if sys.argv[0].endswith("-script.pyw"):
sys.argv[0] = sys.argv[0][:-11]
elif sys.argv[0].endswith(".exe"):
sys.argv[0] = sys.argv[0][:-4]
sys.exit(patched_main())
"###
);
});
Ok(())
}
#[test]
#[cfg(not(windows))]
fn run_gui_script_explicit_stdin_unix() -> Result<()> {