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