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