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 fn new(source: PythonSource, interpreter: Interpreter) -> Self {
43 Self {
44 source,
45 interpreter,
46 }
47 }
48
49 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 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 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 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 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 Error::MissingPython(..) => {}
157 Error::Discovery(ref err) if !err.is_critical() => {}
159 _ => return Err(err),
161 }
162
163 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 let download = match download {
181 Ok(Ok(download)) => Some(download),
182 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 return Err(err.into());
200 }
201 None
202 }
203 };
204
205 let Some(download) = download else {
206 debug_assert!(!downloads_enabled);
209 return Err(err);
210 };
211
212 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 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 pub fn from_interpreter(interpreter: Interpreter) -> Self {
336 Self {
337 source: PythonSource::ProvidedPath,
338 interpreter,
339 }
340 }
341
342 pub fn source(&self) -> &PythonSource {
344 &self.source
345 }
346
347 pub fn key(&self) -> PythonInstallationKey {
348 self.interpreter.key()
349 }
350
351 pub fn python_version(&self) -> &Version {
353 self.interpreter.python_version()
354 }
355
356 pub fn implementation(&self) -> LenientImplementationName {
358 LenientImplementationName::from(self.interpreter.implementation_name())
359 }
360
361 pub fn is_managed(&self) -> bool {
365 self.source.is_managed() || self.interpreter.is_managed()
366 }
367
368 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 pub fn arch(&self) -> Arch {
380 self.interpreter.arch()
381 }
382
383 pub fn libc(&self) -> Libc {
385 self.interpreter.libc()
386 }
387
388 pub fn os(&self) -> Os {
390 self.interpreter.os()
391 }
392
393 pub fn interpreter(&self) -> &Interpreter {
395 &self.interpreter
396 }
397
398 pub fn into_interpreter(self) -> Interpreter {
400 self.interpreter
401 }
402
403 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 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 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 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 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 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 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 .then_with(|| self.platform.cmp(&other.platform).reverse())
720 .then_with(|| self.variant.cmp(&other.variant).reverse())
722 }
723}
724
725#[derive(Clone, Eq, Ord, PartialOrd, RefCast)]
727#[repr(transparent)]
728pub struct PythonInstallationMinorVersionKey(PythonInstallationKey);
729
730impl PythonInstallationMinorVersionKey {
731 #[inline]
733 pub fn ref_cast(key: &PythonInstallationKey) -> &Self {
734 RefCast::ref_cast(key)
735 }
736
737 #[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 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 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 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 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 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 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 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 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}