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