Skip to main content

uv_python/
downloads.rs

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