Skip to main content

uv_python/
downloads.rs

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