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, RetryState, 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::{Digest, 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<Digest<32>>,
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<Digest<32>>,
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, _: &mut RetryState| {
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<&Digest<32>> {
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_ref() {
1270                Some(digest) => {
1271                    // Shorten the hash to avoid too-long-filename errors
1272                    &digest.as_str()[..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 = self
1480            .sha256
1481            .as_ref()
1482            .map(|_| Hasher::from(HashAlgorithm::Sha256));
1483        let mut hasher = uv_extract::hash::HashReader::new(reader, hashers.as_mut_slice());
1484
1485        let target = if let Some(reporter) = reporter {
1486            let progress_key = reporter.on_request_start(direction, &self.key, size);
1487            let mut reader = ProgressReader::new(&mut hasher, progress_key, reporter);
1488            let (target, _) = uv_extract::stream::archive(&mut reader, ext, target)
1489                .await
1490                .map_err(|err| Error::ExtractError(filename.to_owned(), err))?;
1491            reporter.on_request_complete(direction, progress_key);
1492            target
1493        } else {
1494            let (target, _) = uv_extract::stream::archive(&mut hasher, ext, target)
1495                .await
1496                .map_err(|err| Error::ExtractError(filename.to_owned(), err))?;
1497            target
1498        };
1499        hasher.finish().await.map_err(Error::HashExhaustion)?;
1500
1501        // Check the hash
1502        if let Some((expected, hasher)) = self.sha256.as_ref().zip(hashers) {
1503            let actual = HashDigest::from(hasher);
1504            if actual.digest() != expected.as_str() {
1505                return Err(Error::HashMismatch {
1506                    installation: self.key.to_string(),
1507                    expected: expected.as_str().to_string(),
1508                    actual: actual.digest().to_string(),
1509                });
1510            }
1511        }
1512
1513        Ok(target)
1514    }
1515
1516    #[cfg(test)]
1517    fn python_version(&self) -> PythonVersion {
1518        self.key.version()
1519    }
1520
1521    /// Return the ordered list of [`Url`]s to try when downloading the distribution.
1522    ///
1523    /// For CPython without a user-configured mirror, the default Astral mirror is listed first,
1524    /// followed by the canonical GitHub URL as a fallback.
1525    ///
1526    /// For all other cases (user mirror explicitly set, PyPy, GraalPy, Pyodide), a single URL
1527    /// is returned with no fallback.
1528    pub fn download_urls(
1529        &self,
1530        python_install_mirror: Option<&str>,
1531        pypy_install_mirror: Option<&str>,
1532    ) -> Result<Vec<DisplaySafeUrl>, Error> {
1533        let custom_astral_mirror = astral_mirror_url_from_env();
1534        self.download_urls_with_astral_mirror(
1535            python_install_mirror,
1536            pypy_install_mirror,
1537            custom_astral_mirror.as_deref(),
1538        )
1539    }
1540
1541    fn download_urls_with_astral_mirror(
1542        &self,
1543        python_install_mirror: Option<&str>,
1544        pypy_install_mirror: Option<&str>,
1545        astral_mirror_url: Option<&str>,
1546    ) -> Result<Vec<DisplaySafeUrl>, Error> {
1547        let astral_mirror_url = custom_astral_mirror_url(astral_mirror_url);
1548        match self.key.implementation {
1549            LenientImplementationName::Known(ImplementationName::CPython) => {
1550                if let Some(mirror) = python_install_mirror {
1551                    // User-configured mirror: use it exclusively, no automatic fallback.
1552                    let Some(suffix) = self.url.strip_prefix(CPYTHON_DOWNLOADS_URL_PREFIX) else {
1553                        return Err(Error::Mirror(
1554                            EnvVars::UV_PYTHON_INSTALL_MIRROR,
1555                            self.url.to_string(),
1556                        ));
1557                    };
1558                    return Ok(vec![DisplaySafeUrl::parse(
1559                        format!("{}/{}", mirror.trim_end_matches('/'), suffix).as_str(),
1560                    )?]);
1561                }
1562                // No user mirror: try the default/custom Astral mirror first.
1563                if let Some(suffix) = self.url.strip_prefix(CPYTHON_DOWNLOADS_URL_PREFIX) {
1564                    let effective_mirror = effective_cpython_mirror(astral_mirror_url);
1565                    let mirror_url = DisplaySafeUrl::parse(
1566                        format!("{}/{}", effective_mirror.trim_end_matches('/'), suffix).as_str(),
1567                    )?;
1568                    // When a custom Astral mirror is set, use it exclusively.
1569                    if astral_mirror_url.is_some() {
1570                        return Ok(vec![mirror_url]);
1571                    }
1572                    // Otherwise fall back to the canonical GitHub URL.
1573                    let canonical_url = DisplaySafeUrl::parse(&self.url)?;
1574                    return Ok(vec![mirror_url, canonical_url]);
1575                }
1576            }
1577
1578            LenientImplementationName::Known(ImplementationName::PyPy) => {
1579                if let Some(mirror) = pypy_install_mirror {
1580                    let Some(suffix) = self.url.strip_prefix("https://downloads.python.org/pypy/")
1581                    else {
1582                        return Err(Error::Mirror(
1583                            EnvVars::UV_PYPY_INSTALL_MIRROR,
1584                            self.url.to_string(),
1585                        ));
1586                    };
1587                    return Ok(vec![DisplaySafeUrl::parse(
1588                        format!("{}/{}", mirror.trim_end_matches('/'), suffix).as_str(),
1589                    )?]);
1590                }
1591            }
1592
1593            _ => {}
1594        }
1595
1596        Ok(vec![DisplaySafeUrl::parse(&self.url)?])
1597    }
1598}
1599
1600fn parse_json_downloads(
1601    json_downloads: HashMap<String, JsonPythonDownload>,
1602) -> Vec<ManagedPythonDownload> {
1603    json_downloads
1604        .into_iter()
1605        .filter_map(|(key, entry)| {
1606            let implementation = match entry.name.as_str() {
1607                "cpython" => LenientImplementationName::Known(ImplementationName::CPython),
1608                "pypy" => LenientImplementationName::Known(ImplementationName::PyPy),
1609                "graalpy" => LenientImplementationName::Known(ImplementationName::GraalPy),
1610                _ => LenientImplementationName::Unknown(entry.name.clone()),
1611            };
1612
1613            let arch_str = match entry.arch.family.as_str() {
1614                "armv5tel" => Cow::Borrowed("armv5te"),
1615                // The `gc` variant of riscv64 is the common base instruction set and
1616                // is the target in `python-build-standalone`
1617                // See https://github.com/astral-sh/python-build-standalone/issues/504
1618                "riscv64" => Cow::Borrowed("riscv64gc"),
1619                value => Cow::Borrowed(value),
1620            };
1621
1622            let arch_str = if let Some(variant) = entry.arch.variant {
1623                Cow::Owned(format!("{arch_str}_{variant}"))
1624            } else {
1625                arch_str
1626            };
1627
1628            let arch = match Arch::from_str(&arch_str) {
1629                Ok(arch) => arch,
1630                Err(e) => {
1631                    debug!("Skipping entry {key}: Invalid arch '{arch_str}' - {e}");
1632                    return None;
1633                }
1634            };
1635
1636            let os = match Os::from_str(&entry.os) {
1637                Ok(os) => os,
1638                Err(e) => {
1639                    debug!("Skipping entry {}: Invalid OS '{}' - {}", key, entry.os, e);
1640                    return None;
1641                }
1642            };
1643
1644            let libc = match Libc::from_str(&entry.libc) {
1645                Ok(libc) => libc,
1646                Err(e) => {
1647                    debug!(
1648                        "Skipping entry {}: Invalid libc '{}' - {}",
1649                        key, entry.libc, e
1650                    );
1651                    return None;
1652                }
1653            };
1654
1655            let variant = match entry
1656                .variant
1657                .as_deref()
1658                .map(PythonVariant::from_str)
1659                .transpose()
1660            {
1661                Ok(Some(variant)) => variant,
1662                Ok(None) => PythonVariant::default(),
1663                Err(()) => {
1664                    debug!(
1665                        "Skipping entry {key}: Unknown python variant - {}",
1666                        entry.variant.unwrap_or_default()
1667                    );
1668                    return None;
1669                }
1670            };
1671
1672            let version_str = format!(
1673                "{}.{}.{}{}",
1674                entry.major,
1675                entry.minor,
1676                entry.patch,
1677                entry.prerelease.as_deref().unwrap_or_default()
1678            );
1679
1680            let version = match PythonVersion::from_str(&version_str) {
1681                Ok(version) => version,
1682                Err(e) => {
1683                    debug!("Skipping entry {key}: Invalid version '{version_str}' - {e}");
1684                    return None;
1685                }
1686            };
1687
1688            let url = Cow::Owned(entry.url);
1689            let sha256 = entry.sha256;
1690            let build = entry
1691                .build
1692                .map(|s| Box::leak(s.into_boxed_str()) as &'static str);
1693
1694            Some(ManagedPythonDownload {
1695                key: PythonInstallationKey::new_from_version(
1696                    implementation,
1697                    &version,
1698                    Platform::new(os, arch, libc),
1699                    variant,
1700                ),
1701                url,
1702                sha256,
1703                build,
1704            })
1705        })
1706        .sorted_by(|a, b| Ord::cmp(&b.key, &a.key))
1707        .collect()
1708}
1709
1710impl Error {
1711    fn from_reqwest(
1712        url: DisplaySafeUrl,
1713        err: reqwest::Error,
1714        retries: Option<u32>,
1715        start: Instant,
1716    ) -> Self {
1717        let err = Self::NetworkError(url, WrappedReqwestError::from(err));
1718        if let Some(retries) = retries {
1719            Self::NetworkErrorWithRetries {
1720                err: Box::new(err),
1721                retries,
1722                duration: start.elapsed(),
1723            }
1724        } else {
1725            err
1726        }
1727    }
1728
1729    fn from_reqwest_middleware(url: DisplaySafeUrl, err: reqwest_middleware::Error) -> Self {
1730        match err {
1731            reqwest_middleware::Error::Middleware(error) => {
1732                Self::NetworkMiddlewareError(url, error)
1733            }
1734            reqwest_middleware::Error::Reqwest(error) => {
1735                Self::NetworkError(url, WrappedReqwestError::from(error))
1736            }
1737        }
1738    }
1739}
1740
1741impl Display for ManagedPythonDownload {
1742    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1743        write!(f, "{}", self.key)
1744    }
1745}
1746
1747#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1748pub enum Direction {
1749    Download,
1750    Extract,
1751}
1752
1753impl Direction {
1754    fn as_str(&self) -> &str {
1755        match self {
1756            Self::Download => "download",
1757            Self::Extract => "extract",
1758        }
1759    }
1760}
1761
1762impl Display for Direction {
1763    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1764        f.write_str(self.as_str())
1765    }
1766}
1767
1768pub trait Reporter: Send + Sync {
1769    fn on_request_start(
1770        &self,
1771        direction: Direction,
1772        name: &PythonInstallationKey,
1773        size: Option<u64>,
1774    ) -> usize;
1775    fn on_request_progress(&self, id: usize, inc: u64);
1776    fn on_request_complete(&self, direction: Direction, id: usize);
1777}
1778
1779/// An asynchronous reader that reports progress as bytes are read.
1780struct ProgressReader<'a, R> {
1781    reader: R,
1782    index: usize,
1783    reporter: &'a dyn Reporter,
1784}
1785
1786impl<'a, R> ProgressReader<'a, R> {
1787    /// Create a new [`ProgressReader`] that wraps another reader.
1788    fn new(reader: R, index: usize, reporter: &'a dyn Reporter) -> Self {
1789        Self {
1790            reader,
1791            index,
1792            reporter,
1793        }
1794    }
1795}
1796
1797impl<R> AsyncRead for ProgressReader<'_, R>
1798where
1799    R: AsyncRead + Unpin,
1800{
1801    fn poll_read(
1802        mut self: Pin<&mut Self>,
1803        cx: &mut Context<'_>,
1804        buf: &mut ReadBuf<'_>,
1805    ) -> Poll<io::Result<()>> {
1806        Pin::new(&mut self.as_mut().reader)
1807            .poll_read(cx, buf)
1808            .map_ok(|()| {
1809                self.reporter
1810                    .on_request_progress(self.index, buf.filled().len() as u64);
1811            })
1812    }
1813}
1814
1815/// Convert a [`Url`] into an [`AsyncRead`] stream.
1816async fn read_url(
1817    url: &DisplaySafeUrl,
1818    client: &BaseClient,
1819) -> Result<(impl AsyncRead + Unpin, Option<u64>), Error> {
1820    if url.scheme() == "file" {
1821        // Loads downloaded distribution from the given `file://` URL.
1822        let path = url
1823            .to_file_path()
1824            .map_err(|()| Error::InvalidFileUrl(url.to_string()))?;
1825
1826        let size = fs_err::tokio::metadata(&path).await?.len();
1827        let reader = fs_err::tokio::File::open(&path).await?;
1828
1829        Ok((Either::Left(reader), Some(size)))
1830    } else {
1831        let start = Instant::now();
1832        let response = client
1833            .for_host(url)
1834            .get(Url::from(url.clone()))
1835            .send()
1836            .await
1837            .map_err(|err| Error::from_reqwest_middleware(url.clone(), err))?;
1838
1839        let retry_count = response
1840            .extensions()
1841            .get::<reqwest_retry::RetryCount>()
1842            .map(|retries| retries.value());
1843
1844        // Check the status code.
1845        let response = response
1846            .error_for_status()
1847            .map_err(|err| Error::from_reqwest(url.clone(), err, retry_count, start))?;
1848
1849        let size = response.content_length();
1850        let stream = response
1851            .bytes_stream()
1852            .map_err(io::Error::other)
1853            .into_async_read();
1854
1855        Ok((Either::Right(stream.compat()), size))
1856    }
1857}
1858
1859#[cfg(test)]
1860mod tests {
1861    use std::assert_matches;
1862    use std::collections::HashSet;
1863
1864    use crate::PythonVariant;
1865    use crate::implementation::LenientImplementationName;
1866    use crate::installation::PythonInstallationKey;
1867    use uv_platform::{Arch, Libc, Os, Platform};
1868
1869    use super::*;
1870
1871    /// Parse a request with all of its fields.
1872    #[test]
1873    fn test_python_download_request_from_str_complete() {
1874        let request = PythonDownloadRequest::from_str("cpython-3.12.0-linux-x86_64-gnu")
1875            .expect("Test request should be parsed");
1876
1877        assert_eq!(request.implementation, Some(ImplementationName::CPython));
1878        assert_eq!(
1879            request.version,
1880            Some(VersionRequest::from_str("3.12.0").unwrap())
1881        );
1882        assert_eq!(
1883            request.os,
1884            Some(Os::new(target_lexicon::OperatingSystem::Linux))
1885        );
1886        assert_eq!(
1887            request.arch,
1888            Some(ArchRequest::Explicit(Arch::new(
1889                target_lexicon::Architecture::X86_64,
1890                None
1891            )))
1892        );
1893        assert_eq!(
1894            request.libc,
1895            Some(Libc::Some(target_lexicon::Environment::Gnu))
1896        );
1897    }
1898
1899    /// Parse a request with `any` in various positions.
1900    #[test]
1901    fn test_python_download_request_from_str_with_any() {
1902        let request = PythonDownloadRequest::from_str("any-3.11-any-x86_64-any")
1903            .expect("Test request should be parsed");
1904
1905        assert_eq!(request.implementation, None);
1906        assert_eq!(
1907            request.version,
1908            Some(VersionRequest::from_str("3.11").unwrap())
1909        );
1910        assert_eq!(request.os, None);
1911        assert_eq!(
1912            request.arch,
1913            Some(ArchRequest::Explicit(Arch::new(
1914                target_lexicon::Architecture::X86_64,
1915                None
1916            )))
1917        );
1918        assert_eq!(request.libc, None);
1919    }
1920
1921    /// Parse a request with `any` implied by the omission of segments.
1922    #[test]
1923    fn test_python_download_request_from_str_missing_segment() {
1924        let request =
1925            PythonDownloadRequest::from_str("pypy-linux").expect("Test request should be parsed");
1926
1927        assert_eq!(request.implementation, Some(ImplementationName::PyPy));
1928        assert_eq!(request.version, None);
1929        assert_eq!(
1930            request.os,
1931            Some(Os::new(target_lexicon::OperatingSystem::Linux))
1932        );
1933        assert_eq!(request.arch, None);
1934        assert_eq!(request.libc, None);
1935    }
1936
1937    #[test]
1938    fn test_python_download_request_from_str_version_only() {
1939        let request =
1940            PythonDownloadRequest::from_str("3.10.5").expect("Test request should be parsed");
1941
1942        assert_eq!(request.implementation, None);
1943        assert_eq!(
1944            request.version,
1945            Some(VersionRequest::from_str("3.10.5").unwrap())
1946        );
1947        assert_eq!(request.os, None);
1948        assert_eq!(request.arch, None);
1949        assert_eq!(request.libc, None);
1950    }
1951
1952    #[test]
1953    fn test_python_download_request_from_str_implementation_only() {
1954        let request =
1955            PythonDownloadRequest::from_str("cpython").expect("Test request should be parsed");
1956
1957        assert_eq!(request.implementation, Some(ImplementationName::CPython));
1958        assert_eq!(request.version, None);
1959        assert_eq!(request.os, None);
1960        assert_eq!(request.arch, None);
1961        assert_eq!(request.libc, None);
1962    }
1963
1964    /// Parse a request with the OS and architecture specified.
1965    #[test]
1966    fn test_python_download_request_from_str_os_arch() {
1967        let request = PythonDownloadRequest::from_str("windows-x86_64")
1968            .expect("Test request should be parsed");
1969
1970        assert_eq!(request.implementation, None);
1971        assert_eq!(request.version, None);
1972        assert_eq!(
1973            request.os,
1974            Some(Os::new(target_lexicon::OperatingSystem::Windows))
1975        );
1976        assert_eq!(
1977            request.arch,
1978            Some(ArchRequest::Explicit(Arch::new(
1979                target_lexicon::Architecture::X86_64,
1980                None
1981            )))
1982        );
1983        assert_eq!(request.libc, None);
1984    }
1985
1986    /// Parse a request with a pre-release version.
1987    #[test]
1988    fn test_python_download_request_from_str_prerelease() {
1989        let request = PythonDownloadRequest::from_str("cpython-3.13.0rc1")
1990            .expect("Test request should be parsed");
1991
1992        assert_eq!(request.implementation, Some(ImplementationName::CPython));
1993        assert_eq!(
1994            request.version,
1995            Some(VersionRequest::from_str("3.13.0rc1").unwrap())
1996        );
1997        assert_eq!(request.os, None);
1998        assert_eq!(request.arch, None);
1999        assert_eq!(request.libc, None);
2000    }
2001
2002    /// We fail on extra parts in the request.
2003    #[test]
2004    fn test_python_download_request_from_str_too_many_parts() {
2005        let result = PythonDownloadRequest::from_str("cpython-3.12-linux-x86_64-gnu-extra");
2006
2007        assert_matches!(result, Err(Error::TooManyParts(_)));
2008    }
2009
2010    /// We don't allow an empty request.
2011    #[test]
2012    fn test_python_download_request_from_str_empty() {
2013        let result = PythonDownloadRequest::from_str("");
2014
2015        assert_matches!(result, Err(Error::EmptyRequest));
2016    }
2017
2018    /// Parse a request with all "any" segments.
2019    #[test]
2020    fn test_python_download_request_from_str_all_any() {
2021        let request = PythonDownloadRequest::from_str("any-any-any-any-any")
2022            .expect("Test request should be parsed");
2023
2024        assert_eq!(request.implementation, None);
2025        assert_eq!(request.version, None);
2026        assert_eq!(request.os, None);
2027        assert_eq!(request.arch, None);
2028        assert_eq!(request.libc, None);
2029    }
2030
2031    /// Test that "any" is case-insensitive in various positions.
2032    #[test]
2033    fn test_python_download_request_from_str_case_insensitive_any() {
2034        let request = PythonDownloadRequest::from_str("ANY-3.11-Any-x86_64-aNy")
2035            .expect("Test request should be parsed");
2036
2037        assert_eq!(request.implementation, None);
2038        assert_eq!(
2039            request.version,
2040            Some(VersionRequest::from_str("3.11").unwrap())
2041        );
2042        assert_eq!(request.os, None);
2043        assert_eq!(
2044            request.arch,
2045            Some(ArchRequest::Explicit(Arch::new(
2046                target_lexicon::Architecture::X86_64,
2047                None
2048            )))
2049        );
2050        assert_eq!(request.libc, None);
2051    }
2052
2053    /// Parse a request with an invalid leading segment.
2054    #[test]
2055    fn test_python_download_request_from_str_invalid_leading_segment() {
2056        let result = PythonDownloadRequest::from_str("foobar-3.14-windows");
2057
2058        assert_matches!(result, Err(Error::ImplementationError(_)));
2059    }
2060
2061    /// Parse a request with segments in an invalid order.
2062    #[test]
2063    fn test_python_download_request_from_str_out_of_order() {
2064        let result = PythonDownloadRequest::from_str("3.12-cpython");
2065
2066        assert_matches!(result, Err(Error::InvalidRequestPlatform(_)));
2067    }
2068
2069    /// Parse a request with too many "any" segments.
2070    #[test]
2071    fn test_python_download_request_from_str_too_many_any() {
2072        let result = PythonDownloadRequest::from_str("any-any-any-any-any-any");
2073
2074        assert_matches!(result, Err(Error::TooManyParts(_)));
2075    }
2076
2077    /// Test that build filtering works correctly
2078    #[tokio::test]
2079    async fn test_python_download_request_build_filtering() {
2080        let mut request = PythonDownloadRequest::default()
2081            .with_version(VersionRequest::from_str("3.12").unwrap())
2082            .with_implementation(ImplementationName::CPython);
2083        request.build = Some("20240814".to_string());
2084
2085        let client_builder = uv_client::BaseClientBuilder::default();
2086        let cache = uv_cache::Cache::temp().expect("failed to create temp cache");
2087        let download_list = ManagedPythonDownloadList::new(&client_builder, &cache, None)
2088            .await
2089            .unwrap();
2090
2091        let downloads: Vec<_> = download_list
2092            .iter_all()
2093            .filter(|d| request.satisfied_by_download(d))
2094            .collect();
2095
2096        assert!(
2097            !downloads.is_empty(),
2098            "Should find at least one matching download"
2099        );
2100        for download in downloads {
2101            assert_eq!(download.build(), Some("20240814"));
2102        }
2103    }
2104
2105    /// Test that an invalid build results in no matches
2106    #[tokio::test]
2107    async fn test_python_download_request_invalid_build() {
2108        // Create a request with a non-existent build
2109        let mut request = PythonDownloadRequest::default()
2110            .with_version(VersionRequest::from_str("3.12").unwrap())
2111            .with_implementation(ImplementationName::CPython);
2112        request.build = Some("99999999".to_string());
2113
2114        let client_builder = uv_client::BaseClientBuilder::default();
2115        let cache = uv_cache::Cache::temp().expect("failed to create temp cache");
2116        let download_list = ManagedPythonDownloadList::new(&client_builder, &cache, None)
2117            .await
2118            .unwrap();
2119
2120        // Should find no matching downloads
2121        let downloads: Vec<_> = download_list
2122            .iter_all()
2123            .filter(|d| request.satisfied_by_download(d))
2124            .collect();
2125
2126        assert_eq!(downloads.len(), 0);
2127    }
2128
2129    #[test]
2130    fn upgrade_request_native_defaults() {
2131        let request = PythonDownloadRequest::default()
2132            .with_implementation(ImplementationName::CPython)
2133            .with_version(VersionRequest::MajorMinorPatch(
2134                3,
2135                13,
2136                1,
2137                PythonVariant::Default,
2138            ))
2139            .with_os(Os::from_str("linux").unwrap())
2140            .with_arch(Arch::from_str("x86_64").unwrap())
2141            .with_libc(Libc::from_str("gnu").unwrap())
2142            .with_prereleases(false);
2143
2144        let host = Platform::new(
2145            Os::from_str("linux").unwrap(),
2146            Arch::from_str("x86_64").unwrap(),
2147            Libc::from_str("gnu").unwrap(),
2148        );
2149
2150        assert_eq!(
2151            request
2152                .clone()
2153                .unset_defaults_for_host(&host)
2154                .without_patch()
2155                .simplified_display()
2156                .as_deref(),
2157            Some("3.13")
2158        );
2159    }
2160
2161    #[test]
2162    fn upgrade_request_preserves_variant() {
2163        let request = PythonDownloadRequest::default()
2164            .with_implementation(ImplementationName::CPython)
2165            .with_version(VersionRequest::MajorMinorPatch(
2166                3,
2167                13,
2168                0,
2169                PythonVariant::Freethreaded,
2170            ))
2171            .with_os(Os::from_str("linux").unwrap())
2172            .with_arch(Arch::from_str("x86_64").unwrap())
2173            .with_libc(Libc::from_str("gnu").unwrap())
2174            .with_prereleases(false);
2175
2176        let host = Platform::new(
2177            Os::from_str("linux").unwrap(),
2178            Arch::from_str("x86_64").unwrap(),
2179            Libc::from_str("gnu").unwrap(),
2180        );
2181
2182        assert_eq!(
2183            request
2184                .clone()
2185                .unset_defaults_for_host(&host)
2186                .without_patch()
2187                .simplified_display()
2188                .as_deref(),
2189            Some("3.13+freethreaded")
2190        );
2191    }
2192
2193    #[test]
2194    fn upgrade_request_preserves_non_default_platform() {
2195        let request = PythonDownloadRequest::default()
2196            .with_implementation(ImplementationName::CPython)
2197            .with_version(VersionRequest::MajorMinorPatch(
2198                3,
2199                12,
2200                4,
2201                PythonVariant::Default,
2202            ))
2203            .with_os(Os::from_str("linux").unwrap())
2204            .with_arch(Arch::from_str("aarch64").unwrap())
2205            .with_libc(Libc::from_str("gnu").unwrap())
2206            .with_prereleases(false);
2207
2208        let host = Platform::new(
2209            Os::from_str("linux").unwrap(),
2210            Arch::from_str("x86_64").unwrap(),
2211            Libc::from_str("gnu").unwrap(),
2212        );
2213
2214        assert_eq!(
2215            request
2216                .clone()
2217                .unset_defaults_for_host(&host)
2218                .without_patch()
2219                .simplified_display()
2220                .as_deref(),
2221            Some("3.12-aarch64")
2222        );
2223    }
2224
2225    #[test]
2226    fn upgrade_request_preserves_custom_implementation() {
2227        let request = PythonDownloadRequest::default()
2228            .with_implementation(ImplementationName::PyPy)
2229            .with_version(VersionRequest::MajorMinorPatch(
2230                3,
2231                10,
2232                5,
2233                PythonVariant::Default,
2234            ))
2235            .with_os(Os::from_str("linux").unwrap())
2236            .with_arch(Arch::from_str("x86_64").unwrap())
2237            .with_libc(Libc::from_str("gnu").unwrap())
2238            .with_prereleases(false);
2239
2240        let host = Platform::new(
2241            Os::from_str("linux").unwrap(),
2242            Arch::from_str("x86_64").unwrap(),
2243            Libc::from_str("gnu").unwrap(),
2244        );
2245
2246        assert_eq!(
2247            request
2248                .clone()
2249                .unset_defaults_for_host(&host)
2250                .without_patch()
2251                .simplified_display()
2252                .as_deref(),
2253            Some("pypy-3.10")
2254        );
2255    }
2256
2257    #[test]
2258    fn simplified_display_returns_none_when_empty() {
2259        let request = PythonDownloadRequest::default()
2260            .fill_platform()
2261            .expect("should populate defaults");
2262
2263        let host = Platform::from_env().expect("host platform");
2264
2265        assert_eq!(
2266            request.unset_defaults_for_host(&host).simplified_display(),
2267            None
2268        );
2269    }
2270
2271    #[test]
2272    fn simplified_display_omits_environment_arch() {
2273        let mut request = PythonDownloadRequest::default()
2274            .with_version(VersionRequest::MajorMinor(3, 12, PythonVariant::Default))
2275            .with_os(Os::from_str("linux").unwrap())
2276            .with_libc(Libc::from_str("gnu").unwrap());
2277
2278        request.arch = Some(ArchRequest::Environment(Arch::from_str("x86_64").unwrap()));
2279
2280        let host = Platform::new(
2281            Os::from_str("linux").unwrap(),
2282            Arch::from_str("aarch64").unwrap(),
2283            Libc::from_str("gnu").unwrap(),
2284        );
2285
2286        assert_eq!(
2287            request
2288                .unset_defaults_for_host(&host)
2289                .simplified_display()
2290                .as_deref(),
2291            Some("3.12")
2292        );
2293    }
2294
2295    fn cpython_download_for_url(url: &'static str) -> ManagedPythonDownload {
2296        let key = PythonInstallationKey::new(
2297            LenientImplementationName::Known(crate::implementation::ImplementationName::CPython),
2298            3,
2299            12,
2300            4,
2301            None,
2302            Platform::new(
2303                Os::from_str("linux").unwrap(),
2304                Arch::from_str("x86_64").unwrap(),
2305                Libc::from_str("gnu").unwrap(),
2306            ),
2307            crate::PythonVariant::default(),
2308        );
2309
2310        ManagedPythonDownload {
2311            key,
2312            url: Cow::Borrowed(url),
2313            sha256: Some(Digest::from_bytes([0xab; 32])),
2314            build: Some("20240713"),
2315        }
2316    }
2317
2318    #[test]
2319    fn test_cpython_download_urls_custom_astral_mirror() {
2320        let download = cpython_download_for_url(
2321            "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",
2322        );
2323
2324        let urls = download
2325            .download_urls_with_astral_mirror(
2326                None,
2327                None,
2328                Some("https://nexus.example.com/repository/releases.astral.sh/"),
2329            )
2330            .expect("download URLs should be valid");
2331        let urls = urls
2332            .into_iter()
2333            .map(|url| url.to_string())
2334            .collect::<Vec<_>>();
2335        assert_eq!(
2336            urls,
2337            vec![
2338                "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"
2339                    .to_string(),
2340            ]
2341        );
2342    }
2343
2344    #[test]
2345    fn test_cpython_specific_mirror_takes_precedence_over_astral_mirror() {
2346        let download = cpython_download_for_url(
2347            "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",
2348        );
2349
2350        let urls = download
2351            .download_urls_with_astral_mirror(
2352                Some("https://python-mirror.example.com/releases/"),
2353                None,
2354                Some("https://nexus.example.com/repository/releases.astral.sh/"),
2355            )
2356            .expect("download URLs should be valid");
2357        let urls = urls
2358            .into_iter()
2359            .map(|url| url.to_string())
2360            .collect::<Vec<_>>();
2361        assert_eq!(
2362            urls,
2363            vec![
2364                "https://python-mirror.example.com/releases/20240713/cpython-3.12.4%2B20240713-x86_64-unknown-linux-gnu-install_only.tar.gz"
2365                    .to_string(),
2366            ]
2367        );
2368    }
2369
2370    #[test]
2371    fn test_cpython_download_urls_empty_astral_mirror_uses_default() {
2372        let download = cpython_download_for_url(
2373            "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",
2374        );
2375
2376        let default_urls = download
2377            .download_urls_with_astral_mirror(None, None, None)
2378            .expect("download URLs should be valid");
2379        let empty_urls = download
2380            .download_urls_with_astral_mirror(None, None, Some(""))
2381            .expect("download URLs should be valid");
2382
2383        assert_eq!(default_urls, empty_urls);
2384    }
2385
2386    /// A hash mismatch is a post-download integrity failure — retrying a different URL cannot fix
2387    /// it, so it should not trigger a fallback.
2388    #[test]
2389    fn test_should_try_next_url_hash_mismatch() {
2390        let err = Error::HashMismatch {
2391            installation: "cpython-3.12.0".to_string(),
2392            expected: "abc".to_string(),
2393            actual: "def".to_string(),
2394        };
2395        assert!(!err.should_try_next_url());
2396    }
2397
2398    /// A local filesystem error during extraction (e.g. permission denied writing to disk) is not
2399    /// a network failure — a different URL would produce the same outcome.
2400    #[test]
2401    fn test_should_try_next_url_extract_error_filesystem() {
2402        let err = Error::ExtractError(
2403            "archive.tar.gz".to_string(),
2404            uv_extract::Error::Io(io::Error::new(io::ErrorKind::PermissionDenied, "")),
2405        );
2406        assert!(!err.should_try_next_url());
2407    }
2408
2409    /// A generic IO error from a local filesystem operation (e.g. permission denied on cache
2410    /// directory) should not trigger a fallback to a different URL.
2411    #[test]
2412    fn test_should_try_next_url_io_error_filesystem() {
2413        let err = Error::Io(io::Error::new(io::ErrorKind::PermissionDenied, ""));
2414        assert!(!err.should_try_next_url());
2415    }
2416
2417    /// A network IO error (e.g. connection reset mid-download) surfaces as `Error::Io` from
2418    /// `download_archive`. It should trigger a fallback because a different mirror may succeed.
2419    #[test]
2420    fn test_should_try_next_url_io_error_network() {
2421        let err = Error::Io(io::Error::new(io::ErrorKind::ConnectionReset, ""));
2422        assert!(err.should_try_next_url());
2423    }
2424
2425    /// A 404 HTTP response from the mirror becomes `Error::NetworkError` — it should trigger a
2426    /// URL fallback, because a 404 on the mirror does not mean the file is absent from GitHub.
2427    #[test]
2428    fn test_should_try_next_url_network_error_404() {
2429        let url =
2430            DisplaySafeUrl::from_str("https://releases.astral.sh/python/cpython-3.12.0.tar.gz")
2431                .unwrap();
2432        // `NetworkError` wraps a `WrappedReqwestError`; we use a middleware error as a
2433        // stand-in because `should_try_next_url` only inspects the variant, not the contents.
2434        let wrapped = WrappedReqwestError::with_problem_details(
2435            reqwest_middleware::Error::Middleware(anyhow::anyhow!("404 Not Found")),
2436            None,
2437        );
2438        let err = Error::NetworkError(url, wrapped);
2439        assert!(err.should_try_next_url());
2440    }
2441
2442    /// Every [`PythonVersion`] in the embedded download metadata must be convertible
2443    /// to a [`VersionRequest`] to avoid runtime panics.
2444    #[test]
2445    fn embedded_download_versions_convert_to_version_requests() {
2446        let downloads = ManagedPythonDownloadList::new_only_embedded()
2447            .expect("embedded download metadata should load");
2448
2449        let unique_versions: HashSet<PythonVersion> = downloads
2450            .iter_all()
2451            .map(ManagedPythonDownload::python_version)
2452            .collect();
2453
2454        for version in &unique_versions {
2455            let _ = VersionRequest::from(version);
2456        }
2457    }
2458}