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