Skip to main content

uv_python/
managed.rs

1use core::fmt;
2use std::borrow::Cow;
3use std::cmp::{Ordering, Reverse};
4use std::ffi::OsStr;
5use std::io::{self, Write};
6#[cfg(windows)]
7use std::os::windows::fs::MetadataExt;
8use std::path::{Path, PathBuf};
9use std::str::FromStr;
10
11use fs_err as fs;
12use itertools::Itertools;
13use thiserror::Error;
14use tracing::{debug, warn};
15#[cfg(windows)]
16use windows::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT;
17
18use uv_fs::{
19    LockedFile, LockedFileError, LockedFileMode, Simplified, replace_symlink, symlink_or_copy_file,
20};
21use uv_platform::{Error as PlatformError, Os};
22use uv_platform::{LibcDetectionError, Platform};
23use uv_state::{StateBucket, StateStore};
24use uv_static::EnvVars;
25use uv_trampoline_builder::{Launcher, LauncherKind};
26
27use crate::discovery::VersionRequest;
28use crate::downloads::{Error as DownloadError, ManagedPythonDownload};
29use crate::implementation::{
30    Error as ImplementationError, ImplementationName, LenientImplementationName,
31};
32use crate::installation::{self, PythonInstallationKey};
33use crate::interpreter::Interpreter;
34use crate::python_version::PythonVersion;
35use crate::{
36    PythonInstallationMinorVersionKey, PythonRequest, PythonVariant, macos_dylib, sysconfig,
37};
38
39#[derive(Error, Debug)]
40pub enum Error {
41    #[error(transparent)]
42    Io(#[from] io::Error),
43    #[error(transparent)]
44    LockedFile(#[from] LockedFileError),
45    #[error(transparent)]
46    Download(#[from] DownloadError),
47    #[error(transparent)]
48    PlatformError(#[from] PlatformError),
49    #[error(transparent)]
50    ImplementationError(#[from] ImplementationError),
51    #[error("Invalid python version: {0}")]
52    InvalidPythonVersion(String),
53    #[error(transparent)]
54    ExtractError(#[from] uv_extract::Error),
55    #[error(transparent)]
56    SysconfigError(#[from] sysconfig::Error),
57    #[error("Missing expected Python executable at {}", _0.user_display())]
58    MissingExecutable(PathBuf),
59    #[error("Missing expected target directory for Python minor version link at {}", _0.user_display())]
60    MissingPythonMinorVersionLinkTargetDirectory(PathBuf),
61    #[error("Failed to create canonical Python executable")]
62    CanonicalizeExecutable(#[source] io::Error),
63    #[error("Failed to create Python executable link")]
64    LinkExecutable {
65        to: PathBuf,
66        #[source]
67        err: io::Error,
68    },
69    #[error("Failed to create Python minor version link directory")]
70    PythonMinorVersionLinkDirectory(#[source] io::Error),
71    #[error("Failed to create directory for Python executable link")]
72    ExecutableDirectory(#[source] io::Error),
73    #[error("Failed to read Python installation directory")]
74    ReadError(#[source] io::Error),
75    #[error("Failed to find a directory to install executables into")]
76    NoExecutableDirectory,
77    #[error(transparent)]
78    LauncherError(#[from] uv_trampoline_builder::Error),
79    #[error("Failed to read managed Python directory name: {0}")]
80    NameError(String),
81    #[error("Failed to construct absolute path to managed Python directory: {}", _0.user_display())]
82    AbsolutePath(PathBuf, #[source] io::Error),
83    #[error(transparent)]
84    NameParseError(#[from] installation::PythonInstallationKeyError),
85    #[error("Failed to determine the libc used on the current platform")]
86    LibcDetection(#[from] LibcDetectionError),
87    #[error(transparent)]
88    MacOsDylib(#[from] macos_dylib::Error),
89}
90
91/// Compare two build version strings.
92///
93/// Build versions are typically YYYYMMDD date strings. Comparison is done numerically
94/// if both values parse as integers, otherwise falls back to lexicographic comparison.
95pub fn compare_build_versions(a: &str, b: &str) -> Ordering {
96    match (a.parse::<u64>(), b.parse::<u64>()) {
97        (Ok(a_num), Ok(b_num)) => a_num.cmp(&b_num),
98        _ => a.cmp(b),
99    }
100}
101
102/// A collection of uv-managed Python installations installed on the current system.
103#[derive(Debug, Clone, Eq, PartialEq)]
104pub struct ManagedPythonInstallations {
105    /// The path to the top-level directory of the installed Python versions.
106    root: PathBuf,
107}
108
109impl ManagedPythonInstallations {
110    /// A directory for Python installations at `root`.
111    fn from_path(root: impl Into<PathBuf>) -> Self {
112        Self { root: root.into() }
113    }
114
115    /// Grab a file lock for the managed Python distribution directory to prevent concurrent access
116    /// across processes.
117    pub async fn lock(&self) -> Result<LockedFile, Error> {
118        Ok(LockedFile::acquire(
119            self.root.join(".lock"),
120            LockedFileMode::Exclusive,
121            self.root.user_display(),
122        )
123        .await?)
124    }
125
126    /// Prefer, in order:
127    ///
128    /// 1. The specific Python directory passed via the `install_dir` argument.
129    /// 2. The specific Python directory specified with the `UV_PYTHON_INSTALL_DIR` environment variable.
130    /// 3. A directory in the system-appropriate user-level data directory, e.g., `~/.local/uv/python`.
131    /// 4. A directory in the local data directory, e.g., `./.uv/python`.
132    pub fn from_settings(install_dir: Option<PathBuf>) -> Result<Self, Error> {
133        if let Some(install_dir) = install_dir {
134            Ok(Self::from_path(install_dir))
135        } else if let Some(install_dir) =
136            std::env::var_os(EnvVars::UV_PYTHON_INSTALL_DIR).filter(|s| !s.is_empty())
137        {
138            Ok(Self::from_path(install_dir))
139        } else {
140            Ok(Self::from_path(
141                StateStore::from_settings(None)?.bucket(StateBucket::ManagedPython),
142            ))
143        }
144    }
145
146    /// Create a temporary Python installation directory.
147    pub fn temp() -> Result<Self, Error> {
148        Ok(Self::from_path(
149            StateStore::temp()?.bucket(StateBucket::ManagedPython),
150        ))
151    }
152
153    /// Return the location of the scratch directory for managed Python installations.
154    pub fn scratch(&self) -> PathBuf {
155        self.root.join(".temp")
156    }
157
158    /// Initialize the Python installation directory.
159    ///
160    /// Ensures the directory is created.
161    pub fn init(self) -> Result<Self, Error> {
162        let root = &self.root;
163
164        // Support `toolchains` -> `python` migration transparently.
165        if !root.exists()
166            && root
167                .parent()
168                .is_some_and(|parent| parent.join("toolchains").exists())
169        {
170            let deprecated = root.parent().unwrap().join("toolchains");
171            // Move the deprecated directory to the new location.
172            fs::rename(&deprecated, root)?;
173            // Create a link or junction to at the old location
174            uv_fs::replace_symlink(root, &deprecated)?;
175        } else {
176            fs::create_dir_all(root)?;
177        }
178
179        // Create the directory, if it doesn't exist.
180        fs::create_dir_all(root)?;
181
182        // Create the scratch directory, if it doesn't exist.
183        let scratch = self.scratch();
184        fs::create_dir_all(&scratch)?;
185
186        // Add a .gitignore.
187        match fs::OpenOptions::new()
188            .write(true)
189            .create_new(true)
190            .open(root.join(".gitignore"))
191        {
192            Ok(mut file) => file.write_all(b"*")?,
193            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => (),
194            Err(err) => return Err(err.into()),
195        }
196
197        Ok(self)
198    }
199
200    /// Iterate over each Python installation in this directory.
201    ///
202    /// Pythons are sorted by [`PythonInstallationKey`], for the same implementation name, the newest versions come first.
203    /// This ensures a consistent ordering across all platforms.
204    pub fn find_all(
205        &self,
206    ) -> Result<impl DoubleEndedIterator<Item = ManagedPythonInstallation> + use<>, Error> {
207        let dirs = match fs_err::read_dir(&self.root) {
208            Ok(installation_dirs) => {
209                // Collect sorted directory paths; `read_dir` is not stable across platforms
210                let directories: Vec<_> = installation_dirs
211                    .filter_map(|read_dir| match read_dir {
212                        Ok(entry) => match entry.file_type() {
213                            Ok(file_type) => file_type.is_dir().then_some(Ok(entry.path())),
214                            Err(err) => Some(Err(err)),
215                        },
216                        Err(err) => Some(Err(err)),
217                    })
218                    .collect::<Result<_, io::Error>>()
219                    .map_err(Error::ReadError)?;
220                directories
221            }
222            Err(err) if err.kind() == io::ErrorKind::NotFound => vec![],
223            Err(err) => {
224                return Err(Error::ReadError(err));
225            }
226        };
227        let scratch = self.scratch();
228        Ok(dirs
229            .into_iter()
230            // Ignore the scratch directory
231            .filter(|path| *path != scratch)
232            // Ignore any `.` prefixed directories
233            .filter(|path| {
234                path.file_name()
235                    .and_then(OsStr::to_str)
236                    .map(|name| !name.starts_with('.'))
237                    .unwrap_or(true)
238            })
239            .filter_map(|path| {
240                ManagedPythonInstallation::from_path(path)
241                    .inspect_err(|err| {
242                        warn!("Ignoring malformed managed Python entry:\n    {err}");
243                    })
244                    .ok()
245            })
246            .sorted_unstable_by_key(|installation| Reverse(installation.key().clone())))
247    }
248
249    /// Iterate over Python installations that support the current platform.
250    pub fn find_matching_current_platform(
251        &self,
252    ) -> Result<impl DoubleEndedIterator<Item = ManagedPythonInstallation> + use<>, Error> {
253        let platform = Platform::from_env()?;
254
255        let iter = Self::from_settings(None)?
256            .find_all()?
257            .filter(move |installation| {
258                if !platform.supports(installation.platform()) {
259                    debug!("Skipping managed installation `{installation}`: not supported by current platform `{platform}`");
260                    return false;
261                }
262                true
263            });
264
265        Ok(iter)
266    }
267
268    /// Iterate over managed Python installations that satisfy the requested version on this platform.
269    ///
270    /// ## Errors
271    ///
272    /// - The platform metadata cannot be read
273    /// - A directory for the installation cannot be read
274    pub fn find_version<'a>(
275        &'a self,
276        version: &'a PythonVersion,
277    ) -> Result<impl DoubleEndedIterator<Item = ManagedPythonInstallation> + 'a, Error> {
278        let request = VersionRequest::from(version);
279        Ok(self
280            .find_matching_current_platform()?
281            .filter(move |installation| request.matches_installation_key(installation.key())))
282    }
283
284    pub fn root(&self) -> &Path {
285        &self.root
286    }
287}
288
289static EXTERNALLY_MANAGED: &str = "[externally-managed]
290Error=This Python installation is managed by uv and should not be modified.
291";
292
293/// A uv-managed Python installation on the current system.
294#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
295pub struct ManagedPythonInstallation {
296    /// The path to the top-level directory of the installed Python.
297    path: PathBuf,
298    /// An install key for the Python version.
299    key: PythonInstallationKey,
300    /// The URL with the Python archive.
301    ///
302    /// Empty when self was constructed from a path.
303    url: Option<Cow<'static, str>>,
304    /// The SHA256 of the Python archive at the URL.
305    ///
306    /// Empty when self was constructed from a path.
307    sha256: Option<Cow<'static, str>>,
308    /// The build version of the Python installation.
309    ///
310    /// Empty when self was constructed from a path without a BUILD file.
311    build: Option<Cow<'static, str>>,
312}
313
314impl ManagedPythonInstallation {
315    pub fn new(path: PathBuf, download: &ManagedPythonDownload) -> Self {
316        Self {
317            path,
318            key: download.key().clone(),
319            url: Some(download.url().clone()),
320            sha256: download.sha256().cloned(),
321            build: download.build().map(Cow::Borrowed),
322        }
323    }
324
325    pub(crate) fn from_path(path: impl AsRef<Path>) -> Result<Self, Error> {
326        let path = path.as_ref();
327
328        let key = PythonInstallationKey::from_str(
329            path.file_name()
330                .ok_or(Error::NameError("name is empty".to_string()))?
331                .to_str()
332                .ok_or(Error::NameError("not a valid string".to_string()))?,
333        )?;
334
335        let path = std::path::absolute(path)
336            .map_err(|err| Error::AbsolutePath(path.to_path_buf(), err))?;
337
338        // Try to read the BUILD file if it exists
339        let build = match fs::read_to_string(path.join("BUILD")) {
340            Ok(content) => Some(Cow::Owned(content.trim().to_string())),
341            Err(err) if err.kind() == io::ErrorKind::NotFound => None,
342            Err(err) => return Err(err.into()),
343        };
344
345        Ok(Self {
346            path,
347            key,
348            url: None,
349            sha256: None,
350            build,
351        })
352    }
353
354    /// Try to create a [`ManagedPythonInstallation`] from an [`Interpreter`].
355    ///
356    /// Returns `None` if the interpreter is not a managed installation.
357    pub fn try_from_interpreter(interpreter: &Interpreter) -> Option<Self> {
358        let managed_root = ManagedPythonInstallations::from_settings(None).ok()?;
359
360        // Canonicalize both paths to handle Windows path format differences
361        // (e.g., \\?\ prefix, different casing, junction vs actual path).
362        // Fall back to the original path if canonicalization fails (e.g., target doesn't exist).
363        let sys_base_prefix = dunce::canonicalize(interpreter.sys_base_prefix())
364            .unwrap_or_else(|_| interpreter.sys_base_prefix().to_path_buf());
365        let root = dunce::canonicalize(managed_root.root())
366            .unwrap_or_else(|_| managed_root.root().to_path_buf());
367
368        // Verify the interpreter's base prefix is within the managed root
369        let suffix = sys_base_prefix.strip_prefix(&root).ok()?;
370
371        let first_component = suffix.components().next()?;
372        let name = first_component.as_os_str().to_str()?;
373
374        // Verify it's a valid installation key
375        PythonInstallationKey::from_str(name).ok()?;
376
377        // Construct the installation from the path within the managed root
378        let path = managed_root.root().join(name);
379        Self::from_path(path).ok()
380    }
381
382    /// The path to this managed installation's Python executable.
383    ///
384    /// If the installation has multiple executables i.e., `python`, `python3`, etc., this will
385    /// return the _canonical_ executable name which the other names link to. On Unix, this is
386    /// `python{major}.{minor}{variant}` and on Windows, this is `python{exe}`.
387    ///
388    /// If windowed is true, `pythonw.exe` is selected over `python.exe` on windows, with no changes
389    /// on non-windows.
390    pub fn executable(&self, windowed: bool) -> PathBuf {
391        let version = match self.implementation() {
392            ImplementationName::CPython => {
393                if cfg!(unix) {
394                    format!("{}.{}", self.key.major, self.key.minor)
395                } else {
396                    String::new()
397                }
398            }
399            // PyPy uses a full version number, even on Windows.
400            ImplementationName::PyPy => format!("{}.{}", self.key.major, self.key.minor),
401            // Pyodide and GraalPy do not have a version suffix.
402            ImplementationName::Pyodide => String::new(),
403            ImplementationName::GraalPy => String::new(),
404        };
405
406        // On Windows, the executable is just `python.exe` even for alternative variants
407        // GraalPy always uses `graalpy.exe` as the main executable
408        let variant = if self.implementation() == ImplementationName::GraalPy {
409            ""
410        } else if cfg!(unix) {
411            self.key.variant.executable_suffix()
412        } else if cfg!(windows) && windowed {
413            // Use windowed Python that doesn't open a terminal.
414            "w"
415        } else {
416            ""
417        };
418
419        let name = format!(
420            "{implementation}{version}{variant}{exe}",
421            implementation = self.implementation().executable_name(),
422            exe = std::env::consts::EXE_SUFFIX
423        );
424
425        let executable = executable_path_from_base(
426            self.python_dir().as_path(),
427            &name,
428            &LenientImplementationName::from(self.implementation()),
429            *self.key.os(),
430        );
431
432        // Workaround for python-build-standalone v20241016 which is missing the standard
433        // `python.exe` executable in free-threaded distributions on Windows.
434        //
435        // See https://github.com/astral-sh/uv/issues/8298
436        if cfg!(windows)
437            && matches!(self.key.variant, PythonVariant::Freethreaded)
438            && !executable.exists()
439        {
440            // This is the alternative executable name for the freethreaded variant
441            return self.python_dir().join(format!(
442                "python{}.{}t{}",
443                self.key.major,
444                self.key.minor,
445                std::env::consts::EXE_SUFFIX
446            ));
447        }
448
449        executable
450    }
451
452    fn python_dir(&self) -> PathBuf {
453        let install = self.path.join("install");
454        if install.is_dir() {
455            install
456        } else {
457            self.path.clone()
458        }
459    }
460
461    /// The [`PythonVersion`] of the toolchain.
462    pub fn version(&self) -> PythonVersion {
463        self.key.version()
464    }
465
466    pub fn implementation(&self) -> ImplementationName {
467        match self.key.implementation().into_owned() {
468            LenientImplementationName::Known(implementation) => implementation,
469            LenientImplementationName::Unknown(_) => {
470                panic!("Managed Python installations should have a known implementation")
471            }
472        }
473    }
474
475    pub fn path(&self) -> &Path {
476        &self.path
477    }
478
479    pub fn key(&self) -> &PythonInstallationKey {
480        &self.key
481    }
482
483    pub fn platform(&self) -> &Platform {
484        self.key.platform()
485    }
486
487    /// The build version of this installation, if available.
488    pub fn build(&self) -> Option<&str> {
489        self.build.as_deref()
490    }
491
492    pub fn minor_version_key(&self) -> &PythonInstallationMinorVersionKey {
493        PythonInstallationMinorVersionKey::ref_cast(&self.key)
494    }
495
496    pub fn satisfies(&self, request: &PythonRequest) -> bool {
497        match request {
498            PythonRequest::File(path) => self.executable(false) == *path,
499            PythonRequest::Default | PythonRequest::Any => true,
500            PythonRequest::Directory(path) => self.path() == *path,
501            PythonRequest::ExecutableName(name) => self
502                .executable(false)
503                .file_name()
504                .is_some_and(|filename| filename.to_string_lossy() == *name),
505            PythonRequest::Implementation(implementation) => {
506                *implementation == self.implementation()
507            }
508            PythonRequest::ImplementationVersion(implementation, version) => {
509                *implementation == self.implementation() && version.matches_version(&self.version())
510            }
511            PythonRequest::Version(version) => version.matches_version(&self.version()),
512            PythonRequest::Key(request) => request.satisfied_by_key(self.key()),
513        }
514    }
515
516    /// Ensure the environment contains the canonical Python executable names.
517    pub fn ensure_canonical_executables(&self) -> Result<(), Error> {
518        let python = self.executable(false);
519
520        let canonical_names = &["python"];
521
522        for name in canonical_names {
523            let executable =
524                python.with_file_name(format!("{name}{exe}", exe = std::env::consts::EXE_SUFFIX));
525
526            // Do not attempt to perform same-file copies — this is fine on Unix but fails on
527            // Windows with a permission error instead of 'already exists'
528            if executable == python {
529                continue;
530            }
531
532            match symlink_or_copy_file(&python, &executable) {
533                Ok(()) => {
534                    debug!(
535                        "Created link {} -> {}",
536                        executable.user_display(),
537                        python.user_display(),
538                    );
539                }
540                Err(err) if err.kind() == io::ErrorKind::NotFound => {
541                    return Err(Error::MissingExecutable(python.clone()));
542                }
543                Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
544                Err(err) => {
545                    return Err(Error::CanonicalizeExecutable(err));
546                }
547            }
548        }
549
550        Ok(())
551    }
552
553    /// Ensure the environment contains the symlink directory (or junction on Windows)
554    /// pointing to the patch directory for this minor version.
555    pub fn ensure_minor_version_link(&self) -> Result<(), Error> {
556        if let Some(minor_version_link) = PythonMinorVersionLink::from_installation(self) {
557            minor_version_link.create_directory()?;
558        }
559        Ok(())
560    }
561
562    /// Ensure the environment is marked as externally managed with the
563    /// standard `EXTERNALLY-MANAGED` file.
564    pub fn ensure_externally_managed(&self) -> Result<(), Error> {
565        if self.key.os().is_emscripten() {
566            // Emscripten's stdlib is a zip file so we can't put an
567            // EXTERNALLY-MANAGED inside.
568            return Ok(());
569        }
570        // Construct the path to the `stdlib` directory.
571        let stdlib = if self.key.os().is_windows() {
572            self.python_dir().join("Lib")
573        } else {
574            let lib_suffix = self.key.variant.lib_suffix();
575            let python = if matches!(
576                self.key.implementation,
577                LenientImplementationName::Known(ImplementationName::PyPy)
578            ) {
579                format!("pypy{}", self.key.version().python_version())
580            } else {
581                format!("python{}{lib_suffix}", self.key.version().python_version())
582            };
583            self.python_dir().join("lib").join(python)
584        };
585
586        let file = stdlib.join("EXTERNALLY-MANAGED");
587        fs_err::write(file, EXTERNALLY_MANAGED)?;
588
589        Ok(())
590    }
591
592    /// Ensure that the `sysconfig` data is patched to match the installation path.
593    pub fn ensure_sysconfig_patched(&self) -> Result<(), Error> {
594        if cfg!(unix) {
595            if self.key.os().is_emscripten() {
596                // Emscripten's stdlib is a zip file so we can't update the
597                // sysconfig directly
598                return Ok(());
599            }
600            if self.implementation() == ImplementationName::CPython {
601                sysconfig::update_sysconfig(
602                    self.path(),
603                    self.key.major,
604                    self.key.minor,
605                    self.key.variant.lib_suffix(),
606                )?;
607            }
608        }
609        Ok(())
610    }
611
612    /// On macOS, ensure that the `install_name` for the Python dylib is set
613    /// correctly, rather than pointing at `/install/lib/libpython{version}.dylib`.
614    /// This is necessary to ensure that native extensions written in Rust
615    /// link to the correct location for the Python library.
616    ///
617    /// See <https://github.com/astral-sh/uv/issues/10598> for more information.
618    pub fn ensure_dylib_patched(&self) -> Result<(), macos_dylib::Error> {
619        if cfg!(target_os = "macos") {
620            if self.key().os().is_like_darwin() {
621                if self.implementation() == ImplementationName::CPython {
622                    let dylib_path = self.python_dir().join("lib").join(format!(
623                        "{}python{}{}{}",
624                        std::env::consts::DLL_PREFIX,
625                        self.key.version().python_version(),
626                        self.key.variant().executable_suffix(),
627                        std::env::consts::DLL_SUFFIX
628                    ));
629                    macos_dylib::patch_dylib_install_name(dylib_path)?;
630                }
631            }
632        }
633        Ok(())
634    }
635
636    /// Ensure the build version is written to a BUILD file in the installation directory.
637    pub fn ensure_build_file(&self) -> Result<(), Error> {
638        if let Some(ref build) = self.build {
639            let build_file = self.path.join("BUILD");
640            fs::write(&build_file, build.as_ref())?;
641        }
642        Ok(())
643    }
644
645    /// Returns `true` if the path is a link to this installation's binary, e.g., as created by
646    /// [`create_bin_link`].
647    pub fn is_bin_link(&self, path: &Path) -> bool {
648        if cfg!(unix) {
649            same_file::is_same_file(path, self.executable(false)).unwrap_or_default()
650        } else if cfg!(windows) {
651            let Some(launcher) = Launcher::try_from_path(path).unwrap_or_default() else {
652                return false;
653            };
654            if !matches!(launcher.kind, LauncherKind::Python) {
655                return false;
656            }
657            // We canonicalize the target path of the launcher in case it includes a minor version
658            // junction directory. If canonicalization fails, we check against the launcher path
659            // directly.
660            dunce::canonicalize(&launcher.python_path).unwrap_or(launcher.python_path)
661                == self.executable(false)
662        } else {
663            unreachable!("Only Windows and Unix are supported")
664        }
665    }
666
667    /// Returns `true` if self is a suitable upgrade of other.
668    pub fn is_upgrade_of(&self, other: &Self) -> bool {
669        // Require matching implementation
670        if self.key.implementation != other.key.implementation {
671            return false;
672        }
673        // Require a matching variant
674        if self.key.variant != other.key.variant {
675            return false;
676        }
677        // Require matching minor version
678        if (self.key.major, self.key.minor) != (other.key.major, other.key.minor) {
679            return false;
680        }
681        // If the patch versions are the same, we're handling a pre-release upgrade
682        // or a build version upgrade
683        if self.key.patch == other.key.patch {
684            return match (self.key.prerelease, other.key.prerelease) {
685                // Require a newer pre-release, if present on both
686                (Some(self_pre), Some(other_pre)) => self_pre > other_pre,
687                // Allow upgrade from pre-release to stable
688                (None, Some(_)) => true,
689                // Do not upgrade from stable to pre-release
690                (Some(_), None) => false,
691                // For matching stable versions (same patch, no prerelease), check build version
692                (None, None) => match (self.build.as_deref(), other.build.as_deref()) {
693                    // Download has build, installation doesn't -> upgrade (legacy)
694                    (Some(_), None) => true,
695                    // Both have build, compare them
696                    (Some(self_build), Some(other_build)) => {
697                        compare_build_versions(self_build, other_build) == Ordering::Greater
698                    }
699                    // Download doesn't have build -> no upgrade
700                    (None, _) => false,
701                },
702            };
703        }
704        // Require a newer patch version
705        if self.key.patch < other.key.patch {
706            return false;
707        }
708        true
709    }
710
711    pub fn url(&self) -> Option<&str> {
712        self.url.as_deref()
713    }
714
715    pub fn sha256(&self) -> Option<&str> {
716        self.sha256.as_deref()
717    }
718}
719
720/// A representation of a minor version symlink directory (or junction on Windows)
721/// linking to the home directory of a Python installation.
722#[derive(Clone, Debug)]
723pub struct PythonMinorVersionLink {
724    /// The symlink directory (or junction on Windows).
725    pub symlink_directory: PathBuf,
726    /// The full path to the executable including the symlink directory
727    /// (or junction on Windows).
728    pub symlink_executable: PathBuf,
729    /// The target directory for the symlink. This is the home directory for
730    /// a Python installation.
731    pub target_directory: PathBuf,
732}
733
734impl PythonMinorVersionLink {
735    /// Attempt to derive a path from an executable path that substitutes a minor
736    /// version symlink directory (or junction on Windows) for the patch version
737    /// directory.
738    ///
739    /// The implementation is expected to be CPython and, on Unix, the base Python is
740    /// expected to be in `<home>/bin/` on Unix. If either condition isn't true,
741    /// return [`None`].
742    ///
743    /// # Examples
744    ///
745    /// ## Unix
746    /// For a Python 3.10.8 installation in `/path/to/uv/python/cpython-3.10.8-macos-aarch64-none/bin/python3.10`,
747    /// the symlink directory would be `/path/to/uv/python/cpython-3.10-macos-aarch64-none` and the executable path including the
748    /// symlink directory would be `/path/to/uv/python/cpython-3.10-macos-aarch64-none/bin/python3.10`.
749    ///
750    /// ## Windows
751    /// For a Python 3.10.8 installation in `C:\path\to\uv\python\cpython-3.10.8-windows-x86_64-none\python.exe`,
752    /// the junction would be `C:\path\to\uv\python\cpython-3.10-windows-x86_64-none` and the executable path including the
753    /// junction would be `C:\path\to\uv\python\cpython-3.10-windows-x86_64-none\python.exe`.
754    fn from_executable(executable: &Path, key: &PythonInstallationKey) -> Option<Self> {
755        let implementation = key.implementation();
756        if !matches!(
757            implementation.as_ref(),
758            LenientImplementationName::Known(ImplementationName::CPython)
759        ) {
760            // We don't currently support transparent upgrades for PyPy or GraalPy.
761            return None;
762        }
763        let executable_name = executable
764            .file_name()
765            .expect("Executable file name should exist");
766        let symlink_directory_name = PythonInstallationMinorVersionKey::ref_cast(key).to_string();
767        let parent = executable
768            .parent()
769            .expect("Executable should have parent directory");
770
771        // The home directory of the Python installation
772        let target_directory = if cfg!(unix) {
773            if parent
774                .components()
775                .next_back()
776                .is_some_and(|c| c.as_os_str() == "bin")
777            {
778                parent.parent()?.to_path_buf()
779            } else {
780                return None;
781            }
782        } else if cfg!(windows) {
783            parent.to_path_buf()
784        } else {
785            unimplemented!("Only Windows and Unix systems are supported.")
786        };
787        let symlink_directory = target_directory.with_file_name(symlink_directory_name);
788        // If this would create a circular link, return `None`.
789        if target_directory == symlink_directory {
790            return None;
791        }
792        // The full executable path including the symlink directory (or junction).
793        let symlink_executable = executable_path_from_base(
794            symlink_directory.as_path(),
795            &executable_name.to_string_lossy(),
796            &implementation,
797            *key.os(),
798        );
799        let minor_version_link = Self {
800            symlink_directory,
801            symlink_executable,
802            target_directory,
803        };
804        Some(minor_version_link)
805    }
806
807    pub fn from_installation(installation: &ManagedPythonInstallation) -> Option<Self> {
808        Self::from_executable(installation.executable(false).as_path(), installation.key())
809    }
810
811    pub fn create_directory(&self) -> Result<(), Error> {
812        match replace_symlink(
813            self.target_directory.as_path(),
814            self.symlink_directory.as_path(),
815        ) {
816            Ok(()) => {
817                debug!(
818                    "Created link {} -> {}",
819                    &self.symlink_directory.user_display(),
820                    &self.target_directory.user_display(),
821                );
822            }
823            Err(err) if err.kind() == io::ErrorKind::NotFound => {
824                return Err(Error::MissingPythonMinorVersionLinkTargetDirectory(
825                    self.target_directory.clone(),
826                ));
827            }
828            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
829            Err(err) => {
830                return Err(Error::PythonMinorVersionLinkDirectory(err));
831            }
832        }
833        Ok(())
834    }
835
836    /// Check if the minor version link exists and points to the expected target directory.
837    ///
838    /// This verifies both that the symlink/junction exists AND that it points to the
839    /// `target_directory` specified in this struct. This is important because the link
840    /// may exist but point to a different installation (e.g., after an upgrade), in which
841    /// case we should not use the link for the current installation.
842    pub fn exists(&self) -> bool {
843        #[cfg(unix)]
844        {
845            self.symlink_directory
846                .symlink_metadata()
847                .is_ok_and(|metadata| metadata.file_type().is_symlink())
848                && self
849                    .read_target()
850                    .is_some_and(|target| target == self.target_directory)
851        }
852        #[cfg(windows)]
853        {
854            self.symlink_directory
855                .symlink_metadata()
856                .is_ok_and(|metadata| {
857                    // Check that this is a reparse point, which indicates this
858                    // is a symlink or junction.
859                    (metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT.0) != 0
860                })
861                && self
862                    .read_target()
863                    .is_some_and(|target| target == self.target_directory)
864        }
865    }
866
867    /// Read the target of the minor version link.
868    ///
869    /// On Unix, this reads the symlink target. On Windows, this reads the junction target
870    /// using the `junction` crate which properly handles the `\??\` prefix that Windows
871    /// uses internally for junction targets.
872    pub fn read_target(&self) -> Option<PathBuf> {
873        #[cfg(unix)]
874        {
875            self.symlink_directory.read_link().ok()
876        }
877        #[cfg(windows)]
878        {
879            junction::get_target(&self.symlink_directory).ok()
880        }
881    }
882}
883
884/// Derive the full path to an executable from the given base path and executable
885/// name. On Unix, this is, e.g., `<base>/bin/python3.10`. On Windows, this is,
886/// e.g., `<base>\python.exe`.
887fn executable_path_from_base(
888    base: &Path,
889    executable_name: &str,
890    implementation: &LenientImplementationName,
891    os: Os,
892) -> PathBuf {
893    if matches!(
894        implementation,
895        &LenientImplementationName::Known(ImplementationName::GraalPy)
896    ) {
897        // GraalPy is always in `bin/` regardless of the os
898        base.join("bin").join(executable_name)
899    } else if os.is_emscripten()
900        || matches!(
901            implementation,
902            &LenientImplementationName::Known(ImplementationName::Pyodide)
903        )
904    {
905        // Emscripten's canonical executable is in the base directory
906        base.join(executable_name)
907    } else if os.is_windows() {
908        // On Windows, the executable is in the base directory
909        base.join(executable_name)
910    } else {
911        // On Unix, the executable is in `bin/`
912        base.join("bin").join(executable_name)
913    }
914}
915
916/// Create a link to a managed Python executable.
917///
918/// If the file already exists at the link path, an error will be returned.
919pub fn create_link_to_executable(link: &Path, executable: &Path) -> Result<(), Error> {
920    let link_parent = link.parent().ok_or(Error::NoExecutableDirectory)?;
921    fs_err::create_dir_all(link_parent).map_err(Error::ExecutableDirectory)?;
922
923    if cfg!(unix) {
924        // Note this will never copy on Unix — we use it here to allow compilation on Windows
925        match symlink_or_copy_file(executable, link) {
926            Ok(()) => Ok(()),
927            Err(err) if err.kind() == io::ErrorKind::NotFound => {
928                Err(Error::MissingExecutable(executable.to_path_buf()))
929            }
930            Err(err) => Err(Error::LinkExecutable {
931                to: link.to_path_buf(),
932                err,
933            }),
934        }
935    } else if cfg!(windows) {
936        use uv_trampoline_builder::windows_python_launcher;
937
938        // TODO(zanieb): Install GUI launchers as well
939        let launcher = windows_python_launcher(executable, false)?;
940
941        // OK to use `std::fs` here, `fs_err` does not support `File::create_new` and we attach
942        // error context anyway
943        #[expect(clippy::disallowed_types)]
944        {
945            std::fs::File::create_new(link)
946                .and_then(|mut file| file.write_all(launcher.as_ref()))
947                .map_err(|err| Error::LinkExecutable {
948                    to: link.to_path_buf(),
949                    err,
950                })
951        }
952    } else {
953        unimplemented!("Only Windows and Unix are supported.")
954    }
955}
956
957/// Create or replace a link to a managed Python executable.
958///
959/// If a file already exists at the link path, it will be atomically replaced.
960///
961/// See [`create_link_to_executable`] for a variant that errors if the link already exists.
962pub fn replace_link_to_executable(link: &Path, executable: &Path) -> Result<(), Error> {
963    let link_parent = link.parent().ok_or(Error::NoExecutableDirectory)?;
964    fs_err::create_dir_all(link_parent).map_err(Error::ExecutableDirectory)?;
965
966    if cfg!(unix) {
967        replace_symlink(executable, link).map_err(|err| Error::LinkExecutable {
968            to: link.to_path_buf(),
969            err,
970        })
971    } else if cfg!(windows) {
972        use uv_trampoline_builder::windows_python_launcher;
973
974        let launcher = windows_python_launcher(executable, false)?;
975
976        uv_fs::write_atomic_sync(link, &*launcher).map_err(|err| Error::LinkExecutable {
977            to: link.to_path_buf(),
978            err,
979        })
980    } else {
981        unimplemented!("Only Windows and Unix are supported.")
982    }
983}
984
985// TODO(zanieb): Only used in tests now.
986/// Generate a platform portion of a key from the environment.
987pub fn platform_key_from_env() -> Result<String, Error> {
988    Ok(Platform::from_env()?.to_string().to_lowercase())
989}
990
991impl fmt::Display for ManagedPythonInstallation {
992    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
993        write!(
994            f,
995            "{}",
996            self.path
997                .file_name()
998                .unwrap_or(self.path.as_os_str())
999                .to_string_lossy()
1000        )
1001    }
1002}
1003
1004/// Find the directory to install Python executables into.
1005pub fn python_executable_dir() -> Result<PathBuf, Error> {
1006    uv_dirs::user_executable_directory(Some(EnvVars::UV_PYTHON_BIN_DIR))
1007        .ok_or(Error::NoExecutableDirectory)
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012    use super::*;
1013    use crate::implementation::LenientImplementationName;
1014    use crate::installation::PythonInstallationKey;
1015    use crate::{ImplementationName, PythonVariant};
1016    use std::path::PathBuf;
1017    use std::str::FromStr;
1018    use uv_pep440::{Prerelease, PrereleaseKind};
1019    use uv_platform::Platform;
1020
1021    fn create_test_installation(
1022        implementation: ImplementationName,
1023        major: u8,
1024        minor: u8,
1025        patch: u8,
1026        prerelease: Option<Prerelease>,
1027        variant: PythonVariant,
1028        build: Option<&str>,
1029    ) -> ManagedPythonInstallation {
1030        let platform = Platform::from_str("linux-x86_64-gnu").unwrap();
1031        let key = PythonInstallationKey::new(
1032            LenientImplementationName::Known(implementation),
1033            major,
1034            minor,
1035            patch,
1036            prerelease,
1037            platform,
1038            variant,
1039        );
1040        ManagedPythonInstallation {
1041            path: PathBuf::from("/test/path"),
1042            key,
1043            url: None,
1044            sha256: None,
1045            build: build.map(|s| Cow::Owned(s.to_owned())),
1046        }
1047    }
1048
1049    #[test]
1050    fn test_is_upgrade_of_same_version() {
1051        let installation = create_test_installation(
1052            ImplementationName::CPython,
1053            3,
1054            10,
1055            8,
1056            None,
1057            PythonVariant::Default,
1058            None,
1059        );
1060
1061        // Same patch version should not be an upgrade
1062        assert!(!installation.is_upgrade_of(&installation));
1063    }
1064
1065    #[test]
1066    fn test_is_upgrade_of_patch_version() {
1067        let older = create_test_installation(
1068            ImplementationName::CPython,
1069            3,
1070            10,
1071            8,
1072            None,
1073            PythonVariant::Default,
1074            None,
1075        );
1076        let newer = create_test_installation(
1077            ImplementationName::CPython,
1078            3,
1079            10,
1080            9,
1081            None,
1082            PythonVariant::Default,
1083            None,
1084        );
1085
1086        // Newer patch version should be an upgrade
1087        assert!(newer.is_upgrade_of(&older));
1088        // Older patch version should not be an upgrade
1089        assert!(!older.is_upgrade_of(&newer));
1090    }
1091
1092    #[test]
1093    fn test_is_upgrade_of_different_minor_version() {
1094        let py310 = create_test_installation(
1095            ImplementationName::CPython,
1096            3,
1097            10,
1098            8,
1099            None,
1100            PythonVariant::Default,
1101            None,
1102        );
1103        let py311 = create_test_installation(
1104            ImplementationName::CPython,
1105            3,
1106            11,
1107            0,
1108            None,
1109            PythonVariant::Default,
1110            None,
1111        );
1112
1113        // Different minor versions should not be upgrades
1114        assert!(!py311.is_upgrade_of(&py310));
1115        assert!(!py310.is_upgrade_of(&py311));
1116    }
1117
1118    #[test]
1119    fn test_is_upgrade_of_different_implementation() {
1120        let cpython = create_test_installation(
1121            ImplementationName::CPython,
1122            3,
1123            10,
1124            8,
1125            None,
1126            PythonVariant::Default,
1127            None,
1128        );
1129        let pypy = create_test_installation(
1130            ImplementationName::PyPy,
1131            3,
1132            10,
1133            9,
1134            None,
1135            PythonVariant::Default,
1136            None,
1137        );
1138
1139        // Different implementations should not be upgrades
1140        assert!(!pypy.is_upgrade_of(&cpython));
1141        assert!(!cpython.is_upgrade_of(&pypy));
1142    }
1143
1144    #[test]
1145    fn test_is_upgrade_of_different_variant() {
1146        let default = create_test_installation(
1147            ImplementationName::CPython,
1148            3,
1149            10,
1150            8,
1151            None,
1152            PythonVariant::Default,
1153            None,
1154        );
1155        let freethreaded = create_test_installation(
1156            ImplementationName::CPython,
1157            3,
1158            10,
1159            9,
1160            None,
1161            PythonVariant::Freethreaded,
1162            None,
1163        );
1164
1165        // Different variants should not be upgrades
1166        assert!(!freethreaded.is_upgrade_of(&default));
1167        assert!(!default.is_upgrade_of(&freethreaded));
1168    }
1169
1170    #[test]
1171    fn test_is_upgrade_of_prerelease() {
1172        let stable = create_test_installation(
1173            ImplementationName::CPython,
1174            3,
1175            10,
1176            8,
1177            None,
1178            PythonVariant::Default,
1179            None,
1180        );
1181        let prerelease = create_test_installation(
1182            ImplementationName::CPython,
1183            3,
1184            10,
1185            8,
1186            Some(Prerelease {
1187                kind: PrereleaseKind::Alpha,
1188                number: 1,
1189            }),
1190            PythonVariant::Default,
1191            None,
1192        );
1193
1194        // A stable version is an upgrade from prerelease
1195        assert!(stable.is_upgrade_of(&prerelease));
1196
1197        // Prerelease are not upgrades of stable versions
1198        assert!(!prerelease.is_upgrade_of(&stable));
1199    }
1200
1201    #[test]
1202    fn test_is_upgrade_of_prerelease_to_prerelease() {
1203        let alpha1 = create_test_installation(
1204            ImplementationName::CPython,
1205            3,
1206            10,
1207            8,
1208            Some(Prerelease {
1209                kind: PrereleaseKind::Alpha,
1210                number: 1,
1211            }),
1212            PythonVariant::Default,
1213            None,
1214        );
1215        let alpha2 = create_test_installation(
1216            ImplementationName::CPython,
1217            3,
1218            10,
1219            8,
1220            Some(Prerelease {
1221                kind: PrereleaseKind::Alpha,
1222                number: 2,
1223            }),
1224            PythonVariant::Default,
1225            None,
1226        );
1227
1228        // Later prerelease should be an upgrade
1229        assert!(alpha2.is_upgrade_of(&alpha1));
1230        // Earlier prerelease should not be an upgrade
1231        assert!(!alpha1.is_upgrade_of(&alpha2));
1232    }
1233
1234    #[test]
1235    fn test_is_upgrade_of_prerelease_same_patch() {
1236        let prerelease = create_test_installation(
1237            ImplementationName::CPython,
1238            3,
1239            10,
1240            8,
1241            Some(Prerelease {
1242                kind: PrereleaseKind::Alpha,
1243                number: 1,
1244            }),
1245            PythonVariant::Default,
1246            None,
1247        );
1248
1249        // Same prerelease should not be an upgrade
1250        assert!(!prerelease.is_upgrade_of(&prerelease));
1251    }
1252
1253    #[test]
1254    fn test_is_upgrade_of_build_version() {
1255        let older_build = create_test_installation(
1256            ImplementationName::CPython,
1257            3,
1258            10,
1259            8,
1260            None,
1261            PythonVariant::Default,
1262            Some("20240101"),
1263        );
1264        let newer_build = create_test_installation(
1265            ImplementationName::CPython,
1266            3,
1267            10,
1268            8,
1269            None,
1270            PythonVariant::Default,
1271            Some("20240201"),
1272        );
1273
1274        // Newer build version should be an upgrade
1275        assert!(newer_build.is_upgrade_of(&older_build));
1276        // Older build version should not be an upgrade
1277        assert!(!older_build.is_upgrade_of(&newer_build));
1278    }
1279
1280    #[test]
1281    fn test_is_upgrade_of_build_version_same() {
1282        let installation = create_test_installation(
1283            ImplementationName::CPython,
1284            3,
1285            10,
1286            8,
1287            None,
1288            PythonVariant::Default,
1289            Some("20240101"),
1290        );
1291
1292        // Same build version should not be an upgrade
1293        assert!(!installation.is_upgrade_of(&installation));
1294    }
1295
1296    #[test]
1297    fn test_is_upgrade_of_build_with_legacy_installation() {
1298        let legacy = create_test_installation(
1299            ImplementationName::CPython,
1300            3,
1301            10,
1302            8,
1303            None,
1304            PythonVariant::Default,
1305            None,
1306        );
1307        let with_build = create_test_installation(
1308            ImplementationName::CPython,
1309            3,
1310            10,
1311            8,
1312            None,
1313            PythonVariant::Default,
1314            Some("20240101"),
1315        );
1316
1317        // Installation with build should upgrade legacy installation without build
1318        assert!(with_build.is_upgrade_of(&legacy));
1319        // Legacy installation should not upgrade installation with build
1320        assert!(!legacy.is_upgrade_of(&with_build));
1321    }
1322
1323    #[test]
1324    fn test_is_upgrade_of_patch_takes_precedence_over_build() {
1325        let older_patch_newer_build = create_test_installation(
1326            ImplementationName::CPython,
1327            3,
1328            10,
1329            8,
1330            None,
1331            PythonVariant::Default,
1332            Some("20240201"),
1333        );
1334        let newer_patch_older_build = create_test_installation(
1335            ImplementationName::CPython,
1336            3,
1337            10,
1338            9,
1339            None,
1340            PythonVariant::Default,
1341            Some("20240101"),
1342        );
1343
1344        // Newer patch version should be an upgrade regardless of build
1345        assert!(newer_patch_older_build.is_upgrade_of(&older_patch_newer_build));
1346        // Older patch version should not be an upgrade even with newer build
1347        assert!(!older_patch_newer_build.is_upgrade_of(&newer_patch_older_build));
1348    }
1349
1350    #[test]
1351    fn test_find_version_matching() {
1352        use crate::PythonVersion;
1353
1354        let platform = Platform::from_env().unwrap();
1355        let temp_dir = tempfile::tempdir().unwrap();
1356
1357        // Create mock installation directories
1358        fs::create_dir(temp_dir.path().join(format!("cpython-3.10.0-{platform}"))).unwrap();
1359
1360        temp_env::with_var(
1361            uv_static::EnvVars::UV_PYTHON_INSTALL_DIR,
1362            Some(temp_dir.path()),
1363            || {
1364                let installations = ManagedPythonInstallations::from_settings(None).unwrap();
1365
1366                // Version 3.1 should NOT match 3.10
1367                let v3_1 = PythonVersion::from_str("3.1").unwrap();
1368                let matched: Vec<_> = installations.find_version(&v3_1).unwrap().collect();
1369                assert_eq!(matched.len(), 0);
1370
1371                // Check that 3.10 matches
1372                let v3_10 = PythonVersion::from_str("3.10").unwrap();
1373                let matched: Vec<_> = installations.find_version(&v3_10).unwrap().collect();
1374                assert_eq!(matched.len(), 1);
1375            },
1376        );
1377    }
1378}