1use std::borrow::Cow;
2use std::collections::HashMap;
3use std::fmt::Display;
4use std::path::{Path, PathBuf};
5use std::pin::Pin;
6use std::str::FromStr;
7use std::task::{Context, Poll};
8use std::time::{Duration, Instant, SystemTimeError};
9use std::{env, io};
10
11use futures::TryStreamExt;
12use itertools::Itertools;
13use owo_colors::OwoColorize;
14use reqwest_retry::RetryError;
15use reqwest_retry::policies::ExponentialBackoff;
16use serde::Deserialize;
17use thiserror::Error;
18use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, BufWriter, ReadBuf};
19use tokio_util::compat::FuturesAsyncReadCompatExt;
20use tokio_util::either::Either;
21use tracing::{debug, instrument};
22use url::Url;
23
24use uv_client::{
25 BaseClient, RetriableError, WrappedReqwestError, fetch_with_url_fallback,
26 retryable_on_request_failure,
27};
28use uv_distribution_filename::{ExtensionError, SourceDistExtension};
29use uv_extract::hash::Hasher;
30use uv_fs::{Simplified, rename_with_retry};
31use uv_platform::{self as platform, Arch, Libc, Os, Platform};
32use uv_pypi_types::{HashAlgorithm, HashDigest};
33use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError};
34use uv_static::{
35 EnvVars, astral_mirror_base_url, astral_mirror_url_from_env, custom_astral_mirror_url,
36};
37
38use crate::PythonVariant;
39use crate::implementation::{
40 Error as ImplementationError, ImplementationName, LenientImplementationName,
41};
42use crate::installation::PythonInstallationKey;
43use crate::managed::ManagedPythonInstallation;
44use crate::python_version::{BuildVersionError, python_build_version_from_env};
45use crate::{Interpreter, PythonRequest, PythonVersion, VersionRequest};
46
47#[derive(Error, Debug)]
48pub enum Error {
49 #[error(transparent)]
50 Io(#[from] io::Error),
51 #[error(transparent)]
52 ImplementationError(#[from] ImplementationError),
53 #[error("Expected download URL (`{0}`) to end in a supported file extension: {1}")]
54 MissingExtension(String, ExtensionError),
55 #[error("Invalid Python version: {0}")]
56 InvalidPythonVersion(String),
57 #[error("Invalid request key (empty request)")]
58 EmptyRequest,
59 #[error("Invalid request key (too many parts): {0}")]
60 TooManyParts(String),
61 #[error("Failed to download {0}")]
62 NetworkError(DisplaySafeUrl, #[source] WrappedReqwestError),
63 #[error(
64 "Request failed after {retries} {subject} in {duration:.1}s",
65 subject = if *retries > 1 { "retries" } else { "retry" },
66 duration = duration.as_secs_f32()
67 )]
68 NetworkErrorWithRetries {
69 #[source]
70 err: Box<Self>,
71 retries: u32,
72 duration: Duration,
73 },
74 #[error("Failed to download {0}")]
75 NetworkMiddlewareError(DisplaySafeUrl, #[source] anyhow::Error),
76 #[error("Failed to extract archive: {0}")]
77 ExtractError(String, #[source] uv_extract::Error),
78 #[error("Failed to hash installation")]
79 HashExhaustion(#[source] io::Error),
80 #[error("Hash mismatch for `{installation}`\n\nExpected:\n{expected}\n\nComputed:\n{actual}")]
81 HashMismatch {
82 installation: String,
83 expected: String,
84 actual: String,
85 },
86 #[error("Invalid download URL")]
87 InvalidUrl(#[from] DisplaySafeUrlError),
88 #[error("Invalid download URL: {0}")]
89 InvalidUrlFormat(DisplaySafeUrl),
90 #[error("Invalid path in file URL: `{0}`")]
91 InvalidFileUrl(String),
92 #[error("Failed to create download directory")]
93 DownloadDirError(#[source] io::Error),
94 #[error("Failed to copy to: {0}", to.user_display())]
95 CopyError {
96 to: PathBuf,
97 #[source]
98 err: io::Error,
99 },
100 #[error("Failed to read managed Python installation directory: {0}", dir.user_display())]
101 ReadError {
102 dir: PathBuf,
103 #[source]
104 err: io::Error,
105 },
106 #[error("Failed to parse request part")]
107 InvalidRequestPlatform(#[from] platform::Error),
108 #[error("No download found for request: {}", _0.green())]
109 NoDownloadFound(PythonDownloadRequest),
110 #[error("A mirror was provided via `{0}`, but the URL does not match the expected format: {0}")]
111 Mirror(&'static str, String),
112 #[error("Failed to determine the libc used on the current platform")]
113 LibcDetection(#[from] platform::LibcDetectionError),
114 #[error("Unable to parse the JSON Python download list at {0}")]
115 InvalidPythonDownloadsJSON(String, #[source] serde_json::Error),
116 #[error("This version of uv is too old to support the JSON Python download list at {0}")]
117 UnsupportedPythonDownloadsJSON(String),
118 #[error("Error while fetching remote python downloads json from '{0}'")]
119 FetchingPythonDownloadsJSONError(String, #[source] Box<Self>),
120 #[error("An offline Python installation was requested, but {file} (from {url}) is missing in {}", python_builds_dir.user_display())]
121 OfflinePythonMissing {
122 file: Box<PythonInstallationKey>,
123 url: Box<DisplaySafeUrl>,
124 python_builds_dir: PathBuf,
125 },
126 #[error(transparent)]
127 BuildVersion(#[from] BuildVersionError),
128 #[error("No download URL found for Python")]
129 NoPythonDownloadUrlFound,
130 #[error(transparent)]
131 SystemTime(#[from] SystemTimeError),
132}
133
134impl RetriableError for Error {
135 fn retries(&self) -> u32 {
140 if let Self::NetworkErrorWithRetries { retries, .. } = self {
144 return *retries;
145 }
146 if let Self::NetworkMiddlewareError(_, anyhow_error) = self
147 && let Some(RetryError::WithRetries { retries, .. }) =
148 anyhow_error.downcast_ref::<RetryError>()
149 {
150 return *retries;
151 }
152 0
153 }
154
155 fn should_try_next_url(&self) -> bool {
161 match self {
162 Self::NetworkError(..)
167 | Self::NetworkMiddlewareError(..)
168 | Self::NetworkErrorWithRetries { .. } => true,
169 Self::Io(err) => retryable_on_request_failure(err).is_some(),
173 _ => false,
174 }
175 }
176
177 fn into_retried(self, retries: u32, duration: Duration) -> Self {
178 Self::NetworkErrorWithRetries {
179 err: Box::new(self),
180 retries,
181 duration,
182 }
183 }
184}
185
186const CPYTHON_DOWNLOADS_URL_PREFIX: &str =
188 "https://github.com/astral-sh/python-build-standalone/releases/download/";
189
190const CPYTHON_MIRROR_SUFFIX: &str = "/github/python-build-standalone/releases/download/";
192
193fn effective_cpython_mirror(astral_mirror_url: Option<&str>) -> String {
195 format!(
196 "{}{CPYTHON_MIRROR_SUFFIX}",
197 astral_mirror_base_url(astral_mirror_url)
198 )
199}
200
201#[derive(Debug, PartialEq, Eq, Clone, Hash)]
202pub struct ManagedPythonDownload {
203 key: PythonInstallationKey,
204 url: Cow<'static, str>,
205 sha256: Option<Cow<'static, str>>,
206 build: Option<&'static str>,
207}
208
209#[derive(Debug, Clone, Default, Eq, PartialEq, Hash)]
210pub struct PythonDownloadRequest {
211 pub(crate) version: Option<VersionRequest>,
212 pub(crate) implementation: Option<ImplementationName>,
213 pub(crate) arch: Option<ArchRequest>,
214 pub(crate) os: Option<Os>,
215 pub(crate) libc: Option<Libc>,
216 pub(crate) build: Option<String>,
217
218 pub(crate) prereleases: Option<bool>,
221}
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
224pub enum ArchRequest {
225 Explicit(Arch),
226 Environment(Arch),
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
230pub struct PlatformRequest {
231 pub(crate) os: Option<Os>,
232 pub(crate) arch: Option<ArchRequest>,
233 pub(crate) libc: Option<Libc>,
234}
235
236impl PlatformRequest {
237 pub fn matches(&self, platform: &Platform) -> bool {
239 if let Some(os) = self.os {
240 if !platform.os.supports(os) {
241 return false;
242 }
243 }
244
245 if let Some(arch) = self.arch {
246 if !arch.satisfied_by(platform) {
247 return false;
248 }
249 }
250
251 if let Some(libc) = self.libc {
252 if platform.libc != libc {
253 return false;
254 }
255 }
256
257 true
258 }
259}
260
261impl Display for PlatformRequest {
262 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263 let mut parts = Vec::new();
264 if let Some(os) = &self.os {
265 parts.push(os.to_string());
266 }
267 if let Some(arch) = &self.arch {
268 parts.push(arch.to_string());
269 }
270 if let Some(libc) = &self.libc {
271 parts.push(libc.to_string());
272 }
273 write!(f, "{}", parts.join("-"))
274 }
275}
276
277impl Display for ArchRequest {
278 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279 match self {
280 Self::Explicit(arch) | Self::Environment(arch) => write!(f, "{arch}"),
281 }
282 }
283}
284
285impl ArchRequest {
286 pub(crate) fn satisfied_by(self, platform: &Platform) -> bool {
287 match self {
288 Self::Explicit(request) => request == platform.arch,
289 Self::Environment(env) => {
290 let env_platform = Platform::new(platform.os, env, platform.libc);
292 env_platform.supports(platform)
293 }
294 }
295 }
296
297 pub fn inner(&self) -> Arch {
298 match self {
299 Self::Explicit(arch) | Self::Environment(arch) => *arch,
300 }
301 }
302}
303
304impl PythonDownloadRequest {
305 pub fn new(
306 version: Option<VersionRequest>,
307 implementation: Option<ImplementationName>,
308 arch: Option<ArchRequest>,
309 os: Option<Os>,
310 libc: Option<Libc>,
311 prereleases: Option<bool>,
312 ) -> Self {
313 Self {
314 version,
315 implementation,
316 arch,
317 os,
318 libc,
319 build: None,
320 prereleases,
321 }
322 }
323
324 #[must_use]
325 pub fn with_implementation(mut self, implementation: ImplementationName) -> Self {
326 match implementation {
327 ImplementationName::Pyodide => {
329 self = self.with_os(Os::new(target_lexicon::OperatingSystem::Emscripten));
330 self = self.with_arch(Arch::new(target_lexicon::Architecture::Wasm32, None));
331 self = self.with_libc(Libc::Some(target_lexicon::Environment::Musl));
332 }
333 _ => {
334 self.implementation = Some(implementation);
335 }
336 }
337 self
338 }
339
340 #[must_use]
341 pub fn with_version(mut self, version: VersionRequest) -> Self {
342 self.version = Some(version);
343 self
344 }
345
346 #[must_use]
347 pub fn with_arch(mut self, arch: Arch) -> Self {
348 self.arch = Some(ArchRequest::Explicit(arch));
349 self
350 }
351
352 #[must_use]
353 pub fn with_any_arch(mut self) -> Self {
354 self.arch = None;
355 self
356 }
357
358 #[must_use]
359 pub fn with_os(mut self, os: Os) -> Self {
360 self.os = Some(os);
361 self
362 }
363
364 #[must_use]
365 pub fn with_libc(mut self, libc: Libc) -> Self {
366 self.libc = Some(libc);
367 self
368 }
369
370 #[must_use]
371 pub fn with_prereleases(mut self, prereleases: bool) -> Self {
372 self.prereleases = Some(prereleases);
373 self
374 }
375
376 #[must_use]
377 pub fn with_build(mut self, build: String) -> Self {
378 self.build = Some(build);
379 self
380 }
381
382 pub fn from_request(request: &PythonRequest) -> Option<Self> {
387 match request {
388 PythonRequest::Version(version) => Some(Self::default().with_version(version.clone())),
389 PythonRequest::Implementation(implementation) => {
390 Some(Self::default().with_implementation(*implementation))
391 }
392 PythonRequest::ImplementationVersion(implementation, version) => Some(
393 Self::default()
394 .with_implementation(*implementation)
395 .with_version(version.clone()),
396 ),
397 PythonRequest::Key(request) => Some(request.clone()),
398 PythonRequest::Any => Some(Self {
399 prereleases: Some(true), ..Self::default()
401 }),
402 PythonRequest::Default => Some(Self::default()),
403 PythonRequest::Directory(_)
405 | PythonRequest::ExecutableName(_)
406 | PythonRequest::File(_) => None,
407 }
408 }
409
410 pub fn fill_platform(mut self) -> Result<Self, Error> {
414 let platform = Platform::from_env().map_err(|err| match err {
415 platform::Error::LibcDetectionError(err) => Error::LibcDetection(err),
416 err => Error::InvalidRequestPlatform(err),
417 })?;
418 if self.arch.is_none() {
419 self.arch = Some(ArchRequest::Environment(platform.arch));
420 }
421 if self.os.is_none() {
422 self.os = Some(platform.os);
423 }
424 if self.libc.is_none() {
425 self.libc = Some(platform.libc);
426 }
427 Ok(self)
428 }
429
430 pub fn fill_build_from_env(mut self) -> Result<Self, Error> {
432 if self.build.is_some() {
433 return Ok(self);
434 }
435 let Some(implementation) = self.implementation else {
436 return Ok(self);
437 };
438
439 self.build = python_build_version_from_env(implementation)?;
440 Ok(self)
441 }
442
443 pub fn fill(mut self) -> Result<Self, Error> {
444 if self.implementation.is_none() {
445 self.implementation = Some(ImplementationName::CPython);
446 }
447 self = self.fill_platform()?;
448 self = self.fill_build_from_env()?;
449 Ok(self)
450 }
451
452 pub fn implementation(&self) -> Option<&ImplementationName> {
453 self.implementation.as_ref()
454 }
455
456 pub fn version(&self) -> Option<&VersionRequest> {
457 self.version.as_ref()
458 }
459
460 pub fn arch(&self) -> Option<&ArchRequest> {
461 self.arch.as_ref()
462 }
463
464 pub fn os(&self) -> Option<&Os> {
465 self.os.as_ref()
466 }
467
468 pub fn libc(&self) -> Option<&Libc> {
469 self.libc.as_ref()
470 }
471
472 pub fn take_version(&mut self) -> Option<VersionRequest> {
473 self.version.take()
474 }
475
476 #[must_use]
479 pub fn unset_defaults(self) -> Self {
480 let request = self.unset_non_platform_defaults();
481
482 if let Ok(host) = Platform::from_env() {
483 request.unset_platform_defaults(&host)
484 } else {
485 request
486 }
487 }
488
489 fn unset_non_platform_defaults(mut self) -> Self {
490 self.implementation = self
491 .implementation
492 .filter(|implementation_name| *implementation_name != ImplementationName::default());
493
494 self.version = self
495 .version
496 .filter(|version| !matches!(version, VersionRequest::Any | VersionRequest::Default));
497
498 self.arch = self
500 .arch
501 .filter(|arch| !matches!(arch, ArchRequest::Environment(_)));
502
503 self
504 }
505
506 #[cfg(test)]
507 pub(crate) fn unset_defaults_for_host(self, host: &Platform) -> Self {
508 self.unset_non_platform_defaults()
509 .unset_platform_defaults(host)
510 }
511
512 pub(crate) fn unset_platform_defaults(mut self, host: &Platform) -> Self {
513 self.os = self.os.filter(|os| *os != host.os);
514
515 self.libc = self.libc.filter(|libc| *libc != host.libc);
516
517 self.arch = self
518 .arch
519 .filter(|arch| !matches!(arch, ArchRequest::Explicit(explicit_arch) if *explicit_arch == host.arch));
520
521 self
522 }
523
524 #[must_use]
526 pub fn without_patch(mut self) -> Self {
527 self.version = self.version.take().map(VersionRequest::only_minor);
528 self.prereleases = None;
529 self.build = None;
530 self
531 }
532
533 pub fn simplified_display(self) -> Option<String> {
538 let parts = [
539 self.implementation
540 .map(|implementation| implementation.to_string()),
541 self.version.map(|version| version.to_string()),
542 self.os.map(|os| os.to_string()),
543 self.arch.map(|arch| arch.to_string()),
544 self.libc.map(|libc| libc.to_string()),
545 ];
546
547 let joined = parts.into_iter().flatten().collect::<Vec<_>>().join("-");
548
549 if joined.is_empty() {
550 None
551 } else {
552 Some(joined)
553 }
554 }
555
556 pub fn satisfied_by_key(&self, key: &PythonInstallationKey) -> bool {
558 let request = PlatformRequest {
560 os: self.os,
561 arch: self.arch,
562 libc: self.libc,
563 };
564 if !request.matches(key.platform()) {
565 return false;
566 }
567
568 if let Some(implementation) = &self.implementation {
569 if key.implementation != LenientImplementationName::from(*implementation) {
570 return false;
571 }
572 }
573 if !self.allows_prereleases() && key.prerelease.is_some() {
575 return false;
576 }
577 if let Some(version) = &self.version {
578 if !version.matches_major_minor_patch_prerelease(
579 key.major,
580 key.minor,
581 key.patch,
582 key.prerelease,
583 ) {
584 return false;
585 }
586 if let Some(variant) = version.variant() {
587 if variant != key.variant {
588 return false;
589 }
590 }
591 }
592 true
593 }
594
595 pub fn satisfied_by_download(&self, download: &ManagedPythonDownload) -> bool {
597 if !self.satisfied_by_key(download.key()) {
599 return false;
600 }
601
602 if let Some(ref requested_build) = self.build {
604 let Some(download_build) = download.build() else {
605 debug!(
606 "Skipping download `{}`: a build version was requested but is not available for this download",
607 download
608 );
609 return false;
610 };
611
612 if download_build != requested_build {
613 debug!(
614 "Skipping download `{}`: requested build version `{}` does not match download build version `{}`",
615 download, requested_build, download_build
616 );
617 return false;
618 }
619 }
620
621 true
622 }
623
624 pub fn allows_prereleases(&self) -> bool {
626 self.prereleases.unwrap_or_else(|| {
627 self.version
628 .as_ref()
629 .is_some_and(VersionRequest::allows_prereleases)
630 })
631 }
632
633 pub fn allows_debug(&self) -> bool {
635 self.version.as_ref().is_some_and(VersionRequest::is_debug)
636 }
637
638 pub fn allows_alternative_implementations(&self) -> bool {
640 self.implementation
641 .is_some_and(|implementation| !matches!(implementation, ImplementationName::CPython))
642 || self.os.is_some_and(|os| os.is_emscripten())
643 }
644
645 pub fn satisfied_by_interpreter(&self, interpreter: &Interpreter) -> bool {
646 let executable = interpreter.sys_executable().display();
647 if let Some(version) = self.version() {
648 if !version.matches_interpreter(interpreter) {
649 let interpreter_version = interpreter.python_version();
650 debug!(
651 "Skipping interpreter at `{executable}`: version `{interpreter_version}` does not match request `{version}`"
652 );
653 return false;
654 }
655 }
656 let platform = self.platform();
657 let interpreter_platform = Platform::from(interpreter.platform());
658 if !platform.matches(&interpreter_platform) {
659 debug!(
660 "Skipping interpreter at `{executable}`: platform `{interpreter_platform}` does not match request `{platform}`",
661 );
662 return false;
663 }
664 if let Some(implementation) = self.implementation() {
665 if !implementation.matches_interpreter(interpreter) {
666 debug!(
667 "Skipping interpreter at `{executable}`: implementation `{}` does not match request `{implementation}`",
668 interpreter.implementation_name(),
669 );
670 return false;
671 }
672 }
673 true
674 }
675
676 pub fn platform(&self) -> PlatformRequest {
678 PlatformRequest {
679 os: self.os,
680 arch: self.arch,
681 libc: self.libc,
682 }
683 }
684}
685
686impl TryFrom<&PythonInstallationKey> for PythonDownloadRequest {
687 type Error = LenientImplementationName;
688
689 fn try_from(key: &PythonInstallationKey) -> Result<Self, Self::Error> {
690 let implementation = match key.implementation().into_owned() {
691 LenientImplementationName::Known(name) => name,
692 unknown @ LenientImplementationName::Unknown(_) => return Err(unknown),
693 };
694
695 Ok(Self::new(
696 Some(VersionRequest::MajorMinor(
697 key.major(),
698 key.minor(),
699 *key.variant(),
700 )),
701 Some(implementation),
702 Some(ArchRequest::Explicit(*key.arch())),
703 Some(*key.os()),
704 Some(*key.libc()),
705 Some(key.prerelease().is_some()),
706 ))
707 }
708}
709
710impl From<&ManagedPythonInstallation> for PythonDownloadRequest {
711 fn from(installation: &ManagedPythonInstallation) -> Self {
712 let key = installation.key();
713 Self::new(
714 Some(VersionRequest::from(&key.version())),
715 match &key.implementation {
716 LenientImplementationName::Known(implementation) => Some(*implementation),
717 LenientImplementationName::Unknown(name) => unreachable!(
718 "Managed Python installations are expected to always have known implementation names, found {name}"
719 ),
720 },
721 Some(ArchRequest::Explicit(*key.arch())),
722 Some(*key.os()),
723 Some(*key.libc()),
724 Some(key.prerelease.is_some()),
725 )
726 }
727}
728
729impl Display for PythonDownloadRequest {
730 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
731 let mut parts = Vec::new();
732 if let Some(implementation) = self.implementation {
733 parts.push(implementation.to_string());
734 } else {
735 parts.push("any".to_string());
736 }
737 if let Some(version) = &self.version {
738 parts.push(version.to_string());
739 } else {
740 parts.push("any".to_string());
741 }
742 if let Some(os) = &self.os {
743 parts.push(os.to_string());
744 } else {
745 parts.push("any".to_string());
746 }
747 if let Some(arch) = self.arch {
748 parts.push(arch.to_string());
749 } else {
750 parts.push("any".to_string());
751 }
752 if let Some(libc) = self.libc {
753 parts.push(libc.to_string());
754 } else {
755 parts.push("any".to_string());
756 }
757 write!(f, "{}", parts.join("-"))
758 }
759}
760impl FromStr for PythonDownloadRequest {
761 type Err = Error;
762
763 fn from_str(s: &str) -> Result<Self, Self::Err> {
764 #[derive(Debug, Clone)]
765 enum Position {
766 Start,
767 Implementation,
768 Version,
769 Os,
770 Arch,
771 Libc,
772 End,
773 }
774
775 impl Position {
776 pub(crate) fn next(&self) -> Self {
777 match self {
778 Self::Start => Self::Implementation,
779 Self::Implementation => Self::Version,
780 Self::Version => Self::Os,
781 Self::Os => Self::Arch,
782 Self::Arch => Self::Libc,
783 Self::Libc => Self::End,
784 Self::End => Self::End,
785 }
786 }
787 }
788
789 #[derive(Debug)]
790 struct State<'a, P: Iterator<Item = &'a str>> {
791 parts: P,
792 part: Option<&'a str>,
793 position: Position,
794 error: Option<Error>,
795 count: usize,
796 }
797
798 impl<'a, P: Iterator<Item = &'a str>> State<'a, P> {
799 fn new(parts: P) -> Self {
800 Self {
801 parts,
802 part: None,
803 position: Position::Start,
804 error: None,
805 count: 0,
806 }
807 }
808
809 fn next_part(&mut self) {
810 self.next_position();
811 self.part = self.parts.next();
812 self.count += 1;
813 self.error.take();
814 }
815
816 fn next_position(&mut self) {
817 self.position = self.position.next();
818 }
819
820 fn record_err(&mut self, err: Error) {
821 self.error.get_or_insert(err);
824 }
825 }
826
827 if s.is_empty() {
828 return Err(Error::EmptyRequest);
829 }
830
831 let mut parts = s.split('-');
832
833 let mut implementation = None;
834 let mut version = None;
835 let mut os = None;
836 let mut arch = None;
837 let mut libc = None;
838
839 let mut state = State::new(parts.by_ref());
840 state.next_part();
841
842 while let Some(part) = state.part {
843 match state.position {
844 Position::Start => unreachable!("We start before the loop"),
845 Position::Implementation => {
846 if part.eq_ignore_ascii_case("any") {
847 state.next_part();
848 continue;
849 }
850 match ImplementationName::from_str(part) {
851 Ok(val) => {
852 implementation = Some(val);
853 state.next_part();
854 }
855 Err(err) => {
856 state.next_position();
857 state.record_err(err.into());
858 }
859 }
860 }
861 Position::Version => {
862 if part.eq_ignore_ascii_case("any") {
863 state.next_part();
864 continue;
865 }
866 match VersionRequest::from_str(part)
867 .map_err(|_| Error::InvalidPythonVersion(part.to_string()))
868 {
869 Ok(val) => {
871 version = Some(val);
872 state.next_part();
873 }
874 Err(err) => {
875 state.next_position();
876 state.record_err(err);
877 }
878 }
879 }
880 Position::Os => {
881 if part.eq_ignore_ascii_case("any") {
882 state.next_part();
883 continue;
884 }
885 match Os::from_str(part) {
886 Ok(val) => {
887 os = Some(val);
888 state.next_part();
889 }
890 Err(err) => {
891 state.next_position();
892 state.record_err(err.into());
893 }
894 }
895 }
896 Position::Arch => {
897 if part.eq_ignore_ascii_case("any") {
898 state.next_part();
899 continue;
900 }
901 match Arch::from_str(part) {
902 Ok(val) => {
903 arch = Some(ArchRequest::Explicit(val));
904 state.next_part();
905 }
906 Err(err) => {
907 state.next_position();
908 state.record_err(err.into());
909 }
910 }
911 }
912 Position::Libc => {
913 if part.eq_ignore_ascii_case("any") {
914 state.next_part();
915 continue;
916 }
917 match Libc::from_str(part) {
918 Ok(val) => {
919 libc = Some(val);
920 state.next_part();
921 }
922 Err(err) => {
923 state.next_position();
924 state.record_err(err.into());
925 }
926 }
927 }
928 Position::End => {
929 if state.count > 5 {
930 return Err(Error::TooManyParts(s.to_string()));
931 }
932
933 if let Some(err) = state.error {
940 return Err(err);
941 }
942 state.next_part();
943 }
944 }
945 }
946
947 Ok(Self::new(version, implementation, arch, os, libc, None))
948 }
949}
950
951const BUILTIN_PYTHON_DOWNLOADS_JSON: &[u8] =
952 include_bytes!(concat!(env!("OUT_DIR"), "/download-metadata-minified.json"));
953
954pub struct ManagedPythonDownloadList {
955 downloads: Vec<ManagedPythonDownload>,
956}
957
958#[derive(Debug, Deserialize, Clone)]
959struct JsonPythonDownload {
960 name: String,
961 arch: JsonArch,
962 os: String,
963 libc: String,
964 major: u8,
965 minor: u8,
966 patch: u8,
967 prerelease: Option<String>,
968 url: String,
969 sha256: Option<String>,
970 variant: Option<String>,
971 build: Option<String>,
972}
973
974#[derive(Debug, Deserialize, Clone)]
975struct JsonArch {
976 family: String,
977 variant: Option<String>,
978}
979
980#[derive(Debug, Clone)]
981pub enum DownloadResult {
982 AlreadyAvailable(PathBuf),
983 Fetched(PathBuf),
984}
985
986pub struct ManagedPythonDownloadWithBuild<'a>(&'a ManagedPythonDownload);
988
989impl Display for ManagedPythonDownloadWithBuild<'_> {
990 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
991 if let Some(build) = self.0.build {
992 write!(f, "{}+{}", self.0.key, build)
993 } else {
994 write!(f, "{}", self.0.key)
995 }
996 }
997}
998
999impl ManagedPythonDownloadList {
1000 fn iter_all(&self) -> impl Iterator<Item = &ManagedPythonDownload> {
1002 self.downloads.iter()
1003 }
1004
1005 pub fn iter_matching(
1007 &self,
1008 request: &PythonDownloadRequest,
1009 ) -> impl Iterator<Item = &ManagedPythonDownload> {
1010 self.iter_all()
1011 .filter(move |download| request.satisfied_by_download(download))
1012 }
1013
1014 pub fn find(&self, request: &PythonDownloadRequest) -> Result<&ManagedPythonDownload, Error> {
1019 if let Some(download) = self.iter_matching(request).next() {
1020 return Ok(download);
1021 }
1022
1023 if !request.allows_prereleases() {
1024 if let Some(download) = self
1025 .iter_matching(&request.clone().with_prereleases(true))
1026 .next()
1027 {
1028 return Ok(download);
1029 }
1030 }
1031
1032 Err(Error::NoDownloadFound(request.clone()))
1033 }
1034
1035 pub async fn new(
1044 client: &BaseClient,
1045 python_downloads_json_url: Option<&str>,
1046 ) -> Result<Self, Error> {
1047 enum Source<'a> {
1052 BuiltIn,
1053 Path(Cow<'a, Path>),
1054 Http(DisplaySafeUrl),
1055 }
1056
1057 let json_source = if let Some(url_or_path) = python_downloads_json_url {
1058 if let Ok(url) = DisplaySafeUrl::parse(url_or_path) {
1059 match url.scheme() {
1060 "http" | "https" => Source::Http(url),
1061 "file" => Source::Path(Cow::Owned(
1062 url.to_file_path().or(Err(Error::InvalidUrlFormat(url)))?,
1063 )),
1064 _ => Source::Path(Cow::Borrowed(Path::new(url_or_path))),
1065 }
1066 } else {
1067 Source::Path(Cow::Borrowed(Path::new(url_or_path)))
1068 }
1069 } else {
1070 Source::BuiltIn
1071 };
1072
1073 let buf: Cow<'_, [u8]> = match json_source {
1074 Source::BuiltIn => BUILTIN_PYTHON_DOWNLOADS_JSON.into(),
1075 Source::Path(ref path) => fs_err::read(path.as_ref())?.into(),
1076 Source::Http(ref url) => fetch_bytes_from_url(client, url)
1077 .await
1078 .map_err(|e| Error::FetchingPythonDownloadsJSONError(url.to_string(), Box::new(e)))?
1079 .into(),
1080 };
1081 let json_downloads: HashMap<String, JsonPythonDownload> = serde_json::from_slice(&buf)
1082 .map_err(
1083 #[expect(clippy::zero_sized_map_values)]
1090 |e| {
1091 let source = match json_source {
1092 Source::BuiltIn => "EMBEDDED IN THE BINARY".to_owned(),
1093 Source::Path(path) => path.to_string_lossy().to_string(),
1094 Source::Http(url) => url.to_string(),
1095 };
1096 if let Ok(keys) =
1097 serde_json::from_slice::<HashMap<String, serde::de::IgnoredAny>>(&buf)
1098 && keys.contains_key("version")
1099 {
1100 Error::UnsupportedPythonDownloadsJSON(source)
1101 } else {
1102 Error::InvalidPythonDownloadsJSON(source, e)
1103 }
1104 },
1105 )?;
1106
1107 let result = parse_json_downloads(json_downloads);
1108 Ok(Self { downloads: result })
1109 }
1110
1111 pub fn new_only_embedded() -> Result<Self, Error> {
1114 let json_downloads: HashMap<String, JsonPythonDownload> =
1115 serde_json::from_slice(BUILTIN_PYTHON_DOWNLOADS_JSON).map_err(|e| {
1116 Error::InvalidPythonDownloadsJSON("EMBEDDED IN THE BINARY".to_owned(), e)
1117 })?;
1118 let result = parse_json_downloads(json_downloads);
1119 Ok(Self { downloads: result })
1120 }
1121}
1122
1123async fn fetch_bytes_from_url(client: &BaseClient, url: &DisplaySafeUrl) -> Result<Vec<u8>, Error> {
1124 let (mut reader, size) = read_url(url, client).await?;
1125 let capacity = size.and_then(|s| s.try_into().ok()).unwrap_or(1_048_576);
1126 let mut buf = Vec::with_capacity(capacity);
1127 reader.read_to_end(&mut buf).await?;
1128 Ok(buf)
1129}
1130
1131impl ManagedPythonDownload {
1132 pub fn to_display_with_build(&self) -> ManagedPythonDownloadWithBuild<'_> {
1134 ManagedPythonDownloadWithBuild(self)
1135 }
1136
1137 pub fn url(&self) -> &Cow<'static, str> {
1138 &self.url
1139 }
1140
1141 pub fn key(&self) -> &PythonInstallationKey {
1142 &self.key
1143 }
1144
1145 pub fn os(&self) -> &Os {
1146 self.key.os()
1147 }
1148
1149 pub fn sha256(&self) -> Option<&Cow<'static, str>> {
1150 self.sha256.as_ref()
1151 }
1152
1153 pub fn build(&self) -> Option<&'static str> {
1154 self.build
1155 }
1156
1157 #[instrument(skip(client, installation_dir, scratch_dir, reporter), fields(download = % self.key()))]
1163 pub async fn fetch_with_retry(
1164 &self,
1165 client: &BaseClient,
1166 retry_policy: &ExponentialBackoff,
1167 installation_dir: &Path,
1168 scratch_dir: &Path,
1169 reinstall: bool,
1170 python_install_mirror: Option<&str>,
1171 pypy_install_mirror: Option<&str>,
1172 reporter: Option<&dyn Reporter>,
1173 ) -> Result<DownloadResult, Error> {
1174 let urls = self.download_urls(python_install_mirror, pypy_install_mirror)?;
1175 if urls.is_empty() {
1176 return Err(Error::NoPythonDownloadUrlFound);
1177 }
1178 fetch_with_url_fallback(&urls, *retry_policy, &format!("`{}`", self.key()), |url| {
1179 self.fetch_from_url(
1180 url,
1181 client,
1182 installation_dir,
1183 scratch_dir,
1184 reinstall,
1185 reporter,
1186 )
1187 })
1188 .await
1189 }
1190
1191 #[instrument(skip(client, installation_dir, scratch_dir, reporter), fields(download = % self.key()))]
1193 pub async fn fetch(
1194 &self,
1195 client: &BaseClient,
1196 installation_dir: &Path,
1197 scratch_dir: &Path,
1198 reinstall: bool,
1199 python_install_mirror: Option<&str>,
1200 pypy_install_mirror: Option<&str>,
1201 reporter: Option<&dyn Reporter>,
1202 ) -> Result<DownloadResult, Error> {
1203 let urls = self.download_urls(python_install_mirror, pypy_install_mirror)?;
1204 let url = urls
1205 .into_iter()
1206 .next()
1207 .ok_or(Error::NoPythonDownloadUrlFound)?;
1208 self.fetch_from_url(
1209 url,
1210 client,
1211 installation_dir,
1212 scratch_dir,
1213 reinstall,
1214 reporter,
1215 )
1216 .await
1217 }
1218
1219 async fn fetch_from_url(
1221 &self,
1222 url: DisplaySafeUrl,
1223 client: &BaseClient,
1224 installation_dir: &Path,
1225 scratch_dir: &Path,
1226 reinstall: bool,
1227 reporter: Option<&dyn Reporter>,
1228 ) -> Result<DownloadResult, Error> {
1229 let path = installation_dir.join(self.key().to_string());
1230
1231 if !reinstall && path.is_dir() {
1233 return Ok(DownloadResult::AlreadyAvailable(path));
1234 }
1235
1236 let filename = url
1239 .path_segments()
1240 .ok_or_else(|| Error::InvalidUrlFormat(url.clone()))?
1241 .next_back()
1242 .ok_or_else(|| Error::InvalidUrlFormat(url.clone()))?
1243 .replace("%2B", "-");
1244 debug_assert!(
1245 filename
1246 .chars()
1247 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.'),
1248 "Unexpected char in filename: {filename}"
1249 );
1250 let ext = SourceDistExtension::from_path(&filename)
1251 .map_err(|err| Error::MissingExtension(url.to_string(), err))?;
1252
1253 let temp_dir = tempfile::tempdir_in(scratch_dir).map_err(Error::DownloadDirError)?;
1254
1255 if let Some(python_builds_dir) =
1256 env::var_os(EnvVars::UV_PYTHON_CACHE_DIR).filter(|s| !s.is_empty())
1257 {
1258 let python_builds_dir = PathBuf::from(python_builds_dir);
1259 fs_err::create_dir_all(&python_builds_dir)?;
1260 let hash_prefix = match self.sha256.as_deref() {
1261 Some(sha) => {
1262 &sha[..9]
1264 }
1265 None => "none",
1266 };
1267 let target_cache_file = python_builds_dir.join(format!("{hash_prefix}-{filename}"));
1268
1269 let (reader, size): (Box<dyn AsyncRead + Unpin>, Option<u64>) =
1273 match fs_err::tokio::File::open(&target_cache_file).await {
1274 Ok(file) => {
1275 debug!(
1276 "Extracting existing `{}`",
1277 target_cache_file.simplified_display()
1278 );
1279 let size = file.metadata().await?.len();
1280 let reader = Box::new(tokio::io::BufReader::new(file));
1281 (reader, Some(size))
1282 }
1283 Err(err) if err.kind() == io::ErrorKind::NotFound => {
1284 if client.connectivity().is_offline() {
1286 return Err(Error::OfflinePythonMissing {
1287 file: Box::new(self.key().clone()),
1288 url: Box::new(url.clone()),
1289 python_builds_dir,
1290 });
1291 }
1292
1293 self.download_archive(
1294 &url,
1295 client,
1296 reporter,
1297 &python_builds_dir,
1298 &target_cache_file,
1299 )
1300 .await?;
1301
1302 debug!("Extracting `{}`", target_cache_file.simplified_display());
1303 let file = fs_err::tokio::File::open(&target_cache_file).await?;
1304 let size = file.metadata().await?.len();
1305 let reader = Box::new(tokio::io::BufReader::new(file));
1306 (reader, Some(size))
1307 }
1308 Err(err) => return Err(err.into()),
1309 };
1310
1311 self.extract_reader(
1313 reader,
1314 temp_dir.path(),
1315 &filename,
1316 ext,
1317 size,
1318 reporter,
1319 Direction::Extract,
1320 )
1321 .await?;
1322 } else {
1323 debug!("Downloading {url}");
1325 debug!(
1326 "Extracting {filename} to temporary location: {}",
1327 temp_dir.path().simplified_display()
1328 );
1329
1330 let (reader, size) = read_url(&url, client).await?;
1331 self.extract_reader(
1332 reader,
1333 temp_dir.path(),
1334 &filename,
1335 ext,
1336 size,
1337 reporter,
1338 Direction::Download,
1339 )
1340 .await?;
1341 }
1342
1343 let mut extracted = match uv_extract::strip_component(temp_dir.path()) {
1345 Ok(top_level) => top_level,
1346 Err(uv_extract::Error::NonSingularArchive(_)) => temp_dir.keep(),
1347 Err(err) => return Err(Error::ExtractError(filename, err)),
1348 };
1349
1350 if extracted.join("install").is_dir() {
1352 extracted = extracted.join("install");
1353 } else if self.os().is_emscripten() {
1355 extracted = extracted.join("pyodide-root").join("dist");
1356 }
1357
1358 #[cfg(unix)]
1359 {
1360 if self.os().is_emscripten() {
1364 fs_err::create_dir_all(extracted.join("bin"))?;
1365 fs_err::os::unix::fs::symlink(
1366 "../python",
1367 extracted
1368 .join("bin")
1369 .join(format!("python{}.{}", self.key.major, self.key.minor)),
1370 )?;
1371 }
1372
1373 match fs_err::os::unix::fs::symlink(
1380 format!("python{}.{}", self.key.major, self.key.minor),
1381 extracted.join("bin").join("python"),
1382 ) {
1383 Ok(()) => {}
1384 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
1385 Err(err) => return Err(err.into()),
1386 }
1387 }
1388
1389 if path.is_dir() {
1391 debug!("Removing existing directory: {}", path.user_display());
1392 fs_err::tokio::remove_dir_all(&path).await?;
1393 }
1394
1395 debug!("Moving {} to {}", extracted.display(), path.user_display());
1397 rename_with_retry(extracted, &path)
1398 .await
1399 .map_err(|err| Error::CopyError {
1400 to: path.clone(),
1401 err,
1402 })?;
1403
1404 Ok(DownloadResult::Fetched(path))
1405 }
1406
1407 async fn download_archive(
1409 &self,
1410 url: &DisplaySafeUrl,
1411 client: &BaseClient,
1412 reporter: Option<&dyn Reporter>,
1413 python_builds_dir: &Path,
1414 target_cache_file: &Path,
1415 ) -> Result<(), Error> {
1416 debug!(
1417 "Downloading {} to `{}`",
1418 url,
1419 target_cache_file.simplified_display()
1420 );
1421
1422 let (mut reader, size) = read_url(url, client).await?;
1423 let temp_dir = tempfile::tempdir_in(python_builds_dir)?;
1424 let temp_file = temp_dir.path().join("download");
1425
1426 {
1428 let mut archive_writer = BufWriter::new(fs_err::tokio::File::create(&temp_file).await?);
1429
1430 if let Some(reporter) = reporter {
1432 let key = reporter.on_request_start(Direction::Download, &self.key, size);
1433 tokio::io::copy(
1434 &mut ProgressReader::new(reader, key, reporter),
1435 &mut archive_writer,
1436 )
1437 .await?;
1438 reporter.on_request_complete(Direction::Download, key);
1439 } else {
1440 tokio::io::copy(&mut reader, &mut archive_writer).await?;
1441 }
1442
1443 archive_writer.flush().await?;
1444 }
1445 match rename_with_retry(&temp_file, target_cache_file).await {
1447 Ok(()) => {}
1448 Err(_) if target_cache_file.is_file() => {}
1449 Err(err) => return Err(err.into()),
1450 }
1451 Ok(())
1452 }
1453
1454 async fn extract_reader(
1457 &self,
1458 reader: impl AsyncRead + Unpin,
1459 target: &Path,
1460 filename: &String,
1461 ext: SourceDistExtension,
1462 size: Option<u64>,
1463 reporter: Option<&dyn Reporter>,
1464 direction: Direction,
1465 ) -> Result<(), Error> {
1466 let mut hashers = if self.sha256.is_some() {
1467 vec![Hasher::from(HashAlgorithm::Sha256)]
1468 } else {
1469 vec![]
1470 };
1471 let mut hasher = uv_extract::hash::HashReader::new(reader, &mut hashers);
1472
1473 if let Some(reporter) = reporter {
1474 let progress_key = reporter.on_request_start(direction, &self.key, size);
1475 let mut reader = ProgressReader::new(&mut hasher, progress_key, reporter);
1476 uv_extract::stream::archive(filename, &mut reader, ext, target)
1477 .await
1478 .map_err(|err| Error::ExtractError(filename.to_owned(), err))?;
1479 reporter.on_request_complete(direction, progress_key);
1480 } else {
1481 uv_extract::stream::archive(filename, &mut hasher, ext, target)
1482 .await
1483 .map_err(|err| Error::ExtractError(filename.to_owned(), err))?;
1484 }
1485 hasher.finish().await.map_err(Error::HashExhaustion)?;
1486
1487 if let Some(expected) = self.sha256.as_deref() {
1489 let actual = HashDigest::from(hashers.pop().unwrap()).digest;
1490 if !actual.eq_ignore_ascii_case(expected) {
1491 return Err(Error::HashMismatch {
1492 installation: self.key.to_string(),
1493 expected: expected.to_string(),
1494 actual: actual.to_string(),
1495 });
1496 }
1497 }
1498
1499 Ok(())
1500 }
1501
1502 pub fn python_version(&self) -> PythonVersion {
1503 self.key.version()
1504 }
1505
1506 pub fn download_url(
1512 &self,
1513 python_install_mirror: Option<&str>,
1514 pypy_install_mirror: Option<&str>,
1515 ) -> Result<DisplaySafeUrl, Error> {
1516 self.download_urls(python_install_mirror, pypy_install_mirror)
1517 .map(|mut urls| urls.remove(0))
1518 }
1519
1520 pub fn download_urls(
1528 &self,
1529 python_install_mirror: Option<&str>,
1530 pypy_install_mirror: Option<&str>,
1531 ) -> Result<Vec<DisplaySafeUrl>, Error> {
1532 let custom_astral_mirror = astral_mirror_url_from_env();
1533 self.download_urls_with_astral_mirror(
1534 python_install_mirror,
1535 pypy_install_mirror,
1536 custom_astral_mirror.as_deref(),
1537 )
1538 }
1539
1540 fn download_urls_with_astral_mirror(
1541 &self,
1542 python_install_mirror: Option<&str>,
1543 pypy_install_mirror: Option<&str>,
1544 astral_mirror_url: Option<&str>,
1545 ) -> Result<Vec<DisplaySafeUrl>, Error> {
1546 let astral_mirror_url = custom_astral_mirror_url(astral_mirror_url);
1547 match self.key.implementation {
1548 LenientImplementationName::Known(ImplementationName::CPython) => {
1549 if let Some(mirror) = python_install_mirror {
1550 let Some(suffix) = self.url.strip_prefix(CPYTHON_DOWNLOADS_URL_PREFIX) else {
1552 return Err(Error::Mirror(
1553 EnvVars::UV_PYTHON_INSTALL_MIRROR,
1554 self.url.to_string(),
1555 ));
1556 };
1557 return Ok(vec![DisplaySafeUrl::parse(
1558 format!("{}/{}", mirror.trim_end_matches('/'), suffix).as_str(),
1559 )?]);
1560 }
1561 if let Some(suffix) = self.url.strip_prefix(CPYTHON_DOWNLOADS_URL_PREFIX) {
1563 let effective_mirror = effective_cpython_mirror(astral_mirror_url);
1564 let mirror_url = DisplaySafeUrl::parse(
1565 format!("{}/{}", effective_mirror.trim_end_matches('/'), suffix).as_str(),
1566 )?;
1567 if astral_mirror_url.is_some() {
1569 return Ok(vec![mirror_url]);
1570 }
1571 let canonical_url = DisplaySafeUrl::parse(&self.url)?;
1573 return Ok(vec![mirror_url, canonical_url]);
1574 }
1575 }
1576
1577 LenientImplementationName::Known(ImplementationName::PyPy) => {
1578 if let Some(mirror) = pypy_install_mirror {
1579 let Some(suffix) = self.url.strip_prefix("https://downloads.python.org/pypy/")
1580 else {
1581 return Err(Error::Mirror(
1582 EnvVars::UV_PYPY_INSTALL_MIRROR,
1583 self.url.to_string(),
1584 ));
1585 };
1586 return Ok(vec![DisplaySafeUrl::parse(
1587 format!("{}/{}", mirror.trim_end_matches('/'), suffix).as_str(),
1588 )?]);
1589 }
1590 }
1591
1592 _ => {}
1593 }
1594
1595 Ok(vec![DisplaySafeUrl::parse(&self.url)?])
1596 }
1597}
1598
1599fn parse_json_downloads(
1600 json_downloads: HashMap<String, JsonPythonDownload>,
1601) -> Vec<ManagedPythonDownload> {
1602 json_downloads
1603 .into_iter()
1604 .filter_map(|(key, entry)| {
1605 let implementation = match entry.name.as_str() {
1606 "cpython" => LenientImplementationName::Known(ImplementationName::CPython),
1607 "pypy" => LenientImplementationName::Known(ImplementationName::PyPy),
1608 "graalpy" => LenientImplementationName::Known(ImplementationName::GraalPy),
1609 _ => LenientImplementationName::Unknown(entry.name.clone()),
1610 };
1611
1612 let arch_str = match entry.arch.family.as_str() {
1613 "armv5tel" => "armv5te".to_string(),
1614 "riscv64" => "riscv64gc".to_string(),
1618 value => value.to_string(),
1619 };
1620
1621 let arch_str = if let Some(variant) = entry.arch.variant {
1622 format!("{arch_str}_{variant}")
1623 } else {
1624 arch_str
1625 };
1626
1627 let arch = match Arch::from_str(&arch_str) {
1628 Ok(arch) => arch,
1629 Err(e) => {
1630 debug!("Skipping entry {key}: Invalid arch '{arch_str}' - {e}");
1631 return None;
1632 }
1633 };
1634
1635 let os = match Os::from_str(&entry.os) {
1636 Ok(os) => os,
1637 Err(e) => {
1638 debug!("Skipping entry {}: Invalid OS '{}' - {}", key, entry.os, e);
1639 return None;
1640 }
1641 };
1642
1643 let libc = match Libc::from_str(&entry.libc) {
1644 Ok(libc) => libc,
1645 Err(e) => {
1646 debug!(
1647 "Skipping entry {}: Invalid libc '{}' - {}",
1648 key, entry.libc, e
1649 );
1650 return None;
1651 }
1652 };
1653
1654 let variant = match entry
1655 .variant
1656 .as_deref()
1657 .map(PythonVariant::from_str)
1658 .transpose()
1659 {
1660 Ok(Some(variant)) => variant,
1661 Ok(None) => PythonVariant::default(),
1662 Err(()) => {
1663 debug!(
1664 "Skipping entry {key}: Unknown python variant - {}",
1665 entry.variant.unwrap_or_default()
1666 );
1667 return None;
1668 }
1669 };
1670
1671 let version_str = format!(
1672 "{}.{}.{}{}",
1673 entry.major,
1674 entry.minor,
1675 entry.patch,
1676 entry.prerelease.as_deref().unwrap_or_default()
1677 );
1678
1679 let version = match PythonVersion::from_str(&version_str) {
1680 Ok(version) => version,
1681 Err(e) => {
1682 debug!("Skipping entry {key}: Invalid version '{version_str}' - {e}");
1683 return None;
1684 }
1685 };
1686
1687 let url = Cow::Owned(entry.url);
1688 let sha256 = entry.sha256.map(Cow::Owned);
1689 let build = entry
1690 .build
1691 .map(|s| Box::leak(s.into_boxed_str()) as &'static str);
1692
1693 Some(ManagedPythonDownload {
1694 key: PythonInstallationKey::new_from_version(
1695 implementation,
1696 &version,
1697 Platform::new(os, arch, libc),
1698 variant,
1699 ),
1700 url,
1701 sha256,
1702 build,
1703 })
1704 })
1705 .sorted_by(|a, b| Ord::cmp(&b.key, &a.key))
1706 .collect()
1707}
1708
1709impl Error {
1710 pub(crate) fn from_reqwest(
1711 url: DisplaySafeUrl,
1712 err: reqwest::Error,
1713 retries: Option<u32>,
1714 start: Instant,
1715 ) -> Self {
1716 let err = Self::NetworkError(url, WrappedReqwestError::from(err));
1717 if let Some(retries) = retries {
1718 Self::NetworkErrorWithRetries {
1719 err: Box::new(err),
1720 retries,
1721 duration: start.elapsed(),
1722 }
1723 } else {
1724 err
1725 }
1726 }
1727
1728 pub(crate) fn from_reqwest_middleware(
1729 url: DisplaySafeUrl,
1730 err: reqwest_middleware::Error,
1731 ) -> Self {
1732 match err {
1733 reqwest_middleware::Error::Middleware(error) => {
1734 Self::NetworkMiddlewareError(url, error)
1735 }
1736 reqwest_middleware::Error::Reqwest(error) => {
1737 Self::NetworkError(url, WrappedReqwestError::from(error))
1738 }
1739 }
1740 }
1741}
1742
1743impl Display for ManagedPythonDownload {
1744 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1745 write!(f, "{}", self.key)
1746 }
1747}
1748
1749#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1750pub enum Direction {
1751 Download,
1752 Extract,
1753}
1754
1755impl Direction {
1756 fn as_str(&self) -> &str {
1757 match self {
1758 Self::Download => "download",
1759 Self::Extract => "extract",
1760 }
1761 }
1762}
1763
1764impl Display for Direction {
1765 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1766 f.write_str(self.as_str())
1767 }
1768}
1769
1770pub trait Reporter: Send + Sync {
1771 fn on_request_start(
1772 &self,
1773 direction: Direction,
1774 name: &PythonInstallationKey,
1775 size: Option<u64>,
1776 ) -> usize;
1777 fn on_request_progress(&self, id: usize, inc: u64);
1778 fn on_request_complete(&self, direction: Direction, id: usize);
1779}
1780
1781struct ProgressReader<'a, R> {
1783 reader: R,
1784 index: usize,
1785 reporter: &'a dyn Reporter,
1786}
1787
1788impl<'a, R> ProgressReader<'a, R> {
1789 fn new(reader: R, index: usize, reporter: &'a dyn Reporter) -> Self {
1791 Self {
1792 reader,
1793 index,
1794 reporter,
1795 }
1796 }
1797}
1798
1799impl<R> AsyncRead for ProgressReader<'_, R>
1800where
1801 R: AsyncRead + Unpin,
1802{
1803 fn poll_read(
1804 mut self: Pin<&mut Self>,
1805 cx: &mut Context<'_>,
1806 buf: &mut ReadBuf<'_>,
1807 ) -> Poll<io::Result<()>> {
1808 Pin::new(&mut self.as_mut().reader)
1809 .poll_read(cx, buf)
1810 .map_ok(|()| {
1811 self.reporter
1812 .on_request_progress(self.index, buf.filled().len() as u64);
1813 })
1814 }
1815}
1816
1817async fn read_url(
1819 url: &DisplaySafeUrl,
1820 client: &BaseClient,
1821) -> Result<(impl AsyncRead + Unpin, Option<u64>), Error> {
1822 if url.scheme() == "file" {
1823 let path = url
1825 .to_file_path()
1826 .map_err(|()| Error::InvalidFileUrl(url.to_string()))?;
1827
1828 let size = fs_err::tokio::metadata(&path).await?.len();
1829 let reader = fs_err::tokio::File::open(&path).await?;
1830
1831 Ok((Either::Left(reader), Some(size)))
1832 } else {
1833 let start = Instant::now();
1834 let response = client
1835 .for_host(url)
1836 .get(Url::from(url.clone()))
1837 .send()
1838 .await
1839 .map_err(|err| Error::from_reqwest_middleware(url.clone(), err))?;
1840
1841 let retry_count = response
1842 .extensions()
1843 .get::<reqwest_retry::RetryCount>()
1844 .map(|retries| retries.value());
1845
1846 let response = response
1848 .error_for_status()
1849 .map_err(|err| Error::from_reqwest(url.clone(), err, retry_count, start))?;
1850
1851 let size = response.content_length();
1852 let stream = response
1853 .bytes_stream()
1854 .map_err(io::Error::other)
1855 .into_async_read();
1856
1857 Ok((Either::Right(stream.compat()), size))
1858 }
1859}
1860
1861#[cfg(test)]
1862mod tests {
1863 use std::collections::HashSet;
1864
1865 use crate::PythonVariant;
1866 use crate::implementation::LenientImplementationName;
1867 use crate::installation::PythonInstallationKey;
1868 use uv_platform::{Arch, Libc, Os, Platform};
1869
1870 use super::*;
1871
1872 #[test]
1874 fn test_python_download_request_from_str_complete() {
1875 let request = PythonDownloadRequest::from_str("cpython-3.12.0-linux-x86_64-gnu")
1876 .expect("Test request should be parsed");
1877
1878 assert_eq!(request.implementation, Some(ImplementationName::CPython));
1879 assert_eq!(
1880 request.version,
1881 Some(VersionRequest::from_str("3.12.0").unwrap())
1882 );
1883 assert_eq!(
1884 request.os,
1885 Some(Os::new(target_lexicon::OperatingSystem::Linux))
1886 );
1887 assert_eq!(
1888 request.arch,
1889 Some(ArchRequest::Explicit(Arch::new(
1890 target_lexicon::Architecture::X86_64,
1891 None
1892 )))
1893 );
1894 assert_eq!(
1895 request.libc,
1896 Some(Libc::Some(target_lexicon::Environment::Gnu))
1897 );
1898 }
1899
1900 #[test]
1902 fn test_python_download_request_from_str_with_any() {
1903 let request = PythonDownloadRequest::from_str("any-3.11-any-x86_64-any")
1904 .expect("Test request should be parsed");
1905
1906 assert_eq!(request.implementation, None);
1907 assert_eq!(
1908 request.version,
1909 Some(VersionRequest::from_str("3.11").unwrap())
1910 );
1911 assert_eq!(request.os, None);
1912 assert_eq!(
1913 request.arch,
1914 Some(ArchRequest::Explicit(Arch::new(
1915 target_lexicon::Architecture::X86_64,
1916 None
1917 )))
1918 );
1919 assert_eq!(request.libc, None);
1920 }
1921
1922 #[test]
1924 fn test_python_download_request_from_str_missing_segment() {
1925 let request =
1926 PythonDownloadRequest::from_str("pypy-linux").expect("Test request should be parsed");
1927
1928 assert_eq!(request.implementation, Some(ImplementationName::PyPy));
1929 assert_eq!(request.version, None);
1930 assert_eq!(
1931 request.os,
1932 Some(Os::new(target_lexicon::OperatingSystem::Linux))
1933 );
1934 assert_eq!(request.arch, None);
1935 assert_eq!(request.libc, None);
1936 }
1937
1938 #[test]
1939 fn test_python_download_request_from_str_version_only() {
1940 let request =
1941 PythonDownloadRequest::from_str("3.10.5").expect("Test request should be parsed");
1942
1943 assert_eq!(request.implementation, None);
1944 assert_eq!(
1945 request.version,
1946 Some(VersionRequest::from_str("3.10.5").unwrap())
1947 );
1948 assert_eq!(request.os, None);
1949 assert_eq!(request.arch, None);
1950 assert_eq!(request.libc, None);
1951 }
1952
1953 #[test]
1954 fn test_python_download_request_from_str_implementation_only() {
1955 let request =
1956 PythonDownloadRequest::from_str("cpython").expect("Test request should be parsed");
1957
1958 assert_eq!(request.implementation, Some(ImplementationName::CPython));
1959 assert_eq!(request.version, None);
1960 assert_eq!(request.os, None);
1961 assert_eq!(request.arch, None);
1962 assert_eq!(request.libc, None);
1963 }
1964
1965 #[test]
1967 fn test_python_download_request_from_str_os_arch() {
1968 let request = PythonDownloadRequest::from_str("windows-x86_64")
1969 .expect("Test request should be parsed");
1970
1971 assert_eq!(request.implementation, None);
1972 assert_eq!(request.version, None);
1973 assert_eq!(
1974 request.os,
1975 Some(Os::new(target_lexicon::OperatingSystem::Windows))
1976 );
1977 assert_eq!(
1978 request.arch,
1979 Some(ArchRequest::Explicit(Arch::new(
1980 target_lexicon::Architecture::X86_64,
1981 None
1982 )))
1983 );
1984 assert_eq!(request.libc, None);
1985 }
1986
1987 #[test]
1989 fn test_python_download_request_from_str_prerelease() {
1990 let request = PythonDownloadRequest::from_str("cpython-3.13.0rc1")
1991 .expect("Test request should be parsed");
1992
1993 assert_eq!(request.implementation, Some(ImplementationName::CPython));
1994 assert_eq!(
1995 request.version,
1996 Some(VersionRequest::from_str("3.13.0rc1").unwrap())
1997 );
1998 assert_eq!(request.os, None);
1999 assert_eq!(request.arch, None);
2000 assert_eq!(request.libc, None);
2001 }
2002
2003 #[test]
2005 fn test_python_download_request_from_str_too_many_parts() {
2006 let result = PythonDownloadRequest::from_str("cpython-3.12-linux-x86_64-gnu-extra");
2007
2008 assert!(matches!(result, Err(Error::TooManyParts(_))));
2009 }
2010
2011 #[test]
2013 fn test_python_download_request_from_str_empty() {
2014 let result = PythonDownloadRequest::from_str("");
2015
2016 assert!(matches!(result, Err(Error::EmptyRequest)), "{result:?}");
2017 }
2018
2019 #[test]
2021 fn test_python_download_request_from_str_all_any() {
2022 let request = PythonDownloadRequest::from_str("any-any-any-any-any")
2023 .expect("Test request should be parsed");
2024
2025 assert_eq!(request.implementation, None);
2026 assert_eq!(request.version, None);
2027 assert_eq!(request.os, None);
2028 assert_eq!(request.arch, None);
2029 assert_eq!(request.libc, None);
2030 }
2031
2032 #[test]
2034 fn test_python_download_request_from_str_case_insensitive_any() {
2035 let request = PythonDownloadRequest::from_str("ANY-3.11-Any-x86_64-aNy")
2036 .expect("Test request should be parsed");
2037
2038 assert_eq!(request.implementation, None);
2039 assert_eq!(
2040 request.version,
2041 Some(VersionRequest::from_str("3.11").unwrap())
2042 );
2043 assert_eq!(request.os, None);
2044 assert_eq!(
2045 request.arch,
2046 Some(ArchRequest::Explicit(Arch::new(
2047 target_lexicon::Architecture::X86_64,
2048 None
2049 )))
2050 );
2051 assert_eq!(request.libc, None);
2052 }
2053
2054 #[test]
2056 fn test_python_download_request_from_str_invalid_leading_segment() {
2057 let result = PythonDownloadRequest::from_str("foobar-3.14-windows");
2058
2059 assert!(
2060 matches!(result, Err(Error::ImplementationError(_))),
2061 "{result:?}"
2062 );
2063 }
2064
2065 #[test]
2067 fn test_python_download_request_from_str_out_of_order() {
2068 let result = PythonDownloadRequest::from_str("3.12-cpython");
2069
2070 assert!(
2071 matches!(result, Err(Error::InvalidRequestPlatform(_))),
2072 "{result:?}"
2073 );
2074 }
2075
2076 #[test]
2078 fn test_python_download_request_from_str_too_many_any() {
2079 let result = PythonDownloadRequest::from_str("any-any-any-any-any-any");
2080
2081 assert!(matches!(result, Err(Error::TooManyParts(_))));
2082 }
2083
2084 #[tokio::test]
2086 async fn test_python_download_request_build_filtering() {
2087 let request = PythonDownloadRequest::default()
2088 .with_version(VersionRequest::from_str("3.12").unwrap())
2089 .with_implementation(ImplementationName::CPython)
2090 .with_build("20240814".to_string());
2091
2092 let client = uv_client::BaseClientBuilder::default()
2093 .build()
2094 .expect("failed to build base client");
2095 let download_list = ManagedPythonDownloadList::new(&client, None).await.unwrap();
2096
2097 let downloads: Vec<_> = download_list
2098 .iter_all()
2099 .filter(|d| request.satisfied_by_download(d))
2100 .collect();
2101
2102 assert!(
2103 !downloads.is_empty(),
2104 "Should find at least one matching download"
2105 );
2106 for download in downloads {
2107 assert_eq!(download.build(), Some("20240814"));
2108 }
2109 }
2110
2111 #[tokio::test]
2113 async fn test_python_download_request_invalid_build() {
2114 let request = PythonDownloadRequest::default()
2116 .with_version(VersionRequest::from_str("3.12").unwrap())
2117 .with_implementation(ImplementationName::CPython)
2118 .with_build("99999999".to_string());
2119
2120 let client = uv_client::BaseClientBuilder::default()
2121 .build()
2122 .expect("failed to build base client");
2123 let download_list = ManagedPythonDownloadList::new(&client, None).await.unwrap();
2124
2125 let downloads: Vec<_> = download_list
2127 .iter_all()
2128 .filter(|d| request.satisfied_by_download(d))
2129 .collect();
2130
2131 assert_eq!(downloads.len(), 0);
2132 }
2133
2134 #[test]
2135 fn upgrade_request_native_defaults() {
2136 let request = PythonDownloadRequest::default()
2137 .with_implementation(ImplementationName::CPython)
2138 .with_version(VersionRequest::MajorMinorPatch(
2139 3,
2140 13,
2141 1,
2142 PythonVariant::Default,
2143 ))
2144 .with_os(Os::from_str("linux").unwrap())
2145 .with_arch(Arch::from_str("x86_64").unwrap())
2146 .with_libc(Libc::from_str("gnu").unwrap())
2147 .with_prereleases(false);
2148
2149 let host = Platform::new(
2150 Os::from_str("linux").unwrap(),
2151 Arch::from_str("x86_64").unwrap(),
2152 Libc::from_str("gnu").unwrap(),
2153 );
2154
2155 assert_eq!(
2156 request
2157 .clone()
2158 .unset_defaults_for_host(&host)
2159 .without_patch()
2160 .simplified_display()
2161 .as_deref(),
2162 Some("3.13")
2163 );
2164 }
2165
2166 #[test]
2167 fn upgrade_request_preserves_variant() {
2168 let request = PythonDownloadRequest::default()
2169 .with_implementation(ImplementationName::CPython)
2170 .with_version(VersionRequest::MajorMinorPatch(
2171 3,
2172 13,
2173 0,
2174 PythonVariant::Freethreaded,
2175 ))
2176 .with_os(Os::from_str("linux").unwrap())
2177 .with_arch(Arch::from_str("x86_64").unwrap())
2178 .with_libc(Libc::from_str("gnu").unwrap())
2179 .with_prereleases(false);
2180
2181 let host = Platform::new(
2182 Os::from_str("linux").unwrap(),
2183 Arch::from_str("x86_64").unwrap(),
2184 Libc::from_str("gnu").unwrap(),
2185 );
2186
2187 assert_eq!(
2188 request
2189 .clone()
2190 .unset_defaults_for_host(&host)
2191 .without_patch()
2192 .simplified_display()
2193 .as_deref(),
2194 Some("3.13+freethreaded")
2195 );
2196 }
2197
2198 #[test]
2199 fn upgrade_request_preserves_non_default_platform() {
2200 let request = PythonDownloadRequest::default()
2201 .with_implementation(ImplementationName::CPython)
2202 .with_version(VersionRequest::MajorMinorPatch(
2203 3,
2204 12,
2205 4,
2206 PythonVariant::Default,
2207 ))
2208 .with_os(Os::from_str("linux").unwrap())
2209 .with_arch(Arch::from_str("aarch64").unwrap())
2210 .with_libc(Libc::from_str("gnu").unwrap())
2211 .with_prereleases(false);
2212
2213 let host = Platform::new(
2214 Os::from_str("linux").unwrap(),
2215 Arch::from_str("x86_64").unwrap(),
2216 Libc::from_str("gnu").unwrap(),
2217 );
2218
2219 assert_eq!(
2220 request
2221 .clone()
2222 .unset_defaults_for_host(&host)
2223 .without_patch()
2224 .simplified_display()
2225 .as_deref(),
2226 Some("3.12-aarch64")
2227 );
2228 }
2229
2230 #[test]
2231 fn upgrade_request_preserves_custom_implementation() {
2232 let request = PythonDownloadRequest::default()
2233 .with_implementation(ImplementationName::PyPy)
2234 .with_version(VersionRequest::MajorMinorPatch(
2235 3,
2236 10,
2237 5,
2238 PythonVariant::Default,
2239 ))
2240 .with_os(Os::from_str("linux").unwrap())
2241 .with_arch(Arch::from_str("x86_64").unwrap())
2242 .with_libc(Libc::from_str("gnu").unwrap())
2243 .with_prereleases(false);
2244
2245 let host = Platform::new(
2246 Os::from_str("linux").unwrap(),
2247 Arch::from_str("x86_64").unwrap(),
2248 Libc::from_str("gnu").unwrap(),
2249 );
2250
2251 assert_eq!(
2252 request
2253 .clone()
2254 .unset_defaults_for_host(&host)
2255 .without_patch()
2256 .simplified_display()
2257 .as_deref(),
2258 Some("pypy-3.10")
2259 );
2260 }
2261
2262 #[test]
2263 fn simplified_display_returns_none_when_empty() {
2264 let request = PythonDownloadRequest::default()
2265 .fill_platform()
2266 .expect("should populate defaults");
2267
2268 let host = Platform::from_env().expect("host platform");
2269
2270 assert_eq!(
2271 request.unset_defaults_for_host(&host).simplified_display(),
2272 None
2273 );
2274 }
2275
2276 #[test]
2277 fn simplified_display_omits_environment_arch() {
2278 let mut request = PythonDownloadRequest::default()
2279 .with_version(VersionRequest::MajorMinor(3, 12, PythonVariant::Default))
2280 .with_os(Os::from_str("linux").unwrap())
2281 .with_libc(Libc::from_str("gnu").unwrap());
2282
2283 request.arch = Some(ArchRequest::Environment(Arch::from_str("x86_64").unwrap()));
2284
2285 let host = Platform::new(
2286 Os::from_str("linux").unwrap(),
2287 Arch::from_str("aarch64").unwrap(),
2288 Libc::from_str("gnu").unwrap(),
2289 );
2290
2291 assert_eq!(
2292 request
2293 .unset_defaults_for_host(&host)
2294 .simplified_display()
2295 .as_deref(),
2296 Some("3.12")
2297 );
2298 }
2299
2300 fn cpython_download_for_url(url: &'static str) -> ManagedPythonDownload {
2301 let key = PythonInstallationKey::new(
2302 LenientImplementationName::Known(crate::implementation::ImplementationName::CPython),
2303 3,
2304 12,
2305 4,
2306 None,
2307 Platform::new(
2308 Os::from_str("linux").unwrap(),
2309 Arch::from_str("x86_64").unwrap(),
2310 Libc::from_str("gnu").unwrap(),
2311 ),
2312 crate::PythonVariant::default(),
2313 );
2314
2315 ManagedPythonDownload {
2316 key,
2317 url: Cow::Borrowed(url),
2318 sha256: Some(Cow::Borrowed("abc123")),
2319 build: Some("20240713"),
2320 }
2321 }
2322
2323 #[test]
2324 fn test_cpython_download_urls_custom_astral_mirror() {
2325 let download = cpython_download_for_url(
2326 "https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-x86_64-unknown-linux-gnu-install_only.tar.gz",
2327 );
2328
2329 let urls = download
2330 .download_urls_with_astral_mirror(
2331 None,
2332 None,
2333 Some("https://nexus.example.com/repository/releases.astral.sh/"),
2334 )
2335 .expect("download URLs should be valid");
2336 let urls = urls
2337 .into_iter()
2338 .map(|url| url.to_string())
2339 .collect::<Vec<_>>();
2340 assert_eq!(
2341 urls,
2342 vec![
2343 "https://nexus.example.com/repository/releases.astral.sh/github/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-x86_64-unknown-linux-gnu-install_only.tar.gz"
2344 .to_string(),
2345 ]
2346 );
2347 }
2348
2349 #[test]
2350 fn test_cpython_specific_mirror_takes_precedence_over_astral_mirror() {
2351 let download = cpython_download_for_url(
2352 "https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-x86_64-unknown-linux-gnu-install_only.tar.gz",
2353 );
2354
2355 let urls = download
2356 .download_urls_with_astral_mirror(
2357 Some("https://python-mirror.example.com/releases/"),
2358 None,
2359 Some("https://nexus.example.com/repository/releases.astral.sh/"),
2360 )
2361 .expect("download URLs should be valid");
2362 let urls = urls
2363 .into_iter()
2364 .map(|url| url.to_string())
2365 .collect::<Vec<_>>();
2366 assert_eq!(
2367 urls,
2368 vec![
2369 "https://python-mirror.example.com/releases/20240713/cpython-3.12.4%2B20240713-x86_64-unknown-linux-gnu-install_only.tar.gz"
2370 .to_string(),
2371 ]
2372 );
2373 }
2374
2375 #[test]
2376 fn test_cpython_download_urls_empty_astral_mirror_uses_default() {
2377 let download = cpython_download_for_url(
2378 "https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-x86_64-unknown-linux-gnu-install_only.tar.gz",
2379 );
2380
2381 let default_urls = download
2382 .download_urls_with_astral_mirror(None, None, None)
2383 .expect("download URLs should be valid");
2384 let empty_urls = download
2385 .download_urls_with_astral_mirror(None, None, Some(""))
2386 .expect("download URLs should be valid");
2387
2388 assert_eq!(default_urls, empty_urls);
2389 }
2390
2391 #[test]
2393 fn test_managed_python_download_build_display() {
2394 let key = PythonInstallationKey::new(
2396 LenientImplementationName::Known(crate::implementation::ImplementationName::CPython),
2397 3,
2398 12,
2399 0,
2400 None,
2401 Platform::new(
2402 Os::from_str("linux").unwrap(),
2403 Arch::from_str("x86_64").unwrap(),
2404 Libc::from_str("gnu").unwrap(),
2405 ),
2406 crate::PythonVariant::default(),
2407 );
2408
2409 let download_with_build = ManagedPythonDownload {
2410 key,
2411 url: Cow::Borrowed("https://example.com/python.tar.gz"),
2412 sha256: Some(Cow::Borrowed("abc123")),
2413 build: Some("20240101"),
2414 };
2415
2416 assert_eq!(
2418 download_with_build.to_display_with_build().to_string(),
2419 "cpython-3.12.0-linux-x86_64-gnu+20240101"
2420 );
2421
2422 let download_without_build = ManagedPythonDownload {
2424 key: download_with_build.key.clone(),
2425 url: Cow::Borrowed("https://example.com/python.tar.gz"),
2426 sha256: Some(Cow::Borrowed("abc123")),
2427 build: None,
2428 };
2429
2430 assert_eq!(
2432 download_without_build.to_display_with_build().to_string(),
2433 "cpython-3.12.0-linux-x86_64-gnu"
2434 );
2435 }
2436
2437 #[test]
2440 fn test_should_try_next_url_hash_mismatch() {
2441 let err = Error::HashMismatch {
2442 installation: "cpython-3.12.0".to_string(),
2443 expected: "abc".to_string(),
2444 actual: "def".to_string(),
2445 };
2446 assert!(!err.should_try_next_url());
2447 }
2448
2449 #[test]
2452 fn test_should_try_next_url_extract_error_filesystem() {
2453 let err = Error::ExtractError(
2454 "archive.tar.gz".to_string(),
2455 uv_extract::Error::Io(io::Error::new(io::ErrorKind::PermissionDenied, "")),
2456 );
2457 assert!(!err.should_try_next_url());
2458 }
2459
2460 #[test]
2463 fn test_should_try_next_url_io_error_filesystem() {
2464 let err = Error::Io(io::Error::new(io::ErrorKind::PermissionDenied, ""));
2465 assert!(!err.should_try_next_url());
2466 }
2467
2468 #[test]
2471 fn test_should_try_next_url_io_error_network() {
2472 let err = Error::Io(io::Error::new(io::ErrorKind::ConnectionReset, ""));
2473 assert!(err.should_try_next_url());
2474 }
2475
2476 #[test]
2479 fn test_should_try_next_url_network_error_404() {
2480 let url =
2481 DisplaySafeUrl::from_str("https://releases.astral.sh/python/cpython-3.12.0.tar.gz")
2482 .unwrap();
2483 let wrapped = WrappedReqwestError::with_problem_details(
2486 reqwest_middleware::Error::Middleware(anyhow::anyhow!("404 Not Found")),
2487 None,
2488 );
2489 let err = Error::NetworkError(url, wrapped);
2490 assert!(err.should_try_next_url());
2491 }
2492
2493 #[test]
2496 fn embedded_download_versions_convert_to_version_requests() {
2497 let downloads = ManagedPythonDownloadList::new_only_embedded()
2498 .expect("embedded download metadata should load");
2499
2500 let unique_versions: HashSet<PythonVersion> = downloads
2501 .iter_all()
2502 .map(ManagedPythonDownload::python_version)
2503 .collect();
2504
2505 for version in &unique_versions {
2506 let _ = VersionRequest::from(version);
2507 }
2508 }
2509}