Fix uv sync --active recreating active environments when UV_PYTHON_INSTALL_DIR is relative (#18398)

## Summary

We need to normalize any relative managed-Python install roots before
checking whether the active environment’s interpreter is uv-managed, so
that `sync --active` reuses the environment.

Closes https://github.com/astral-sh/uv/issues/16631.
This commit is contained in:
Charlie Marsh
2026-03-13 19:47:53 -04:00
committed by GitHub
parent 1375fdb70d
commit 45ea4dd4ec
3 changed files with 112 additions and 5 deletions
+7 -1
View File
@@ -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;
};
+39 -4
View File
@@ -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<PathBuf, Error> {
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<Self> {
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")
);
},
);
}
}
+66
View File
@@ -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"])