Skip to main content

uv_python/
downloads.rs

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