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