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