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