diff --git a/crates/uv-python/src/environment.rs b/crates/uv-python/src/environment.rs index e7a5b7b9d..26e9c449e 100644 --- a/crates/uv-python/src/environment.rs +++ b/crates/uv-python/src/environment.rs @@ -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, cache: &Cache) -> Result { - 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()); diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index d4577e8d4..dfa6f7226 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -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]/ diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index a8fa635eb..02a8d00b6 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -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<()> {