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