Skip to main content

uv_python/
installation.rs

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