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