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