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