Skip to main content

uv_python/
managed.rs

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