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