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