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