Skip to main content

uv_python/
downloads.rs

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