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        )
264        .await?;
265
266        installation.warn_if_outdated_prerelease(request, &download_list);
267
268        Ok(installation)
269    }
270
271    /// Download and install the requested installation.
272    pub async fn fetch(
273        download: &ManagedPythonDownload,
274        client: &BaseClient,
275        retry_policy: &ExponentialBackoff,
276        cache: &Cache,
277        reporter: Option<&dyn Reporter>,
278        python_install_mirror: Option<&str>,
279        pypy_install_mirror: Option<&str>,
280    ) -> Result<Self, Error> {
281        let installations = ManagedPythonInstallations::from_settings(None)?.init()?;
282        let installations_dir = installations.root();
283        let scratch_dir = installations.scratch();
284        let _lock = installations.lock().await?;
285
286        info!("Fetching requested Python...");
287        let result = download
288            .fetch_with_retry(
289                client,
290                retry_policy,
291                installations_dir,
292                &scratch_dir,
293                false,
294                python_install_mirror,
295                pypy_install_mirror,
296                reporter,
297            )
298            .await?;
299
300        let path = match result {
301            DownloadResult::AlreadyAvailable(path) => path,
302            DownloadResult::Fetched(path) => path,
303        };
304
305        let installed = ManagedPythonInstallation::new(path, download);
306        installed.ensure_externally_managed()?;
307        installed.ensure_sysconfig_patched()?;
308        installed.ensure_canonical_executables()?;
309        installed.ensure_build_file()?;
310
311        let minor_version = installed.minor_version_key();
312        let highest_patch = installations
313            .find_all()?
314            .filter(|installation| installation.minor_version_key() == minor_version)
315            .filter_map(|installation| installation.version().patch())
316            .fold(0, std::cmp::max);
317        if installed
318            .version()
319            .patch()
320            .is_some_and(|p| p >= highest_patch)
321        {
322            installed.ensure_minor_version_link()?;
323        }
324
325        if let Err(e) = installed.ensure_dylib_patched() {
326            e.warn_user(&installed);
327        }
328
329        Ok(Self {
330            source: PythonSource::Managed,
331            interpreter: Interpreter::query(installed.executable(false), cache)?,
332        })
333    }
334
335    /// Create a [`PythonInstallation`] from an existing [`Interpreter`].
336    pub fn from_interpreter(interpreter: Interpreter) -> Self {
337        Self {
338            source: PythonSource::ProvidedPath,
339            interpreter,
340        }
341    }
342
343    /// Return the [`PythonSource`] of the Python installation, indicating where it was found.
344    pub fn source(&self) -> &PythonSource {
345        &self.source
346    }
347
348    pub fn key(&self) -> PythonInstallationKey {
349        self.interpreter.key()
350    }
351
352    /// Return the Python [`Version`] of the Python installation as reported by its interpreter.
353    pub fn python_version(&self) -> &Version {
354        self.interpreter.python_version()
355    }
356
357    /// Return the [`LenientImplementationName`] of the Python installation as reported by its interpreter.
358    pub fn implementation(&self) -> LenientImplementationName {
359        LenientImplementationName::from(self.interpreter.implementation_name())
360    }
361
362    /// Whether this is a CPython installation.
363    ///
364    /// Returns false if it is an alternative implementation, e.g., PyPy.
365    pub(crate) fn is_alternative_implementation(&self) -> bool {
366        !matches!(
367            self.implementation(),
368            LenientImplementationName::Known(ImplementationName::CPython)
369        ) || self.os().is_emscripten()
370    }
371
372    /// Return the [`Arch`] of the Python installation as reported by its interpreter.
373    pub fn arch(&self) -> Arch {
374        self.interpreter.arch()
375    }
376
377    /// Return the [`Libc`] of the Python installation as reported by its interpreter.
378    pub fn libc(&self) -> Libc {
379        self.interpreter.libc()
380    }
381
382    /// Return the [`Os`] of the Python installation as reported by its interpreter.
383    pub fn os(&self) -> Os {
384        self.interpreter.os()
385    }
386
387    /// Return the [`Interpreter`] for the Python installation.
388    pub fn interpreter(&self) -> &Interpreter {
389        &self.interpreter
390    }
391
392    /// Consume the [`PythonInstallation`] and return the [`Interpreter`].
393    pub fn into_interpreter(self) -> Interpreter {
394        self.interpreter
395    }
396
397    /// Emit a warning when the interpreter is a managed prerelease and a matching stable
398    /// build can be installed via `uv python upgrade`.
399    pub(crate) fn warn_if_outdated_prerelease(
400        &self,
401        request: &PythonRequest,
402        download_list: &ManagedPythonDownloadList,
403    ) {
404        if request.allows_prereleases() {
405            return;
406        }
407
408        let interpreter = self.interpreter();
409        let version = interpreter.python_version();
410
411        if version.pre().is_none() {
412            return;
413        }
414
415        if !interpreter.is_managed() {
416            return;
417        }
418
419        // Transparent upgrades only exist for CPython, so skip the warning for other
420        // managed implementations.
421        //
422        // See: https://github.com/astral-sh/uv/issues/16675
423        if !interpreter
424            .implementation_name()
425            .eq_ignore_ascii_case("cpython")
426        {
427            return;
428        }
429
430        let release = version.only_release();
431
432        let Ok(download_request) = PythonDownloadRequest::try_from(&interpreter.key()) else {
433            return;
434        };
435
436        let download_request = download_request.with_prereleases(false);
437
438        let has_stable_download = {
439            let mut downloads = download_list.iter_matching(&download_request);
440
441            downloads.any(|download| {
442                let download_version = download.key().version().into_version();
443                download_version.pre().is_none() && download_version.only_release() >= release
444            })
445        };
446
447        if !has_stable_download {
448            return;
449        }
450
451        if let Some(upgrade_request) = download_request
452            .unset_defaults()
453            .without_patch()
454            .simplified_display()
455        {
456            warn_user!(
457                "You're using a pre-release version of Python ({}) but a stable version is available. Use `uv python upgrade {}` to upgrade.",
458                version,
459                upgrade_request
460            );
461        } else {
462            warn_user!(
463                "You're using a pre-release version of Python ({}) but a stable version is available. Run `uv python upgrade` to update your managed interpreters.",
464                version,
465            );
466        }
467    }
468}
469
470#[derive(Error, Debug)]
471pub enum PythonInstallationKeyError {
472    #[error("Failed to parse Python installation key `{0}`: {1}")]
473    ParseError(String, String),
474}
475
476#[derive(Debug, Clone, PartialEq, Eq, Hash)]
477pub struct PythonInstallationKey {
478    pub(crate) implementation: LenientImplementationName,
479    pub(crate) major: u8,
480    pub(crate) minor: u8,
481    pub(crate) patch: u8,
482    pub(crate) prerelease: Option<Prerelease>,
483    pub(crate) platform: Platform,
484    pub(crate) variant: PythonVariant,
485}
486
487impl PythonInstallationKey {
488    pub fn new(
489        implementation: LenientImplementationName,
490        major: u8,
491        minor: u8,
492        patch: u8,
493        prerelease: Option<Prerelease>,
494        platform: Platform,
495        variant: PythonVariant,
496    ) -> Self {
497        Self {
498            implementation,
499            major,
500            minor,
501            patch,
502            prerelease,
503            platform,
504            variant,
505        }
506    }
507
508    pub fn new_from_version(
509        implementation: LenientImplementationName,
510        version: &PythonVersion,
511        platform: Platform,
512        variant: PythonVariant,
513    ) -> Self {
514        Self {
515            implementation,
516            major: version.major(),
517            minor: version.minor(),
518            patch: version.patch().unwrap_or_default(),
519            prerelease: version.pre(),
520            platform,
521            variant,
522        }
523    }
524
525    pub fn implementation(&self) -> Cow<'_, LenientImplementationName> {
526        if self.os().is_emscripten() {
527            Cow::Owned(LenientImplementationName::from(ImplementationName::Pyodide))
528        } else {
529            Cow::Borrowed(&self.implementation)
530        }
531    }
532
533    pub fn version(&self) -> PythonVersion {
534        PythonVersion::from_str(&format!(
535            "{}.{}.{}{}",
536            self.major,
537            self.minor,
538            self.patch,
539            self.prerelease
540                .map(|pre| pre.to_string())
541                .unwrap_or_default()
542        ))
543        .expect("Python installation keys must have valid Python versions")
544    }
545
546    /// The version in `x.y.z` format.
547    pub fn sys_version(&self) -> String {
548        format!("{}.{}.{}", self.major, self.minor, self.patch)
549    }
550
551    pub fn major(&self) -> u8 {
552        self.major
553    }
554
555    pub fn minor(&self) -> u8 {
556        self.minor
557    }
558
559    pub fn prerelease(&self) -> Option<Prerelease> {
560        self.prerelease
561    }
562
563    pub fn platform(&self) -> &Platform {
564        &self.platform
565    }
566
567    pub fn arch(&self) -> &Arch {
568        &self.platform.arch
569    }
570
571    pub fn os(&self) -> &Os {
572        &self.platform.os
573    }
574
575    pub fn libc(&self) -> &Libc {
576        &self.platform.libc
577    }
578
579    pub fn variant(&self) -> &PythonVariant {
580        &self.variant
581    }
582
583    /// Return a canonical name for a minor versioned executable.
584    pub fn executable_name_minor(&self) -> String {
585        format!(
586            "{name}{maj}.{min}{var}{exe}",
587            name = self.implementation().executable_install_name(),
588            maj = self.major,
589            min = self.minor,
590            var = self.variant.executable_suffix(),
591            exe = std::env::consts::EXE_SUFFIX
592        )
593    }
594
595    /// Return a canonical name for a major versioned executable.
596    pub fn executable_name_major(&self) -> String {
597        format!(
598            "{name}{maj}{var}{exe}",
599            name = self.implementation().executable_install_name(),
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            "{name}{var}{exe}",
610            name = self.implementation().executable_install_name(),
611            var = self.variant.executable_suffix(),
612            exe = std::env::consts::EXE_SUFFIX
613        )
614    }
615}
616
617impl fmt::Display for PythonInstallationKey {
618    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
619        let variant = match self.variant {
620            PythonVariant::Default => String::new(),
621            _ => format!("+{}", self.variant),
622        };
623        write!(
624            f,
625            "{}-{}.{}.{}{}{}-{}",
626            self.implementation(),
627            self.major,
628            self.minor,
629            self.patch,
630            self.prerelease
631                .map(|pre| pre.to_string())
632                .unwrap_or_default(),
633            variant,
634            self.platform
635        )
636    }
637}
638
639impl FromStr for PythonInstallationKey {
640    type Err = PythonInstallationKeyError;
641
642    fn from_str(key: &str) -> Result<Self, Self::Err> {
643        let parts = key.split('-').collect::<Vec<_>>();
644
645        // We need exactly implementation-version-os-arch-libc
646        if parts.len() != 5 {
647            return Err(PythonInstallationKeyError::ParseError(
648                key.to_string(),
649                format!(
650                    "expected exactly 5 `-`-separated values, got {}",
651                    parts.len()
652                ),
653            ));
654        }
655
656        let [implementation_str, version_str, os, arch, libc] = parts.as_slice() else {
657            unreachable!()
658        };
659
660        let implementation = LenientImplementationName::from(*implementation_str);
661
662        let (version, variant) = match version_str.split_once('+') {
663            Some((version, variant)) => {
664                let variant = PythonVariant::from_str(variant).map_err(|()| {
665                    PythonInstallationKeyError::ParseError(
666                        key.to_string(),
667                        format!("invalid Python variant: {variant}"),
668                    )
669                })?;
670                (version, variant)
671            }
672            None => (*version_str, PythonVariant::Default),
673        };
674
675        let version = PythonVersion::from_str(version).map_err(|err| {
676            PythonInstallationKeyError::ParseError(
677                key.to_string(),
678                format!("invalid Python version: {err}"),
679            )
680        })?;
681
682        let platform = Platform::from_parts(os, arch, libc).map_err(|err| {
683            PythonInstallationKeyError::ParseError(
684                key.to_string(),
685                format!("invalid platform: {err}"),
686            )
687        })?;
688
689        Ok(Self {
690            implementation,
691            major: version.major(),
692            minor: version.minor(),
693            patch: version.patch().unwrap_or_default(),
694            prerelease: version.pre(),
695            platform,
696            variant,
697        })
698    }
699}
700
701impl PartialOrd for PythonInstallationKey {
702    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
703        Some(self.cmp(other))
704    }
705}
706
707impl Ord for PythonInstallationKey {
708    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
709        self.implementation
710            .cmp(&other.implementation)
711            .then_with(|| self.version().cmp(&other.version()))
712            // Platforms are sorted in preferred order for the target
713            .then_with(|| self.platform.cmp(&other.platform).reverse())
714            // Python variants are sorted in preferred order, with `Default` first
715            .then_with(|| self.variant.cmp(&other.variant).reverse())
716    }
717}
718
719/// A view into a [`PythonInstallationKey`] that excludes the patch and prerelease versions.
720#[derive(Clone, Eq, Ord, PartialOrd, RefCast)]
721#[repr(transparent)]
722pub struct PythonInstallationMinorVersionKey(PythonInstallationKey);
723
724impl PythonInstallationMinorVersionKey {
725    /// Cast a `&PythonInstallationKey` to a `&PythonInstallationMinorVersionKey` using ref-cast.
726    #[inline]
727    pub fn ref_cast(key: &PythonInstallationKey) -> &Self {
728        RefCast::ref_cast(key)
729    }
730
731    /// Takes an [`IntoIterator`] of [`ManagedPythonInstallation`]s and returns an [`FxHashMap`] from
732    /// [`PythonInstallationMinorVersionKey`] to the installation with highest [`PythonInstallationKey`]
733    /// for that minor version key.
734    #[inline]
735    pub fn highest_installations_by_minor_version_key<'a, I>(
736        installations: I,
737    ) -> IndexMap<Self, ManagedPythonInstallation>
738    where
739        I: IntoIterator<Item = &'a ManagedPythonInstallation>,
740    {
741        let mut minor_versions = IndexMap::default();
742        for installation in installations {
743            minor_versions
744                .entry(installation.minor_version_key().clone())
745                .and_modify(|high_installation: &mut ManagedPythonInstallation| {
746                    if installation.key() >= high_installation.key() {
747                        *high_installation = installation.clone();
748                    }
749                })
750                .or_insert_with(|| installation.clone());
751        }
752        minor_versions
753    }
754}
755
756impl fmt::Display for PythonInstallationMinorVersionKey {
757    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
758        // Display every field on the wrapped key except the patch
759        // and prerelease (with special formatting for the variant).
760        let variant = match self.0.variant {
761            PythonVariant::Default => String::new(),
762            _ => format!("+{}", self.0.variant),
763        };
764        write!(
765            f,
766            "{}-{}.{}{}-{}",
767            self.0.implementation, self.0.major, self.0.minor, variant, self.0.platform,
768        )
769    }
770}
771
772impl fmt::Debug for PythonInstallationMinorVersionKey {
773    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
774        // Display every field on the wrapped key except the patch
775        // and prerelease.
776        f.debug_struct("PythonInstallationMinorVersionKey")
777            .field("implementation", &self.0.implementation)
778            .field("major", &self.0.major)
779            .field("minor", &self.0.minor)
780            .field("variant", &self.0.variant)
781            .field("os", &self.0.platform.os)
782            .field("arch", &self.0.platform.arch)
783            .field("libc", &self.0.platform.libc)
784            .finish()
785    }
786}
787
788impl PartialEq for PythonInstallationMinorVersionKey {
789    fn eq(&self, other: &Self) -> bool {
790        // Compare every field on the wrapped key except the patch
791        // and prerelease.
792        self.0.implementation == other.0.implementation
793            && self.0.major == other.0.major
794            && self.0.minor == other.0.minor
795            && self.0.platform == other.0.platform
796            && self.0.variant == other.0.variant
797    }
798}
799
800impl Hash for PythonInstallationMinorVersionKey {
801    fn hash<H: Hasher>(&self, state: &mut H) {
802        // Hash every field on the wrapped key except the patch
803        // and prerelease.
804        self.0.implementation.hash(state);
805        self.0.major.hash(state);
806        self.0.minor.hash(state);
807        self.0.platform.hash(state);
808        self.0.variant.hash(state);
809    }
810}
811
812impl From<PythonInstallationKey> for PythonInstallationMinorVersionKey {
813    fn from(key: PythonInstallationKey) -> Self {
814        Self(key)
815    }
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821    use uv_platform::ArchVariant;
822
823    #[test]
824    fn test_python_installation_key_from_str() {
825        // Test basic parsing
826        let key = PythonInstallationKey::from_str("cpython-3.12.0-linux-x86_64-gnu").unwrap();
827        assert_eq!(
828            key.implementation,
829            LenientImplementationName::Known(ImplementationName::CPython)
830        );
831        assert_eq!(key.major, 3);
832        assert_eq!(key.minor, 12);
833        assert_eq!(key.patch, 0);
834        assert_eq!(
835            key.platform.os,
836            Os::new(target_lexicon::OperatingSystem::Linux)
837        );
838        assert_eq!(
839            key.platform.arch,
840            Arch::new(target_lexicon::Architecture::X86_64, None)
841        );
842        assert_eq!(
843            key.platform.libc,
844            Libc::Some(target_lexicon::Environment::Gnu)
845        );
846
847        // Test with architecture variant
848        let key = PythonInstallationKey::from_str("cpython-3.11.2-linux-x86_64_v3-musl").unwrap();
849        assert_eq!(
850            key.implementation,
851            LenientImplementationName::Known(ImplementationName::CPython)
852        );
853        assert_eq!(key.major, 3);
854        assert_eq!(key.minor, 11);
855        assert_eq!(key.patch, 2);
856        assert_eq!(
857            key.platform.os,
858            Os::new(target_lexicon::OperatingSystem::Linux)
859        );
860        assert_eq!(
861            key.platform.arch,
862            Arch::new(target_lexicon::Architecture::X86_64, Some(ArchVariant::V3))
863        );
864        assert_eq!(
865            key.platform.libc,
866            Libc::Some(target_lexicon::Environment::Musl)
867        );
868
869        // Test with Python variant (freethreaded)
870        let key = PythonInstallationKey::from_str("cpython-3.13.0+freethreaded-macos-aarch64-none")
871            .unwrap();
872        assert_eq!(
873            key.implementation,
874            LenientImplementationName::Known(ImplementationName::CPython)
875        );
876        assert_eq!(key.major, 3);
877        assert_eq!(key.minor, 13);
878        assert_eq!(key.patch, 0);
879        assert_eq!(key.variant, PythonVariant::Freethreaded);
880        assert_eq!(
881            key.platform.os,
882            Os::new(target_lexicon::OperatingSystem::Darwin(None))
883        );
884        assert_eq!(
885            key.platform.arch,
886            Arch::new(
887                target_lexicon::Architecture::Aarch64(target_lexicon::Aarch64Architecture::Aarch64),
888                None
889            )
890        );
891        assert_eq!(key.platform.libc, Libc::None);
892
893        // Test error cases
894        assert!(PythonInstallationKey::from_str("cpython-3.12.0-linux-x86_64").is_err());
895        assert!(PythonInstallationKey::from_str("cpython-3.12.0").is_err());
896        assert!(PythonInstallationKey::from_str("cpython").is_err());
897    }
898
899    #[test]
900    fn test_python_installation_key_display() {
901        let key = PythonInstallationKey {
902            implementation: LenientImplementationName::from("cpython"),
903            major: 3,
904            minor: 12,
905            patch: 0,
906            prerelease: None,
907            platform: Platform::from_str("linux-x86_64-gnu").unwrap(),
908            variant: PythonVariant::Default,
909        };
910        assert_eq!(key.to_string(), "cpython-3.12.0-linux-x86_64-gnu");
911
912        let key_with_variant = PythonInstallationKey {
913            implementation: LenientImplementationName::from("cpython"),
914            major: 3,
915            minor: 13,
916            patch: 0,
917            prerelease: None,
918            platform: Platform::from_str("macos-aarch64-none").unwrap(),
919            variant: PythonVariant::Freethreaded,
920        };
921        assert_eq!(
922            key_with_variant.to_string(),
923            "cpython-3.13.0+freethreaded-macos-aarch64-none"
924        );
925    }
926}