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, 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::{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<Cow<'static, str>>,
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 match &key.implementation {
715 LenientImplementationName::Known(implementation) => Some(*implementation),
716 LenientImplementationName::Unknown(name) => unreachable!(
717 "Managed Python installations are expected to always have known implementation names, found {name}"
718 ),
719 },
720 Some(ArchRequest::Explicit(*key.arch())),
721 Some(*key.os()),
722 Some(*key.libc()),
723 Some(key.prerelease.is_some()),
724 )
725 }
726}
727
728impl Display for PythonDownloadRequest {
729 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
730 let mut parts = Vec::new();
731 if let Some(implementation) = self.implementation {
732 parts.push(implementation.to_string());
733 } else {
734 parts.push("any".to_string());
735 }
736 if let Some(version) = &self.version {
737 parts.push(version.to_string());
738 } else {
739 parts.push("any".to_string());
740 }
741 if let Some(os) = &self.os {
742 parts.push(os.to_string());
743 } else {
744 parts.push("any".to_string());
745 }
746 if let Some(arch) = self.arch {
747 parts.push(arch.to_string());
748 } else {
749 parts.push("any".to_string());
750 }
751 if let Some(libc) = self.libc {
752 parts.push(libc.to_string());
753 } else {
754 parts.push("any".to_string());
755 }
756 write!(f, "{}", parts.join("-"))
757 }
758}
759impl FromStr for PythonDownloadRequest {
760 type Err = Error;
761
762 fn from_str(s: &str) -> Result<Self, Self::Err> {
763 #[derive(Debug, Clone)]
764 enum Position {
765 Start,
766 Implementation,
767 Version,
768 Os,
769 Arch,
770 Libc,
771 End,
772 }
773
774 impl Position {
775 fn next(&self) -> Self {
776 match self {
777 Self::Start => Self::Implementation,
778 Self::Implementation => Self::Version,
779 Self::Version => Self::Os,
780 Self::Os => Self::Arch,
781 Self::Arch => Self::Libc,
782 Self::Libc => Self::End,
783 Self::End => Self::End,
784 }
785 }
786 }
787
788 #[derive(Debug)]
789 struct State<'a, P: Iterator<Item = &'a str>> {
790 parts: P,
791 part: Option<&'a str>,
792 position: Position,
793 error: Option<Error>,
794 count: usize,
795 }
796
797 impl<'a, P: Iterator<Item = &'a str>> State<'a, P> {
798 fn new(parts: P) -> Self {
799 Self {
800 parts,
801 part: None,
802 position: Position::Start,
803 error: None,
804 count: 0,
805 }
806 }
807
808 fn next_part(&mut self) {
809 self.next_position();
810 self.part = self.parts.next();
811 self.count += 1;
812 self.error.take();
813 }
814
815 fn next_position(&mut self) {
816 self.position = self.position.next();
817 }
818
819 fn record_err(&mut self, err: Error) {
820 self.error.get_or_insert(err);
823 }
824 }
825
826 if s.is_empty() {
827 return Err(Error::EmptyRequest);
828 }
829
830 let mut parts = s.split('-');
831
832 let mut implementation = None;
833 let mut version = None;
834 let mut os = None;
835 let mut arch = None;
836 let mut libc = None;
837
838 let mut state = State::new(parts.by_ref());
839 state.next_part();
840
841 while let Some(part) = state.part {
842 match state.position {
843 Position::Start => unreachable!("We start before the loop"),
844 Position::Implementation => {
845 if part.eq_ignore_ascii_case("any") {
846 state.next_part();
847 continue;
848 }
849 match ImplementationName::from_str(part) {
850 Ok(val) => {
851 implementation = Some(val);
852 state.next_part();
853 }
854 Err(err) => {
855 state.next_position();
856 state.record_err(err.into());
857 }
858 }
859 }
860 Position::Version => {
861 if part.eq_ignore_ascii_case("any") {
862 state.next_part();
863 continue;
864 }
865 match VersionRequest::from_str(part)
866 .map_err(|_| Error::InvalidPythonVersion(part.to_string()))
867 {
868 Ok(val) => {
870 version = Some(val);
871 state.next_part();
872 }
873 Err(err) => {
874 state.next_position();
875 state.record_err(err);
876 }
877 }
878 }
879 Position::Os => {
880 if part.eq_ignore_ascii_case("any") {
881 state.next_part();
882 continue;
883 }
884 match Os::from_str(part) {
885 Ok(val) => {
886 os = Some(val);
887 state.next_part();
888 }
889 Err(err) => {
890 state.next_position();
891 state.record_err(err.into());
892 }
893 }
894 }
895 Position::Arch => {
896 if part.eq_ignore_ascii_case("any") {
897 state.next_part();
898 continue;
899 }
900 match Arch::from_str(part) {
901 Ok(val) => {
902 arch = Some(ArchRequest::Explicit(val));
903 state.next_part();
904 }
905 Err(err) => {
906 state.next_position();
907 state.record_err(err.into());
908 }
909 }
910 }
911 Position::Libc => {
912 if part.eq_ignore_ascii_case("any") {
913 state.next_part();
914 continue;
915 }
916 match Libc::from_str(part) {
917 Ok(val) => {
918 libc = Some(val);
919 state.next_part();
920 }
921 Err(err) => {
922 state.next_position();
923 state.record_err(err.into());
924 }
925 }
926 }
927 Position::End => {
928 if state.count > 5 {
929 return Err(Error::TooManyParts(s.to_string()));
930 }
931
932 if let Some(err) = state.error {
939 return Err(err);
940 }
941 state.next_part();
942 }
943 }
944 }
945
946 Ok(Self::new(version, implementation, arch, os, libc, None))
947 }
948}
949
950const BUILTIN_PYTHON_DOWNLOADS_JSON: &[u8] =
951 include_bytes!(concat!(env!("OUT_DIR"), "/download-metadata-minified.json"));
952
953pub struct ManagedPythonDownloadList {
954 downloads: Vec<ManagedPythonDownload>,
955}
956
957#[derive(Debug, Deserialize, Serialize, Clone)]
958struct JsonPythonDownload {
959 name: String,
960 arch: JsonArch,
961 os: String,
962 libc: String,
963 major: u8,
964 minor: u8,
965 patch: u8,
966 prerelease: Option<String>,
967 url: String,
968 sha256: Option<String>,
969 variant: Option<String>,
970 build: Option<String>,
971}
972
973#[derive(Debug, Deserialize, Serialize, Clone)]
974struct JsonArch {
975 family: String,
976 variant: Option<String>,
977}
978
979#[derive(Debug, Clone)]
980pub enum DownloadResult {
981 AlreadyAvailable(PathBuf),
982 Fetched(PathBuf),
983}
984
985impl ManagedPythonDownloadList {
986 fn iter_all(&self) -> impl Iterator<Item = &ManagedPythonDownload> {
988 self.downloads.iter()
989 }
990
991 pub fn iter_matching(
993 &self,
994 request: &PythonDownloadRequest,
995 ) -> impl Iterator<Item = &ManagedPythonDownload> {
996 self.iter_all()
997 .filter(move |download| request.satisfied_by_download(download))
998 }
999
1000 pub fn find(&self, request: &PythonDownloadRequest) -> Result<&ManagedPythonDownload, Error> {
1005 if let Some(download) = self.iter_matching(request).next() {
1006 return Ok(download);
1007 }
1008
1009 if !request.allows_prereleases()
1010 && let Some(download) = self
1011 .iter_matching(&request.clone().with_prereleases(true))
1012 .next()
1013 {
1014 return Ok(download);
1015 }
1016
1017 Err(Error::NoDownloadFound(request.clone()))
1018 }
1019
1020 pub async fn new(
1025 client_builder: &BaseClientBuilder<'_>,
1026 cache: &Cache,
1027 python_downloads_json_url: Option<&str>,
1028 ) -> Result<Self, Error> {
1029 enum Source<'a> {
1034 BuiltIn,
1035 Path(Cow<'a, Path>),
1036 Http(DisplaySafeUrl),
1037 }
1038
1039 let json_source = if let Some(url_or_path) = python_downloads_json_url {
1040 if let Ok(url) = DisplaySafeUrl::parse(url_or_path) {
1041 match url.scheme() {
1042 "http" | "https" => Source::Http(url),
1043 "file" => Source::Path(Cow::Owned(
1044 url.to_file_path().or(Err(Error::InvalidUrlFormat(url)))?,
1045 )),
1046 _ => Source::Path(Cow::Borrowed(Path::new(url_or_path))),
1047 }
1048 } else {
1049 Source::Path(Cow::Borrowed(Path::new(url_or_path)))
1050 }
1051 } else {
1052 Source::BuiltIn
1053 };
1054
1055 let json_downloads = match json_source {
1056 Source::BuiltIn => parse_downloads_json(
1057 BUILTIN_PYTHON_DOWNLOADS_JSON,
1058 "EMBEDDED IN THE BINARY".to_owned(),
1059 )?,
1060 Source::Path(ref path) => parse_downloads_json(
1061 &fs_err::read(path.as_ref())?,
1062 path.to_string_lossy().to_string(),
1063 )?,
1064 Source::Http(ref url) => {
1065 let client = CachedClient::new(
1066 client_builder
1067 .build()
1068 .map_err(|err| Error::ClientBuild(Box::new(err)))?,
1069 );
1070 fetch_downloads_from_url(&client, cache, url)
1071 .await
1072 .map_err(|e| match e {
1073 e @ (Error::InvalidPythonDownloadsJSON(..)
1074 | Error::UnsupportedPythonDownloadsJSON(..)) => e,
1075 e => Error::FetchingPythonDownloadsJSONError(url.to_string(), Box::new(e)),
1076 })?
1077 }
1078 };
1079
1080 let downloads = parse_json_downloads(json_downloads);
1081 Ok(Self { downloads })
1082 }
1083
1084 pub fn new_only_embedded() -> Result<Self, Error> {
1087 let json_downloads: HashMap<String, JsonPythonDownload> =
1088 serde_json::from_slice(BUILTIN_PYTHON_DOWNLOADS_JSON).map_err(|e| {
1089 Error::InvalidPythonDownloadsJSON("EMBEDDED IN THE BINARY".to_owned(), e)
1090 })?;
1091 let result = parse_json_downloads(json_downloads);
1092 Ok(Self { downloads: result })
1093 }
1094}
1095
1096fn parse_downloads_json(
1100 buf: &[u8],
1101 source: String,
1102) -> Result<HashMap<String, JsonPythonDownload>, Error> {
1103 match serde_json::from_slice(buf) {
1104 Ok(data) => Ok(data),
1105 Err(e) => {
1106 #[expect(clippy::zero_sized_map_values)]
1113 if let Ok(keys) = serde_json::from_slice::<HashMap<String, serde::de::IgnoredAny>>(buf)
1114 && keys.contains_key("version")
1115 {
1116 Err(Error::UnsupportedPythonDownloadsJSON(source))
1117 } else {
1118 Err(Error::InvalidPythonDownloadsJSON(source, e))
1119 }
1120 }
1121 }
1122}
1123
1124async fn fetch_downloads_from_url(
1125 client: &CachedClient,
1126 cache: &Cache,
1127 url: &DisplaySafeUrl,
1128) -> Result<HashMap<String, JsonPythonDownload>, Error> {
1129 let cache_entry = cache.entry(
1130 CacheBucket::Python,
1131 "downloads-json",
1132 format!("{}.msgpack", cache_digest(&url.as_str())),
1133 );
1134 let cache_control = match client.uncached().connectivity() {
1135 Connectivity::Online => CacheControl::from(cache.freshness(&cache_entry, None, None)?),
1136 Connectivity::Offline => CacheControl::AllowStale,
1137 };
1138
1139 let request = client
1140 .uncached()
1141 .for_host(url)
1142 .get(Url::from(url.clone()))
1143 .build()
1144 .map_err(|err| Error::NetworkError(url.clone(), WrappedReqwestError::from(err)))?;
1145
1146 let response_callback = async |response: Response| {
1147 let bytes = response
1148 .bytes()
1149 .await
1150 .map_err(|err| Error::NetworkError(url.clone(), WrappedReqwestError::from(err)))?;
1151 parse_downloads_json(&bytes, url.to_string())
1152 };
1153
1154 client
1155 .get_serde_with_retry(request, &cache_entry, cache_control, response_callback)
1156 .await
1157 .map_err(|err| match err {
1158 CachedClientError::Client(err) => Error::RemotePythonDownloadsJSONClient(Box::new(err)),
1159 CachedClientError::Callback {
1160 err,
1161 retries,
1162 duration,
1163 } => match err {
1164 err @ (Error::InvalidPythonDownloadsJSON(..)
1166 | Error::UnsupportedPythonDownloadsJSON(..)) => err,
1167 err if retries > 0 => err.into_retried(retries, duration),
1168 err => err,
1169 },
1170 })
1171}
1172
1173impl ManagedPythonDownload {
1174 pub(crate) fn url(&self) -> &Cow<'static, str> {
1175 &self.url
1176 }
1177
1178 pub fn key(&self) -> &PythonInstallationKey {
1179 &self.key
1180 }
1181
1182 fn os(&self) -> &Os {
1183 self.key.os()
1184 }
1185
1186 pub(crate) fn sha256(&self) -> Option<&Cow<'static, str>> {
1187 self.sha256.as_ref()
1188 }
1189
1190 pub fn build(&self) -> Option<&'static str> {
1191 self.build
1192 }
1193
1194 #[instrument(skip_all, fields(download = % self.key()))]
1200 pub async fn fetch_with_retry(
1201 &self,
1202 client: &BaseClient,
1203 retry_policy: &ExponentialBackoff,
1204 installation_dir: &Path,
1205 scratch_dir: &Path,
1206 reinstall: bool,
1207 python_install_mirror: Option<&str>,
1208 pypy_install_mirror: Option<&str>,
1209 reporter: Option<&dyn Reporter>,
1210 ) -> Result<DownloadResult, Error> {
1211 let urls = self.download_urls(python_install_mirror, pypy_install_mirror)?;
1212 if urls.is_empty() {
1213 return Err(Error::NoPythonDownloadUrlFound);
1214 }
1215 fetch_with_url_fallback(&urls, *retry_policy, &format!("`{}`", self.key()), |url| {
1216 self.fetch_from_url(
1217 url,
1218 client,
1219 installation_dir,
1220 scratch_dir,
1221 reinstall,
1222 reporter,
1223 )
1224 })
1225 .await
1226 }
1227
1228 async fn fetch_from_url(
1230 &self,
1231 url: DisplaySafeUrl,
1232 client: &BaseClient,
1233 installation_dir: &Path,
1234 scratch_dir: &Path,
1235 reinstall: bool,
1236 reporter: Option<&dyn Reporter>,
1237 ) -> Result<DownloadResult, Error> {
1238 let path = installation_dir.join(self.key().to_string());
1239
1240 if !reinstall && path.is_dir() {
1242 return Ok(DownloadResult::AlreadyAvailable(path));
1243 }
1244
1245 let filename = url
1248 .path_segments()
1249 .ok_or_else(|| Error::InvalidUrlFormat(url.clone()))?
1250 .next_back()
1251 .ok_or_else(|| Error::InvalidUrlFormat(url.clone()))?
1252 .replace("%2B", "-");
1253 debug_assert!(
1254 filename
1255 .chars()
1256 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.'),
1257 "Unexpected char in filename: {filename}"
1258 );
1259 let ext = SourceDistExtension::from_path(&filename)
1260 .map_err(|err| Error::MissingExtension(url.to_string(), err))?;
1261
1262 let temp_dir = tempfile::tempdir_in(scratch_dir).map_err(Error::DownloadDirError)?;
1263
1264 let temp_dir = if let Some(python_builds_dir) =
1265 env::var_os(EnvVars::UV_PYTHON_CACHE_DIR).filter(|s| !s.is_empty())
1266 {
1267 let python_builds_dir = PathBuf::from(python_builds_dir);
1268 fs_err::create_dir_all(&python_builds_dir)?;
1269 let hash_prefix = match self.sha256.as_deref() {
1270 Some(sha) => {
1271 &sha[..9]
1273 }
1274 None => "none",
1275 };
1276 let target_cache_file = python_builds_dir.join(format!("{hash_prefix}-{filename}"));
1277
1278 let (reader, size): (Box<dyn AsyncRead + Unpin>, Option<u64>) =
1282 match fs_err::tokio::File::open(&target_cache_file).await {
1283 Ok(file) => {
1284 debug!(
1285 "Extracting existing `{}`",
1286 target_cache_file.simplified_display()
1287 );
1288 let size = file.metadata().await?.len();
1289 let reader = Box::new(tokio::io::BufReader::new(file));
1290 (reader, Some(size))
1291 }
1292 Err(err) if err.kind() == io::ErrorKind::NotFound => {
1293 if client.connectivity().is_offline() {
1295 return Err(Error::OfflinePythonMissing {
1296 file: Box::new(self.key().clone()),
1297 url: Box::new(url.clone()),
1298 python_builds_dir,
1299 });
1300 }
1301
1302 self.download_archive(
1303 &url,
1304 client,
1305 reporter,
1306 &python_builds_dir,
1307 &target_cache_file,
1308 )
1309 .await?;
1310
1311 debug!("Extracting `{}`", target_cache_file.simplified_display());
1312 let file = fs_err::tokio::File::open(&target_cache_file).await?;
1313 let size = file.metadata().await?.len();
1314 let reader = Box::new(tokio::io::BufReader::new(file));
1315 (reader, Some(size))
1316 }
1317 Err(err) => return Err(err.into()),
1318 };
1319
1320 self.extract_reader(
1322 reader,
1323 temp_dir,
1324 &filename,
1325 ext,
1326 size,
1327 reporter,
1328 Direction::Extract,
1329 )
1330 .await?
1331 } else {
1332 debug!("Downloading {url}");
1334 debug!(
1335 "Extracting {filename} to temporary location: {}",
1336 temp_dir.path().simplified_display()
1337 );
1338
1339 let (reader, size) = read_url(&url, client).await?;
1340 self.extract_reader(
1341 reader,
1342 temp_dir,
1343 &filename,
1344 ext,
1345 size,
1346 reporter,
1347 Direction::Download,
1348 )
1349 .await?
1350 };
1351
1352 let mut extracted = match uv_extract::strip_component(temp_dir.path()) {
1354 Ok(top_level) => top_level,
1355 Err(uv_extract::Error::NonSingularArchive(_)) => temp_dir.path().to_path_buf(),
1356 Err(err) => return Err(Error::ExtractError(filename, err)),
1357 };
1358
1359 if extracted.join("install").is_dir() {
1361 extracted = extracted.join("install");
1362 } else if self.os().is_emscripten() {
1364 extracted = extracted.join("pyodide-root").join("dist");
1365 }
1366
1367 #[cfg(unix)]
1368 {
1369 if self.os().is_emscripten() {
1373 fs_err::create_dir_all(extracted.join("bin"))?;
1374 fs_err::os::unix::fs::symlink(
1375 "../python",
1376 extracted
1377 .join("bin")
1378 .join(format!("python{}.{}", self.key.major, self.key.minor)),
1379 )?;
1380 }
1381
1382 if !self.os().is_windows() {
1391 match fs_err::os::unix::fs::symlink(
1392 format!("python{}.{}", self.key.major, self.key.minor),
1393 extracted.join("bin").join("python"),
1394 ) {
1395 Ok(()) => {}
1396 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
1397 Err(err) => return Err(err.into()),
1398 }
1399 }
1400 }
1401
1402 if path.is_dir() {
1404 debug!("Removing existing directory: {}", path.user_display());
1405 fs_err::tokio::remove_dir_all(&path).await?;
1406 }
1407
1408 debug!("Moving {} to {}", extracted.display(), path.user_display());
1410 rename_with_retry(extracted, &path)
1411 .await
1412 .map_err(|err| Error::CopyError {
1413 to: path.clone(),
1414 err,
1415 })?;
1416
1417 Ok(DownloadResult::Fetched(path))
1418 }
1419
1420 async fn download_archive(
1422 &self,
1423 url: &DisplaySafeUrl,
1424 client: &BaseClient,
1425 reporter: Option<&dyn Reporter>,
1426 python_builds_dir: &Path,
1427 target_cache_file: &Path,
1428 ) -> Result<(), Error> {
1429 debug!(
1430 "Downloading {} to `{}`",
1431 url,
1432 target_cache_file.simplified_display()
1433 );
1434
1435 let (mut reader, size) = read_url(url, client).await?;
1436 let temp_dir = tempfile::tempdir_in(python_builds_dir)?;
1437 let temp_file = temp_dir.path().join("download");
1438
1439 {
1441 let mut archive_writer = BufWriter::new(fs_err::tokio::File::create(&temp_file).await?);
1442
1443 if let Some(reporter) = reporter {
1445 let key = reporter.on_request_start(Direction::Download, &self.key, size);
1446 tokio::io::copy(
1447 &mut ProgressReader::new(reader, key, reporter),
1448 &mut archive_writer,
1449 )
1450 .await?;
1451 reporter.on_request_complete(Direction::Download, key);
1452 } else {
1453 tokio::io::copy(&mut reader, &mut archive_writer).await?;
1454 }
1455
1456 archive_writer.flush().await?;
1457 }
1458 match rename_with_retry(&temp_file, target_cache_file).await {
1460 Ok(()) => {}
1461 Err(_) if target_cache_file.is_file() => {}
1462 Err(err) => return Err(err.into()),
1463 }
1464 Ok(())
1465 }
1466
1467 async fn extract_reader(
1470 &self,
1471 reader: impl AsyncRead + Unpin,
1472 target: TempDir,
1473 filename: &String,
1474 ext: SourceDistExtension,
1475 size: Option<u64>,
1476 reporter: Option<&dyn Reporter>,
1477 direction: Direction,
1478 ) -> Result<TempDir, Error> {
1479 let mut hashers = if self.sha256.is_some() {
1480 vec![Hasher::from(HashAlgorithm::Sha256)]
1481 } else {
1482 vec![]
1483 };
1484 let mut hasher = uv_extract::hash::HashReader::new(reader, &mut hashers);
1485
1486 let target = if let Some(reporter) = reporter {
1487 let progress_key = reporter.on_request_start(direction, &self.key, size);
1488 let mut reader = ProgressReader::new(&mut hasher, progress_key, reporter);
1489 let (target, _) = uv_extract::stream::archive(&mut reader, ext, target)
1490 .await
1491 .map_err(|err| Error::ExtractError(filename.to_owned(), err))?;
1492 reporter.on_request_complete(direction, progress_key);
1493 target
1494 } else {
1495 let (target, _) = uv_extract::stream::archive(&mut hasher, ext, target)
1496 .await
1497 .map_err(|err| Error::ExtractError(filename.to_owned(), err))?;
1498 target
1499 };
1500 hasher.finish().await.map_err(Error::HashExhaustion)?;
1501
1502 if let Some(expected) = self.sha256.as_deref() {
1504 let actual = HashDigest::from(hashers.pop().unwrap()).digest;
1505 if !actual.eq_ignore_ascii_case(expected) {
1506 return Err(Error::HashMismatch {
1507 installation: self.key.to_string(),
1508 expected: expected.to_string(),
1509 actual: actual.to_string(),
1510 });
1511 }
1512 }
1513
1514 Ok(target)
1515 }
1516
1517 #[cfg(test)]
1518 fn python_version(&self) -> PythonVersion {
1519 self.key.version()
1520 }
1521
1522 pub fn download_urls(
1530 &self,
1531 python_install_mirror: Option<&str>,
1532 pypy_install_mirror: Option<&str>,
1533 ) -> Result<Vec<DisplaySafeUrl>, Error> {
1534 let custom_astral_mirror = astral_mirror_url_from_env();
1535 self.download_urls_with_astral_mirror(
1536 python_install_mirror,
1537 pypy_install_mirror,
1538 custom_astral_mirror.as_deref(),
1539 )
1540 }
1541
1542 fn download_urls_with_astral_mirror(
1543 &self,
1544 python_install_mirror: Option<&str>,
1545 pypy_install_mirror: Option<&str>,
1546 astral_mirror_url: Option<&str>,
1547 ) -> Result<Vec<DisplaySafeUrl>, Error> {
1548 let astral_mirror_url = custom_astral_mirror_url(astral_mirror_url);
1549 match self.key.implementation {
1550 LenientImplementationName::Known(ImplementationName::CPython) => {
1551 if let Some(mirror) = python_install_mirror {
1552 let Some(suffix) = self.url.strip_prefix(CPYTHON_DOWNLOADS_URL_PREFIX) else {
1554 return Err(Error::Mirror(
1555 EnvVars::UV_PYTHON_INSTALL_MIRROR,
1556 self.url.to_string(),
1557 ));
1558 };
1559 return Ok(vec![DisplaySafeUrl::parse(
1560 format!("{}/{}", mirror.trim_end_matches('/'), suffix).as_str(),
1561 )?]);
1562 }
1563 if let Some(suffix) = self.url.strip_prefix(CPYTHON_DOWNLOADS_URL_PREFIX) {
1565 let effective_mirror = effective_cpython_mirror(astral_mirror_url);
1566 let mirror_url = DisplaySafeUrl::parse(
1567 format!("{}/{}", effective_mirror.trim_end_matches('/'), suffix).as_str(),
1568 )?;
1569 if astral_mirror_url.is_some() {
1571 return Ok(vec![mirror_url]);
1572 }
1573 let canonical_url = DisplaySafeUrl::parse(&self.url)?;
1575 return Ok(vec![mirror_url, canonical_url]);
1576 }
1577 }
1578
1579 LenientImplementationName::Known(ImplementationName::PyPy) => {
1580 if let Some(mirror) = pypy_install_mirror {
1581 let Some(suffix) = self.url.strip_prefix("https://downloads.python.org/pypy/")
1582 else {
1583 return Err(Error::Mirror(
1584 EnvVars::UV_PYPY_INSTALL_MIRROR,
1585 self.url.to_string(),
1586 ));
1587 };
1588 return Ok(vec![DisplaySafeUrl::parse(
1589 format!("{}/{}", mirror.trim_end_matches('/'), suffix).as_str(),
1590 )?]);
1591 }
1592 }
1593
1594 _ => {}
1595 }
1596
1597 Ok(vec![DisplaySafeUrl::parse(&self.url)?])
1598 }
1599}
1600
1601fn parse_json_downloads(
1602 json_downloads: HashMap<String, JsonPythonDownload>,
1603) -> Vec<ManagedPythonDownload> {
1604 json_downloads
1605 .into_iter()
1606 .filter_map(|(key, entry)| {
1607 let implementation = match entry.name.as_str() {
1608 "cpython" => LenientImplementationName::Known(ImplementationName::CPython),
1609 "pypy" => LenientImplementationName::Known(ImplementationName::PyPy),
1610 "graalpy" => LenientImplementationName::Known(ImplementationName::GraalPy),
1611 _ => LenientImplementationName::Unknown(entry.name.clone()),
1612 };
1613
1614 let arch_str = match entry.arch.family.as_str() {
1615 "armv5tel" => Cow::Borrowed("armv5te"),
1616 "riscv64" => Cow::Borrowed("riscv64gc"),
1620 value => Cow::Borrowed(value),
1621 };
1622
1623 let arch_str = if let Some(variant) = entry.arch.variant {
1624 Cow::Owned(format!("{arch_str}_{variant}"))
1625 } else {
1626 arch_str
1627 };
1628
1629 let arch = match Arch::from_str(&arch_str) {
1630 Ok(arch) => arch,
1631 Err(e) => {
1632 debug!("Skipping entry {key}: Invalid arch '{arch_str}' - {e}");
1633 return None;
1634 }
1635 };
1636
1637 let os = match Os::from_str(&entry.os) {
1638 Ok(os) => os,
1639 Err(e) => {
1640 debug!("Skipping entry {}: Invalid OS '{}' - {}", key, entry.os, e);
1641 return None;
1642 }
1643 };
1644
1645 let libc = match Libc::from_str(&entry.libc) {
1646 Ok(libc) => libc,
1647 Err(e) => {
1648 debug!(
1649 "Skipping entry {}: Invalid libc '{}' - {}",
1650 key, entry.libc, e
1651 );
1652 return None;
1653 }
1654 };
1655
1656 let variant = match entry
1657 .variant
1658 .as_deref()
1659 .map(PythonVariant::from_str)
1660 .transpose()
1661 {
1662 Ok(Some(variant)) => variant,
1663 Ok(None) => PythonVariant::default(),
1664 Err(()) => {
1665 debug!(
1666 "Skipping entry {key}: Unknown python variant - {}",
1667 entry.variant.unwrap_or_default()
1668 );
1669 return None;
1670 }
1671 };
1672
1673 let version_str = format!(
1674 "{}.{}.{}{}",
1675 entry.major,
1676 entry.minor,
1677 entry.patch,
1678 entry.prerelease.as_deref().unwrap_or_default()
1679 );
1680
1681 let version = match PythonVersion::from_str(&version_str) {
1682 Ok(version) => version,
1683 Err(e) => {
1684 debug!("Skipping entry {key}: Invalid version '{version_str}' - {e}");
1685 return None;
1686 }
1687 };
1688
1689 let url = Cow::Owned(entry.url);
1690 let sha256 = entry.sha256.map(Cow::Owned);
1691 let build = entry
1692 .build
1693 .map(|s| Box::leak(s.into_boxed_str()) as &'static str);
1694
1695 Some(ManagedPythonDownload {
1696 key: PythonInstallationKey::new_from_version(
1697 implementation,
1698 &version,
1699 Platform::new(os, arch, libc),
1700 variant,
1701 ),
1702 url,
1703 sha256,
1704 build,
1705 })
1706 })
1707 .sorted_by(|a, b| Ord::cmp(&b.key, &a.key))
1708 .collect()
1709}
1710
1711impl Error {
1712 fn from_reqwest(
1713 url: DisplaySafeUrl,
1714 err: reqwest::Error,
1715 retries: Option<u32>,
1716 start: Instant,
1717 ) -> Self {
1718 let err = Self::NetworkError(url, WrappedReqwestError::from(err));
1719 if let Some(retries) = retries {
1720 Self::NetworkErrorWithRetries {
1721 err: Box::new(err),
1722 retries,
1723 duration: start.elapsed(),
1724 }
1725 } else {
1726 err
1727 }
1728 }
1729
1730 fn from_reqwest_middleware(url: DisplaySafeUrl, err: reqwest_middleware::Error) -> Self {
1731 match err {
1732 reqwest_middleware::Error::Middleware(error) => {
1733 Self::NetworkMiddlewareError(url, error)
1734 }
1735 reqwest_middleware::Error::Reqwest(error) => {
1736 Self::NetworkError(url, WrappedReqwestError::from(error))
1737 }
1738 }
1739 }
1740}
1741
1742impl Display for ManagedPythonDownload {
1743 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1744 write!(f, "{}", self.key)
1745 }
1746}
1747
1748#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1749pub enum Direction {
1750 Download,
1751 Extract,
1752}
1753
1754impl Direction {
1755 fn as_str(&self) -> &str {
1756 match self {
1757 Self::Download => "download",
1758 Self::Extract => "extract",
1759 }
1760 }
1761}
1762
1763impl Display for Direction {
1764 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1765 f.write_str(self.as_str())
1766 }
1767}
1768
1769pub trait Reporter: Send + Sync {
1770 fn on_request_start(
1771 &self,
1772 direction: Direction,
1773 name: &PythonInstallationKey,
1774 size: Option<u64>,
1775 ) -> usize;
1776 fn on_request_progress(&self, id: usize, inc: u64);
1777 fn on_request_complete(&self, direction: Direction, id: usize);
1778}
1779
1780struct ProgressReader<'a, R> {
1782 reader: R,
1783 index: usize,
1784 reporter: &'a dyn Reporter,
1785}
1786
1787impl<'a, R> ProgressReader<'a, R> {
1788 fn new(reader: R, index: usize, reporter: &'a dyn Reporter) -> Self {
1790 Self {
1791 reader,
1792 index,
1793 reporter,
1794 }
1795 }
1796}
1797
1798impl<R> AsyncRead for ProgressReader<'_, R>
1799where
1800 R: AsyncRead + Unpin,
1801{
1802 fn poll_read(
1803 mut self: Pin<&mut Self>,
1804 cx: &mut Context<'_>,
1805 buf: &mut ReadBuf<'_>,
1806 ) -> Poll<io::Result<()>> {
1807 Pin::new(&mut self.as_mut().reader)
1808 .poll_read(cx, buf)
1809 .map_ok(|()| {
1810 self.reporter
1811 .on_request_progress(self.index, buf.filled().len() as u64);
1812 })
1813 }
1814}
1815
1816async fn read_url(
1818 url: &DisplaySafeUrl,
1819 client: &BaseClient,
1820) -> Result<(impl AsyncRead + Unpin, Option<u64>), Error> {
1821 if url.scheme() == "file" {
1822 let path = url
1824 .to_file_path()
1825 .map_err(|()| Error::InvalidFileUrl(url.to_string()))?;
1826
1827 let size = fs_err::tokio::metadata(&path).await?.len();
1828 let reader = fs_err::tokio::File::open(&path).await?;
1829
1830 Ok((Either::Left(reader), Some(size)))
1831 } else {
1832 let start = Instant::now();
1833 let response = client
1834 .for_host(url)
1835 .get(Url::from(url.clone()))
1836 .send()
1837 .await
1838 .map_err(|err| Error::from_reqwest_middleware(url.clone(), err))?;
1839
1840 let retry_count = response
1841 .extensions()
1842 .get::<reqwest_retry::RetryCount>()
1843 .map(|retries| retries.value());
1844
1845 let response = response
1847 .error_for_status()
1848 .map_err(|err| Error::from_reqwest(url.clone(), err, retry_count, start))?;
1849
1850 let size = response.content_length();
1851 let stream = response
1852 .bytes_stream()
1853 .map_err(io::Error::other)
1854 .into_async_read();
1855
1856 Ok((Either::Right(stream.compat()), size))
1857 }
1858}
1859
1860#[cfg(test)]
1861mod tests {
1862 use std::assert_matches;
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));
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_matches!(result, Err(Error::ImplementationError(_)));
2060 }
2061
2062 #[test]
2064 fn test_python_download_request_from_str_out_of_order() {
2065 let result = PythonDownloadRequest::from_str("3.12-cpython");
2066
2067 assert_matches!(result, Err(Error::InvalidRequestPlatform(_)));
2068 }
2069
2070 #[test]
2072 fn test_python_download_request_from_str_too_many_any() {
2073 let result = PythonDownloadRequest::from_str("any-any-any-any-any-any");
2074
2075 assert_matches!(result, Err(Error::TooManyParts(_)));
2076 }
2077
2078 #[tokio::test]
2080 async fn test_python_download_request_build_filtering() {
2081 let mut request = PythonDownloadRequest::default()
2082 .with_version(VersionRequest::from_str("3.12").unwrap())
2083 .with_implementation(ImplementationName::CPython);
2084 request.build = Some("20240814".to_string());
2085
2086 let client_builder = uv_client::BaseClientBuilder::default();
2087 let cache = uv_cache::Cache::temp().expect("failed to create temp cache");
2088 let download_list = ManagedPythonDownloadList::new(&client_builder, &cache, None)
2089 .await
2090 .unwrap();
2091
2092 let downloads: Vec<_> = download_list
2093 .iter_all()
2094 .filter(|d| request.satisfied_by_download(d))
2095 .collect();
2096
2097 assert!(
2098 !downloads.is_empty(),
2099 "Should find at least one matching download"
2100 );
2101 for download in downloads {
2102 assert_eq!(download.build(), Some("20240814"));
2103 }
2104 }
2105
2106 #[tokio::test]
2108 async fn test_python_download_request_invalid_build() {
2109 let mut request = PythonDownloadRequest::default()
2111 .with_version(VersionRequest::from_str("3.12").unwrap())
2112 .with_implementation(ImplementationName::CPython);
2113 request.build = Some("99999999".to_string());
2114
2115 let client_builder = uv_client::BaseClientBuilder::default();
2116 let cache = uv_cache::Cache::temp().expect("failed to create temp cache");
2117 let download_list = ManagedPythonDownloadList::new(&client_builder, &cache, None)
2118 .await
2119 .unwrap();
2120
2121 let downloads: Vec<_> = download_list
2123 .iter_all()
2124 .filter(|d| request.satisfied_by_download(d))
2125 .collect();
2126
2127 assert_eq!(downloads.len(), 0);
2128 }
2129
2130 #[test]
2131 fn upgrade_request_native_defaults() {
2132 let request = PythonDownloadRequest::default()
2133 .with_implementation(ImplementationName::CPython)
2134 .with_version(VersionRequest::MajorMinorPatch(
2135 3,
2136 13,
2137 1,
2138 PythonVariant::Default,
2139 ))
2140 .with_os(Os::from_str("linux").unwrap())
2141 .with_arch(Arch::from_str("x86_64").unwrap())
2142 .with_libc(Libc::from_str("gnu").unwrap())
2143 .with_prereleases(false);
2144
2145 let host = Platform::new(
2146 Os::from_str("linux").unwrap(),
2147 Arch::from_str("x86_64").unwrap(),
2148 Libc::from_str("gnu").unwrap(),
2149 );
2150
2151 assert_eq!(
2152 request
2153 .clone()
2154 .unset_defaults_for_host(&host)
2155 .without_patch()
2156 .simplified_display()
2157 .as_deref(),
2158 Some("3.13")
2159 );
2160 }
2161
2162 #[test]
2163 fn upgrade_request_preserves_variant() {
2164 let request = PythonDownloadRequest::default()
2165 .with_implementation(ImplementationName::CPython)
2166 .with_version(VersionRequest::MajorMinorPatch(
2167 3,
2168 13,
2169 0,
2170 PythonVariant::Freethreaded,
2171 ))
2172 .with_os(Os::from_str("linux").unwrap())
2173 .with_arch(Arch::from_str("x86_64").unwrap())
2174 .with_libc(Libc::from_str("gnu").unwrap())
2175 .with_prereleases(false);
2176
2177 let host = Platform::new(
2178 Os::from_str("linux").unwrap(),
2179 Arch::from_str("x86_64").unwrap(),
2180 Libc::from_str("gnu").unwrap(),
2181 );
2182
2183 assert_eq!(
2184 request
2185 .clone()
2186 .unset_defaults_for_host(&host)
2187 .without_patch()
2188 .simplified_display()
2189 .as_deref(),
2190 Some("3.13+freethreaded")
2191 );
2192 }
2193
2194 #[test]
2195 fn upgrade_request_preserves_non_default_platform() {
2196 let request = PythonDownloadRequest::default()
2197 .with_implementation(ImplementationName::CPython)
2198 .with_version(VersionRequest::MajorMinorPatch(
2199 3,
2200 12,
2201 4,
2202 PythonVariant::Default,
2203 ))
2204 .with_os(Os::from_str("linux").unwrap())
2205 .with_arch(Arch::from_str("aarch64").unwrap())
2206 .with_libc(Libc::from_str("gnu").unwrap())
2207 .with_prereleases(false);
2208
2209 let host = Platform::new(
2210 Os::from_str("linux").unwrap(),
2211 Arch::from_str("x86_64").unwrap(),
2212 Libc::from_str("gnu").unwrap(),
2213 );
2214
2215 assert_eq!(
2216 request
2217 .clone()
2218 .unset_defaults_for_host(&host)
2219 .without_patch()
2220 .simplified_display()
2221 .as_deref(),
2222 Some("3.12-aarch64")
2223 );
2224 }
2225
2226 #[test]
2227 fn upgrade_request_preserves_custom_implementation() {
2228 let request = PythonDownloadRequest::default()
2229 .with_implementation(ImplementationName::PyPy)
2230 .with_version(VersionRequest::MajorMinorPatch(
2231 3,
2232 10,
2233 5,
2234 PythonVariant::Default,
2235 ))
2236 .with_os(Os::from_str("linux").unwrap())
2237 .with_arch(Arch::from_str("x86_64").unwrap())
2238 .with_libc(Libc::from_str("gnu").unwrap())
2239 .with_prereleases(false);
2240
2241 let host = Platform::new(
2242 Os::from_str("linux").unwrap(),
2243 Arch::from_str("x86_64").unwrap(),
2244 Libc::from_str("gnu").unwrap(),
2245 );
2246
2247 assert_eq!(
2248 request
2249 .clone()
2250 .unset_defaults_for_host(&host)
2251 .without_patch()
2252 .simplified_display()
2253 .as_deref(),
2254 Some("pypy-3.10")
2255 );
2256 }
2257
2258 #[test]
2259 fn simplified_display_returns_none_when_empty() {
2260 let request = PythonDownloadRequest::default()
2261 .fill_platform()
2262 .expect("should populate defaults");
2263
2264 let host = Platform::from_env().expect("host platform");
2265
2266 assert_eq!(
2267 request.unset_defaults_for_host(&host).simplified_display(),
2268 None
2269 );
2270 }
2271
2272 #[test]
2273 fn simplified_display_omits_environment_arch() {
2274 let mut request = PythonDownloadRequest::default()
2275 .with_version(VersionRequest::MajorMinor(3, 12, PythonVariant::Default))
2276 .with_os(Os::from_str("linux").unwrap())
2277 .with_libc(Libc::from_str("gnu").unwrap());
2278
2279 request.arch = Some(ArchRequest::Environment(Arch::from_str("x86_64").unwrap()));
2280
2281 let host = Platform::new(
2282 Os::from_str("linux").unwrap(),
2283 Arch::from_str("aarch64").unwrap(),
2284 Libc::from_str("gnu").unwrap(),
2285 );
2286
2287 assert_eq!(
2288 request
2289 .unset_defaults_for_host(&host)
2290 .simplified_display()
2291 .as_deref(),
2292 Some("3.12")
2293 );
2294 }
2295
2296 fn cpython_download_for_url(url: &'static str) -> ManagedPythonDownload {
2297 let key = PythonInstallationKey::new(
2298 LenientImplementationName::Known(crate::implementation::ImplementationName::CPython),
2299 3,
2300 12,
2301 4,
2302 None,
2303 Platform::new(
2304 Os::from_str("linux").unwrap(),
2305 Arch::from_str("x86_64").unwrap(),
2306 Libc::from_str("gnu").unwrap(),
2307 ),
2308 crate::PythonVariant::default(),
2309 );
2310
2311 ManagedPythonDownload {
2312 key,
2313 url: Cow::Borrowed(url),
2314 sha256: Some(Cow::Borrowed("abc123")),
2315 build: Some("20240713"),
2316 }
2317 }
2318
2319 #[test]
2320 fn test_cpython_download_urls_custom_astral_mirror() {
2321 let download = cpython_download_for_url(
2322 "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",
2323 );
2324
2325 let urls = download
2326 .download_urls_with_astral_mirror(
2327 None,
2328 None,
2329 Some("https://nexus.example.com/repository/releases.astral.sh/"),
2330 )
2331 .expect("download URLs should be valid");
2332 let urls = urls
2333 .into_iter()
2334 .map(|url| url.to_string())
2335 .collect::<Vec<_>>();
2336 assert_eq!(
2337 urls,
2338 vec![
2339 "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"
2340 .to_string(),
2341 ]
2342 );
2343 }
2344
2345 #[test]
2346 fn test_cpython_specific_mirror_takes_precedence_over_astral_mirror() {
2347 let download = cpython_download_for_url(
2348 "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",
2349 );
2350
2351 let urls = download
2352 .download_urls_with_astral_mirror(
2353 Some("https://python-mirror.example.com/releases/"),
2354 None,
2355 Some("https://nexus.example.com/repository/releases.astral.sh/"),
2356 )
2357 .expect("download URLs should be valid");
2358 let urls = urls
2359 .into_iter()
2360 .map(|url| url.to_string())
2361 .collect::<Vec<_>>();
2362 assert_eq!(
2363 urls,
2364 vec![
2365 "https://python-mirror.example.com/releases/20240713/cpython-3.12.4%2B20240713-x86_64-unknown-linux-gnu-install_only.tar.gz"
2366 .to_string(),
2367 ]
2368 );
2369 }
2370
2371 #[test]
2372 fn test_cpython_download_urls_empty_astral_mirror_uses_default() {
2373 let download = cpython_download_for_url(
2374 "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",
2375 );
2376
2377 let default_urls = download
2378 .download_urls_with_astral_mirror(None, None, None)
2379 .expect("download URLs should be valid");
2380 let empty_urls = download
2381 .download_urls_with_astral_mirror(None, None, Some(""))
2382 .expect("download URLs should be valid");
2383
2384 assert_eq!(default_urls, empty_urls);
2385 }
2386
2387 #[test]
2390 fn test_should_try_next_url_hash_mismatch() {
2391 let err = Error::HashMismatch {
2392 installation: "cpython-3.12.0".to_string(),
2393 expected: "abc".to_string(),
2394 actual: "def".to_string(),
2395 };
2396 assert!(!err.should_try_next_url());
2397 }
2398
2399 #[test]
2402 fn test_should_try_next_url_extract_error_filesystem() {
2403 let err = Error::ExtractError(
2404 "archive.tar.gz".to_string(),
2405 uv_extract::Error::Io(io::Error::new(io::ErrorKind::PermissionDenied, "")),
2406 );
2407 assert!(!err.should_try_next_url());
2408 }
2409
2410 #[test]
2413 fn test_should_try_next_url_io_error_filesystem() {
2414 let err = Error::Io(io::Error::new(io::ErrorKind::PermissionDenied, ""));
2415 assert!(!err.should_try_next_url());
2416 }
2417
2418 #[test]
2421 fn test_should_try_next_url_io_error_network() {
2422 let err = Error::Io(io::Error::new(io::ErrorKind::ConnectionReset, ""));
2423 assert!(err.should_try_next_url());
2424 }
2425
2426 #[test]
2429 fn test_should_try_next_url_network_error_404() {
2430 let url =
2431 DisplaySafeUrl::from_str("https://releases.astral.sh/python/cpython-3.12.0.tar.gz")
2432 .unwrap();
2433 let wrapped = WrappedReqwestError::with_problem_details(
2436 reqwest_middleware::Error::Middleware(anyhow::anyhow!("404 Not Found")),
2437 None,
2438 );
2439 let err = Error::NetworkError(url, wrapped);
2440 assert!(err.should_try_next_url());
2441 }
2442
2443 #[test]
2446 fn embedded_download_versions_convert_to_version_requests() {
2447 let downloads = ManagedPythonDownloadList::new_only_embedded()
2448 .expect("embedded download metadata should load");
2449
2450 let unique_versions: HashSet<PythonVersion> = downloads
2451 .iter_all()
2452 .map(ManagedPythonDownload::python_version)
2453 .collect();
2454
2455 for version in &unique_versions {
2456 let _ = VersionRequest::from(version);
2457 }
2458 }
2459}