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