Skip to main content

uv_python/
downloads.rs

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