Skip to main content

uv_python/
downloads.rs

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    // Return the number of retries that were made to complete this request before this error was
144    // returned.
145    //
146    // Note that e.g. 3 retries equates to 4 attempts.
147    fn retries(&self) -> u32 {
148        // Unfortunately different variants of `Error` track retry counts in different ways. We
149        // could consider unifying the variants we handle here in `Error::from_reqwest_middleware`
150        // instead, but both approaches will be fragile as new variants get added over time.
151        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    /// Returns `true` if trying an alternative URL makes sense after this error.
164    ///
165    /// HTTP-level failures (4xx, 5xx) and connection-level failures return `true`.
166    /// Hash mismatches, extraction failures, and similar post-download errors return `false`
167    /// because switching to a different host would not fix them.
168    fn should_try_next_url(&self) -> bool {
169        match self {
170            // There are two primary reasons to try an alternative URL:
171            // - HTTP/DNS/TCP/etc errors due to a mirror being blocked at various layers
172            // - HTTP 404s from the mirror, which may mean the next URL still works
173            // So we catch all network-level errors here.
174            Self::NetworkError(..)
175            | Self::NetworkMiddlewareError(..)
176            | Self::NetworkErrorWithRetries { .. } => true,
177            // `Io` uses `#[error(transparent)]`, so `source()` delegates to the inner error's
178            // own source rather than returning the `io::Error` itself. We must unwrap it
179            // explicitly so that `retryable_on_request_failure` can inspect the io error kind.
180            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
194/// The URL prefix used by `python-build-standalone` releases on GitHub.
195const CPYTHON_DOWNLOADS_URL_PREFIX: &str =
196    "https://github.com/astral-sh/python-build-standalone/releases/download/";
197
198/// The suffix appended to the Astral mirror base for `python-build-standalone` releases.
199const CPYTHON_MIRROR_SUFFIX: &str = "/github/python-build-standalone/releases/download/";
200
201/// Return the Astral mirror base URL for CPython downloads.
202fn 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    /// Whether to allow pre-releases or not. If not set, defaults to true if [`Self::version`] is
227    /// not None, and false otherwise.
228    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    /// Check if this platform request is satisfied by a platform.
246    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                // Check if the environment's platform can run the target platform
299                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            // Pyodide is actually CPython with an Emscripten OS, we paper over that for usability
336            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    /// Construct a new [`PythonDownloadRequest`] from a [`PythonRequest`] if possible.
385    ///
386    /// Returns [`None`] if the request kind is not compatible with a download, e.g., it is
387    /// a request for a specific directory or executable name.
388    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), // Explicitly allow pre-releases for PythonRequest::Any
402                ..Self::default()
403            }),
404            PythonRequest::Default => Some(Self::default()),
405            // We can't download a managed installation for these request kinds
406            PythonRequest::Directory(_)
407            | PythonRequest::ExecutableName(_)
408            | PythonRequest::File(_) => None,
409        }
410    }
411
412    /// Fill empty entries with default values.
413    ///
414    /// Platform information is pulled from the environment.
415    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    /// Fill the build field from the environment variable relevant for the [`ImplementationName`].
433    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    /// Remove default implementation and platform details so the request only contains
475    /// explicitly user-specified segments.
476    #[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        // Drop implicit architecture derived from environment so only user overrides remain.
497        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    /// Drop patch and prerelease information so the request can be re-used for upgrades.
523    #[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    /// Return a compact string representation suitable for user-facing display.
532    ///
533    /// The resulting string only includes explicitly-set pieces of the request and returns
534    /// [`None`] when no segments are explicitly set.
535    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    /// Whether this request is satisfied by an installation key.
555    pub fn satisfied_by_key(&self, key: &PythonInstallationKey) -> bool {
556        // Check platform requirements
557        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 we don't allow pre-releases, don't match a key with a pre-release tag
572        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    /// Whether this request is satisfied by a Python download.
594    fn satisfied_by_download(&self, download: &ManagedPythonDownload) -> bool {
595        // First check the key
596        if !self.satisfied_by_key(download.key()) {
597            return false;
598        }
599
600        // Then check the build if specified
601        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    /// Whether this download request opts-in to pre-release Python versions.
623    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    /// Whether this download request opts-in to a debug Python version.
632    pub(crate) fn allows_debug(&self) -> bool {
633        self.version.as_ref().is_some_and(VersionRequest::is_debug)
634    }
635
636    /// Whether this download request opts-in to alternative Python implementations.
637    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    /// Extract the platform components of this request.
675    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                // For now, we only record the first error encountered. We could record all of the
820                // errors for a given part, then pick the most appropriate one later.
821                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                        // Err(err) if !first_part => return Err(err),
868                        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                    // Throw the first error for the current part
932                    //
933                    // TODO(zanieb): It's plausible another error variant is a better match but it
934                    // sounds hard to explain how? We could peek at the next item in the parts, and
935                    // see if that informs the type of this one, or we could use some sort of
936                    // similarity or common error matching, but this sounds harder.
937                    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    /// Iterate over all [`ManagedPythonDownload`]s.
986    fn iter_all(&self) -> impl Iterator<Item = &ManagedPythonDownload> {
987        self.downloads.iter()
988    }
989
990    /// Iterate over all [`ManagedPythonDownload`]s that match the request.
991    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    /// Return the first [`ManagedPythonDownload`] matching a request, if any.
1000    ///
1001    /// If there is no stable version matching the request, a compatible pre-release version will
1002    /// be searched for — even if a pre-release was not explicitly requested.
1003    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    /// Load available Python distributions from a provided source or the compiled-in list.
1020    ///
1021    /// Returns an error if the provided list could not be opened, if the JSON is invalid, or if it
1022    /// does not parse into the expected data structure.
1023    pub async fn new(
1024        client_builder: &BaseClientBuilder<'_>,
1025        cache: &Cache,
1026        python_downloads_json_url: Option<&str>,
1027    ) -> Result<Self, Error> {
1028        // file:// URLs are converted to local file reads, and we also support parsing bare
1029        // filenames like "/tmp/py.json", not just "file:///tmp/py.json". Note that
1030        // "C:\Temp\py.json" should be considered a filename, even though Url::parse would
1031        // successfully misparse it as a URL with scheme "C".
1032        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    /// Load available Python distributions from the compiled-in list only.
1084    /// for testing purposes.
1085    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
1095/// Parse the downloads JSON.
1096///
1097/// `source` is where the JSON came from for error reporting.
1098fn 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            // As an explicit compatibility mechanism, if there's a top-level "version" key, it
1106            // means it's a newer format than we know how to deal with. Before reporting a
1107            // parse error about the format of JsonPythonDownload, check for that key. We can do
1108            // this by parsing into a Map<String, IgnoredAny> which allows any valid JSON on the
1109            // value side. (Because it's zero-sized, Clippy suggests Set<String>, but that won't
1110            // have the same parsing effect.)
1111            #[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                // Avoid double-wrapping errors.
1164                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    /// Download and extract a Python distribution, retrying on failure.
1194    ///
1195    /// For CPython without a user-configured mirror, the default Astral mirror is tried first.
1196    /// Each attempt tries all URLs in sequence without backoff between them; backoff is only
1197    /// applied after all URLs have been exhausted.
1198    #[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    /// Download and extract a Python distribution from the given URL.
1228    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 it is not a reinstall and the dir already exists, return it.
1240        if !reinstall && path.is_dir() {
1241            return Ok(DownloadResult::AlreadyAvailable(path));
1242        }
1243
1244        // We improve filesystem compatibility by using neither the URL-encoded `%2B` nor the `+` it
1245        // decodes to.
1246        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                    // Shorten the hash to avoid too-long-filename errors
1271                    &sha[..9]
1272                }
1273                None => "none",
1274            };
1275            let target_cache_file = python_builds_dir.join(format!("{hash_prefix}-{filename}"));
1276
1277            // Download the archive to the cache, or return a reader if we have it in cache.
1278            // TODO(konsti): We should "tee" the write so we can do the download-to-cache and unpacking
1279            // in one step.
1280            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                        // Point the user to which file is missing where and where to download it
1293                        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            // Extract the downloaded archive into a temporary directory.
1320            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            // Avoid overlong log lines
1332            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        // Extract the top-level directory.
1352        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 the distribution is a `full` archive, the Python installation is in the `install` directory.
1359        if extracted.join("install").is_dir() {
1360            extracted = extracted.join("install");
1361        // If the distribution is a Pyodide archive, the Python installation is in the `pyodide-root/dist` directory.
1362        } else if self.os().is_emscripten() {
1363            extracted = extracted.join("pyodide-root").join("dist");
1364        }
1365
1366        #[cfg(unix)]
1367        {
1368            // Pyodide distributions require all of the supporting files to be alongside the Python
1369            // executable, so they don't have a `bin` directory. We create it and link
1370            // `bin/pythonX.Y` to `dist/python`.
1371            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 the distribution is missing a `python` -> `pythonX.Y` symlink, add it.
1382            //
1383            // We skip for Windows distributions, allowing cross-installs from Unix.
1384            //
1385            // Pyodide releases never contain this link by default.
1386            //
1387            // PEP 394 permits it, and python-build-standalone releases after `20240726` include it,
1388            // but releases prior to that date do not.
1389            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        // Remove the target if it already exists.
1402        if path.is_dir() {
1403            debug!("Removing existing directory: {}", path.user_display());
1404            fs_err::tokio::remove_dir_all(&path).await?;
1405        }
1406
1407        // Persist it to the target.
1408        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    /// Download the managed Python archive into the cache directory.
1420    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        // Download to a temporary file. We verify the hash when unpacking the file.
1439        {
1440            let mut archive_writer = BufWriter::new(fs_err::tokio::File::create(&temp_file).await?);
1441
1442            // Download with or without progress bar.
1443            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        // Move the completed file into place, invalidating the `File` instance.
1458        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    /// Extract a Python interpreter archive into a (temporary) directory, either from a file or
1467    /// from a download stream.
1468    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        // Check the hash
1500        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    #[cfg(test)]
1515    fn python_version(&self) -> PythonVersion {
1516        self.key.version()
1517    }
1518
1519    /// Return the ordered list of [`Url`]s to try when downloading the distribution.
1520    ///
1521    /// For CPython without a user-configured mirror, the default Astral mirror is listed first,
1522    /// followed by the canonical GitHub URL as a fallback.
1523    ///
1524    /// For all other cases (user mirror explicitly set, PyPy, GraalPy, Pyodide), a single URL
1525    /// is returned with no fallback.
1526    pub fn download_urls(
1527        &self,
1528        python_install_mirror: Option<&str>,
1529        pypy_install_mirror: Option<&str>,
1530    ) -> Result<Vec<DisplaySafeUrl>, Error> {
1531        let custom_astral_mirror = astral_mirror_url_from_env();
1532        self.download_urls_with_astral_mirror(
1533            python_install_mirror,
1534            pypy_install_mirror,
1535            custom_astral_mirror.as_deref(),
1536        )
1537    }
1538
1539    fn download_urls_with_astral_mirror(
1540        &self,
1541        python_install_mirror: Option<&str>,
1542        pypy_install_mirror: Option<&str>,
1543        astral_mirror_url: Option<&str>,
1544    ) -> Result<Vec<DisplaySafeUrl>, Error> {
1545        let astral_mirror_url = custom_astral_mirror_url(astral_mirror_url);
1546        match self.key.implementation {
1547            LenientImplementationName::Known(ImplementationName::CPython) => {
1548                if let Some(mirror) = python_install_mirror {
1549                    // User-configured mirror: use it exclusively, no automatic fallback.
1550                    let Some(suffix) = self.url.strip_prefix(CPYTHON_DOWNLOADS_URL_PREFIX) else {
1551                        return Err(Error::Mirror(
1552                            EnvVars::UV_PYTHON_INSTALL_MIRROR,
1553                            self.url.to_string(),
1554                        ));
1555                    };
1556                    return Ok(vec![DisplaySafeUrl::parse(
1557                        format!("{}/{}", mirror.trim_end_matches('/'), suffix).as_str(),
1558                    )?]);
1559                }
1560                // No user mirror: try the default/custom Astral mirror first.
1561                if let Some(suffix) = self.url.strip_prefix(CPYTHON_DOWNLOADS_URL_PREFIX) {
1562                    let effective_mirror = effective_cpython_mirror(astral_mirror_url);
1563                    let mirror_url = DisplaySafeUrl::parse(
1564                        format!("{}/{}", effective_mirror.trim_end_matches('/'), suffix).as_str(),
1565                    )?;
1566                    // When a custom Astral mirror is set, use it exclusively.
1567                    if astral_mirror_url.is_some() {
1568                        return Ok(vec![mirror_url]);
1569                    }
1570                    // Otherwise fall back to the canonical GitHub URL.
1571                    let canonical_url = DisplaySafeUrl::parse(&self.url)?;
1572                    return Ok(vec![mirror_url, canonical_url]);
1573                }
1574            }
1575
1576            LenientImplementationName::Known(ImplementationName::PyPy) => {
1577                if let Some(mirror) = pypy_install_mirror {
1578                    let Some(suffix) = self.url.strip_prefix("https://downloads.python.org/pypy/")
1579                    else {
1580                        return Err(Error::Mirror(
1581                            EnvVars::UV_PYPY_INSTALL_MIRROR,
1582                            self.url.to_string(),
1583                        ));
1584                    };
1585                    return Ok(vec![DisplaySafeUrl::parse(
1586                        format!("{}/{}", mirror.trim_end_matches('/'), suffix).as_str(),
1587                    )?]);
1588                }
1589            }
1590
1591            _ => {}
1592        }
1593
1594        Ok(vec![DisplaySafeUrl::parse(&self.url)?])
1595    }
1596}
1597
1598fn parse_json_downloads(
1599    json_downloads: HashMap<String, JsonPythonDownload>,
1600) -> Vec<ManagedPythonDownload> {
1601    json_downloads
1602        .into_iter()
1603        .filter_map(|(key, entry)| {
1604            let implementation = match entry.name.as_str() {
1605                "cpython" => LenientImplementationName::Known(ImplementationName::CPython),
1606                "pypy" => LenientImplementationName::Known(ImplementationName::PyPy),
1607                "graalpy" => LenientImplementationName::Known(ImplementationName::GraalPy),
1608                _ => LenientImplementationName::Unknown(entry.name.clone()),
1609            };
1610
1611            let arch_str = match entry.arch.family.as_str() {
1612                "armv5tel" => Cow::Borrowed("armv5te"),
1613                // The `gc` variant of riscv64 is the common base instruction set and
1614                // is the target in `python-build-standalone`
1615                // See https://github.com/astral-sh/python-build-standalone/issues/504
1616                "riscv64" => Cow::Borrowed("riscv64gc"),
1617                value => Cow::Borrowed(value),
1618            };
1619
1620            let arch_str = if let Some(variant) = entry.arch.variant {
1621                Cow::Owned(format!("{arch_str}_{variant}"))
1622            } else {
1623                arch_str
1624            };
1625
1626            let arch = match Arch::from_str(&arch_str) {
1627                Ok(arch) => arch,
1628                Err(e) => {
1629                    debug!("Skipping entry {key}: Invalid arch '{arch_str}' - {e}");
1630                    return None;
1631                }
1632            };
1633
1634            let os = match Os::from_str(&entry.os) {
1635                Ok(os) => os,
1636                Err(e) => {
1637                    debug!("Skipping entry {}: Invalid OS '{}' - {}", key, entry.os, e);
1638                    return None;
1639                }
1640            };
1641
1642            let libc = match Libc::from_str(&entry.libc) {
1643                Ok(libc) => libc,
1644                Err(e) => {
1645                    debug!(
1646                        "Skipping entry {}: Invalid libc '{}' - {}",
1647                        key, entry.libc, e
1648                    );
1649                    return None;
1650                }
1651            };
1652
1653            let variant = match entry
1654                .variant
1655                .as_deref()
1656                .map(PythonVariant::from_str)
1657                .transpose()
1658            {
1659                Ok(Some(variant)) => variant,
1660                Ok(None) => PythonVariant::default(),
1661                Err(()) => {
1662                    debug!(
1663                        "Skipping entry {key}: Unknown python variant - {}",
1664                        entry.variant.unwrap_or_default()
1665                    );
1666                    return None;
1667                }
1668            };
1669
1670            let version_str = format!(
1671                "{}.{}.{}{}",
1672                entry.major,
1673                entry.minor,
1674                entry.patch,
1675                entry.prerelease.as_deref().unwrap_or_default()
1676            );
1677
1678            let version = match PythonVersion::from_str(&version_str) {
1679                Ok(version) => version,
1680                Err(e) => {
1681                    debug!("Skipping entry {key}: Invalid version '{version_str}' - {e}");
1682                    return None;
1683                }
1684            };
1685
1686            let url = Cow::Owned(entry.url);
1687            let sha256 = entry.sha256.map(Cow::Owned);
1688            let build = entry
1689                .build
1690                .map(|s| Box::leak(s.into_boxed_str()) as &'static str);
1691
1692            Some(ManagedPythonDownload {
1693                key: PythonInstallationKey::new_from_version(
1694                    implementation,
1695                    &version,
1696                    Platform::new(os, arch, libc),
1697                    variant,
1698                ),
1699                url,
1700                sha256,
1701                build,
1702            })
1703        })
1704        .sorted_by(|a, b| Ord::cmp(&b.key, &a.key))
1705        .collect()
1706}
1707
1708impl Error {
1709    fn from_reqwest(
1710        url: DisplaySafeUrl,
1711        err: reqwest::Error,
1712        retries: Option<u32>,
1713        start: Instant,
1714    ) -> Self {
1715        let err = Self::NetworkError(url, WrappedReqwestError::from(err));
1716        if let Some(retries) = retries {
1717            Self::NetworkErrorWithRetries {
1718                err: Box::new(err),
1719                retries,
1720                duration: start.elapsed(),
1721            }
1722        } else {
1723            err
1724        }
1725    }
1726
1727    fn from_reqwest_middleware(url: DisplaySafeUrl, err: reqwest_middleware::Error) -> Self {
1728        match err {
1729            reqwest_middleware::Error::Middleware(error) => {
1730                Self::NetworkMiddlewareError(url, error)
1731            }
1732            reqwest_middleware::Error::Reqwest(error) => {
1733                Self::NetworkError(url, WrappedReqwestError::from(error))
1734            }
1735        }
1736    }
1737}
1738
1739impl Display for ManagedPythonDownload {
1740    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1741        write!(f, "{}", self.key)
1742    }
1743}
1744
1745#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1746pub enum Direction {
1747    Download,
1748    Extract,
1749}
1750
1751impl Direction {
1752    fn as_str(&self) -> &str {
1753        match self {
1754            Self::Download => "download",
1755            Self::Extract => "extract",
1756        }
1757    }
1758}
1759
1760impl Display for Direction {
1761    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1762        f.write_str(self.as_str())
1763    }
1764}
1765
1766pub trait Reporter: Send + Sync {
1767    fn on_request_start(
1768        &self,
1769        direction: Direction,
1770        name: &PythonInstallationKey,
1771        size: Option<u64>,
1772    ) -> usize;
1773    fn on_request_progress(&self, id: usize, inc: u64);
1774    fn on_request_complete(&self, direction: Direction, id: usize);
1775}
1776
1777/// An asynchronous reader that reports progress as bytes are read.
1778struct ProgressReader<'a, R> {
1779    reader: R,
1780    index: usize,
1781    reporter: &'a dyn Reporter,
1782}
1783
1784impl<'a, R> ProgressReader<'a, R> {
1785    /// Create a new [`ProgressReader`] that wraps another reader.
1786    fn new(reader: R, index: usize, reporter: &'a dyn Reporter) -> Self {
1787        Self {
1788            reader,
1789            index,
1790            reporter,
1791        }
1792    }
1793}
1794
1795impl<R> AsyncRead for ProgressReader<'_, R>
1796where
1797    R: AsyncRead + Unpin,
1798{
1799    fn poll_read(
1800        mut self: Pin<&mut Self>,
1801        cx: &mut Context<'_>,
1802        buf: &mut ReadBuf<'_>,
1803    ) -> Poll<io::Result<()>> {
1804        Pin::new(&mut self.as_mut().reader)
1805            .poll_read(cx, buf)
1806            .map_ok(|()| {
1807                self.reporter
1808                    .on_request_progress(self.index, buf.filled().len() as u64);
1809            })
1810    }
1811}
1812
1813/// Convert a [`Url`] into an [`AsyncRead`] stream.
1814async fn read_url(
1815    url: &DisplaySafeUrl,
1816    client: &BaseClient,
1817) -> Result<(impl AsyncRead + Unpin, Option<u64>), Error> {
1818    if url.scheme() == "file" {
1819        // Loads downloaded distribution from the given `file://` URL.
1820        let path = url
1821            .to_file_path()
1822            .map_err(|()| Error::InvalidFileUrl(url.to_string()))?;
1823
1824        let size = fs_err::tokio::metadata(&path).await?.len();
1825        let reader = fs_err::tokio::File::open(&path).await?;
1826
1827        Ok((Either::Left(reader), Some(size)))
1828    } else {
1829        let start = Instant::now();
1830        let response = client
1831            .for_host(url)
1832            .get(Url::from(url.clone()))
1833            .send()
1834            .await
1835            .map_err(|err| Error::from_reqwest_middleware(url.clone(), err))?;
1836
1837        let retry_count = response
1838            .extensions()
1839            .get::<reqwest_retry::RetryCount>()
1840            .map(|retries| retries.value());
1841
1842        // Check the status code.
1843        let response = response
1844            .error_for_status()
1845            .map_err(|err| Error::from_reqwest(url.clone(), err, retry_count, start))?;
1846
1847        let size = response.content_length();
1848        let stream = response
1849            .bytes_stream()
1850            .map_err(io::Error::other)
1851            .into_async_read();
1852
1853        Ok((Either::Right(stream.compat()), size))
1854    }
1855}
1856
1857#[cfg(test)]
1858mod tests {
1859    use std::collections::HashSet;
1860
1861    use crate::PythonVariant;
1862    use crate::implementation::LenientImplementationName;
1863    use crate::installation::PythonInstallationKey;
1864    use uv_platform::{Arch, Libc, Os, Platform};
1865
1866    use super::*;
1867
1868    /// Parse a request with all of its fields.
1869    #[test]
1870    fn test_python_download_request_from_str_complete() {
1871        let request = PythonDownloadRequest::from_str("cpython-3.12.0-linux-x86_64-gnu")
1872            .expect("Test request should be parsed");
1873
1874        assert_eq!(request.implementation, Some(ImplementationName::CPython));
1875        assert_eq!(
1876            request.version,
1877            Some(VersionRequest::from_str("3.12.0").unwrap())
1878        );
1879        assert_eq!(
1880            request.os,
1881            Some(Os::new(target_lexicon::OperatingSystem::Linux))
1882        );
1883        assert_eq!(
1884            request.arch,
1885            Some(ArchRequest::Explicit(Arch::new(
1886                target_lexicon::Architecture::X86_64,
1887                None
1888            )))
1889        );
1890        assert_eq!(
1891            request.libc,
1892            Some(Libc::Some(target_lexicon::Environment::Gnu))
1893        );
1894    }
1895
1896    /// Parse a request with `any` in various positions.
1897    #[test]
1898    fn test_python_download_request_from_str_with_any() {
1899        let request = PythonDownloadRequest::from_str("any-3.11-any-x86_64-any")
1900            .expect("Test request should be parsed");
1901
1902        assert_eq!(request.implementation, None);
1903        assert_eq!(
1904            request.version,
1905            Some(VersionRequest::from_str("3.11").unwrap())
1906        );
1907        assert_eq!(request.os, None);
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!(request.libc, None);
1916    }
1917
1918    /// Parse a request with `any` implied by the omission of segments.
1919    #[test]
1920    fn test_python_download_request_from_str_missing_segment() {
1921        let request =
1922            PythonDownloadRequest::from_str("pypy-linux").expect("Test request should be parsed");
1923
1924        assert_eq!(request.implementation, Some(ImplementationName::PyPy));
1925        assert_eq!(request.version, None);
1926        assert_eq!(
1927            request.os,
1928            Some(Os::new(target_lexicon::OperatingSystem::Linux))
1929        );
1930        assert_eq!(request.arch, None);
1931        assert_eq!(request.libc, None);
1932    }
1933
1934    #[test]
1935    fn test_python_download_request_from_str_version_only() {
1936        let request =
1937            PythonDownloadRequest::from_str("3.10.5").expect("Test request should be parsed");
1938
1939        assert_eq!(request.implementation, None);
1940        assert_eq!(
1941            request.version,
1942            Some(VersionRequest::from_str("3.10.5").unwrap())
1943        );
1944        assert_eq!(request.os, None);
1945        assert_eq!(request.arch, None);
1946        assert_eq!(request.libc, None);
1947    }
1948
1949    #[test]
1950    fn test_python_download_request_from_str_implementation_only() {
1951        let request =
1952            PythonDownloadRequest::from_str("cpython").expect("Test request should be parsed");
1953
1954        assert_eq!(request.implementation, Some(ImplementationName::CPython));
1955        assert_eq!(request.version, None);
1956        assert_eq!(request.os, None);
1957        assert_eq!(request.arch, None);
1958        assert_eq!(request.libc, None);
1959    }
1960
1961    /// Parse a request with the OS and architecture specified.
1962    #[test]
1963    fn test_python_download_request_from_str_os_arch() {
1964        let request = PythonDownloadRequest::from_str("windows-x86_64")
1965            .expect("Test request should be parsed");
1966
1967        assert_eq!(request.implementation, None);
1968        assert_eq!(request.version, None);
1969        assert_eq!(
1970            request.os,
1971            Some(Os::new(target_lexicon::OperatingSystem::Windows))
1972        );
1973        assert_eq!(
1974            request.arch,
1975            Some(ArchRequest::Explicit(Arch::new(
1976                target_lexicon::Architecture::X86_64,
1977                None
1978            )))
1979        );
1980        assert_eq!(request.libc, None);
1981    }
1982
1983    /// Parse a request with a pre-release version.
1984    #[test]
1985    fn test_python_download_request_from_str_prerelease() {
1986        let request = PythonDownloadRequest::from_str("cpython-3.13.0rc1")
1987            .expect("Test request should be parsed");
1988
1989        assert_eq!(request.implementation, Some(ImplementationName::CPython));
1990        assert_eq!(
1991            request.version,
1992            Some(VersionRequest::from_str("3.13.0rc1").unwrap())
1993        );
1994        assert_eq!(request.os, None);
1995        assert_eq!(request.arch, None);
1996        assert_eq!(request.libc, None);
1997    }
1998
1999    /// We fail on extra parts in the request.
2000    #[test]
2001    fn test_python_download_request_from_str_too_many_parts() {
2002        let result = PythonDownloadRequest::from_str("cpython-3.12-linux-x86_64-gnu-extra");
2003
2004        assert!(matches!(result, Err(Error::TooManyParts(_))));
2005    }
2006
2007    /// We don't allow an empty request.
2008    #[test]
2009    fn test_python_download_request_from_str_empty() {
2010        let result = PythonDownloadRequest::from_str("");
2011
2012        assert!(matches!(result, Err(Error::EmptyRequest)), "{result:?}");
2013    }
2014
2015    /// Parse a request with all "any" segments.
2016    #[test]
2017    fn test_python_download_request_from_str_all_any() {
2018        let request = PythonDownloadRequest::from_str("any-any-any-any-any")
2019            .expect("Test request should be parsed");
2020
2021        assert_eq!(request.implementation, None);
2022        assert_eq!(request.version, None);
2023        assert_eq!(request.os, None);
2024        assert_eq!(request.arch, None);
2025        assert_eq!(request.libc, None);
2026    }
2027
2028    /// Test that "any" is case-insensitive in various positions.
2029    #[test]
2030    fn test_python_download_request_from_str_case_insensitive_any() {
2031        let request = PythonDownloadRequest::from_str("ANY-3.11-Any-x86_64-aNy")
2032            .expect("Test request should be parsed");
2033
2034        assert_eq!(request.implementation, None);
2035        assert_eq!(
2036            request.version,
2037            Some(VersionRequest::from_str("3.11").unwrap())
2038        );
2039        assert_eq!(request.os, None);
2040        assert_eq!(
2041            request.arch,
2042            Some(ArchRequest::Explicit(Arch::new(
2043                target_lexicon::Architecture::X86_64,
2044                None
2045            )))
2046        );
2047        assert_eq!(request.libc, None);
2048    }
2049
2050    /// Parse a request with an invalid leading segment.
2051    #[test]
2052    fn test_python_download_request_from_str_invalid_leading_segment() {
2053        let result = PythonDownloadRequest::from_str("foobar-3.14-windows");
2054
2055        assert!(
2056            matches!(result, Err(Error::ImplementationError(_))),
2057            "{result:?}"
2058        );
2059    }
2060
2061    /// Parse a request with segments in an invalid order.
2062    #[test]
2063    fn test_python_download_request_from_str_out_of_order() {
2064        let result = PythonDownloadRequest::from_str("3.12-cpython");
2065
2066        assert!(
2067            matches!(result, Err(Error::InvalidRequestPlatform(_))),
2068            "{result:?}"
2069        );
2070    }
2071
2072    /// Parse a request with too many "any" segments.
2073    #[test]
2074    fn test_python_download_request_from_str_too_many_any() {
2075        let result = PythonDownloadRequest::from_str("any-any-any-any-any-any");
2076
2077        assert!(matches!(result, Err(Error::TooManyParts(_))));
2078    }
2079
2080    /// Test that build filtering works correctly
2081    #[tokio::test]
2082    async fn test_python_download_request_build_filtering() {
2083        let mut request = PythonDownloadRequest::default()
2084            .with_version(VersionRequest::from_str("3.12").unwrap())
2085            .with_implementation(ImplementationName::CPython);
2086        request.build = Some("20240814".to_string());
2087
2088        let client_builder = uv_client::BaseClientBuilder::default();
2089        let cache = uv_cache::Cache::temp().expect("failed to create temp cache");
2090        let download_list = ManagedPythonDownloadList::new(&client_builder, &cache, None)
2091            .await
2092            .unwrap();
2093
2094        let downloads: Vec<_> = download_list
2095            .iter_all()
2096            .filter(|d| request.satisfied_by_download(d))
2097            .collect();
2098
2099        assert!(
2100            !downloads.is_empty(),
2101            "Should find at least one matching download"
2102        );
2103        for download in downloads {
2104            assert_eq!(download.build(), Some("20240814"));
2105        }
2106    }
2107
2108    /// Test that an invalid build results in no matches
2109    #[tokio::test]
2110    async fn test_python_download_request_invalid_build() {
2111        // Create a request with a non-existent build
2112        let mut request = PythonDownloadRequest::default()
2113            .with_version(VersionRequest::from_str("3.12").unwrap())
2114            .with_implementation(ImplementationName::CPython);
2115        request.build = Some("99999999".to_string());
2116
2117        let client_builder = uv_client::BaseClientBuilder::default();
2118        let cache = uv_cache::Cache::temp().expect("failed to create temp cache");
2119        let download_list = ManagedPythonDownloadList::new(&client_builder, &cache, None)
2120            .await
2121            .unwrap();
2122
2123        // Should find no matching downloads
2124        let downloads: Vec<_> = download_list
2125            .iter_all()
2126            .filter(|d| request.satisfied_by_download(d))
2127            .collect();
2128
2129        assert_eq!(downloads.len(), 0);
2130    }
2131
2132    #[test]
2133    fn upgrade_request_native_defaults() {
2134        let request = PythonDownloadRequest::default()
2135            .with_implementation(ImplementationName::CPython)
2136            .with_version(VersionRequest::MajorMinorPatch(
2137                3,
2138                13,
2139                1,
2140                PythonVariant::Default,
2141            ))
2142            .with_os(Os::from_str("linux").unwrap())
2143            .with_arch(Arch::from_str("x86_64").unwrap())
2144            .with_libc(Libc::from_str("gnu").unwrap())
2145            .with_prereleases(false);
2146
2147        let host = Platform::new(
2148            Os::from_str("linux").unwrap(),
2149            Arch::from_str("x86_64").unwrap(),
2150            Libc::from_str("gnu").unwrap(),
2151        );
2152
2153        assert_eq!(
2154            request
2155                .clone()
2156                .unset_defaults_for_host(&host)
2157                .without_patch()
2158                .simplified_display()
2159                .as_deref(),
2160            Some("3.13")
2161        );
2162    }
2163
2164    #[test]
2165    fn upgrade_request_preserves_variant() {
2166        let request = PythonDownloadRequest::default()
2167            .with_implementation(ImplementationName::CPython)
2168            .with_version(VersionRequest::MajorMinorPatch(
2169                3,
2170                13,
2171                0,
2172                PythonVariant::Freethreaded,
2173            ))
2174            .with_os(Os::from_str("linux").unwrap())
2175            .with_arch(Arch::from_str("x86_64").unwrap())
2176            .with_libc(Libc::from_str("gnu").unwrap())
2177            .with_prereleases(false);
2178
2179        let host = Platform::new(
2180            Os::from_str("linux").unwrap(),
2181            Arch::from_str("x86_64").unwrap(),
2182            Libc::from_str("gnu").unwrap(),
2183        );
2184
2185        assert_eq!(
2186            request
2187                .clone()
2188                .unset_defaults_for_host(&host)
2189                .without_patch()
2190                .simplified_display()
2191                .as_deref(),
2192            Some("3.13+freethreaded")
2193        );
2194    }
2195
2196    #[test]
2197    fn upgrade_request_preserves_non_default_platform() {
2198        let request = PythonDownloadRequest::default()
2199            .with_implementation(ImplementationName::CPython)
2200            .with_version(VersionRequest::MajorMinorPatch(
2201                3,
2202                12,
2203                4,
2204                PythonVariant::Default,
2205            ))
2206            .with_os(Os::from_str("linux").unwrap())
2207            .with_arch(Arch::from_str("aarch64").unwrap())
2208            .with_libc(Libc::from_str("gnu").unwrap())
2209            .with_prereleases(false);
2210
2211        let host = Platform::new(
2212            Os::from_str("linux").unwrap(),
2213            Arch::from_str("x86_64").unwrap(),
2214            Libc::from_str("gnu").unwrap(),
2215        );
2216
2217        assert_eq!(
2218            request
2219                .clone()
2220                .unset_defaults_for_host(&host)
2221                .without_patch()
2222                .simplified_display()
2223                .as_deref(),
2224            Some("3.12-aarch64")
2225        );
2226    }
2227
2228    #[test]
2229    fn upgrade_request_preserves_custom_implementation() {
2230        let request = PythonDownloadRequest::default()
2231            .with_implementation(ImplementationName::PyPy)
2232            .with_version(VersionRequest::MajorMinorPatch(
2233                3,
2234                10,
2235                5,
2236                PythonVariant::Default,
2237            ))
2238            .with_os(Os::from_str("linux").unwrap())
2239            .with_arch(Arch::from_str("x86_64").unwrap())
2240            .with_libc(Libc::from_str("gnu").unwrap())
2241            .with_prereleases(false);
2242
2243        let host = Platform::new(
2244            Os::from_str("linux").unwrap(),
2245            Arch::from_str("x86_64").unwrap(),
2246            Libc::from_str("gnu").unwrap(),
2247        );
2248
2249        assert_eq!(
2250            request
2251                .clone()
2252                .unset_defaults_for_host(&host)
2253                .without_patch()
2254                .simplified_display()
2255                .as_deref(),
2256            Some("pypy-3.10")
2257        );
2258    }
2259
2260    #[test]
2261    fn simplified_display_returns_none_when_empty() {
2262        let request = PythonDownloadRequest::default()
2263            .fill_platform()
2264            .expect("should populate defaults");
2265
2266        let host = Platform::from_env().expect("host platform");
2267
2268        assert_eq!(
2269            request.unset_defaults_for_host(&host).simplified_display(),
2270            None
2271        );
2272    }
2273
2274    #[test]
2275    fn simplified_display_omits_environment_arch() {
2276        let mut request = PythonDownloadRequest::default()
2277            .with_version(VersionRequest::MajorMinor(3, 12, PythonVariant::Default))
2278            .with_os(Os::from_str("linux").unwrap())
2279            .with_libc(Libc::from_str("gnu").unwrap());
2280
2281        request.arch = Some(ArchRequest::Environment(Arch::from_str("x86_64").unwrap()));
2282
2283        let host = Platform::new(
2284            Os::from_str("linux").unwrap(),
2285            Arch::from_str("aarch64").unwrap(),
2286            Libc::from_str("gnu").unwrap(),
2287        );
2288
2289        assert_eq!(
2290            request
2291                .unset_defaults_for_host(&host)
2292                .simplified_display()
2293                .as_deref(),
2294            Some("3.12")
2295        );
2296    }
2297
2298    fn cpython_download_for_url(url: &'static str) -> ManagedPythonDownload {
2299        let key = PythonInstallationKey::new(
2300            LenientImplementationName::Known(crate::implementation::ImplementationName::CPython),
2301            3,
2302            12,
2303            4,
2304            None,
2305            Platform::new(
2306                Os::from_str("linux").unwrap(),
2307                Arch::from_str("x86_64").unwrap(),
2308                Libc::from_str("gnu").unwrap(),
2309            ),
2310            crate::PythonVariant::default(),
2311        );
2312
2313        ManagedPythonDownload {
2314            key,
2315            url: Cow::Borrowed(url),
2316            sha256: Some(Cow::Borrowed("abc123")),
2317            build: Some("20240713"),
2318        }
2319    }
2320
2321    #[test]
2322    fn test_cpython_download_urls_custom_astral_mirror() {
2323        let download = cpython_download_for_url(
2324            "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",
2325        );
2326
2327        let urls = download
2328            .download_urls_with_astral_mirror(
2329                None,
2330                None,
2331                Some("https://nexus.example.com/repository/releases.astral.sh/"),
2332            )
2333            .expect("download URLs should be valid");
2334        let urls = urls
2335            .into_iter()
2336            .map(|url| url.to_string())
2337            .collect::<Vec<_>>();
2338        assert_eq!(
2339            urls,
2340            vec![
2341                "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"
2342                    .to_string(),
2343            ]
2344        );
2345    }
2346
2347    #[test]
2348    fn test_cpython_specific_mirror_takes_precedence_over_astral_mirror() {
2349        let download = cpython_download_for_url(
2350            "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",
2351        );
2352
2353        let urls = download
2354            .download_urls_with_astral_mirror(
2355                Some("https://python-mirror.example.com/releases/"),
2356                None,
2357                Some("https://nexus.example.com/repository/releases.astral.sh/"),
2358            )
2359            .expect("download URLs should be valid");
2360        let urls = urls
2361            .into_iter()
2362            .map(|url| url.to_string())
2363            .collect::<Vec<_>>();
2364        assert_eq!(
2365            urls,
2366            vec![
2367                "https://python-mirror.example.com/releases/20240713/cpython-3.12.4%2B20240713-x86_64-unknown-linux-gnu-install_only.tar.gz"
2368                    .to_string(),
2369            ]
2370        );
2371    }
2372
2373    #[test]
2374    fn test_cpython_download_urls_empty_astral_mirror_uses_default() {
2375        let download = cpython_download_for_url(
2376            "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",
2377        );
2378
2379        let default_urls = download
2380            .download_urls_with_astral_mirror(None, None, None)
2381            .expect("download URLs should be valid");
2382        let empty_urls = download
2383            .download_urls_with_astral_mirror(None, None, Some(""))
2384            .expect("download URLs should be valid");
2385
2386        assert_eq!(default_urls, empty_urls);
2387    }
2388
2389    /// A hash mismatch is a post-download integrity failure — retrying a different URL cannot fix
2390    /// it, so it should not trigger a fallback.
2391    #[test]
2392    fn test_should_try_next_url_hash_mismatch() {
2393        let err = Error::HashMismatch {
2394            installation: "cpython-3.12.0".to_string(),
2395            expected: "abc".to_string(),
2396            actual: "def".to_string(),
2397        };
2398        assert!(!err.should_try_next_url());
2399    }
2400
2401    /// A local filesystem error during extraction (e.g. permission denied writing to disk) is not
2402    /// a network failure — a different URL would produce the same outcome.
2403    #[test]
2404    fn test_should_try_next_url_extract_error_filesystem() {
2405        let err = Error::ExtractError(
2406            "archive.tar.gz".to_string(),
2407            uv_extract::Error::Io(io::Error::new(io::ErrorKind::PermissionDenied, "")),
2408        );
2409        assert!(!err.should_try_next_url());
2410    }
2411
2412    /// A generic IO error from a local filesystem operation (e.g. permission denied on cache
2413    /// directory) should not trigger a fallback to a different URL.
2414    #[test]
2415    fn test_should_try_next_url_io_error_filesystem() {
2416        let err = Error::Io(io::Error::new(io::ErrorKind::PermissionDenied, ""));
2417        assert!(!err.should_try_next_url());
2418    }
2419
2420    /// A network IO error (e.g. connection reset mid-download) surfaces as `Error::Io` from
2421    /// `download_archive`. It should trigger a fallback because a different mirror may succeed.
2422    #[test]
2423    fn test_should_try_next_url_io_error_network() {
2424        let err = Error::Io(io::Error::new(io::ErrorKind::ConnectionReset, ""));
2425        assert!(err.should_try_next_url());
2426    }
2427
2428    /// A 404 HTTP response from the mirror becomes `Error::NetworkError` — it should trigger a
2429    /// URL fallback, because a 404 on the mirror does not mean the file is absent from GitHub.
2430    #[test]
2431    fn test_should_try_next_url_network_error_404() {
2432        let url =
2433            DisplaySafeUrl::from_str("https://releases.astral.sh/python/cpython-3.12.0.tar.gz")
2434                .unwrap();
2435        // `NetworkError` wraps a `WrappedReqwestError`; we use a middleware error as a
2436        // stand-in because `should_try_next_url` only inspects the variant, not the contents.
2437        let wrapped = WrappedReqwestError::with_problem_details(
2438            reqwest_middleware::Error::Middleware(anyhow::anyhow!("404 Not Found")),
2439            None,
2440        );
2441        let err = Error::NetworkError(url, wrapped);
2442        assert!(err.should_try_next_url());
2443    }
2444
2445    /// Every [`PythonVersion`] in the embedded download metadata must be convertible
2446    /// to a [`VersionRequest`] to avoid runtime panics.
2447    #[test]
2448    fn embedded_download_versions_convert_to_version_requests() {
2449        let downloads = ManagedPythonDownloadList::new_only_embedded()
2450            .expect("embedded download metadata should load");
2451
2452        let unique_versions: HashSet<PythonVersion> = downloads
2453            .iter_all()
2454            .map(ManagedPythonDownload::python_version)
2455            .collect();
2456
2457        for version in &unique_versions {
2458            let _ = VersionRequest::from(version);
2459        }
2460    }
2461}