Skip to main content

uv_python/
downloads.rs

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