Stabilize Python upgrades (#17766)

Includes a few things...

- Drops preview warnings for use of `uv python upgrade` and `uv python
install --upgrade`
- Adds `--resolve-links` to `uv python find`, which I needed in test
cases to retain existing snapshots
- Fixes issues in our "Using environment ..." messages on Windows which
were incorrect
- Refactors `from_executable` for the `PythonMinorVersionLink` type
(https://github.com/astral-sh/uv/pull/17842/commits/28b2ed2525327d94fdf5372a29bbbc476d74680f)
to use the type system to prevent incorrect construction (for above)
- Removes special casing where we only upgrade links if they already
exist, which existed so preview wasn't needed on every invocation
- Fixes a bug with `PythonMinorVersionLink::exists` which returned
`true` even if the link pointed to the wrong Python installation leading
to discovery failures
This commit is contained in:
Zanie Blue
2026-02-05 11:26:54 -06:00
parent f3d50129c6
commit d2ab2d0208
35 changed files with 221 additions and 239 deletions
+1
View File
@@ -68,6 +68,7 @@ url = { workspace = true }
which = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
junction = { workspace = true }
windows-registry = { workspace = true }
windows = { workspace = true }
+1 -4
View File
@@ -356,7 +356,6 @@ fn python_executables_from_installed<'a>(
implementation: Option<&'a ImplementationName>,
platform: PlatformRequest,
preference: PythonPreference,
preview: Preview,
) -> Box<dyn Iterator<Item = Result<(PythonSource, PathBuf), Error>> + 'a> {
let from_managed_installations = iter::once_with(move || {
ManagedPythonInstallations::from_settings(None)
@@ -411,7 +410,6 @@ fn python_executables_from_installed<'a>(
.then(|| {
PythonMinorVersionLink::from_installation(
&installation,
preview,
)
.filter(PythonMinorVersionLink::exists)
.map(
@@ -547,7 +545,7 @@ fn python_executables<'a>(
let from_virtual_environments = python_executables_from_virtual_environments(preview);
let from_installed =
python_executables_from_installed(version, implementation, platform, preference, preview);
python_executables_from_installed(version, implementation, platform, preference);
// Limit the search to the relevant environment preference; this avoids unnecessary work like
// traversal of the file system. Subsequent filtering should be done by the caller with
@@ -1542,7 +1540,6 @@ pub(crate) async fn find_best_python_installation(
reporter,
python_install_mirror,
pypy_install_mirror,
preview,
)
.await
.map(Some),
+1 -3
View File
@@ -260,7 +260,6 @@ impl PythonInstallation {
reporter,
python_install_mirror,
pypy_install_mirror,
preview,
)
.await?;
@@ -278,7 +277,6 @@ impl PythonInstallation {
reporter: Option<&dyn Reporter>,
python_install_mirror: Option<&str>,
pypy_install_mirror: Option<&str>,
preview: Preview,
) -> Result<Self, Error> {
let installations = ManagedPythonInstallations::from_settings(None)?.init()?;
let installations_dir = installations.root();
@@ -321,7 +319,7 @@ impl PythonInstallation {
.patch()
.is_some_and(|p| p >= highest_patch)
{
installed.ensure_minor_version_link(preview)?;
installed.ensure_minor_version_link()?;
}
if let Err(e) = installed.ensure_dylib_patched() {
+68 -42
View File
@@ -12,7 +12,6 @@ use fs_err as fs;
use itertools::Itertools;
use thiserror::Error;
use tracing::{debug, warn};
use uv_preview::{Preview, PreviewFeature};
#[cfg(windows)]
use windows::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT;
@@ -30,6 +29,7 @@ use crate::implementation::{
Error as ImplementationError, ImplementationName, LenientImplementationName,
};
use crate::installation::{self, PythonInstallationKey};
use crate::interpreter::Interpreter;
use crate::python_version::PythonVersion;
use crate::{
PythonInstallationMinorVersionKey, PythonRequest, PythonVariant, macos_dylib, sysconfig,
@@ -357,7 +357,9 @@ impl ManagedPythonInstallation {
}
}
pub(crate) fn from_path(path: PathBuf) -> Result<Self, Error> {
pub(crate) fn from_path(path: impl AsRef<Path>) -> Result<Self, Error> {
let path = path.as_ref();
let key = PythonInstallationKey::from_str(
path.file_name()
.ok_or(Error::NameError("name is empty".to_string()))?
@@ -365,7 +367,8 @@ impl ManagedPythonInstallation {
.ok_or(Error::NameError("not a valid string".to_string()))?,
)?;
let path = std::path::absolute(&path).map_err(|err| Error::AbsolutePath(path, err))?;
let path = std::path::absolute(path)
.map_err(|err| Error::AbsolutePath(path.to_path_buf(), err))?;
// Try to read the BUILD file if it exists
let build = match fs::read_to_string(path.join("BUILD")) {
@@ -383,6 +386,34 @@ impl ManagedPythonInstallation {
})
}
/// Try to create a [`ManagedPythonInstallation`] from an [`Interpreter`].
///
/// 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()?;
// 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());
// Verify the interpreter's base prefix is within the managed root
let suffix = sys_base_prefix.strip_prefix(&root).ok()?;
let first_component = suffix.components().next()?;
let name = first_component.as_os_str().to_str()?;
// Verify it's a valid installation key
PythonInstallationKey::from_str(name).ok()?;
// Construct the installation from the path within the managed root
let path = managed_root.root().join(name);
Self::from_path(path).ok()
}
/// The path to this managed installation's Python executable.
///
/// If the installation has multiple executables i.e., `python`, `python3`, etc., this will
@@ -560,23 +591,8 @@ impl ManagedPythonInstallation {
/// Ensure the environment contains the symlink directory (or junction on Windows)
/// pointing to the patch directory for this minor version.
pub fn ensure_minor_version_link(&self, preview: Preview) -> Result<(), Error> {
if let Some(minor_version_link) = PythonMinorVersionLink::from_installation(self, preview) {
minor_version_link.create_directory()?;
}
Ok(())
}
/// If the environment contains a symlink directory (or junction on Windows),
/// update it to the latest patch directory for this minor version.
///
/// Unlike [`ensure_minor_version_link`], will not create a new symlink directory
/// if one doesn't already exist,
pub fn update_minor_version_link(&self, preview: Preview) -> Result<(), Error> {
if let Some(minor_version_link) = PythonMinorVersionLink::from_installation(self, preview) {
if !minor_version_link.exists() {
return Ok(());
}
pub fn ensure_minor_version_link(&self) -> Result<(), Error> {
if let Some(minor_version_link) = PythonMinorVersionLink::from_installation(self) {
minor_version_link.create_directory()?;
}
Ok(())
@@ -774,11 +790,7 @@ impl PythonMinorVersionLink {
/// For a Python 3.10.8 installation in `C:\path\to\uv\python\cpython-3.10.8-windows-x86_64-none\python.exe`,
/// the junction would be `C:\path\to\uv\python\cpython-3.10-windows-x86_64-none` and the executable path including the
/// junction would be `C:\path\to\uv\python\cpython-3.10-windows-x86_64-none\python.exe`.
pub fn from_executable(
executable: &Path,
key: &PythonInstallationKey,
preview: Preview,
) -> Option<Self> {
fn from_executable(executable: &Path, key: &PythonInstallationKey) -> Option<Self> {
let implementation = key.implementation();
if !matches!(
implementation.as_ref(),
@@ -828,24 +840,11 @@ impl PythonMinorVersionLink {
symlink_executable,
target_directory,
};
// If preview mode is disabled, still return a `MinorVersionSymlink` for
// existing symlinks, allowing continued operations without the `--preview`
// flag after initial symlink directory installation.
if !preview.is_enabled(PreviewFeature::PythonUpgrade) && !minor_version_link.exists() {
return None;
}
Some(minor_version_link)
}
pub fn from_installation(
installation: &ManagedPythonInstallation,
preview: Preview,
) -> Option<Self> {
Self::from_executable(
installation.executable(false).as_path(),
installation.key(),
preview,
)
pub fn from_installation(installation: &ManagedPythonInstallation) -> Option<Self> {
Self::from_executable(installation.executable(false).as_path(), installation.key())
}
pub fn create_directory(&self) -> Result<(), Error> {
@@ -877,13 +876,21 @@ impl PythonMinorVersionLink {
Ok(())
}
/// Check if the minor version link exists and points to the expected target directory.
///
/// This verifies both that the symlink/junction exists AND that it points to the
/// `target_directory` specified in this struct. This is important because the link
/// may exist but point to a different installation (e.g., after an upgrade), in which
/// case we should not use the link for the current installation.
pub fn exists(&self) -> bool {
#[cfg(unix)]
{
self.symlink_directory
.symlink_metadata()
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
.is_ok_and(|metadata| metadata.file_type().is_symlink())
&& self
.read_target()
.is_some_and(|target| target == self.target_directory)
}
#[cfg(windows)]
{
@@ -894,6 +901,25 @@ impl PythonMinorVersionLink {
// is a symlink or junction.
(metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT.0) != 0
})
&& self
.read_target()
.is_some_and(|target| target == self.target_directory)
}
}
/// Read the target of the minor version link.
///
/// On Unix, this reads the symlink target. On Windows, this reads the junction target
/// using the `junction` crate which properly handles the `\??\` prefix that Windows
/// uses internally for junction targets.
pub fn read_target(&self) -> Option<PathBuf> {
#[cfg(unix)]
{
self.symlink_directory.read_link().ok()
}
#[cfg(windows)]
{
junction::get_target(&self.symlink_directory).ok()
}
}
}