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