Skip to main content

uv_python/
managed.rs

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