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