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