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:
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user