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