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