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, PythonDownloads, PythonPreference, PythonSource,
29    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_missing_python_hint(
206                        "uv embeds available Python downloads and may require an update to install new versions. Consider retrying on a newer version of uv."
207                            .to_string(),
208                    ));
209                }
210                None
211            }
212            Err(err) | Ok(Err(err)) => {
213                if downloads_enabled {
214                    // We failed to determine the platform information
215                    return Err(err.into());
216                }
217                None
218            }
219        };
220
221        let Some(download) = download else {
222            // N.B. We should only be in this case when downloads are disabled; when downloads are
223            // enabled, we should fail eagerly when something goes wrong with the download.
224            debug_assert!(!downloads_enabled);
225            return Err(err);
226        };
227
228        // If the download is available, but not usable, we attach a hint to the original error.
229        if !downloads_enabled {
230            let for_request = match request {
231                PythonRequest::Default | PythonRequest::Any => String::new(),
232                _ => format!(" for {request}"),
233            };
234
235            match python_downloads {
236                PythonDownloads::Automatic => {}
237                PythonDownloads::Manual => {
238                    return Err(err.with_missing_python_hint(format!(
239                        "A managed Python download is available{for_request}, but Python downloads are set to 'manual', use `uv python install {}` to install the required version",
240                        request.to_canonical_string(),
241                    )));
242                }
243                PythonDownloads::Never => {
244                    return Err(err.with_missing_python_hint(format!(
245                        "A managed Python download is available{for_request}, but Python downloads are set to 'never'"
246                    )));
247                }
248            }
249
250            match preference {
251                PythonPreference::OnlySystem => {
252                    return Err(err.with_missing_python_hint(format!(
253                        "A managed Python download is available{for_request}, but the Python preference is set to 'only system'"
254                    )));
255                }
256                PythonPreference::Managed
257                | PythonPreference::OnlyManaged
258                | PythonPreference::System => {}
259            }
260
261            if !client_builder.connectivity.is_online() {
262                return Err(err.with_missing_python_hint(format!(
263                    "A managed Python download is available{for_request}, but uv is set to offline mode"
264                )));
265            }
266
267            return Err(err);
268        }
269
270        // Python downloads are performing their own retries to catch stream errors, disable the
271        // default retries to avoid the middleware performing uncontrolled retries.
272        let retry_policy = client_builder.retry_policy();
273        let download_client = client_builder.clone().retries(0).build()?;
274
275        let installation = Self::fetch(
276            download,
277            &download_client,
278            &retry_policy,
279            cache,
280            reporter,
281            python_install_mirror,
282            pypy_install_mirror,
283        )
284        .await?;
285
286        installation.warn_if_outdated_prerelease(request, &download_list);
287
288        Ok(installation)
289    }
290
291    /// Download and install the requested installation.
292    pub async fn fetch(
293        download: &ManagedPythonDownload,
294        client: &BaseClient,
295        retry_policy: &ExponentialBackoff,
296        cache: &Cache,
297        reporter: Option<&dyn Reporter>,
298        python_install_mirror: Option<&str>,
299        pypy_install_mirror: Option<&str>,
300    ) -> Result<Self, Error> {
301        let installations = ManagedPythonInstallations::from_settings(None)?.init()?;
302        let installations_dir = installations.root();
303        let scratch_dir = installations.scratch();
304        let _lock = installations.lock().await?;
305
306        info!("Fetching requested Python...");
307        let result = download
308            .fetch_with_retry(
309                client,
310                retry_policy,
311                installations_dir,
312                &scratch_dir,
313                false,
314                python_install_mirror,
315                pypy_install_mirror,
316                reporter,
317            )
318            .await?;
319
320        let path = match result {
321            DownloadResult::AlreadyAvailable(path) => path,
322            DownloadResult::Fetched(path) => path,
323        };
324
325        let installed = ManagedPythonInstallation::new(path, download);
326        installed.ensure_externally_managed()?;
327        installed.ensure_sysconfig_patched()?;
328        installed.ensure_canonical_executables()?;
329        installed.ensure_build_file()?;
330
331        let minor_version = installed.minor_version_key();
332        let highest_patch = installations
333            .find_all()?
334            .filter(|installation| installation.minor_version_key() == minor_version)
335            .filter_map(|installation| installation.version().patch())
336            .fold(0, std::cmp::max);
337        if installed
338            .version()
339            .patch()
340            .is_some_and(|p| p >= highest_patch)
341        {
342            installed.ensure_minor_version_link()?;
343        }
344
345        if let Err(e) = installed.ensure_dylib_patched() {
346            e.warn_user(&installed);
347        }
348
349        Ok(Self {
350            source: PythonSource::Managed,
351            interpreter: Interpreter::query(installed.executable(false), cache)?,
352        })
353    }
354
355    /// Create a [`PythonInstallation`] from an existing [`Interpreter`].
356    pub fn from_interpreter(interpreter: Interpreter) -> Self {
357        Self {
358            source: PythonSource::ProvidedPath,
359            interpreter,
360        }
361    }
362
363    /// Return the [`PythonSource`] of the Python installation, indicating where it was found.
364    pub fn source(&self) -> &PythonSource {
365        &self.source
366    }
367
368    pub fn key(&self) -> PythonInstallationKey {
369        self.interpreter.key()
370    }
371
372    /// Return the Python [`Version`] of the Python installation as reported by its interpreter.
373    pub fn python_version(&self) -> &Version {
374        self.interpreter.python_version()
375    }
376
377    /// Return the [`LenientImplementationName`] of the Python installation as reported by its interpreter.
378    pub fn implementation(&self) -> LenientImplementationName {
379        LenientImplementationName::from(self.interpreter.implementation_name())
380    }
381
382    /// Returns `true` if this is a managed (uv-installed) Python installation.
383    ///
384    /// Uses the source as a fast path, then falls back to checking the interpreter's base prefix.
385    pub fn is_managed(&self) -> bool {
386        self.source.is_managed() || self.interpreter.is_managed()
387    }
388
389    /// Whether this is a CPython installation.
390    ///
391    /// Returns false if it is an alternative implementation, e.g., PyPy.
392    pub(crate) fn is_alternative_implementation(&self) -> bool {
393        !matches!(
394            self.implementation(),
395            LenientImplementationName::Known(ImplementationName::CPython)
396        ) || self.os().is_emscripten()
397    }
398
399    /// Return the [`Arch`] of the Python installation as reported by its interpreter.
400    pub fn arch(&self) -> Arch {
401        self.interpreter.arch()
402    }
403
404    /// Return the [`Libc`] of the Python installation as reported by its interpreter.
405    pub fn libc(&self) -> Libc {
406        self.interpreter.libc()
407    }
408
409    /// Return the [`Os`] of the Python installation as reported by its interpreter.
410    pub fn os(&self) -> Os {
411        self.interpreter.os()
412    }
413
414    /// Return the [`Interpreter`] for the Python installation.
415    pub fn interpreter(&self) -> &Interpreter {
416        &self.interpreter
417    }
418
419    /// Consume the [`PythonInstallation`] and return the [`Interpreter`].
420    pub fn into_interpreter(self) -> Interpreter {
421        self.interpreter
422    }
423
424    /// Return `true` when checking for an outdated managed prerelease warning may be necessary.
425    fn should_check_outdated_prerelease_warning(&self, request: &PythonRequest) -> bool {
426        if request.allows_prereleases() {
427            return false;
428        }
429
430        let interpreter = self.interpreter();
431
432        if interpreter.python_version().pre().is_none() {
433            return false;
434        }
435
436        if !interpreter.is_managed() {
437            return false;
438        }
439
440        // Transparent upgrades only exist for CPython, so skip the warning for other
441        // managed implementations.
442        //
443        // See: https://github.com/astral-sh/uv/issues/16675
444        if !interpreter
445            .implementation_name()
446            .eq_ignore_ascii_case("cpython")
447        {
448            return false;
449        }
450
451        true
452    }
453
454    /// Emit a warning when the interpreter is a managed prerelease and a matching stable
455    /// build can be installed via `uv python upgrade`.
456    pub(crate) fn warn_if_outdated_prerelease(
457        &self,
458        request: &PythonRequest,
459        download_list: &ManagedPythonDownloadList,
460    ) {
461        if !self.should_check_outdated_prerelease_warning(request) {
462            return;
463        }
464
465        let interpreter = self.interpreter();
466        let version = interpreter.python_version();
467
468        let release = version.only_release();
469
470        let Ok(download_request) = PythonDownloadRequest::try_from(&interpreter.key()) else {
471            return;
472        };
473
474        let download_request = download_request.with_prereleases(false);
475
476        let has_stable_download = {
477            let mut downloads = download_list.iter_matching(&download_request);
478
479            downloads.any(|download| {
480                let download_version = download.key().version().into_version();
481                download_version.pre().is_none() && download_version.only_release() >= release
482            })
483        };
484
485        if !has_stable_download {
486            return;
487        }
488
489        if let Some(upgrade_request) = download_request
490            .unset_defaults()
491            .without_patch()
492            .simplified_display()
493        {
494            warn_user!(
495                "You're using a pre-release version of Python ({}) but a stable version is available. Use `uv python upgrade {}` to upgrade.",
496                version,
497                upgrade_request
498            );
499        } else {
500            warn_user!(
501                "You're using a pre-release version of Python ({}) but a stable version is available. Run `uv python upgrade` to update your managed interpreters.",
502                version,
503            );
504        }
505    }
506
507    /// Emit a warning when the interpreter is a managed prerelease and a matching stable
508    /// build can be installed via `uv python upgrade`.
509    ///
510    /// Avoids loading the Python download list unless the discovered interpreter could require
511    /// the warning.
512    pub async fn download_and_warn_if_outdated_prerelease(
513        &self,
514        request: &PythonRequest,
515        client_builder: &BaseClientBuilder<'_>,
516        python_downloads_json_url: Option<&str>,
517    ) -> Result<(), Error> {
518        if !self.should_check_outdated_prerelease_warning(request) {
519            return Ok(());
520        }
521
522        let download_list_client = client_builder.build()?;
523        let download_list =
524            ManagedPythonDownloadList::new(&download_list_client, python_downloads_json_url)
525                .await?;
526        self.warn_if_outdated_prerelease(request, &download_list);
527
528        Ok(())
529    }
530}
531
532#[derive(Error, Debug)]
533pub enum PythonInstallationKeyError {
534    #[error("Failed to parse Python installation key `{0}`: {1}")]
535    ParseError(String, String),
536}
537
538#[derive(Debug, Clone, PartialEq, Eq, Hash)]
539pub struct PythonInstallationKey {
540    pub(crate) implementation: LenientImplementationName,
541    pub(crate) major: u8,
542    pub(crate) minor: u8,
543    pub(crate) patch: u8,
544    pub(crate) prerelease: Option<Prerelease>,
545    pub(crate) platform: Platform,
546    pub(crate) variant: PythonVariant,
547}
548
549impl PythonInstallationKey {
550    pub fn new(
551        implementation: LenientImplementationName,
552        major: u8,
553        minor: u8,
554        patch: u8,
555        prerelease: Option<Prerelease>,
556        platform: Platform,
557        variant: PythonVariant,
558    ) -> Self {
559        Self {
560            implementation,
561            major,
562            minor,
563            patch,
564            prerelease,
565            platform,
566            variant,
567        }
568    }
569
570    pub fn new_from_version(
571        implementation: LenientImplementationName,
572        version: &PythonVersion,
573        platform: Platform,
574        variant: PythonVariant,
575    ) -> Self {
576        Self {
577            implementation,
578            major: version.major(),
579            minor: version.minor(),
580            patch: version.patch().unwrap_or_default(),
581            prerelease: version.pre(),
582            platform,
583            variant,
584        }
585    }
586
587    pub fn implementation(&self) -> Cow<'_, LenientImplementationName> {
588        if self.os().is_emscripten() {
589            Cow::Owned(LenientImplementationName::from(ImplementationName::Pyodide))
590        } else {
591            Cow::Borrowed(&self.implementation)
592        }
593    }
594
595    pub fn version(&self) -> PythonVersion {
596        PythonVersion::from_str(&format!(
597            "{}.{}.{}{}",
598            self.major,
599            self.minor,
600            self.patch,
601            self.prerelease
602                .map(|pre| pre.to_string())
603                .unwrap_or_default()
604        ))
605        .expect("Python installation keys must have valid Python versions")
606    }
607
608    /// The version in `x.y.z` format.
609    pub fn sys_version(&self) -> String {
610        format!("{}.{}.{}", self.major, self.minor, self.patch)
611    }
612
613    pub fn major(&self) -> u8 {
614        self.major
615    }
616
617    pub fn minor(&self) -> u8 {
618        self.minor
619    }
620
621    pub fn prerelease(&self) -> Option<Prerelease> {
622        self.prerelease
623    }
624
625    pub fn platform(&self) -> &Platform {
626        &self.platform
627    }
628
629    pub fn arch(&self) -> &Arch {
630        &self.platform.arch
631    }
632
633    pub fn os(&self) -> &Os {
634        &self.platform.os
635    }
636
637    pub fn libc(&self) -> &Libc {
638        &self.platform.libc
639    }
640
641    pub fn variant(&self) -> &PythonVariant {
642        &self.variant
643    }
644
645    /// Return a canonical name for a minor versioned executable.
646    pub fn executable_name_minor(&self) -> String {
647        format!(
648            "{name}{maj}.{min}{var}{exe}",
649            name = self.implementation().executable_install_name(),
650            maj = self.major,
651            min = self.minor,
652            var = self.variant.executable_suffix(),
653            exe = std::env::consts::EXE_SUFFIX
654        )
655    }
656
657    /// Return a canonical name for a major versioned executable.
658    pub fn executable_name_major(&self) -> String {
659        format!(
660            "{name}{maj}{var}{exe}",
661            name = self.implementation().executable_install_name(),
662            maj = self.major,
663            var = self.variant.executable_suffix(),
664            exe = std::env::consts::EXE_SUFFIX
665        )
666    }
667
668    /// Return a canonical name for an un-versioned executable.
669    pub fn executable_name(&self) -> String {
670        format!(
671            "{name}{var}{exe}",
672            name = self.implementation().executable_install_name(),
673            var = self.variant.executable_suffix(),
674            exe = std::env::consts::EXE_SUFFIX
675        )
676    }
677}
678
679impl fmt::Display for PythonInstallationKey {
680    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
681        let variant = match self.variant {
682            PythonVariant::Default => String::new(),
683            _ => format!("+{}", self.variant),
684        };
685        write!(
686            f,
687            "{}-{}.{}.{}{}{}-{}",
688            self.implementation(),
689            self.major,
690            self.minor,
691            self.patch,
692            self.prerelease
693                .map(|pre| pre.to_string())
694                .unwrap_or_default(),
695            variant,
696            self.platform
697        )
698    }
699}
700
701impl FromStr for PythonInstallationKey {
702    type Err = PythonInstallationKeyError;
703
704    fn from_str(key: &str) -> Result<Self, Self::Err> {
705        let parts = key.split('-').collect::<Vec<_>>();
706
707        // We need exactly implementation-version-os-arch-libc
708        if parts.len() != 5 {
709            return Err(PythonInstallationKeyError::ParseError(
710                key.to_string(),
711                format!(
712                    "expected exactly 5 `-`-separated values, got {}",
713                    parts.len()
714                ),
715            ));
716        }
717
718        let [implementation_str, version_str, os, arch, libc] = parts.as_slice() else {
719            unreachable!()
720        };
721
722        let implementation = LenientImplementationName::from(*implementation_str);
723
724        let (version, variant) = match version_str.split_once('+') {
725            Some((version, variant)) => {
726                let variant = PythonVariant::from_str(variant).map_err(|()| {
727                    PythonInstallationKeyError::ParseError(
728                        key.to_string(),
729                        format!("invalid Python variant: {variant}"),
730                    )
731                })?;
732                (version, variant)
733            }
734            None => (*version_str, PythonVariant::Default),
735        };
736
737        let version = PythonVersion::from_str(version).map_err(|err| {
738            PythonInstallationKeyError::ParseError(
739                key.to_string(),
740                format!("invalid Python version: {err}"),
741            )
742        })?;
743
744        let platform = Platform::from_parts(os, arch, libc).map_err(|err| {
745            PythonInstallationKeyError::ParseError(
746                key.to_string(),
747                format!("invalid platform: {err}"),
748            )
749        })?;
750
751        Ok(Self {
752            implementation,
753            major: version.major(),
754            minor: version.minor(),
755            patch: version.patch().unwrap_or_default(),
756            prerelease: version.pre(),
757            platform,
758            variant,
759        })
760    }
761}
762
763impl PartialOrd for PythonInstallationKey {
764    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
765        Some(self.cmp(other))
766    }
767}
768
769impl Ord for PythonInstallationKey {
770    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
771        self.implementation
772            .cmp(&other.implementation)
773            .then_with(|| self.version().cmp(&other.version()))
774            // Platforms are sorted in preferred order for the target
775            .then_with(|| self.platform.cmp(&other.platform).reverse())
776            // Python variants are sorted in preferred order, with `Default` first
777            .then_with(|| self.variant.cmp(&other.variant).reverse())
778    }
779}
780
781/// A view into a [`PythonInstallationKey`] that excludes the patch and prerelease versions.
782#[derive(Clone, Eq, Ord, PartialOrd, RefCast)]
783#[repr(transparent)]
784pub struct PythonInstallationMinorVersionKey(PythonInstallationKey);
785
786impl PythonInstallationMinorVersionKey {
787    /// Cast a `&PythonInstallationKey` to a `&PythonInstallationMinorVersionKey` using ref-cast.
788    #[inline]
789    pub fn ref_cast(key: &PythonInstallationKey) -> &Self {
790        RefCast::ref_cast(key)
791    }
792
793    /// Takes an [`IntoIterator`] of [`ManagedPythonInstallation`]s and returns an [`FxHashMap`] from
794    /// [`PythonInstallationMinorVersionKey`] to the installation with highest [`PythonInstallationKey`]
795    /// for that minor version key.
796    #[inline]
797    pub fn highest_installations_by_minor_version_key<'a, I>(
798        installations: I,
799    ) -> IndexMap<Self, ManagedPythonInstallation>
800    where
801        I: IntoIterator<Item = &'a ManagedPythonInstallation>,
802    {
803        let mut minor_versions = IndexMap::default();
804        for installation in installations {
805            minor_versions
806                .entry(installation.minor_version_key().clone())
807                .and_modify(|high_installation: &mut ManagedPythonInstallation| {
808                    if installation.key() >= high_installation.key() {
809                        *high_installation = installation.clone();
810                    }
811                })
812                .or_insert_with(|| installation.clone());
813        }
814        minor_versions
815    }
816}
817
818impl fmt::Display for PythonInstallationMinorVersionKey {
819    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
820        // Display every field on the wrapped key except the patch
821        // and prerelease (with special formatting for the variant).
822        let variant = match self.0.variant {
823            PythonVariant::Default => String::new(),
824            _ => format!("+{}", self.0.variant),
825        };
826        write!(
827            f,
828            "{}-{}.{}{}-{}",
829            self.0.implementation, self.0.major, self.0.minor, variant, self.0.platform,
830        )
831    }
832}
833
834impl fmt::Debug for PythonInstallationMinorVersionKey {
835    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
836        // Display every field on the wrapped key except the patch
837        // and prerelease.
838        f.debug_struct("PythonInstallationMinorVersionKey")
839            .field("implementation", &self.0.implementation)
840            .field("major", &self.0.major)
841            .field("minor", &self.0.minor)
842            .field("variant", &self.0.variant)
843            .field("os", &self.0.platform.os)
844            .field("arch", &self.0.platform.arch)
845            .field("libc", &self.0.platform.libc)
846            .finish()
847    }
848}
849
850impl PartialEq for PythonInstallationMinorVersionKey {
851    fn eq(&self, other: &Self) -> bool {
852        // Compare every field on the wrapped key except the patch
853        // and prerelease.
854        self.0.implementation == other.0.implementation
855            && self.0.major == other.0.major
856            && self.0.minor == other.0.minor
857            && self.0.platform == other.0.platform
858            && self.0.variant == other.0.variant
859    }
860}
861
862impl Hash for PythonInstallationMinorVersionKey {
863    fn hash<H: Hasher>(&self, state: &mut H) {
864        // Hash every field on the wrapped key except the patch
865        // and prerelease.
866        self.0.implementation.hash(state);
867        self.0.major.hash(state);
868        self.0.minor.hash(state);
869        self.0.platform.hash(state);
870        self.0.variant.hash(state);
871    }
872}
873
874impl From<PythonInstallationKey> for PythonInstallationMinorVersionKey {
875    fn from(key: PythonInstallationKey) -> Self {
876        Self(key)
877    }
878}
879
880#[cfg(test)]
881mod tests {
882    use super::*;
883    use uv_platform::ArchVariant;
884
885    #[test]
886    fn test_python_installation_key_from_str() {
887        // Test basic parsing
888        let key = PythonInstallationKey::from_str("cpython-3.12.0-linux-x86_64-gnu").unwrap();
889        assert_eq!(
890            key.implementation,
891            LenientImplementationName::Known(ImplementationName::CPython)
892        );
893        assert_eq!(key.major, 3);
894        assert_eq!(key.minor, 12);
895        assert_eq!(key.patch, 0);
896        assert_eq!(
897            key.platform.os,
898            Os::new(target_lexicon::OperatingSystem::Linux)
899        );
900        assert_eq!(
901            key.platform.arch,
902            Arch::new(target_lexicon::Architecture::X86_64, None)
903        );
904        assert_eq!(
905            key.platform.libc,
906            Libc::Some(target_lexicon::Environment::Gnu)
907        );
908
909        // Test with architecture variant
910        let key = PythonInstallationKey::from_str("cpython-3.11.2-linux-x86_64_v3-musl").unwrap();
911        assert_eq!(
912            key.implementation,
913            LenientImplementationName::Known(ImplementationName::CPython)
914        );
915        assert_eq!(key.major, 3);
916        assert_eq!(key.minor, 11);
917        assert_eq!(key.patch, 2);
918        assert_eq!(
919            key.platform.os,
920            Os::new(target_lexicon::OperatingSystem::Linux)
921        );
922        assert_eq!(
923            key.platform.arch,
924            Arch::new(target_lexicon::Architecture::X86_64, Some(ArchVariant::V3))
925        );
926        assert_eq!(
927            key.platform.libc,
928            Libc::Some(target_lexicon::Environment::Musl)
929        );
930
931        // Test with Python variant (freethreaded)
932        let key = PythonInstallationKey::from_str("cpython-3.13.0+freethreaded-macos-aarch64-none")
933            .unwrap();
934        assert_eq!(
935            key.implementation,
936            LenientImplementationName::Known(ImplementationName::CPython)
937        );
938        assert_eq!(key.major, 3);
939        assert_eq!(key.minor, 13);
940        assert_eq!(key.patch, 0);
941        assert_eq!(key.variant, PythonVariant::Freethreaded);
942        assert_eq!(
943            key.platform.os,
944            Os::new(target_lexicon::OperatingSystem::Darwin(None))
945        );
946        assert_eq!(
947            key.platform.arch,
948            Arch::new(
949                target_lexicon::Architecture::Aarch64(target_lexicon::Aarch64Architecture::Aarch64),
950                None
951            )
952        );
953        assert_eq!(key.platform.libc, Libc::None);
954
955        // Test error cases
956        assert!(PythonInstallationKey::from_str("cpython-3.12.0-linux-x86_64").is_err());
957        assert!(PythonInstallationKey::from_str("cpython-3.12.0").is_err());
958        assert!(PythonInstallationKey::from_str("cpython").is_err());
959    }
960
961    #[test]
962    fn test_python_installation_key_display() {
963        let key = PythonInstallationKey {
964            implementation: LenientImplementationName::from("cpython"),
965            major: 3,
966            minor: 12,
967            patch: 0,
968            prerelease: None,
969            platform: Platform::from_str("linux-x86_64-gnu").unwrap(),
970            variant: PythonVariant::Default,
971        };
972        assert_eq!(key.to_string(), "cpython-3.12.0-linux-x86_64-gnu");
973
974        let key_with_variant = PythonInstallationKey {
975            implementation: LenientImplementationName::from("cpython"),
976            major: 3,
977            minor: 13,
978            patch: 0,
979            prerelease: None,
980            platform: Platform::from_str("macos-aarch64-none").unwrap(),
981            variant: PythonVariant::Freethreaded,
982        };
983        assert_eq!(
984            key_with_variant.to_string(),
985            "cpython-3.13.0+freethreaded-macos-aarch64-none"
986        );
987    }
988}