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