Skip to main content

uv_python/
downloads.rs

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