diff --git a/crates/uv-python/src/interpreter.rs b/crates/uv-python/src/interpreter.rs index 7d1b53599..6eb7bcfa9 100644 --- a/crates/uv-python/src/interpreter.rs +++ b/crates/uv-python/src/interpreter.rs @@ -304,8 +304,14 @@ impl Interpreter { let Ok(installations) = ManagedPythonInstallations::from_settings(None) else { return false; }; + let Ok(root) = installations.absolute_root() else { + return false; + }; + let sys_base_prefix = dunce::canonicalize(&self.sys_base_prefix) + .unwrap_or_else(|_| self.sys_base_prefix.clone()); + let root = dunce::canonicalize(&root).unwrap_or(root); - let Ok(suffix) = self.sys_base_prefix.strip_prefix(installations.root()) else { + let Ok(suffix) = sys_base_prefix.strip_prefix(&root) else { return false; }; diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index 145ebe238..a0fe9b660 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -16,7 +16,8 @@ use tracing::{debug, warn}; use windows::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT; use uv_fs::{ - LockedFile, LockedFileError, LockedFileMode, Simplified, replace_symlink, symlink_or_copy_file, + LockedFile, LockedFileError, LockedFileMode, Simplified, normalize_absolute_path, + replace_symlink, symlink_or_copy_file, }; use uv_platform::{Error as PlatformError, Os}; use uv_platform::{LibcDetectionError, Platform}; @@ -280,6 +281,16 @@ impl ManagedPythonInstallations { pub fn root(&self) -> &Path { &self.root } + + pub(crate) fn absolute_root(&self) -> Result { + let root = if self.root.is_absolute() { + self.root.clone() + } else { + crate::current_dir()?.join(&self.root) + }; + + normalize_absolute_path(&root).map_err(|err| Error::AbsolutePath(self.root.clone(), err)) + } } static EXTERNALLY_MANAGED: &str = "[externally-managed] @@ -352,14 +363,14 @@ impl ManagedPythonInstallation { /// Returns `None` if the interpreter is not a managed installation. pub fn try_from_interpreter(interpreter: &Interpreter) -> Option { let managed_root = ManagedPythonInstallations::from_settings(None).ok()?; + let root = managed_root.absolute_root().ok()?; // Canonicalize both paths to handle Windows path format differences // (e.g., \\?\ prefix, different casing, junction vs actual path). // Fall back to the original path if canonicalization fails (e.g., target doesn't exist). let sys_base_prefix = dunce::canonicalize(interpreter.sys_base_prefix()) .unwrap_or_else(|_| interpreter.sys_base_prefix().to_path_buf()); - let root = dunce::canonicalize(managed_root.root()) - .unwrap_or_else(|_| managed_root.root().to_path_buf()); + let root = dunce::canonicalize(&root).unwrap_or(root); // Verify the interpreter's base prefix is within the managed root let suffix = sys_base_prefix.strip_prefix(&root).ok()?; @@ -371,7 +382,7 @@ impl ManagedPythonInstallation { PythonInstallationKey::from_str(name).ok()?; // Construct the installation from the path within the managed root - let path = managed_root.root().join(name); + let path = root.join(name); Self::from_path(path).ok() } @@ -1359,4 +1370,28 @@ mod tests { }, ); } + + #[test] + fn test_relative_install_dir_resolves_against_pwd() { + let temp_dir = tempfile::tempdir().unwrap(); + let workdir = temp_dir.path().join("workdir"); + fs::create_dir(&workdir).unwrap(); + + temp_env::with_vars( + [ + ( + uv_static::EnvVars::UV_PYTHON_INSTALL_DIR, + Some(std::ffi::OsStr::new(".python-installs")), + ), + (uv_static::EnvVars::PWD, Some(workdir.as_os_str())), + ], + || { + let installations = ManagedPythonInstallations::from_settings(None).unwrap(); + assert_eq!( + installations.absolute_root().unwrap(), + workdir.join(".python-installs") + ); + }, + ); + } } diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 031b5e33d..45f38af9f 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -6792,6 +6792,72 @@ fn sync_active_project_environment() -> Result<()> { Ok(()) } +#[test] +#[cfg(feature = "test-python-managed")] +fn sync_active_project_environment_with_relative_managed_python_dir() -> Result<()> { + let context = uv_test::test_context_with_versions!(&[]) + .with_python_download_cache() + .with_empty_python_install_mirror(); + + 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 = [] + "#, + )?; + + context + .venv() + .env(EnvVars::UV_PYTHON_INSTALL_DIR, ".python-installs") + .env(EnvVars::UV_PYTHON_PREFERENCE, "only-managed") + .env(EnvVars::UV_PYTHON_DOWNLOADS, "automatic") + .arg("foobar") + .assert() + .success(); + + context + .temp_dir + .child(".python-installs") + .assert(predicate::path::is_dir()); + + for _ in 0..2 { + let assert = context + .sync() + .env(EnvVars::UV_PYTHON_INSTALL_DIR, ".python-installs") + .env(EnvVars::UV_PYTHON_PREFERENCE, "only-managed") + .env(EnvVars::UV_PYTHON_DOWNLOADS, "automatic") + .env(EnvVars::VIRTUAL_ENV, "foobar") + .arg("--active") + .assert() + .success(); + + let stderr = String::from_utf8_lossy(&assert.get_output().stderr); + assert!( + !stderr.contains("Removed virtual environment at: foobar"), + "expected `uv sync --active` to reuse the active environment, stderr was:\n{stderr}" + ); + assert!( + !stderr.contains("Creating virtual environment at: foobar"), + "expected `uv sync --active` to reuse the active environment, stderr was:\n{stderr}" + ); + } + + context + .temp_dir + .child(".venv") + .assert(predicate::path::missing()); + context + .temp_dir + .child("foobar") + .assert(predicate::path::is_dir()); + + Ok(()) +} + #[test] fn sync_active_script_environment() -> Result<()> { let context = uv_test::test_context_with_versions!(&["3.11", "3.12"])