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