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    pub fn python_version(&self) -> PythonVersion {
1515        self.key.version()
1516    }
1517
1518    /// Return the ordered list of [`Url`]s to try when downloading the distribution.
1519    ///
1520    /// For CPython without a user-configured mirror, the default Astral mirror is listed first,
1521    /// followed by the canonical GitHub URL as a fallback.
1522    ///
1523    /// For all other cases (user mirror explicitly set, PyPy, GraalPy, Pyodide), a single URL
1524    /// is returned with no fallback.
1525    pub fn download_urls(
1526        &self,
1527        python_install_mirror: Option<&str>,
1528        pypy_install_mirror: Option<&str>,
1529    ) -> Result<Vec<DisplaySafeUrl>, Error> {
1530        let custom_astral_mirror = astral_mirror_url_from_env();
1531        self.download_urls_with_astral_mirror(
1532            python_install_mirror,
1533            pypy_install_mirror,
1534            custom_astral_mirror.as_deref(),
1535        )
1536    }
1537
1538    fn download_urls_with_astral_mirror(
1539        &self,
1540        python_install_mirror: Option<&str>,
1541        pypy_install_mirror: Option<&str>,
1542        astral_mirror_url: Option<&str>,
1543    ) -> Result<Vec<DisplaySafeUrl>, Error> {
1544        let astral_mirror_url = custom_astral_mirror_url(astral_mirror_url);
1545        match self.key.implementation {
1546            LenientImplementationName::Known(ImplementationName::CPython) => {
1547                if let Some(mirror) = python_install_mirror {
1548                    // User-configured mirror: use it exclusively, no automatic fallback.
1549                    let Some(suffix) = self.url.strip_prefix(CPYTHON_DOWNLOADS_URL_PREFIX) else {
1550                        return Err(Error::Mirror(
1551                            EnvVars::UV_PYTHON_INSTALL_MIRROR,
1552                            self.url.to_string(),
1553                        ));
1554                    };
1555                    return Ok(vec![DisplaySafeUrl::parse(
1556                        format!("{}/{}", mirror.trim_end_matches('/'), suffix).as_str(),
1557                    )?]);
1558                }
1559                // No user mirror: try the default/custom Astral mirror first.
1560                if let Some(suffix) = self.url.strip_prefix(CPYTHON_DOWNLOADS_URL_PREFIX) {
1561                    let effective_mirror = effective_cpython_mirror(astral_mirror_url);
1562                    let mirror_url = DisplaySafeUrl::parse(
1563                        format!("{}/{}", effective_mirror.trim_end_matches('/'), suffix).as_str(),
1564                    )?;
1565                    // When a custom Astral mirror is set, use it exclusively.
1566                    if astral_mirror_url.is_some() {
1567                        return Ok(vec![mirror_url]);
1568                    }
1569                    // Otherwise fall back to the canonical GitHub URL.
1570                    let canonical_url = DisplaySafeUrl::parse(&self.url)?;
1571                    return Ok(vec![mirror_url, canonical_url]);
1572                }
1573            }
1574
1575            LenientImplementationName::Known(ImplementationName::PyPy) => {
1576                if let Some(mirror) = pypy_install_mirror {
1577                    let Some(suffix) = self.url.strip_prefix("https://downloads.python.org/pypy/")
1578                    else {
1579                        return Err(Error::Mirror(
1580                            EnvVars::UV_PYPY_INSTALL_MIRROR,
1581                            self.url.to_string(),
1582                        ));
1583                    };
1584                    return Ok(vec![DisplaySafeUrl::parse(
1585                        format!("{}/{}", mirror.trim_end_matches('/'), suffix).as_str(),
1586                    )?]);
1587                }
1588            }
1589
1590            _ => {}
1591        }
1592
1593        Ok(vec![DisplaySafeUrl::parse(&self.url)?])
1594    }
1595}
1596
1597fn parse_json_downloads(
1598    json_downloads: HashMap<String, JsonPythonDownload>,
1599) -> Vec<ManagedPythonDownload> {
1600    json_downloads
1601        .into_iter()
1602        .filter_map(|(key, entry)| {
1603            let implementation = match entry.name.as_str() {
1604                "cpython" => LenientImplementationName::Known(ImplementationName::CPython),
1605                "pypy" => LenientImplementationName::Known(ImplementationName::PyPy),
1606                "graalpy" => LenientImplementationName::Known(ImplementationName::GraalPy),
1607                _ => LenientImplementationName::Unknown(entry.name.clone()),
1608            };
1609
1610            let arch_str = match entry.arch.family.as_str() {
1611                "armv5tel" => "armv5te".to_string(),
1612                // The `gc` variant of riscv64 is the common base instruction set and
1613                // is the target in `python-build-standalone`
1614                // See https://github.com/astral-sh/python-build-standalone/issues/504
1615                "riscv64" => "riscv64gc".to_string(),
1616                value => value.to_string(),
1617            };
1618
1619            let arch_str = if let Some(variant) = entry.arch.variant {
1620                format!("{arch_str}_{variant}")
1621            } else {
1622                arch_str
1623            };
1624
1625            let arch = match Arch::from_str(&arch_str) {
1626                Ok(arch) => arch,
1627                Err(e) => {
1628                    debug!("Skipping entry {key}: Invalid arch '{arch_str}' - {e}");
1629                    return None;
1630                }
1631            };
1632
1633            let os = match Os::from_str(&entry.os) {
1634                Ok(os) => os,
1635                Err(e) => {
1636                    debug!("Skipping entry {}: Invalid OS '{}' - {}", key, entry.os, e);
1637                    return None;
1638                }
1639            };
1640
1641            let libc = match Libc::from_str(&entry.libc) {
1642                Ok(libc) => libc,
1643                Err(e) => {
1644                    debug!(
1645                        "Skipping entry {}: Invalid libc '{}' - {}",
1646                        key, entry.libc, e
1647                    );
1648                    return None;
1649                }
1650            };
1651
1652            let variant = match entry
1653                .variant
1654                .as_deref()
1655                .map(PythonVariant::from_str)
1656                .transpose()
1657            {
1658                Ok(Some(variant)) => variant,
1659                Ok(None) => PythonVariant::default(),
1660                Err(()) => {
1661                    debug!(
1662                        "Skipping entry {key}: Unknown python variant - {}",
1663                        entry.variant.unwrap_or_default()
1664                    );
1665                    return None;
1666                }
1667            };
1668
1669            let version_str = format!(
1670                "{}.{}.{}{}",
1671                entry.major,
1672                entry.minor,
1673                entry.patch,
1674                entry.prerelease.as_deref().unwrap_or_default()
1675            );
1676
1677            let version = match PythonVersion::from_str(&version_str) {
1678                Ok(version) => version,
1679                Err(e) => {
1680                    debug!("Skipping entry {key}: Invalid version '{version_str}' - {e}");
1681                    return None;
1682                }
1683            };
1684
1685            let url = Cow::Owned(entry.url);
1686            let sha256 = entry.sha256.map(Cow::Owned);
1687            let build = entry
1688                .build
1689                .map(|s| Box::leak(s.into_boxed_str()) as &'static str);
1690
1691            Some(ManagedPythonDownload {
1692                key: PythonInstallationKey::new_from_version(
1693                    implementation,
1694                    &version,
1695                    Platform::new(os, arch, libc),
1696                    variant,
1697                ),
1698                url,
1699                sha256,
1700                build,
1701            })
1702        })
1703        .sorted_by(|a, b| Ord::cmp(&b.key, &a.key))
1704        .collect()
1705}
1706
1707impl Error {
1708    fn from_reqwest(
1709        url: DisplaySafeUrl,
1710        err: reqwest::Error,
1711        retries: Option<u32>,
1712        start: Instant,
1713    ) -> Self {
1714        let err = Self::NetworkError(url, WrappedReqwestError::from(err));
1715        if let Some(retries) = retries {
1716            Self::NetworkErrorWithRetries {
1717                err: Box::new(err),
1718                retries,
1719                duration: start.elapsed(),
1720            }
1721        } else {
1722            err
1723        }
1724    }
1725
1726    fn from_reqwest_middleware(url: DisplaySafeUrl, err: reqwest_middleware::Error) -> Self {
1727        match err {
1728            reqwest_middleware::Error::Middleware(error) => {
1729                Self::NetworkMiddlewareError(url, error)
1730            }
1731            reqwest_middleware::Error::Reqwest(error) => {
1732                Self::NetworkError(url, WrappedReqwestError::from(error))
1733            }
1734        }
1735    }
1736}
1737
1738impl Display for ManagedPythonDownload {
1739    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1740        write!(f, "{}", self.key)
1741    }
1742}
1743
1744#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1745pub enum Direction {
1746    Download,
1747    Extract,
1748}
1749
1750impl Direction {
1751    fn as_str(&self) -> &str {
1752        match self {
1753            Self::Download => "download",
1754            Self::Extract => "extract",
1755        }
1756    }
1757}
1758
1759impl Display for Direction {
1760    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1761        f.write_str(self.as_str())
1762    }
1763}
1764
1765pub trait Reporter: Send + Sync {
1766    fn on_request_start(
1767        &self,
1768        direction: Direction,
1769        name: &PythonInstallationKey,
1770        size: Option<u64>,
1771    ) -> usize;
1772    fn on_request_progress(&self, id: usize, inc: u64);
1773    fn on_request_complete(&self, direction: Direction, id: usize);
1774}
1775
1776/// An asynchronous reader that reports progress as bytes are read.
1777struct ProgressReader<'a, R> {
1778    reader: R,
1779    index: usize,
1780    reporter: &'a dyn Reporter,
1781}
1782
1783impl<'a, R> ProgressReader<'a, R> {
1784    /// Create a new [`ProgressReader`] that wraps another reader.
1785    fn new(reader: R, index: usize, reporter: &'a dyn Reporter) -> Self {
1786        Self {
1787            reader,
1788            index,
1789            reporter,
1790        }
1791    }
1792}
1793
1794impl<R> AsyncRead for ProgressReader<'_, R>
1795where
1796    R: AsyncRead + Unpin,
1797{
1798    fn poll_read(
1799        mut self: Pin<&mut Self>,
1800        cx: &mut Context<'_>,
1801        buf: &mut ReadBuf<'_>,
1802    ) -> Poll<io::Result<()>> {
1803        Pin::new(&mut self.as_mut().reader)
1804            .poll_read(cx, buf)
1805            .map_ok(|()| {
1806                self.reporter
1807                    .on_request_progress(self.index, buf.filled().len() as u64);
1808            })
1809    }
1810}
1811
1812/// Convert a [`Url`] into an [`AsyncRead`] stream.
1813async fn read_url(
1814    url: &DisplaySafeUrl,
1815    client: &BaseClient,
1816) -> Result<(impl AsyncRead + Unpin, Option<u64>), Error> {
1817    if url.scheme() == "file" {
1818        // Loads downloaded distribution from the given `file://` URL.
1819        let path = url
1820            .to_file_path()
1821            .map_err(|()| Error::InvalidFileUrl(url.to_string()))?;
1822
1823        let size = fs_err::tokio::metadata(&path).await?.len();
1824        let reader = fs_err::tokio::File::open(&path).await?;
1825
1826        Ok((Either::Left(reader), Some(size)))
1827    } else {
1828        let start = Instant::now();
1829        let response = client
1830            .for_host(url)
1831            .get(Url::from(url.clone()))
1832            .send()
1833            .await
1834            .map_err(|err| Error::from_reqwest_middleware(url.clone(), err))?;
1835
1836        let retry_count = response
1837            .extensions()
1838            .get::<reqwest_retry::RetryCount>()
1839            .map(|retries| retries.value());
1840
1841        // Check the status code.
1842        let response = response
1843            .error_for_status()
1844            .map_err(|err| Error::from_reqwest(url.clone(), err, retry_count, start))?;
1845
1846        let size = response.content_length();
1847        let stream = response
1848            .bytes_stream()
1849            .map_err(io::Error::other)
1850            .into_async_read();
1851
1852        Ok((Either::Right(stream.compat()), size))
1853    }
1854}
1855
1856#[cfg(test)]
1857mod tests {
1858    use std::collections::HashSet;
1859
1860    use crate::PythonVariant;
1861    use crate::implementation::LenientImplementationName;
1862    use crate::installation::PythonInstallationKey;
1863    use uv_platform::{Arch, Libc, Os, Platform};
1864
1865    use super::*;
1866
1867    /// Parse a request with all of its fields.
1868    #[test]
1869    fn test_python_download_request_from_str_complete() {
1870        let request = PythonDownloadRequest::from_str("cpython-3.12.0-linux-x86_64-gnu")
1871            .expect("Test request should be parsed");
1872
1873        assert_eq!(request.implementation, Some(ImplementationName::CPython));
1874        assert_eq!(
1875            request.version,
1876            Some(VersionRequest::from_str("3.12.0").unwrap())
1877        );
1878        assert_eq!(
1879            request.os,
1880            Some(Os::new(target_lexicon::OperatingSystem::Linux))
1881        );
1882        assert_eq!(
1883            request.arch,
1884            Some(ArchRequest::Explicit(Arch::new(
1885                target_lexicon::Architecture::X86_64,
1886                None
1887            )))
1888        );
1889        assert_eq!(
1890            request.libc,
1891            Some(Libc::Some(target_lexicon::Environment::Gnu))
1892        );
1893    }
1894
1895    /// Parse a request with `any` in various positions.
1896    #[test]
1897    fn test_python_download_request_from_str_with_any() {
1898        let request = PythonDownloadRequest::from_str("any-3.11-any-x86_64-any")
1899            .expect("Test request should be parsed");
1900
1901        assert_eq!(request.implementation, None);
1902        assert_eq!(
1903            request.version,
1904            Some(VersionRequest::from_str("3.11").unwrap())
1905        );
1906        assert_eq!(request.os, None);
1907        assert_eq!(
1908            request.arch,
1909            Some(ArchRequest::Explicit(Arch::new(
1910                target_lexicon::Architecture::X86_64,
1911                None
1912            )))
1913        );
1914        assert_eq!(request.libc, None);
1915    }
1916
1917    /// Parse a request with `any` implied by the omission of segments.
1918    #[test]
1919    fn test_python_download_request_from_str_missing_segment() {
1920        let request =
1921            PythonDownloadRequest::from_str("pypy-linux").expect("Test request should be parsed");
1922
1923        assert_eq!(request.implementation, Some(ImplementationName::PyPy));
1924        assert_eq!(request.version, None);
1925        assert_eq!(
1926            request.os,
1927            Some(Os::new(target_lexicon::OperatingSystem::Linux))
1928        );
1929        assert_eq!(request.arch, None);
1930        assert_eq!(request.libc, None);
1931    }
1932
1933    #[test]
1934    fn test_python_download_request_from_str_version_only() {
1935        let request =
1936            PythonDownloadRequest::from_str("3.10.5").expect("Test request should be parsed");
1937
1938        assert_eq!(request.implementation, None);
1939        assert_eq!(
1940            request.version,
1941            Some(VersionRequest::from_str("3.10.5").unwrap())
1942        );
1943        assert_eq!(request.os, None);
1944        assert_eq!(request.arch, None);
1945        assert_eq!(request.libc, None);
1946    }
1947
1948    #[test]
1949    fn test_python_download_request_from_str_implementation_only() {
1950        let request =
1951            PythonDownloadRequest::from_str("cpython").expect("Test request should be parsed");
1952
1953        assert_eq!(request.implementation, Some(ImplementationName::CPython));
1954        assert_eq!(request.version, None);
1955        assert_eq!(request.os, None);
1956        assert_eq!(request.arch, None);
1957        assert_eq!(request.libc, None);
1958    }
1959
1960    /// Parse a request with the OS and architecture specified.
1961    #[test]
1962    fn test_python_download_request_from_str_os_arch() {
1963        let request = PythonDownloadRequest::from_str("windows-x86_64")
1964            .expect("Test request should be parsed");
1965
1966        assert_eq!(request.implementation, None);
1967        assert_eq!(request.version, None);
1968        assert_eq!(
1969            request.os,
1970            Some(Os::new(target_lexicon::OperatingSystem::Windows))
1971        );
1972        assert_eq!(
1973            request.arch,
1974            Some(ArchRequest::Explicit(Arch::new(
1975                target_lexicon::Architecture::X86_64,
1976                None
1977            )))
1978        );
1979        assert_eq!(request.libc, None);
1980    }
1981
1982    /// Parse a request with a pre-release version.
1983    #[test]
1984    fn test_python_download_request_from_str_prerelease() {
1985        let request = PythonDownloadRequest::from_str("cpython-3.13.0rc1")
1986            .expect("Test request should be parsed");
1987
1988        assert_eq!(request.implementation, Some(ImplementationName::CPython));
1989        assert_eq!(
1990            request.version,
1991            Some(VersionRequest::from_str("3.13.0rc1").unwrap())
1992        );
1993        assert_eq!(request.os, None);
1994        assert_eq!(request.arch, None);
1995        assert_eq!(request.libc, None);
1996    }
1997
1998    /// We fail on extra parts in the request.
1999    #[test]
2000    fn test_python_download_request_from_str_too_many_parts() {
2001        let result = PythonDownloadRequest::from_str("cpython-3.12-linux-x86_64-gnu-extra");
2002
2003        assert!(matches!(result, Err(Error::TooManyParts(_))));
2004    }
2005
2006    /// We don't allow an empty request.
2007    #[test]
2008    fn test_python_download_request_from_str_empty() {
2009        let result = PythonDownloadRequest::from_str("");
2010
2011        assert!(matches!(result, Err(Error::EmptyRequest)), "{result:?}");
2012    }
2013
2014    /// Parse a request with all "any" segments.
2015    #[test]
2016    fn test_python_download_request_from_str_all_any() {
2017        let request = PythonDownloadRequest::from_str("any-any-any-any-any")
2018            .expect("Test request should be parsed");
2019
2020        assert_eq!(request.implementation, None);
2021        assert_eq!(request.version, None);
2022        assert_eq!(request.os, None);
2023        assert_eq!(request.arch, None);
2024        assert_eq!(request.libc, None);
2025    }
2026
2027    /// Test that "any" is case-insensitive in various positions.
2028    #[test]
2029    fn test_python_download_request_from_str_case_insensitive_any() {
2030        let request = PythonDownloadRequest::from_str("ANY-3.11-Any-x86_64-aNy")
2031            .expect("Test request should be parsed");
2032
2033        assert_eq!(request.implementation, None);
2034        assert_eq!(
2035            request.version,
2036            Some(VersionRequest::from_str("3.11").unwrap())
2037        );
2038        assert_eq!(request.os, None);
2039        assert_eq!(
2040            request.arch,
2041            Some(ArchRequest::Explicit(Arch::new(
2042                target_lexicon::Architecture::X86_64,
2043                None
2044            )))
2045        );
2046        assert_eq!(request.libc, None);
2047    }
2048
2049    /// Parse a request with an invalid leading segment.
2050    #[test]
2051    fn test_python_download_request_from_str_invalid_leading_segment() {
2052        let result = PythonDownloadRequest::from_str("foobar-3.14-windows");
2053
2054        assert!(
2055            matches!(result, Err(Error::ImplementationError(_))),
2056            "{result:?}"
2057        );
2058    }
2059
2060    /// Parse a request with segments in an invalid order.
2061    #[test]
2062    fn test_python_download_request_from_str_out_of_order() {
2063        let result = PythonDownloadRequest::from_str("3.12-cpython");
2064
2065        assert!(
2066            matches!(result, Err(Error::InvalidRequestPlatform(_))),
2067            "{result:?}"
2068        );
2069    }
2070
2071    /// Parse a request with too many "any" segments.
2072    #[test]
2073    fn test_python_download_request_from_str_too_many_any() {
2074        let result = PythonDownloadRequest::from_str("any-any-any-any-any-any");
2075
2076        assert!(matches!(result, Err(Error::TooManyParts(_))));
2077    }
2078
2079    /// Test that build filtering works correctly
2080    #[tokio::test]
2081    async fn test_python_download_request_build_filtering() {
2082        let mut request = PythonDownloadRequest::default()
2083            .with_version(VersionRequest::from_str("3.12").unwrap())
2084            .with_implementation(ImplementationName::CPython);
2085        request.build = Some("20240814".to_string());
2086
2087        let client_builder = uv_client::BaseClientBuilder::default();
2088        let cache = uv_cache::Cache::temp().expect("failed to create temp cache");
2089        let download_list = ManagedPythonDownloadList::new(&client_builder, &cache, None)
2090            .await
2091            .unwrap();
2092
2093        let downloads: Vec<_> = download_list
2094            .iter_all()
2095            .filter(|d| request.satisfied_by_download(d))
2096            .collect();
2097
2098        assert!(
2099            !downloads.is_empty(),
2100            "Should find at least one matching download"
2101        );
2102        for download in downloads {
2103            assert_eq!(download.build(), Some("20240814"));
2104        }
2105    }
2106
2107    /// Test that an invalid build results in no matches
2108    #[tokio::test]
2109    async fn test_python_download_request_invalid_build() {
2110        // Create a request with a non-existent build
2111        let mut request = PythonDownloadRequest::default()
2112            .with_version(VersionRequest::from_str("3.12").unwrap())
2113            .with_implementation(ImplementationName::CPython);
2114        request.build = Some("99999999".to_string());
2115
2116        let client_builder = uv_client::BaseClientBuilder::default();
2117        let cache = uv_cache::Cache::temp().expect("failed to create temp cache");
2118        let download_list = ManagedPythonDownloadList::new(&client_builder, &cache, None)
2119            .await
2120            .unwrap();
2121
2122        // Should find no matching downloads
2123        let downloads: Vec<_> = download_list
2124            .iter_all()
2125            .filter(|d| request.satisfied_by_download(d))
2126            .collect();
2127
2128        assert_eq!(downloads.len(), 0);
2129    }
2130
2131    #[test]
2132    fn upgrade_request_native_defaults() {
2133        let request = PythonDownloadRequest::default()
2134            .with_implementation(ImplementationName::CPython)
2135            .with_version(VersionRequest::MajorMinorPatch(
2136                3,
2137                13,
2138                1,
2139                PythonVariant::Default,
2140            ))
2141            .with_os(Os::from_str("linux").unwrap())
2142            .with_arch(Arch::from_str("x86_64").unwrap())
2143            .with_libc(Libc::from_str("gnu").unwrap())
2144            .with_prereleases(false);
2145
2146        let host = Platform::new(
2147            Os::from_str("linux").unwrap(),
2148            Arch::from_str("x86_64").unwrap(),
2149            Libc::from_str("gnu").unwrap(),
2150        );
2151
2152        assert_eq!(
2153            request
2154                .clone()
2155                .unset_defaults_for_host(&host)
2156                .without_patch()
2157                .simplified_display()
2158                .as_deref(),
2159            Some("3.13")
2160        );
2161    }
2162
2163    #[test]
2164    fn upgrade_request_preserves_variant() {
2165        let request = PythonDownloadRequest::default()
2166            .with_implementation(ImplementationName::CPython)
2167            .with_version(VersionRequest::MajorMinorPatch(
2168                3,
2169                13,
2170                0,
2171                PythonVariant::Freethreaded,
2172            ))
2173            .with_os(Os::from_str("linux").unwrap())
2174            .with_arch(Arch::from_str("x86_64").unwrap())
2175            .with_libc(Libc::from_str("gnu").unwrap())
2176            .with_prereleases(false);
2177
2178        let host = Platform::new(
2179            Os::from_str("linux").unwrap(),
2180            Arch::from_str("x86_64").unwrap(),
2181            Libc::from_str("gnu").unwrap(),
2182        );
2183
2184        assert_eq!(
2185            request
2186                .clone()
2187                .unset_defaults_for_host(&host)
2188                .without_patch()
2189                .simplified_display()
2190                .as_deref(),
2191            Some("3.13+freethreaded")
2192        );
2193    }
2194
2195    #[test]
2196    fn upgrade_request_preserves_non_default_platform() {
2197        let request = PythonDownloadRequest::default()
2198            .with_implementation(ImplementationName::CPython)
2199            .with_version(VersionRequest::MajorMinorPatch(
2200                3,
2201                12,
2202                4,
2203                PythonVariant::Default,
2204            ))
2205            .with_os(Os::from_str("linux").unwrap())
2206            .with_arch(Arch::from_str("aarch64").unwrap())
2207            .with_libc(Libc::from_str("gnu").unwrap())
2208            .with_prereleases(false);
2209
2210        let host = Platform::new(
2211            Os::from_str("linux").unwrap(),
2212            Arch::from_str("x86_64").unwrap(),
2213            Libc::from_str("gnu").unwrap(),
2214        );
2215
2216        assert_eq!(
2217            request
2218                .clone()
2219                .unset_defaults_for_host(&host)
2220                .without_patch()
2221                .simplified_display()
2222                .as_deref(),
2223            Some("3.12-aarch64")
2224        );
2225    }
2226
2227    #[test]
2228    fn upgrade_request_preserves_custom_implementation() {
2229        let request = PythonDownloadRequest::default()
2230            .with_implementation(ImplementationName::PyPy)
2231            .with_version(VersionRequest::MajorMinorPatch(
2232                3,
2233                10,
2234                5,
2235                PythonVariant::Default,
2236            ))
2237            .with_os(Os::from_str("linux").unwrap())
2238            .with_arch(Arch::from_str("x86_64").unwrap())
2239            .with_libc(Libc::from_str("gnu").unwrap())
2240            .with_prereleases(false);
2241
2242        let host = Platform::new(
2243            Os::from_str("linux").unwrap(),
2244            Arch::from_str("x86_64").unwrap(),
2245            Libc::from_str("gnu").unwrap(),
2246        );
2247
2248        assert_eq!(
2249            request
2250                .clone()
2251                .unset_defaults_for_host(&host)
2252                .without_patch()
2253                .simplified_display()
2254                .as_deref(),
2255            Some("pypy-3.10")
2256        );
2257    }
2258
2259    #[test]
2260    fn simplified_display_returns_none_when_empty() {
2261        let request = PythonDownloadRequest::default()
2262            .fill_platform()
2263            .expect("should populate defaults");
2264
2265        let host = Platform::from_env().expect("host platform");
2266
2267        assert_eq!(
2268            request.unset_defaults_for_host(&host).simplified_display(),
2269            None
2270        );
2271    }
2272
2273    #[test]
2274    fn simplified_display_omits_environment_arch() {
2275        let mut request = PythonDownloadRequest::default()
2276            .with_version(VersionRequest::MajorMinor(3, 12, PythonVariant::Default))
2277            .with_os(Os::from_str("linux").unwrap())
2278            .with_libc(Libc::from_str("gnu").unwrap());
2279
2280        request.arch = Some(ArchRequest::Environment(Arch::from_str("x86_64").unwrap()));
2281
2282        let host = Platform::new(
2283            Os::from_str("linux").unwrap(),
2284            Arch::from_str("aarch64").unwrap(),
2285            Libc::from_str("gnu").unwrap(),
2286        );
2287
2288        assert_eq!(
2289            request
2290                .unset_defaults_for_host(&host)
2291                .simplified_display()
2292                .as_deref(),
2293            Some("3.12")
2294        );
2295    }
2296
2297    fn cpython_download_for_url(url: &'static str) -> ManagedPythonDownload {
2298        let key = PythonInstallationKey::new(
2299            LenientImplementationName::Known(crate::implementation::ImplementationName::CPython),
2300            3,
2301            12,
2302            4,
2303            None,
2304            Platform::new(
2305                Os::from_str("linux").unwrap(),
2306                Arch::from_str("x86_64").unwrap(),
2307                Libc::from_str("gnu").unwrap(),
2308            ),
2309            crate::PythonVariant::default(),
2310        );
2311
2312        ManagedPythonDownload {
2313            key,
2314            url: Cow::Borrowed(url),
2315            sha256: Some(Cow::Borrowed("abc123")),
2316            build: Some("20240713"),
2317        }
2318    }
2319
2320    #[test]
2321    fn test_cpython_download_urls_custom_astral_mirror() {
2322        let download = cpython_download_for_url(
2323            "https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-x86_64-unknown-linux-gnu-install_only.tar.gz",
2324        );
2325
2326        let urls = download
2327            .download_urls_with_astral_mirror(
2328                None,
2329                None,
2330                Some("https://nexus.example.com/repository/releases.astral.sh/"),
2331            )
2332            .expect("download URLs should be valid");
2333        let urls = urls
2334            .into_iter()
2335            .map(|url| url.to_string())
2336            .collect::<Vec<_>>();
2337        assert_eq!(
2338            urls,
2339            vec![
2340                "https://nexus.example.com/repository/releases.astral.sh/github/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-x86_64-unknown-linux-gnu-install_only.tar.gz"
2341                    .to_string(),
2342            ]
2343        );
2344    }
2345
2346    #[test]
2347    fn test_cpython_specific_mirror_takes_precedence_over_astral_mirror() {
2348        let download = cpython_download_for_url(
2349            "https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-x86_64-unknown-linux-gnu-install_only.tar.gz",
2350        );
2351
2352        let urls = download
2353            .download_urls_with_astral_mirror(
2354                Some("https://python-mirror.example.com/releases/"),
2355                None,
2356                Some("https://nexus.example.com/repository/releases.astral.sh/"),
2357            )
2358            .expect("download URLs should be valid");
2359        let urls = urls
2360            .into_iter()
2361            .map(|url| url.to_string())
2362            .collect::<Vec<_>>();
2363        assert_eq!(
2364            urls,
2365            vec![
2366                "https://python-mirror.example.com/releases/20240713/cpython-3.12.4%2B20240713-x86_64-unknown-linux-gnu-install_only.tar.gz"
2367                    .to_string(),
2368            ]
2369        );
2370    }
2371
2372    #[test]
2373    fn test_cpython_download_urls_empty_astral_mirror_uses_default() {
2374        let download = cpython_download_for_url(
2375            "https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-x86_64-unknown-linux-gnu-install_only.tar.gz",
2376        );
2377
2378        let default_urls = download
2379            .download_urls_with_astral_mirror(None, None, None)
2380            .expect("download URLs should be valid");
2381        let empty_urls = download
2382            .download_urls_with_astral_mirror(None, None, Some(""))
2383            .expect("download URLs should be valid");
2384
2385        assert_eq!(default_urls, empty_urls);
2386    }
2387
2388    /// A hash mismatch is a post-download integrity failure — retrying a different URL cannot fix
2389    /// it, so it should not trigger a fallback.
2390    #[test]
2391    fn test_should_try_next_url_hash_mismatch() {
2392        let err = Error::HashMismatch {
2393            installation: "cpython-3.12.0".to_string(),
2394            expected: "abc".to_string(),
2395            actual: "def".to_string(),
2396        };
2397        assert!(!err.should_try_next_url());
2398    }
2399
2400    /// A local filesystem error during extraction (e.g. permission denied writing to disk) is not
2401    /// a network failure — a different URL would produce the same outcome.
2402    #[test]
2403    fn test_should_try_next_url_extract_error_filesystem() {
2404        let err = Error::ExtractError(
2405            "archive.tar.gz".to_string(),
2406            uv_extract::Error::Io(io::Error::new(io::ErrorKind::PermissionDenied, "")),
2407        );
2408        assert!(!err.should_try_next_url());
2409    }
2410
2411    /// A generic IO error from a local filesystem operation (e.g. permission denied on cache
2412    /// directory) should not trigger a fallback to a different URL.
2413    #[test]
2414    fn test_should_try_next_url_io_error_filesystem() {
2415        let err = Error::Io(io::Error::new(io::ErrorKind::PermissionDenied, ""));
2416        assert!(!err.should_try_next_url());
2417    }
2418
2419    /// A network IO error (e.g. connection reset mid-download) surfaces as `Error::Io` from
2420    /// `download_archive`. It should trigger a fallback because a different mirror may succeed.
2421    #[test]
2422    fn test_should_try_next_url_io_error_network() {
2423        let err = Error::Io(io::Error::new(io::ErrorKind::ConnectionReset, ""));
2424        assert!(err.should_try_next_url());
2425    }
2426
2427    /// A 404 HTTP response from the mirror becomes `Error::NetworkError` — it should trigger a
2428    /// URL fallback, because a 404 on the mirror does not mean the file is absent from GitHub.
2429    #[test]
2430    fn test_should_try_next_url_network_error_404() {
2431        let url =
2432            DisplaySafeUrl::from_str("https://releases.astral.sh/python/cpython-3.12.0.tar.gz")
2433                .unwrap();
2434        // `NetworkError` wraps a `WrappedReqwestError`; we use a middleware error as a
2435        // stand-in because `should_try_next_url` only inspects the variant, not the contents.
2436        let wrapped = WrappedReqwestError::with_problem_details(
2437            reqwest_middleware::Error::Middleware(anyhow::anyhow!("404 Not Found")),
2438            None,
2439        );
2440        let err = Error::NetworkError(url, wrapped);
2441        assert!(err.should_try_next_url());
2442    }
2443
2444    /// Every [`PythonVersion`] in the embedded download metadata must be convertible
2445    /// to a [`VersionRequest`] to avoid runtime panics.
2446    #[test]
2447    fn embedded_download_versions_convert_to_version_requests() {
2448        let downloads = ManagedPythonDownloadList::new_only_embedded()
2449            .expect("embedded download metadata should load");
2450
2451        let unique_versions: HashSet<PythonVersion> = downloads
2452            .iter_all()
2453            .map(ManagedPythonDownload::python_version)
2454            .collect();
2455
2456        for version in &unique_versions {
2457            let _ = VersionRequest::from(version);
2458        }
2459    }
2460}