Skip to main content

uv_python/
installation.rs

1use std::borrow::Cow;
2use std::fmt;
3use std::hash::{Hash, Hasher};
4use std::str::FromStr;
5
6use indexmap::IndexMap;
7use ref_cast::RefCast;
8use reqwest_retry::policies::ExponentialBackoff;
9use tracing::{debug, info};
10use uv_warnings::warn_user;
11
12use uv_cache::Cache;
13use uv_client::{BaseClient, BaseClientBuilder};
14use uv_pep440::{Prerelease, Version};
15use uv_platform::{Arch, Libc, Os, Platform};
16use uv_preview::Preview;
17
18use crate::discovery::{
19    EnvironmentPreference, PythonRequest, find_best_python_installation, find_python_installation,
20};
21use crate::downloads::{
22    DownloadResult, ManagedPythonDownload, ManagedPythonDownloadList, PythonDownloadRequest,
23    Reporter,
24};
25use crate::implementation::LenientImplementationName;
26use crate::managed::{ManagedPythonInstallation, ManagedPythonInstallations};
27use crate::{
28    Error, ImplementationName, Interpreter, MissingPythonHint, PythonDownloads, PythonPreference,
29    PythonSource, PythonVariant, PythonVersion, downloads,
30};
31
32/// A Python interpreter and accompanying tools.
33#[derive(Clone, Debug)]
34pub struct PythonInstallation {
35    // Public in the crate for test assertions
36    pub(crate) source: PythonSource,
37    pub(crate) interpreter: Interpreter,
38}
39
40impl PythonInstallation {
41    /// Create a new [`PythonInstallation`] from a source and interpreter.
42    pub fn new(source: PythonSource, interpreter: Interpreter) -> Self {
43        Self {
44            source,
45            interpreter,
46        }
47    }
48
49    /// Find an installed [`PythonInstallation`].
50    ///
51    /// This is the standard interface for discovering a Python installation for creating
52    /// an environment. If interested in finding an existing environment, see
53    /// [`PythonEnvironment::find`] instead.
54    ///
55    /// Note we still require an [`EnvironmentPreference`] as this can either bypass virtual environments
56    /// or prefer them. In most cases, this should be [`EnvironmentPreference::OnlySystem`]
57    /// but if you want to allow an interpreter from a virtual environment if it satisfies the request,
58    /// then use [`EnvironmentPreference::Any`].
59    ///
60    /// See [`find_installation`] for implementation details.
61    pub fn find(
62        request: &PythonRequest,
63        environments: EnvironmentPreference,
64        preference: PythonPreference,
65        download_list: &ManagedPythonDownloadList,
66        cache: &Cache,
67        preview: Preview,
68    ) -> Result<Self, Error> {
69        let installation = Self::find_existing(request, environments, preference, cache, preview)?;
70        installation.warn_if_outdated_prerelease(request, download_list);
71        Ok(installation)
72    }
73
74    /// Find an existing [`PythonInstallation`].
75    pub fn find_existing(
76        request: &PythonRequest,
77        environments: EnvironmentPreference,
78        preference: PythonPreference,
79        cache: &Cache,
80        preview: Preview,
81    ) -> Result<Self, Error> {
82        Ok(find_python_installation(
83            request,
84            environments,
85            preference,
86            cache,
87            preview,
88        )??)
89    }
90
91    /// Find or download a [`PythonInstallation`] that satisfies a requested version, if the request
92    /// cannot be satisfied, fallback to the best available Python installation.
93    pub async fn find_best(
94        request: &PythonRequest,
95        environments: EnvironmentPreference,
96        preference: PythonPreference,
97        python_downloads: PythonDownloads,
98        client_builder: &BaseClientBuilder<'_>,
99        cache: &Cache,
100        reporter: Option<&dyn Reporter>,
101        python_install_mirror: Option<&str>,
102        pypy_install_mirror: Option<&str>,
103        python_downloads_json_url: Option<&str>,
104        preview: Preview,
105    ) -> Result<Self, Error> {
106        let downloads_enabled = preference.allows_managed()
107            && python_downloads.is_automatic()
108            && client_builder.connectivity.is_online();
109        let installation = find_best_python_installation(
110            request,
111            environments,
112            preference,
113            downloads_enabled,
114            client_builder,
115            cache,
116            reporter,
117            python_install_mirror,
118            pypy_install_mirror,
119            python_downloads_json_url,
120            preview,
121        )
122        .await?;
123        installation
124            .download_and_warn_if_outdated_prerelease(
125                request,
126                client_builder,
127                python_downloads_json_url,
128            )
129            .await?;
130        Ok(installation)
131    }
132
133    /// Find or fetch a [`PythonInstallation`].
134    ///
135    /// Unlike [`PythonInstallation::find`], if the required Python is not installed it will be installed automatically.
136    pub async fn find_or_download(
137        request: Option<&PythonRequest>,
138        environments: EnvironmentPreference,
139        preference: PythonPreference,
140        python_downloads: PythonDownloads,
141        client_builder: &BaseClientBuilder<'_>,
142        cache: &Cache,
143        reporter: Option<&dyn Reporter>,
144        python_install_mirror: Option<&str>,
145        pypy_install_mirror: Option<&str>,
146        python_downloads_json_url: Option<&str>,
147        preview: Preview,
148    ) -> Result<Self, Error> {
149        let request = request.unwrap_or(&PythonRequest::Default);
150
151        let err = match Self::find_existing(request, environments, preference, cache, preview) {
152            Ok(installation) => {
153                installation
154                    .download_and_warn_if_outdated_prerelease(
155                        request,
156                        client_builder,
157                        python_downloads_json_url,
158                    )
159                    .await?;
160                return Ok(installation);
161            }
162            Err(err) => err,
163        };
164
165        match err {
166            // If Python is missing, we should attempt a download
167            Error::MissingPython(..) => {}
168            // If we raised a non-critical error, we should attempt a download
169            Error::Discovery(ref err) if !err.is_critical() => {}
170            // Otherwise, this is fatal
171            _ => return Err(err),
172        }
173
174        // If we can't convert the request to a download, throw the original error
175        let Some(download_request) = PythonDownloadRequest::from_request(request) else {
176            return Err(err);
177        };
178
179        let download_list_client = client_builder.build()?;
180        let download_list =
181            ManagedPythonDownloadList::new(&download_list_client, python_downloads_json_url)
182                .await?;
183
184        let downloads_enabled = preference.allows_managed()
185            && python_downloads.is_automatic()
186            && client_builder.connectivity.is_online();
187
188        let download = download_request
189            .clone()
190            .fill()
191            .map(|request| download_list.find(&request));
192
193        // Regardless of whether downloads are enabled, we want to determine if the download is
194        // available to power error messages. However, if downloads aren't enabled, we don't want to
195        // report any errors related to them.
196        let download = match download {
197            Ok(Ok(download)) => Some(download),
198            // If the download cannot be found, return the _original_ discovery error
199            Ok(Err(downloads::Error::NoDownloadFound(_))) => {
200                if downloads_enabled {
201                    debug!("No downloads are available for {request}");
202                    if matches!(request, PythonRequest::Default | PythonRequest::Any) {
203                        return Err(err);
204                    }
205                    return Err(err.with_hint(MissingPythonHint::RequiresUpdate));
206                }
207                None
208            }
209            Err(err) | Ok(Err(err)) => {
210                if downloads_enabled {
211                    // We failed to determine the platform information
212                    return Err(err.into());
213                }
214                None
215            }
216        };
217
218        let Some(download) = download else {
219            // N.B. We should only be in this case when downloads are disabled; when downloads are
220            // enabled, we should fail eagerly when something goes wrong with the download.
221            debug_assert!(!downloads_enabled);
222            return Err(err);
223        };
224
225        // If the download is available, but not usable, we attach a hint to the original error.
226        if !downloads_enabled {
227            match python_downloads {
228                PythonDownloads::Automatic => {}
229                PythonDownloads::Manual => {
230                    return Err(err.with_hint(MissingPythonHint::DownloadsManual(request.clone())));
231                }
232                PythonDownloads::Never => {
233                    return Err(err.with_hint(MissingPythonHint::DownloadsNever(request.clone())));
234                }
235            }
236
237            match preference {
238                PythonPreference::OnlySystem => {
239                    return Err(
240                        err.with_hint(MissingPythonHint::PreferenceOnlySystem(request.clone()))
241                    );
242                }
243                PythonPreference::Managed
244                | PythonPreference::OnlyManaged
245                | PythonPreference::System => {}
246            }
247
248            if !client_builder.connectivity.is_online() {
249                return Err(err.with_hint(MissingPythonHint::Offline(request.clone())));
250            }
251
252            return Err(err);
253        }
254
255        // Python downloads are performing their own retries to catch stream errors, disable the
256        // default retries to avoid the middleware performing uncontrolled retries.
257        let retry_policy = client_builder.retry_policy();
258        let download_client = client_builder.clone().retries(0).build()?;
259
260        let installation = Self::fetch(
261            download,
262            &download_client,
263            &retry_policy,
264            cache,
265            reporter,
266            python_install_mirror,
267            pypy_install_mirror,
268        )
269        .await?;
270
271        installation.warn_if_outdated_prerelease(request, &download_list);
272
273        Ok(installation)
274    }
275
276    /// Download and install the requested installation.
277    pub async fn fetch(
278        download: &ManagedPythonDownload,
279        client: &BaseClient,
280        retry_policy: &ExponentialBackoff,
281        cache: &Cache,
282        reporter: Option<&dyn Reporter>,
283        python_install_mirror: Option<&str>,
284        pypy_install_mirror: Option<&str>,
285    ) -> Result<Self, Error> {
286        let installations = ManagedPythonInstallations::from_settings(None)?.init()?;
287        let installations_dir = installations.root();
288        let scratch_dir = installations.scratch();
289        let _lock = installations.lock().await?;
290
291        info!("Fetching requested Python...");
292        let result = download
293            .fetch_with_retry(
294                client,
295                retry_policy,
296                installations_dir,
297                &scratch_dir,
298                false,
299                python_install_mirror,
300                pypy_install_mirror,
301                reporter,
302            )
303            .await?;
304
305        let path = match result {
306            DownloadResult::AlreadyAvailable(path) => path,
307            DownloadResult::Fetched(path) => path,
308        };
309
310        let installed = ManagedPythonInstallation::new(path, download);
311        installed.ensure_externally_managed()?;
312        installed.ensure_sysconfig_patched()?;
313        installed.ensure_canonical_executables()?;
314        installed.ensure_build_file()?;
315
316        let minor_version = installed.minor_version_key();
317        let highest_patch = installations
318            .find_all()?
319            .filter(|installation| installation.minor_version_key() == minor_version)
320            .filter_map(|installation| installation.version().patch())
321            .fold(0, std::cmp::max);
322        if installed
323            .version()
324            .patch()
325            .is_some_and(|p| p >= highest_patch)
326        {
327            installed.ensure_minor_version_link()?;
328        }
329
330        if let Err(e) = installed.ensure_dylib_patched() {
331            e.warn_user(&installed);
332        }
333
334        Ok(Self {
335            source: PythonSource::Managed,
336            interpreter: Interpreter::query(installed.executable(false), cache)?,
337        })
338    }
339
340    /// Create a [`PythonInstallation`] from an existing [`Interpreter`].
341    pub fn from_interpreter(interpreter: Interpreter) -> Self {
342        Self {
343            source: PythonSource::ProvidedPath,
344            interpreter,
345        }
346    }
347
348    /// Return the [`PythonSource`] of the Python installation, indicating where it was found.
349    pub fn source(&self) -> &PythonSource {
350        &self.source
351    }
352
353    pub fn key(&self) -> PythonInstallationKey {
354        self.interpreter.key()
355    }
356
357    /// Return the Python [`Version`] of the Python installation as reported by its interpreter.
358    pub fn python_version(&self) -> &Version {
359        self.interpreter.python_version()
360    }
361
362    /// Return the [`LenientImplementationName`] of the Python installation as reported by its interpreter.
363    pub fn implementation(&self) -> LenientImplementationName {
364        LenientImplementationName::from(self.interpreter.implementation_name())
365    }
366
367    /// Returns `true` if this is a managed (uv-installed) Python installation.
368    ///
369    /// Uses the source as a fast path, then falls back to checking the interpreter's base prefix.
370    pub fn is_managed(&self) -> bool {
371        self.source.is_managed() || self.interpreter.is_managed()
372    }
373
374    /// Whether this is a CPython installation.
375    ///
376    /// Returns false if it is an alternative implementation, e.g., PyPy.
377    pub(crate) fn is_alternative_implementation(&self) -> bool {
378        !matches!(
379            self.implementation(),
380            LenientImplementationName::Known(ImplementationName::CPython)
381        ) || self.os().is_emscripten()
382    }
383
384    /// Return the [`Arch`] of the Python installation as reported by its interpreter.
385    pub fn arch(&self) -> Arch {
386        self.interpreter.arch()
387    }
388
389    /// Return the [`Libc`] of the Python installation as reported by its interpreter.
390    pub fn libc(&self) -> Libc {
391        self.interpreter.libc()
392    }
393
394    /// Return the [`Os`] of the Python installation as reported by its interpreter.
395    pub fn os(&self) -> Os {
396        self.interpreter.os()
397    }
398
399    /// Return the [`Interpreter`] for the Python installation.
400    pub fn interpreter(&self) -> &Interpreter {
401        &self.interpreter
402    }
403
404    /// Consume the [`PythonInstallation`] and return the [`Interpreter`].
405    pub fn into_interpreter(self) -> Interpreter {
406        self.interpreter
407    }
408
409    /// Return `true` when checking for an outdated managed prerelease warning may be necessary.
410    fn should_check_outdated_prerelease_warning(&self, request: &PythonRequest) -> bool {
411        if request.allows_prereleases() {
412            return false;
413        }
414
415        let interpreter = self.interpreter();
416
417        if interpreter.python_version().pre().is_none() {
418            return false;
419        }
420
421        if !interpreter.is_managed() {
422            return false;
423        }
424
425        // Transparent upgrades only exist for CPython, so skip the warning for other
426        // managed implementations.
427        //
428        // See: https://github.com/astral-sh/uv/issues/16675
429        if !interpreter
430            .implementation_name()
431            .eq_ignore_ascii_case("cpython")
432        {
433            return false;
434        }
435
436        true
437    }
438
439    /// Emit a warning when the interpreter is a managed prerelease and a matching stable
440    /// build can be installed via `uv python upgrade`.
441    fn warn_if_outdated_prerelease(
442        &self,
443        request: &PythonRequest,
444        download_list: &ManagedPythonDownloadList,
445    ) {
446        if !self.should_check_outdated_prerelease_warning(request) {
447            return;
448        }
449
450        let interpreter = self.interpreter();
451        let version = interpreter.python_version();
452
453        let release = version.only_release();
454
455        let Ok(download_request) = PythonDownloadRequest::try_from(&interpreter.key()) else {
456            return;
457        };
458
459        let download_request = download_request.with_prereleases(false);
460
461        let has_stable_download = {
462            let mut downloads = download_list.iter_matching(&download_request);
463
464            downloads.any(|download| {
465                let download_version = download.key().version().into_version();
466                download_version.pre().is_none() && download_version.only_release() >= release
467            })
468        };
469
470        if !has_stable_download {
471            return;
472        }
473
474        if let Some(upgrade_request) = download_request
475            .unset_defaults()
476            .without_patch()
477            .simplified_display()
478        {
479            warn_user!(
480                "You're using a pre-release version of Python ({}) but a stable version is available. Use `uv python upgrade {}` to upgrade.",
481                version,
482                upgrade_request
483            );
484        } else {
485            warn_user!(
486                "You're using a pre-release version of Python ({}) but a stable version is available. Run `uv python upgrade` to update your managed interpreters.",
487                version,
488            );
489        }
490    }
491
492    /// Emit a warning when the interpreter is a managed prerelease and a matching stable
493    /// build can be installed via `uv python upgrade`.
494    ///
495    /// Avoids loading the Python download list unless the discovered interpreter could require
496    /// the warning.
497    pub async fn download_and_warn_if_outdated_prerelease(
498        &self,
499        request: &PythonRequest,
500        client_builder: &BaseClientBuilder<'_>,
501        python_downloads_json_url: Option<&str>,
502    ) -> Result<(), Error> {
503        if !self.should_check_outdated_prerelease_warning(request) {
504            return Ok(());
505        }
506
507        let download_list_client = client_builder.build()?;
508        let download_list =
509            ManagedPythonDownloadList::new(&download_list_client, python_downloads_json_url)
510                .await?;
511        self.warn_if_outdated_prerelease(request, &download_list);
512
513        Ok(())
514    }
515}
516
517#[derive(Error, Debug)]
518pub enum PythonInstallationKeyError {
519    #[error("Failed to parse Python installation key `{0}`: {1}")]
520    ParseError(String, String),
521}
522
523#[derive(Debug, Clone, PartialEq, Eq, Hash)]
524pub struct PythonInstallationKey {
525    pub(crate) implementation: LenientImplementationName,
526    pub(crate) major: u8,
527    pub(crate) minor: u8,
528    pub(crate) patch: u8,
529    pub(crate) prerelease: Option<Prerelease>,
530    pub(crate) platform: Platform,
531    pub(crate) variant: PythonVariant,
532}
533
534impl PythonInstallationKey {
535    pub fn new(
536        implementation: LenientImplementationName,
537        major: u8,
538        minor: u8,
539        patch: u8,
540        prerelease: Option<Prerelease>,
541        platform: Platform,
542        variant: PythonVariant,
543    ) -> Self {
544        Self {
545            implementation,
546            major,
547            minor,
548            patch,
549            prerelease,
550            platform,
551            variant,
552        }
553    }
554
555    pub(crate) fn new_from_version(
556        implementation: LenientImplementationName,
557        version: &PythonVersion,
558        platform: Platform,
559        variant: PythonVariant,
560    ) -> Self {
561        Self {
562            implementation,
563            major: version.major(),
564            minor: version.minor(),
565            patch: version.patch().unwrap_or_default(),
566            prerelease: version.pre(),
567            platform,
568            variant,
569        }
570    }
571
572    pub fn implementation(&self) -> Cow<'_, LenientImplementationName> {
573        if self.os().is_emscripten() {
574            Cow::Owned(LenientImplementationName::from(ImplementationName::Pyodide))
575        } else {
576            Cow::Borrowed(&self.implementation)
577        }
578    }
579
580    pub fn version(&self) -> PythonVersion {
581        PythonVersion::from_str(&format!(
582            "{}.{}.{}{}",
583            self.major,
584            self.minor,
585            self.patch,
586            self.prerelease
587                .map(|pre| pre.to_string())
588                .unwrap_or_default()
589        ))
590        .expect("Python installation keys must have valid Python versions")
591    }
592
593    /// The version in `x.y.z` format.
594    #[cfg(windows)]
595    pub(crate) fn sys_version(&self) -> String {
596        format!("{}.{}.{}", self.major, self.minor, self.patch)
597    }
598
599    pub fn major(&self) -> u8 {
600        self.major
601    }
602
603    pub fn minor(&self) -> u8 {
604        self.minor
605    }
606
607    pub fn prerelease(&self) -> Option<Prerelease> {
608        self.prerelease
609    }
610
611    pub fn platform(&self) -> &Platform {
612        &self.platform
613    }
614
615    pub fn arch(&self) -> &Arch {
616        &self.platform.arch
617    }
618
619    pub fn os(&self) -> &Os {
620        &self.platform.os
621    }
622
623    pub fn libc(&self) -> &Libc {
624        &self.platform.libc
625    }
626
627    pub fn variant(&self) -> &PythonVariant {
628        &self.variant
629    }
630
631    /// Return a canonical name for a minor versioned executable.
632    pub fn executable_name_minor(&self) -> String {
633        format!(
634            "{name}{maj}.{min}{var}{exe}",
635            name = self.implementation().executable_install_name(),
636            maj = self.major,
637            min = self.minor,
638            var = self.variant.executable_suffix(),
639            exe = std::env::consts::EXE_SUFFIX
640        )
641    }
642
643    /// Return a canonical name for a major versioned executable.
644    pub fn executable_name_major(&self) -> String {
645        format!(
646            "{name}{maj}{var}{exe}",
647            name = self.implementation().executable_install_name(),
648            maj = self.major,
649            var = self.variant.executable_suffix(),
650            exe = std::env::consts::EXE_SUFFIX
651        )
652    }
653
654    /// Return a canonical name for an un-versioned executable.
655    pub fn executable_name(&self) -> String {
656        format!(
657            "{name}{var}{exe}",
658            name = self.implementation().executable_install_name(),
659            var = self.variant.executable_suffix(),
660            exe = std::env::consts::EXE_SUFFIX
661        )
662    }
663}
664
665impl fmt::Display for PythonInstallationKey {
666    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
667        let variant = match self.variant {
668            PythonVariant::Default => String::new(),
669            _ => format!("+{}", self.variant),
670        };
671        write!(
672            f,
673            "{}-{}.{}.{}{}{}-{}",
674            self.implementation(),
675            self.major,
676            self.minor,
677            self.patch,
678            self.prerelease
679                .map(|pre| pre.to_string())
680                .unwrap_or_default(),
681            variant,
682            self.platform
683        )
684    }
685}
686
687impl FromStr for PythonInstallationKey {
688    type Err = PythonInstallationKeyError;
689
690    fn from_str(key: &str) -> Result<Self, Self::Err> {
691        let parts = key.split('-').collect::<Vec<_>>();
692
693        // We need exactly implementation-version-os-arch-libc
694        if parts.len() != 5 {
695            return Err(PythonInstallationKeyError::ParseError(
696                key.to_string(),
697                format!(
698                    "expected exactly 5 `-`-separated values, got {}",
699                    parts.len()
700                ),
701            ));
702        }
703
704        let [implementation_str, version_str, os, arch, libc] = parts.as_slice() else {
705            unreachable!()
706        };
707
708        let implementation = LenientImplementationName::from(*implementation_str);
709
710        let (version, variant) = match version_str.split_once('+') {
711            Some((version, variant)) => {
712                let variant = PythonVariant::from_str(variant).map_err(|()| {
713                    PythonInstallationKeyError::ParseError(
714                        key.to_string(),
715                        format!("invalid Python variant: {variant}"),
716                    )
717                })?;
718                (version, variant)
719            }
720            None => (*version_str, PythonVariant::Default),
721        };
722
723        let version = PythonVersion::from_str(version).map_err(|err| {
724            PythonInstallationKeyError::ParseError(
725                key.to_string(),
726                format!("invalid Python version: {err}"),
727            )
728        })?;
729
730        let platform = Platform::from_parts(os, arch, libc).map_err(|err| {
731            PythonInstallationKeyError::ParseError(
732                key.to_string(),
733                format!("invalid platform: {err}"),
734            )
735        })?;
736
737        Ok(Self {
738            implementation,
739            major: version.major(),
740            minor: version.minor(),
741            patch: version.patch().unwrap_or_default(),
742            prerelease: version.pre(),
743            platform,
744            variant,
745        })
746    }
747}
748
749impl PartialOrd for PythonInstallationKey {
750    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
751        Some(self.cmp(other))
752    }
753}
754
755impl Ord for PythonInstallationKey {
756    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
757        self.implementation
758            .cmp(&other.implementation)
759            .then_with(|| self.version().cmp(&other.version()))
760            // Platforms are sorted in preferred order for the target
761            .then_with(|| self.platform.cmp(&other.platform).reverse())
762            // Python variants are sorted in preferred order, with `Default` first
763            .then_with(|| self.variant.cmp(&other.variant).reverse())
764    }
765}
766
767/// A view into a [`PythonInstallationKey`] that excludes the patch and prerelease versions.
768#[derive(Clone, Eq, Ord, PartialOrd, RefCast)]
769#[repr(transparent)]
770pub struct PythonInstallationMinorVersionKey(PythonInstallationKey);
771
772impl PythonInstallationMinorVersionKey {
773    /// Cast a `&PythonInstallationKey` to a `&PythonInstallationMinorVersionKey` using ref-cast.
774    #[inline]
775    pub fn ref_cast(key: &PythonInstallationKey) -> &Self {
776        RefCast::ref_cast(key)
777    }
778
779    /// Takes an [`IntoIterator`] of [`ManagedPythonInstallation`]s and returns an [`FxHashMap`] from
780    /// [`PythonInstallationMinorVersionKey`] to the installation with highest [`PythonInstallationKey`]
781    /// for that minor version key.
782    #[inline]
783    pub fn highest_installations_by_minor_version_key<'a, I>(
784        installations: I,
785    ) -> IndexMap<Self, ManagedPythonInstallation>
786    where
787        I: IntoIterator<Item = &'a ManagedPythonInstallation>,
788    {
789        let mut minor_versions = IndexMap::default();
790        for installation in installations {
791            minor_versions
792                .entry(installation.minor_version_key().clone())
793                .and_modify(|high_installation: &mut ManagedPythonInstallation| {
794                    if installation.key() >= high_installation.key() {
795                        *high_installation = installation.clone();
796                    }
797                })
798                .or_insert_with(|| installation.clone());
799        }
800        minor_versions
801    }
802}
803
804impl fmt::Display for PythonInstallationMinorVersionKey {
805    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
806        // Display every field on the wrapped key except the patch
807        // and prerelease (with special formatting for the variant).
808        let variant = match self.0.variant {
809            PythonVariant::Default => String::new(),
810            _ => format!("+{}", self.0.variant),
811        };
812        write!(
813            f,
814            "{}-{}.{}{}-{}",
815            self.0.implementation, self.0.major, self.0.minor, variant, self.0.platform,
816        )
817    }
818}
819
820impl fmt::Debug for PythonInstallationMinorVersionKey {
821    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
822        // Display every field on the wrapped key except the patch
823        // and prerelease.
824        f.debug_struct("PythonInstallationMinorVersionKey")
825            .field("implementation", &self.0.implementation)
826            .field("major", &self.0.major)
827            .field("minor", &self.0.minor)
828            .field("variant", &self.0.variant)
829            .field("os", &self.0.platform.os)
830            .field("arch", &self.0.platform.arch)
831            .field("libc", &self.0.platform.libc)
832            .finish()
833    }
834}
835
836impl PartialEq for PythonInstallationMinorVersionKey {
837    fn eq(&self, other: &Self) -> bool {
838        // Compare every field on the wrapped key except the patch
839        // and prerelease.
840        self.0.implementation == other.0.implementation
841            && self.0.major == other.0.major
842            && self.0.minor == other.0.minor
843            && self.0.platform == other.0.platform
844            && self.0.variant == other.0.variant
845    }
846}
847
848impl Hash for PythonInstallationMinorVersionKey {
849    fn hash<H: Hasher>(&self, state: &mut H) {
850        // Hash every field on the wrapped key except the patch
851        // and prerelease.
852        self.0.implementation.hash(state);
853        self.0.major.hash(state);
854        self.0.minor.hash(state);
855        self.0.platform.hash(state);
856        self.0.variant.hash(state);
857    }
858}
859
860impl From<PythonInstallationKey> for PythonInstallationMinorVersionKey {
861    fn from(key: PythonInstallationKey) -> Self {
862        Self(key)
863    }
864}
865
866#[cfg(test)]
867mod tests {
868    use super::*;
869    use uv_platform::ArchVariant;
870
871    #[test]
872    fn test_python_installation_key_from_str() {
873        // Test basic parsing
874        let key = PythonInstallationKey::from_str("cpython-3.12.0-linux-x86_64-gnu").unwrap();
875        assert_eq!(
876            key.implementation,
877            LenientImplementationName::Known(ImplementationName::CPython)
878        );
879        assert_eq!(key.major, 3);
880        assert_eq!(key.minor, 12);
881        assert_eq!(key.patch, 0);
882        assert_eq!(
883            key.platform.os,
884            Os::new(target_lexicon::OperatingSystem::Linux)
885        );
886        assert_eq!(
887            key.platform.arch,
888            Arch::new(target_lexicon::Architecture::X86_64, None)
889        );
890        assert_eq!(
891            key.platform.libc,
892            Libc::Some(target_lexicon::Environment::Gnu)
893        );
894
895        // Test with architecture variant
896        let key = PythonInstallationKey::from_str("cpython-3.11.2-linux-x86_64_v3-musl").unwrap();
897        assert_eq!(
898            key.implementation,
899            LenientImplementationName::Known(ImplementationName::CPython)
900        );
901        assert_eq!(key.major, 3);
902        assert_eq!(key.minor, 11);
903        assert_eq!(key.patch, 2);
904        assert_eq!(
905            key.platform.os,
906            Os::new(target_lexicon::OperatingSystem::Linux)
907        );
908        assert_eq!(
909            key.platform.arch,
910            Arch::new(target_lexicon::Architecture::X86_64, Some(ArchVariant::V3))
911        );
912        assert_eq!(
913            key.platform.libc,
914            Libc::Some(target_lexicon::Environment::Musl)
915        );
916
917        // Test with Python variant (freethreaded)
918        let key = PythonInstallationKey::from_str("cpython-3.13.0+freethreaded-macos-aarch64-none")
919            .unwrap();
920        assert_eq!(
921            key.implementation,
922            LenientImplementationName::Known(ImplementationName::CPython)
923        );
924        assert_eq!(key.major, 3);
925        assert_eq!(key.minor, 13);
926        assert_eq!(key.patch, 0);
927        assert_eq!(key.variant, PythonVariant::Freethreaded);
928        assert_eq!(
929            key.platform.os,
930            Os::new(target_lexicon::OperatingSystem::Darwin(None))
931        );
932        assert_eq!(
933            key.platform.arch,
934            Arch::new(
935                target_lexicon::Architecture::Aarch64(target_lexicon::Aarch64Architecture::Aarch64),
936                None
937            )
938        );
939        assert_eq!(key.platform.libc, Libc::None);
940
941        // Test error cases
942        assert!(PythonInstallationKey::from_str("cpython-3.12.0-linux-x86_64").is_err());
943        assert!(PythonInstallationKey::from_str("cpython-3.12.0").is_err());
944        assert!(PythonInstallationKey::from_str("cpython").is_err());
945    }
946
947    #[test]
948    fn test_python_installation_key_display() {
949        let key = PythonInstallationKey {
950            implementation: LenientImplementationName::from("cpython"),
951            major: 3,
952            minor: 12,
953            patch: 0,
954            prerelease: None,
955            platform: Platform::from_str("linux-x86_64-gnu").unwrap(),
956            variant: PythonVariant::Default,
957        };
958        assert_eq!(key.to_string(), "cpython-3.12.0-linux-x86_64-gnu");
959
960        let key_with_variant = PythonInstallationKey {
961            implementation: LenientImplementationName::from("cpython"),
962            major: 3,
963            minor: 13,
964            patch: 0,
965            prerelease: None,
966            platform: Platform::from_str("macos-aarch64-none").unwrap(),
967            variant: PythonVariant::Freethreaded,
968        };
969        assert_eq!(
970            key_with_variant.to_string(),
971            "cpython-3.13.0+freethreaded-macos-aarch64-none"
972        );
973    }
974}