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