uv_python/
managed.rs

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