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