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<Self>,
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<Self>),
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        while let Some(part) = state.part {
780            match state.position {
781                Position::Start => unreachable!("We start before the loop"),
782                Position::Implementation => {
783                    if part.eq_ignore_ascii_case("any") {
784                        state.next_part();
785                        continue;
786                    }
787                    match ImplementationName::from_str(part) {
788                        Ok(val) => {
789                            implementation = Some(val);
790                            state.next_part();
791                        }
792                        Err(err) => {
793                            state.next_position();
794                            state.record_err(err.into());
795                        }
796                    }
797                }
798                Position::Version => {
799                    if part.eq_ignore_ascii_case("any") {
800                        state.next_part();
801                        continue;
802                    }
803                    match VersionRequest::from_str(part)
804                        .map_err(|_| Error::InvalidPythonVersion(part.to_string()))
805                    {
806                        // Err(err) if !first_part => return Err(err),
807                        Ok(val) => {
808                            version = Some(val);
809                            state.next_part();
810                        }
811                        Err(err) => {
812                            state.next_position();
813                            state.record_err(err);
814                        }
815                    }
816                }
817                Position::Os => {
818                    if part.eq_ignore_ascii_case("any") {
819                        state.next_part();
820                        continue;
821                    }
822                    match Os::from_str(part) {
823                        Ok(val) => {
824                            os = Some(val);
825                            state.next_part();
826                        }
827                        Err(err) => {
828                            state.next_position();
829                            state.record_err(err.into());
830                        }
831                    }
832                }
833                Position::Arch => {
834                    if part.eq_ignore_ascii_case("any") {
835                        state.next_part();
836                        continue;
837                    }
838                    match Arch::from_str(part) {
839                        Ok(val) => {
840                            arch = Some(ArchRequest::Explicit(val));
841                            state.next_part();
842                        }
843                        Err(err) => {
844                            state.next_position();
845                            state.record_err(err.into());
846                        }
847                    }
848                }
849                Position::Libc => {
850                    if part.eq_ignore_ascii_case("any") {
851                        state.next_part();
852                        continue;
853                    }
854                    match Libc::from_str(part) {
855                        Ok(val) => {
856                            libc = Some(val);
857                            state.next_part();
858                        }
859                        Err(err) => {
860                            state.next_position();
861                            state.record_err(err.into());
862                        }
863                    }
864                }
865                Position::End => {
866                    if state.count > 5 {
867                        return Err(Error::TooManyParts(s.to_string()));
868                    }
869
870                    // Throw the first error for the current part
871                    //
872                    // TODO(zanieb): It's plausible another error variant is a better match but it
873                    // sounds hard to explain how? We could peek at the next item in the parts, and
874                    // see if that informs the type of this one, or we could use some sort of
875                    // similarity or common error matching, but this sounds harder.
876                    if let Some(err) = state.error {
877                        return Err(err);
878                    }
879                    state.next_part();
880                }
881            }
882        }
883
884        Ok(Self::new(version, implementation, arch, os, libc, None))
885    }
886}
887
888const BUILTIN_PYTHON_DOWNLOADS_JSON: &[u8] =
889    include_bytes!(concat!(env!("OUT_DIR"), "/download-metadata-minified.json"));
890
891pub struct ManagedPythonDownloadList {
892    downloads: Vec<ManagedPythonDownload>,
893}
894
895#[derive(Debug, Deserialize, Clone)]
896struct JsonPythonDownload {
897    name: String,
898    arch: JsonArch,
899    os: String,
900    libc: String,
901    major: u8,
902    minor: u8,
903    patch: u8,
904    prerelease: Option<String>,
905    url: String,
906    sha256: Option<String>,
907    variant: Option<String>,
908    build: Option<String>,
909}
910
911#[derive(Debug, Deserialize, Clone)]
912struct JsonArch {
913    family: String,
914    variant: Option<String>,
915}
916
917#[derive(Debug, Clone)]
918pub enum DownloadResult {
919    AlreadyAvailable(PathBuf),
920    Fetched(PathBuf),
921}
922
923/// A wrapper type to display a `ManagedPythonDownload` with its build information.
924pub struct ManagedPythonDownloadWithBuild<'a>(&'a ManagedPythonDownload);
925
926impl Display for ManagedPythonDownloadWithBuild<'_> {
927    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
928        if let Some(build) = self.0.build {
929            write!(f, "{}+{}", self.0.key, build)
930        } else {
931            write!(f, "{}", self.0.key)
932        }
933    }
934}
935
936impl ManagedPythonDownloadList {
937    /// Iterate over all [`ManagedPythonDownload`]s.
938    fn iter_all(&self) -> impl Iterator<Item = &ManagedPythonDownload> {
939        self.downloads.iter()
940    }
941
942    /// Iterate over all [`ManagedPythonDownload`]s that match the request.
943    pub fn iter_matching(
944        &self,
945        request: &PythonDownloadRequest,
946    ) -> impl Iterator<Item = &ManagedPythonDownload> {
947        self.iter_all()
948            .filter(move |download| request.satisfied_by_download(download))
949    }
950
951    /// Return the first [`ManagedPythonDownload`] matching a request, if any.
952    ///
953    /// If there is no stable version matching the request, a compatible pre-release version will
954    /// be searched for — even if a pre-release was not explicitly requested.
955    pub fn find(&self, request: &PythonDownloadRequest) -> Result<&ManagedPythonDownload, Error> {
956        if let Some(download) = self.iter_matching(request).next() {
957            return Ok(download);
958        }
959
960        if !request.allows_prereleases() {
961            if let Some(download) = self
962                .iter_matching(&request.clone().with_prereleases(true))
963                .next()
964            {
965                return Ok(download);
966            }
967        }
968
969        Err(Error::NoDownloadFound(request.clone()))
970    }
971
972    /// Load available Python distributions from a provided source or the compiled-in list.
973    ///
974    /// `python_downloads_json_url` can be either `None`, to use the default list (taken from
975    /// `crates/uv-python/download-metadata.json`), or `Some` local path
976    /// or file://, http://, or https:// URL.
977    ///
978    /// Returns an error if the provided list could not be opened, if the JSON is invalid, or if it
979    /// does not parse into the expected data structure.
980    pub async fn new(
981        client: &BaseClient,
982        python_downloads_json_url: Option<&str>,
983    ) -> Result<Self, Error> {
984        // Although read_url() handles file:// URLs and converts them to local file reads, here we
985        // want to also support parsing bare filenames like "/tmp/py.json", not just
986        // "file:///tmp/py.json". Note that "C:\Temp\py.json" should be considered a filename, even
987        // though Url::parse would successfully misparse it as a URL with scheme "C".
988        enum Source<'a> {
989            BuiltIn,
990            Path(Cow<'a, Path>),
991            Http(DisplaySafeUrl),
992        }
993
994        let json_source = if let Some(url_or_path) = python_downloads_json_url {
995            if let Ok(url) = DisplaySafeUrl::parse(url_or_path) {
996                match url.scheme() {
997                    "http" | "https" => Source::Http(url),
998                    "file" => Source::Path(Cow::Owned(
999                        url.to_file_path().or(Err(Error::InvalidUrlFormat(url)))?,
1000                    )),
1001                    _ => Source::Path(Cow::Borrowed(Path::new(url_or_path))),
1002                }
1003            } else {
1004                Source::Path(Cow::Borrowed(Path::new(url_or_path)))
1005            }
1006        } else {
1007            Source::BuiltIn
1008        };
1009
1010        let buf: Cow<'_, [u8]> = match json_source {
1011            Source::BuiltIn => BUILTIN_PYTHON_DOWNLOADS_JSON.into(),
1012            Source::Path(ref path) => fs_err::read(path.as_ref())?.into(),
1013            Source::Http(ref url) => fetch_bytes_from_url(client, url)
1014                .await
1015                .map_err(|e| Error::FetchingPythonDownloadsJSONError(url.to_string(), Box::new(e)))?
1016                .into(),
1017        };
1018        let json_downloads: HashMap<String, JsonPythonDownload> = serde_json::from_slice(&buf)
1019            .map_err(
1020                // As an explicit compatibility mechanism, if there's a top-level "version" key, it
1021                // means it's a newer format than we know how to deal with.  Before reporting a
1022                // parse error about the format of JsonPythonDownload, check for that key. We can do
1023                // this by parsing into a Map<String, IgnoredAny> which allows any valid JSON on the
1024                // value side. (Because it's zero-sized, Clippy suggests Set<String>, but that won't
1025                // have the same parsing effect.)
1026                #[allow(clippy::zero_sized_map_values)]
1027                |e| {
1028                    let source = match json_source {
1029                        Source::BuiltIn => "EMBEDDED IN THE BINARY".to_owned(),
1030                        Source::Path(path) => path.to_string_lossy().to_string(),
1031                        Source::Http(url) => url.to_string(),
1032                    };
1033                    if let Ok(keys) =
1034                        serde_json::from_slice::<HashMap<String, serde::de::IgnoredAny>>(&buf)
1035                        && keys.contains_key("version")
1036                    {
1037                        Error::UnsupportedPythonDownloadsJSON(source)
1038                    } else {
1039                        Error::InvalidPythonDownloadsJSON(source, e)
1040                    }
1041                },
1042            )?;
1043
1044        let result = parse_json_downloads(json_downloads);
1045        Ok(Self { downloads: result })
1046    }
1047
1048    /// Load available Python distributions from the compiled-in list only.
1049    /// for testing purposes.
1050    pub fn new_only_embedded() -> Result<Self, Error> {
1051        let json_downloads: HashMap<String, JsonPythonDownload> =
1052            serde_json::from_slice(BUILTIN_PYTHON_DOWNLOADS_JSON).map_err(|e| {
1053                Error::InvalidPythonDownloadsJSON("EMBEDDED IN THE BINARY".to_owned(), e)
1054            })?;
1055        let result = parse_json_downloads(json_downloads);
1056        Ok(Self { downloads: result })
1057    }
1058}
1059
1060async fn fetch_bytes_from_url(client: &BaseClient, url: &DisplaySafeUrl) -> Result<Vec<u8>, Error> {
1061    let (mut reader, size) = read_url(url, client).await?;
1062    let capacity = size.and_then(|s| s.try_into().ok()).unwrap_or(1_048_576);
1063    let mut buf = Vec::with_capacity(capacity);
1064    reader.read_to_end(&mut buf).await?;
1065    Ok(buf)
1066}
1067
1068impl ManagedPythonDownload {
1069    /// Return a display type that includes the build information.
1070    pub fn to_display_with_build(&self) -> ManagedPythonDownloadWithBuild<'_> {
1071        ManagedPythonDownloadWithBuild(self)
1072    }
1073
1074    pub fn url(&self) -> &Cow<'static, str> {
1075        &self.url
1076    }
1077
1078    pub fn key(&self) -> &PythonInstallationKey {
1079        &self.key
1080    }
1081
1082    pub fn os(&self) -> &Os {
1083        self.key.os()
1084    }
1085
1086    pub fn sha256(&self) -> Option<&Cow<'static, str>> {
1087        self.sha256.as_ref()
1088    }
1089
1090    pub fn build(&self) -> Option<&'static str> {
1091        self.build
1092    }
1093
1094    /// Download and extract a Python distribution, retrying on failure.
1095    #[instrument(skip(client, installation_dir, scratch_dir, reporter), fields(download = % self.key()))]
1096    pub async fn fetch_with_retry(
1097        &self,
1098        client: &BaseClient,
1099        retry_policy: &ExponentialBackoff,
1100        installation_dir: &Path,
1101        scratch_dir: &Path,
1102        reinstall: bool,
1103        python_install_mirror: Option<&str>,
1104        pypy_install_mirror: Option<&str>,
1105        reporter: Option<&dyn Reporter>,
1106    ) -> Result<DownloadResult, Error> {
1107        let mut retry_state = RetryState::start(
1108            *retry_policy,
1109            self.download_url(python_install_mirror, pypy_install_mirror)?,
1110        );
1111
1112        loop {
1113            let result = self
1114                .fetch(
1115                    client,
1116                    installation_dir,
1117                    scratch_dir,
1118                    reinstall,
1119                    python_install_mirror,
1120                    pypy_install_mirror,
1121                    reporter,
1122                )
1123                .await;
1124            match result {
1125                Ok(download_result) => return Ok(download_result),
1126                Err(err) => {
1127                    if let Some(backoff) = retry_state.should_retry(&err, err.retries()) {
1128                        retry_state.sleep_backoff(backoff).await;
1129                        continue;
1130                    }
1131                    return if retry_state.total_retries() > 0 {
1132                        Err(Error::NetworkErrorWithRetries {
1133                            err: Box::new(err),
1134                            retries: retry_state.total_retries(),
1135                        })
1136                    } else {
1137                        Err(err)
1138                    };
1139                }
1140            };
1141        }
1142    }
1143
1144    /// Download and extract a Python distribution.
1145    #[instrument(skip(client, installation_dir, scratch_dir, reporter), fields(download = % self.key()))]
1146    pub async fn fetch(
1147        &self,
1148        client: &BaseClient,
1149        installation_dir: &Path,
1150        scratch_dir: &Path,
1151        reinstall: bool,
1152        python_install_mirror: Option<&str>,
1153        pypy_install_mirror: Option<&str>,
1154        reporter: Option<&dyn Reporter>,
1155    ) -> Result<DownloadResult, Error> {
1156        let url = self.download_url(python_install_mirror, pypy_install_mirror)?;
1157        let path = installation_dir.join(self.key().to_string());
1158
1159        // If it is not a reinstall and the dir already exists, return it.
1160        if !reinstall && path.is_dir() {
1161            return Ok(DownloadResult::AlreadyAvailable(path));
1162        }
1163
1164        // We improve filesystem compatibility by using neither the URL-encoded `%2B` nor the `+` it
1165        // decodes to.
1166        let filename = url
1167            .path_segments()
1168            .ok_or_else(|| Error::InvalidUrlFormat(url.clone()))?
1169            .next_back()
1170            .ok_or_else(|| Error::InvalidUrlFormat(url.clone()))?
1171            .replace("%2B", "-");
1172        debug_assert!(
1173            filename
1174                .chars()
1175                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.'),
1176            "Unexpected char in filename: {filename}"
1177        );
1178        let ext = SourceDistExtension::from_path(&filename)
1179            .map_err(|err| Error::MissingExtension(url.to_string(), err))?;
1180
1181        let temp_dir = tempfile::tempdir_in(scratch_dir).map_err(Error::DownloadDirError)?;
1182
1183        if let Some(python_builds_dir) =
1184            env::var_os(EnvVars::UV_PYTHON_CACHE_DIR).filter(|s| !s.is_empty())
1185        {
1186            let python_builds_dir = PathBuf::from(python_builds_dir);
1187            fs_err::create_dir_all(&python_builds_dir)?;
1188            let hash_prefix = match self.sha256.as_deref() {
1189                Some(sha) => {
1190                    // Shorten the hash to avoid too-long-filename errors
1191                    &sha[..9]
1192                }
1193                None => "none",
1194            };
1195            let target_cache_file = python_builds_dir.join(format!("{hash_prefix}-{filename}"));
1196
1197            // Download the archive to the cache, or return a reader if we have it in cache.
1198            // TODO(konsti): We should "tee" the write so we can do the download-to-cache and unpacking
1199            // in one step.
1200            let (reader, size): (Box<dyn AsyncRead + Unpin>, Option<u64>) =
1201                match fs_err::tokio::File::open(&target_cache_file).await {
1202                    Ok(file) => {
1203                        debug!(
1204                            "Extracting existing `{}`",
1205                            target_cache_file.simplified_display()
1206                        );
1207                        let size = file.metadata().await?.len();
1208                        let reader = Box::new(tokio::io::BufReader::new(file));
1209                        (reader, Some(size))
1210                    }
1211                    Err(err) if err.kind() == io::ErrorKind::NotFound => {
1212                        // Point the user to which file is missing where and where to download it
1213                        if client.connectivity().is_offline() {
1214                            return Err(Error::OfflinePythonMissing {
1215                                file: Box::new(self.key().clone()),
1216                                url: Box::new(url),
1217                                python_builds_dir,
1218                            });
1219                        }
1220
1221                        self.download_archive(
1222                            &url,
1223                            client,
1224                            reporter,
1225                            &python_builds_dir,
1226                            &target_cache_file,
1227                        )
1228                        .await?;
1229
1230                        debug!("Extracting `{}`", target_cache_file.simplified_display());
1231                        let file = fs_err::tokio::File::open(&target_cache_file).await?;
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) => return Err(err.into()),
1237                };
1238
1239            // Extract the downloaded archive into a temporary directory.
1240            self.extract_reader(
1241                reader,
1242                temp_dir.path(),
1243                &filename,
1244                ext,
1245                size,
1246                reporter,
1247                Direction::Extract,
1248            )
1249            .await?;
1250        } else {
1251            // Avoid overlong log lines
1252            debug!("Downloading {url}");
1253            debug!(
1254                "Extracting {filename} to temporary location: {}",
1255                temp_dir.path().simplified_display()
1256            );
1257
1258            let (reader, size) = read_url(&url, client).await?;
1259            self.extract_reader(
1260                reader,
1261                temp_dir.path(),
1262                &filename,
1263                ext,
1264                size,
1265                reporter,
1266                Direction::Download,
1267            )
1268            .await?;
1269        }
1270
1271        // Extract the top-level directory.
1272        let mut extracted = match uv_extract::strip_component(temp_dir.path()) {
1273            Ok(top_level) => top_level,
1274            Err(uv_extract::Error::NonSingularArchive(_)) => temp_dir.keep(),
1275            Err(err) => return Err(Error::ExtractError(filename, err)),
1276        };
1277
1278        // If the distribution is a `full` archive, the Python installation is in the `install` directory.
1279        if extracted.join("install").is_dir() {
1280            extracted = extracted.join("install");
1281        // If the distribution is a Pyodide archive, the Python installation is in the `pyodide-root/dist` directory.
1282        } else if self.os().is_emscripten() {
1283            extracted = extracted.join("pyodide-root").join("dist");
1284        }
1285
1286        #[cfg(unix)]
1287        {
1288            // Pyodide distributions require all of the supporting files to be alongside the Python
1289            // executable, so they don't have a `bin` directory. We create it and link
1290            // `bin/pythonX.Y` to `dist/python`.
1291            if self.os().is_emscripten() {
1292                fs_err::create_dir_all(extracted.join("bin"))?;
1293                fs_err::os::unix::fs::symlink(
1294                    "../python",
1295                    extracted
1296                        .join("bin")
1297                        .join(format!("python{}.{}", self.key.major, self.key.minor)),
1298                )?;
1299            }
1300
1301            // If the distribution is missing a `python` -> `pythonX.Y` symlink, add it.
1302            //
1303            // Pyodide releases never contain this link by default.
1304            //
1305            // PEP 394 permits it, and python-build-standalone releases after `20240726` include it,
1306            // but releases prior to that date do not.
1307            match fs_err::os::unix::fs::symlink(
1308                format!("python{}.{}", self.key.major, self.key.minor),
1309                extracted.join("bin").join("python"),
1310            ) {
1311                Ok(()) => {}
1312                Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
1313                Err(err) => return Err(err.into()),
1314            }
1315        }
1316
1317        // Remove the target if it already exists.
1318        if path.is_dir() {
1319            debug!("Removing existing directory: {}", path.user_display());
1320            fs_err::tokio::remove_dir_all(&path).await?;
1321        }
1322
1323        // Persist it to the target.
1324        debug!("Moving {} to {}", extracted.display(), path.user_display());
1325        rename_with_retry(extracted, &path)
1326            .await
1327            .map_err(|err| Error::CopyError {
1328                to: path.clone(),
1329                err,
1330            })?;
1331
1332        Ok(DownloadResult::Fetched(path))
1333    }
1334
1335    /// Download the managed Python archive into the cache directory.
1336    async fn download_archive(
1337        &self,
1338        url: &DisplaySafeUrl,
1339        client: &BaseClient,
1340        reporter: Option<&dyn Reporter>,
1341        python_builds_dir: &Path,
1342        target_cache_file: &Path,
1343    ) -> Result<(), Error> {
1344        debug!(
1345            "Downloading {} to `{}`",
1346            url,
1347            target_cache_file.simplified_display()
1348        );
1349
1350        let (mut reader, size) = read_url(url, client).await?;
1351        let temp_dir = tempfile::tempdir_in(python_builds_dir)?;
1352        let temp_file = temp_dir.path().join("download");
1353
1354        // Download to a temporary file. We verify the hash when unpacking the file.
1355        {
1356            let mut archive_writer = BufWriter::new(fs_err::tokio::File::create(&temp_file).await?);
1357
1358            // Download with or without progress bar.
1359            if let Some(reporter) = reporter {
1360                let key = reporter.on_request_start(Direction::Download, &self.key, size);
1361                tokio::io::copy(
1362                    &mut ProgressReader::new(reader, key, reporter),
1363                    &mut archive_writer,
1364                )
1365                .await?;
1366                reporter.on_request_complete(Direction::Download, key);
1367            } else {
1368                tokio::io::copy(&mut reader, &mut archive_writer).await?;
1369            }
1370
1371            archive_writer.flush().await?;
1372        }
1373        // Move the completed file into place, invalidating the `File` instance.
1374        match rename_with_retry(&temp_file, target_cache_file).await {
1375            Ok(()) => {}
1376            Err(_) if target_cache_file.is_file() => {}
1377            Err(err) => return Err(err.into()),
1378        }
1379        Ok(())
1380    }
1381
1382    /// Extract a Python interpreter archive into a (temporary) directory, either from a file or
1383    /// from a download stream.
1384    async fn extract_reader(
1385        &self,
1386        reader: impl AsyncRead + Unpin,
1387        target: &Path,
1388        filename: &String,
1389        ext: SourceDistExtension,
1390        size: Option<u64>,
1391        reporter: Option<&dyn Reporter>,
1392        direction: Direction,
1393    ) -> Result<(), Error> {
1394        let mut hashers = if self.sha256.is_some() {
1395            vec![Hasher::from(HashAlgorithm::Sha256)]
1396        } else {
1397            vec![]
1398        };
1399        let mut hasher = uv_extract::hash::HashReader::new(reader, &mut hashers);
1400
1401        if let Some(reporter) = reporter {
1402            let progress_key = reporter.on_request_start(direction, &self.key, size);
1403            let mut reader = ProgressReader::new(&mut hasher, progress_key, reporter);
1404            uv_extract::stream::archive(&mut reader, ext, target)
1405                .await
1406                .map_err(|err| Error::ExtractError(filename.to_owned(), err))?;
1407            reporter.on_request_complete(direction, progress_key);
1408        } else {
1409            uv_extract::stream::archive(&mut hasher, ext, target)
1410                .await
1411                .map_err(|err| Error::ExtractError(filename.to_owned(), err))?;
1412        }
1413        hasher.finish().await.map_err(Error::HashExhaustion)?;
1414
1415        // Check the hash
1416        if let Some(expected) = self.sha256.as_deref() {
1417            let actual = HashDigest::from(hashers.pop().unwrap()).digest;
1418            if !actual.eq_ignore_ascii_case(expected) {
1419                return Err(Error::HashMismatch {
1420                    installation: self.key.to_string(),
1421                    expected: expected.to_string(),
1422                    actual: actual.to_string(),
1423                });
1424            }
1425        }
1426
1427        Ok(())
1428    }
1429
1430    pub fn python_version(&self) -> PythonVersion {
1431        self.key.version()
1432    }
1433
1434    /// Return the [`Url`] to use when downloading the distribution. If a mirror is set via the
1435    /// appropriate environment variable, use it instead.
1436    pub fn download_url(
1437        &self,
1438        python_install_mirror: Option<&str>,
1439        pypy_install_mirror: Option<&str>,
1440    ) -> Result<DisplaySafeUrl, Error> {
1441        match self.key.implementation {
1442            LenientImplementationName::Known(ImplementationName::CPython) => {
1443                if let Some(mirror) = python_install_mirror {
1444                    let Some(suffix) = self.url.strip_prefix(
1445                        "https://github.com/astral-sh/python-build-standalone/releases/download/",
1446                    ) else {
1447                        return Err(Error::Mirror(
1448                            EnvVars::UV_PYTHON_INSTALL_MIRROR,
1449                            self.url.to_string(),
1450                        ));
1451                    };
1452                    return Ok(DisplaySafeUrl::parse(
1453                        format!("{}/{}", mirror.trim_end_matches('/'), suffix).as_str(),
1454                    )?);
1455                }
1456            }
1457
1458            LenientImplementationName::Known(ImplementationName::PyPy) => {
1459                if let Some(mirror) = pypy_install_mirror {
1460                    let Some(suffix) = self.url.strip_prefix("https://downloads.python.org/pypy/")
1461                    else {
1462                        return Err(Error::Mirror(
1463                            EnvVars::UV_PYPY_INSTALL_MIRROR,
1464                            self.url.to_string(),
1465                        ));
1466                    };
1467                    return Ok(DisplaySafeUrl::parse(
1468                        format!("{}/{}", mirror.trim_end_matches('/'), suffix).as_str(),
1469                    )?);
1470                }
1471            }
1472
1473            _ => {}
1474        }
1475
1476        Ok(DisplaySafeUrl::parse(&self.url)?)
1477    }
1478}
1479
1480fn parse_json_downloads(
1481    json_downloads: HashMap<String, JsonPythonDownload>,
1482) -> Vec<ManagedPythonDownload> {
1483    json_downloads
1484        .into_iter()
1485        .filter_map(|(key, entry)| {
1486            let implementation = match entry.name.as_str() {
1487                "cpython" => LenientImplementationName::Known(ImplementationName::CPython),
1488                "pypy" => LenientImplementationName::Known(ImplementationName::PyPy),
1489                "graalpy" => LenientImplementationName::Known(ImplementationName::GraalPy),
1490                _ => LenientImplementationName::Unknown(entry.name.clone()),
1491            };
1492
1493            let arch_str = match entry.arch.family.as_str() {
1494                "armv5tel" => "armv5te".to_string(),
1495                // The `gc` variant of riscv64 is the common base instruction set and
1496                // is the target in `python-build-standalone`
1497                // See https://github.com/astral-sh/python-build-standalone/issues/504
1498                "riscv64" => "riscv64gc".to_string(),
1499                value => value.to_string(),
1500            };
1501
1502            let arch_str = if let Some(variant) = entry.arch.variant {
1503                format!("{arch_str}_{variant}")
1504            } else {
1505                arch_str
1506            };
1507
1508            let arch = match Arch::from_str(&arch_str) {
1509                Ok(arch) => arch,
1510                Err(e) => {
1511                    debug!("Skipping entry {key}: Invalid arch '{arch_str}' - {e}");
1512                    return None;
1513                }
1514            };
1515
1516            let os = match Os::from_str(&entry.os) {
1517                Ok(os) => os,
1518                Err(e) => {
1519                    debug!("Skipping entry {}: Invalid OS '{}' - {}", key, entry.os, e);
1520                    return None;
1521                }
1522            };
1523
1524            let libc = match Libc::from_str(&entry.libc) {
1525                Ok(libc) => libc,
1526                Err(e) => {
1527                    debug!(
1528                        "Skipping entry {}: Invalid libc '{}' - {}",
1529                        key, entry.libc, e
1530                    );
1531                    return None;
1532                }
1533            };
1534
1535            let variant = match entry
1536                .variant
1537                .as_deref()
1538                .map(PythonVariant::from_str)
1539                .transpose()
1540            {
1541                Ok(Some(variant)) => variant,
1542                Ok(None) => PythonVariant::default(),
1543                Err(()) => {
1544                    debug!(
1545                        "Skipping entry {key}: Unknown python variant - {}",
1546                        entry.variant.unwrap_or_default()
1547                    );
1548                    return None;
1549                }
1550            };
1551
1552            let version_str = format!(
1553                "{}.{}.{}{}",
1554                entry.major,
1555                entry.minor,
1556                entry.patch,
1557                entry.prerelease.as_deref().unwrap_or_default()
1558            );
1559
1560            let version = match PythonVersion::from_str(&version_str) {
1561                Ok(version) => version,
1562                Err(e) => {
1563                    debug!("Skipping entry {key}: Invalid version '{version_str}' - {e}");
1564                    return None;
1565                }
1566            };
1567
1568            let url = Cow::Owned(entry.url);
1569            let sha256 = entry.sha256.map(Cow::Owned);
1570            let build = entry
1571                .build
1572                .map(|s| Box::leak(s.into_boxed_str()) as &'static str);
1573
1574            Some(ManagedPythonDownload {
1575                key: PythonInstallationKey::new_from_version(
1576                    implementation,
1577                    &version,
1578                    Platform::new(os, arch, libc),
1579                    variant,
1580                ),
1581                url,
1582                sha256,
1583                build,
1584            })
1585        })
1586        .sorted_by(|a, b| Ord::cmp(&b.key, &a.key))
1587        .collect()
1588}
1589
1590impl Error {
1591    pub(crate) fn from_reqwest(
1592        url: DisplaySafeUrl,
1593        err: reqwest::Error,
1594        retries: Option<u32>,
1595    ) -> Self {
1596        let err = Self::NetworkError(url, WrappedReqwestError::from(err));
1597        if let Some(retries) = retries {
1598            Self::NetworkErrorWithRetries {
1599                err: Box::new(err),
1600                retries,
1601            }
1602        } else {
1603            err
1604        }
1605    }
1606
1607    pub(crate) fn from_reqwest_middleware(
1608        url: DisplaySafeUrl,
1609        err: reqwest_middleware::Error,
1610    ) -> Self {
1611        match err {
1612            reqwest_middleware::Error::Middleware(error) => {
1613                Self::NetworkMiddlewareError(url, error)
1614            }
1615            reqwest_middleware::Error::Reqwest(error) => {
1616                Self::NetworkError(url, WrappedReqwestError::from(error))
1617            }
1618        }
1619    }
1620}
1621
1622impl Display for ManagedPythonDownload {
1623    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1624        write!(f, "{}", self.key)
1625    }
1626}
1627
1628#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1629pub enum Direction {
1630    Download,
1631    Extract,
1632}
1633
1634impl Direction {
1635    fn as_str(&self) -> &str {
1636        match self {
1637            Self::Download => "download",
1638            Self::Extract => "extract",
1639        }
1640    }
1641}
1642
1643impl Display for Direction {
1644    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1645        f.write_str(self.as_str())
1646    }
1647}
1648
1649pub trait Reporter: Send + Sync {
1650    fn on_request_start(
1651        &self,
1652        direction: Direction,
1653        name: &PythonInstallationKey,
1654        size: Option<u64>,
1655    ) -> usize;
1656    fn on_request_progress(&self, id: usize, inc: u64);
1657    fn on_request_complete(&self, direction: Direction, id: usize);
1658}
1659
1660/// An asynchronous reader that reports progress as bytes are read.
1661struct ProgressReader<'a, R> {
1662    reader: R,
1663    index: usize,
1664    reporter: &'a dyn Reporter,
1665}
1666
1667impl<'a, R> ProgressReader<'a, R> {
1668    /// Create a new [`ProgressReader`] that wraps another reader.
1669    fn new(reader: R, index: usize, reporter: &'a dyn Reporter) -> Self {
1670        Self {
1671            reader,
1672            index,
1673            reporter,
1674        }
1675    }
1676}
1677
1678impl<R> AsyncRead for ProgressReader<'_, R>
1679where
1680    R: AsyncRead + Unpin,
1681{
1682    fn poll_read(
1683        mut self: Pin<&mut Self>,
1684        cx: &mut Context<'_>,
1685        buf: &mut ReadBuf<'_>,
1686    ) -> Poll<io::Result<()>> {
1687        Pin::new(&mut self.as_mut().reader)
1688            .poll_read(cx, buf)
1689            .map_ok(|()| {
1690                self.reporter
1691                    .on_request_progress(self.index, buf.filled().len() as u64);
1692            })
1693    }
1694}
1695
1696/// Convert a [`Url`] into an [`AsyncRead`] stream.
1697async fn read_url(
1698    url: &DisplaySafeUrl,
1699    client: &BaseClient,
1700) -> Result<(impl AsyncRead + Unpin, Option<u64>), Error> {
1701    if url.scheme() == "file" {
1702        // Loads downloaded distribution from the given `file://` URL.
1703        let path = url
1704            .to_file_path()
1705            .map_err(|()| Error::InvalidFileUrl(url.to_string()))?;
1706
1707        let size = fs_err::tokio::metadata(&path).await?.len();
1708        let reader = fs_err::tokio::File::open(&path).await?;
1709
1710        Ok((Either::Left(reader), Some(size)))
1711    } else {
1712        let response = client
1713            .for_host(url)
1714            .get(Url::from(url.clone()))
1715            .send()
1716            .await
1717            .map_err(|err| Error::from_reqwest_middleware(url.clone(), err))?;
1718
1719        let retry_count = response
1720            .extensions()
1721            .get::<reqwest_retry::RetryCount>()
1722            .map(|retries| retries.value());
1723
1724        // Check the status code.
1725        let response = response
1726            .error_for_status()
1727            .map_err(|err| Error::from_reqwest(url.clone(), err, retry_count))?;
1728
1729        let size = response.content_length();
1730        let stream = response
1731            .bytes_stream()
1732            .map_err(io::Error::other)
1733            .into_async_read();
1734
1735        Ok((Either::Right(stream.compat()), size))
1736    }
1737}
1738
1739#[cfg(test)]
1740mod tests {
1741    use crate::PythonVariant;
1742    use crate::implementation::LenientImplementationName;
1743    use crate::installation::PythonInstallationKey;
1744    use uv_platform::{Arch, Libc, Os, Platform};
1745
1746    use super::*;
1747
1748    /// Parse a request with all of its fields.
1749    #[test]
1750    fn test_python_download_request_from_str_complete() {
1751        let request = PythonDownloadRequest::from_str("cpython-3.12.0-linux-x86_64-gnu")
1752            .expect("Test request should be parsed");
1753
1754        assert_eq!(request.implementation, Some(ImplementationName::CPython));
1755        assert_eq!(
1756            request.version,
1757            Some(VersionRequest::from_str("3.12.0").unwrap())
1758        );
1759        assert_eq!(
1760            request.os,
1761            Some(Os::new(target_lexicon::OperatingSystem::Linux))
1762        );
1763        assert_eq!(
1764            request.arch,
1765            Some(ArchRequest::Explicit(Arch::new(
1766                target_lexicon::Architecture::X86_64,
1767                None
1768            )))
1769        );
1770        assert_eq!(
1771            request.libc,
1772            Some(Libc::Some(target_lexicon::Environment::Gnu))
1773        );
1774    }
1775
1776    /// Parse a request with `any` in various positions.
1777    #[test]
1778    fn test_python_download_request_from_str_with_any() {
1779        let request = PythonDownloadRequest::from_str("any-3.11-any-x86_64-any")
1780            .expect("Test request should be parsed");
1781
1782        assert_eq!(request.implementation, None);
1783        assert_eq!(
1784            request.version,
1785            Some(VersionRequest::from_str("3.11").unwrap())
1786        );
1787        assert_eq!(request.os, None);
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!(request.libc, None);
1796    }
1797
1798    /// Parse a request with `any` implied by the omission of segments.
1799    #[test]
1800    fn test_python_download_request_from_str_missing_segment() {
1801        let request =
1802            PythonDownloadRequest::from_str("pypy-linux").expect("Test request should be parsed");
1803
1804        assert_eq!(request.implementation, Some(ImplementationName::PyPy));
1805        assert_eq!(request.version, None);
1806        assert_eq!(
1807            request.os,
1808            Some(Os::new(target_lexicon::OperatingSystem::Linux))
1809        );
1810        assert_eq!(request.arch, None);
1811        assert_eq!(request.libc, None);
1812    }
1813
1814    #[test]
1815    fn test_python_download_request_from_str_version_only() {
1816        let request =
1817            PythonDownloadRequest::from_str("3.10.5").expect("Test request should be parsed");
1818
1819        assert_eq!(request.implementation, None);
1820        assert_eq!(
1821            request.version,
1822            Some(VersionRequest::from_str("3.10.5").unwrap())
1823        );
1824        assert_eq!(request.os, None);
1825        assert_eq!(request.arch, None);
1826        assert_eq!(request.libc, None);
1827    }
1828
1829    #[test]
1830    fn test_python_download_request_from_str_implementation_only() {
1831        let request =
1832            PythonDownloadRequest::from_str("cpython").expect("Test request should be parsed");
1833
1834        assert_eq!(request.implementation, Some(ImplementationName::CPython));
1835        assert_eq!(request.version, None);
1836        assert_eq!(request.os, None);
1837        assert_eq!(request.arch, None);
1838        assert_eq!(request.libc, None);
1839    }
1840
1841    /// Parse a request with the OS and architecture specified.
1842    #[test]
1843    fn test_python_download_request_from_str_os_arch() {
1844        let request = PythonDownloadRequest::from_str("windows-x86_64")
1845            .expect("Test request should be parsed");
1846
1847        assert_eq!(request.implementation, None);
1848        assert_eq!(request.version, None);
1849        assert_eq!(
1850            request.os,
1851            Some(Os::new(target_lexicon::OperatingSystem::Windows))
1852        );
1853        assert_eq!(
1854            request.arch,
1855            Some(ArchRequest::Explicit(Arch::new(
1856                target_lexicon::Architecture::X86_64,
1857                None
1858            )))
1859        );
1860        assert_eq!(request.libc, None);
1861    }
1862
1863    /// Parse a request with a pre-release version.
1864    #[test]
1865    fn test_python_download_request_from_str_prerelease() {
1866        let request = PythonDownloadRequest::from_str("cpython-3.13.0rc1")
1867            .expect("Test request should be parsed");
1868
1869        assert_eq!(request.implementation, Some(ImplementationName::CPython));
1870        assert_eq!(
1871            request.version,
1872            Some(VersionRequest::from_str("3.13.0rc1").unwrap())
1873        );
1874        assert_eq!(request.os, None);
1875        assert_eq!(request.arch, None);
1876        assert_eq!(request.libc, None);
1877    }
1878
1879    /// We fail on extra parts in the request.
1880    #[test]
1881    fn test_python_download_request_from_str_too_many_parts() {
1882        let result = PythonDownloadRequest::from_str("cpython-3.12-linux-x86_64-gnu-extra");
1883
1884        assert!(matches!(result, Err(Error::TooManyParts(_))));
1885    }
1886
1887    /// We don't allow an empty request.
1888    #[test]
1889    fn test_python_download_request_from_str_empty() {
1890        let result = PythonDownloadRequest::from_str("");
1891
1892        assert!(matches!(result, Err(Error::EmptyRequest)), "{result:?}");
1893    }
1894
1895    /// Parse a request with all "any" segments.
1896    #[test]
1897    fn test_python_download_request_from_str_all_any() {
1898        let request = PythonDownloadRequest::from_str("any-any-any-any-any")
1899            .expect("Test request should be parsed");
1900
1901        assert_eq!(request.implementation, None);
1902        assert_eq!(request.version, None);
1903        assert_eq!(request.os, None);
1904        assert_eq!(request.arch, None);
1905        assert_eq!(request.libc, None);
1906    }
1907
1908    /// Test that "any" is case-insensitive in various positions.
1909    #[test]
1910    fn test_python_download_request_from_str_case_insensitive_any() {
1911        let request = PythonDownloadRequest::from_str("ANY-3.11-Any-x86_64-aNy")
1912            .expect("Test request should be parsed");
1913
1914        assert_eq!(request.implementation, None);
1915        assert_eq!(
1916            request.version,
1917            Some(VersionRequest::from_str("3.11").unwrap())
1918        );
1919        assert_eq!(request.os, None);
1920        assert_eq!(
1921            request.arch,
1922            Some(ArchRequest::Explicit(Arch::new(
1923                target_lexicon::Architecture::X86_64,
1924                None
1925            )))
1926        );
1927        assert_eq!(request.libc, None);
1928    }
1929
1930    /// Parse a request with an invalid leading segment.
1931    #[test]
1932    fn test_python_download_request_from_str_invalid_leading_segment() {
1933        let result = PythonDownloadRequest::from_str("foobar-3.14-windows");
1934
1935        assert!(
1936            matches!(result, Err(Error::ImplementationError(_))),
1937            "{result:?}"
1938        );
1939    }
1940
1941    /// Parse a request with segments in an invalid order.
1942    #[test]
1943    fn test_python_download_request_from_str_out_of_order() {
1944        let result = PythonDownloadRequest::from_str("3.12-cpython");
1945
1946        assert!(
1947            matches!(result, Err(Error::InvalidRequestPlatform(_))),
1948            "{result:?}"
1949        );
1950    }
1951
1952    /// Parse a request with too many "any" segments.
1953    #[test]
1954    fn test_python_download_request_from_str_too_many_any() {
1955        let result = PythonDownloadRequest::from_str("any-any-any-any-any-any");
1956
1957        assert!(matches!(result, Err(Error::TooManyParts(_))));
1958    }
1959
1960    /// Test that build filtering works correctly
1961    #[tokio::test]
1962    async fn test_python_download_request_build_filtering() {
1963        let request = PythonDownloadRequest::default()
1964            .with_version(VersionRequest::from_str("3.12").unwrap())
1965            .with_implementation(ImplementationName::CPython)
1966            .with_build("20240814".to_string());
1967
1968        let client = uv_client::BaseClientBuilder::default().build();
1969        let download_list = ManagedPythonDownloadList::new(&client, None).await.unwrap();
1970
1971        let downloads: Vec<_> = download_list
1972            .iter_all()
1973            .filter(|d| request.satisfied_by_download(d))
1974            .collect();
1975
1976        assert!(
1977            !downloads.is_empty(),
1978            "Should find at least one matching download"
1979        );
1980        for download in downloads {
1981            assert_eq!(download.build(), Some("20240814"));
1982        }
1983    }
1984
1985    /// Test that an invalid build results in no matches
1986    #[tokio::test]
1987    async fn test_python_download_request_invalid_build() {
1988        // Create a request with a non-existent build
1989        let request = PythonDownloadRequest::default()
1990            .with_version(VersionRequest::from_str("3.12").unwrap())
1991            .with_implementation(ImplementationName::CPython)
1992            .with_build("99999999".to_string());
1993
1994        let client = uv_client::BaseClientBuilder::default().build();
1995        let download_list = ManagedPythonDownloadList::new(&client, None).await.unwrap();
1996
1997        // Should find no matching downloads
1998        let downloads: Vec<_> = download_list
1999            .iter_all()
2000            .filter(|d| request.satisfied_by_download(d))
2001            .collect();
2002
2003        assert_eq!(downloads.len(), 0);
2004    }
2005
2006    #[test]
2007    fn upgrade_request_native_defaults() {
2008        let request = PythonDownloadRequest::default()
2009            .with_implementation(ImplementationName::CPython)
2010            .with_version(VersionRequest::MajorMinorPatch(
2011                3,
2012                13,
2013                1,
2014                PythonVariant::Default,
2015            ))
2016            .with_os(Os::from_str("linux").unwrap())
2017            .with_arch(Arch::from_str("x86_64").unwrap())
2018            .with_libc(Libc::from_str("gnu").unwrap())
2019            .with_prereleases(false);
2020
2021        let host = Platform::new(
2022            Os::from_str("linux").unwrap(),
2023            Arch::from_str("x86_64").unwrap(),
2024            Libc::from_str("gnu").unwrap(),
2025        );
2026
2027        assert_eq!(
2028            request
2029                .clone()
2030                .unset_defaults_for_host(&host)
2031                .without_patch()
2032                .simplified_display()
2033                .as_deref(),
2034            Some("3.13")
2035        );
2036    }
2037
2038    #[test]
2039    fn upgrade_request_preserves_variant() {
2040        let request = PythonDownloadRequest::default()
2041            .with_implementation(ImplementationName::CPython)
2042            .with_version(VersionRequest::MajorMinorPatch(
2043                3,
2044                13,
2045                0,
2046                PythonVariant::Freethreaded,
2047            ))
2048            .with_os(Os::from_str("linux").unwrap())
2049            .with_arch(Arch::from_str("x86_64").unwrap())
2050            .with_libc(Libc::from_str("gnu").unwrap())
2051            .with_prereleases(false);
2052
2053        let host = Platform::new(
2054            Os::from_str("linux").unwrap(),
2055            Arch::from_str("x86_64").unwrap(),
2056            Libc::from_str("gnu").unwrap(),
2057        );
2058
2059        assert_eq!(
2060            request
2061                .clone()
2062                .unset_defaults_for_host(&host)
2063                .without_patch()
2064                .simplified_display()
2065                .as_deref(),
2066            Some("3.13+freethreaded")
2067        );
2068    }
2069
2070    #[test]
2071    fn upgrade_request_preserves_non_default_platform() {
2072        let request = PythonDownloadRequest::default()
2073            .with_implementation(ImplementationName::CPython)
2074            .with_version(VersionRequest::MajorMinorPatch(
2075                3,
2076                12,
2077                4,
2078                PythonVariant::Default,
2079            ))
2080            .with_os(Os::from_str("linux").unwrap())
2081            .with_arch(Arch::from_str("aarch64").unwrap())
2082            .with_libc(Libc::from_str("gnu").unwrap())
2083            .with_prereleases(false);
2084
2085        let host = Platform::new(
2086            Os::from_str("linux").unwrap(),
2087            Arch::from_str("x86_64").unwrap(),
2088            Libc::from_str("gnu").unwrap(),
2089        );
2090
2091        assert_eq!(
2092            request
2093                .clone()
2094                .unset_defaults_for_host(&host)
2095                .without_patch()
2096                .simplified_display()
2097                .as_deref(),
2098            Some("3.12-aarch64")
2099        );
2100    }
2101
2102    #[test]
2103    fn upgrade_request_preserves_custom_implementation() {
2104        let request = PythonDownloadRequest::default()
2105            .with_implementation(ImplementationName::PyPy)
2106            .with_version(VersionRequest::MajorMinorPatch(
2107                3,
2108                10,
2109                5,
2110                PythonVariant::Default,
2111            ))
2112            .with_os(Os::from_str("linux").unwrap())
2113            .with_arch(Arch::from_str("x86_64").unwrap())
2114            .with_libc(Libc::from_str("gnu").unwrap())
2115            .with_prereleases(false);
2116
2117        let host = Platform::new(
2118            Os::from_str("linux").unwrap(),
2119            Arch::from_str("x86_64").unwrap(),
2120            Libc::from_str("gnu").unwrap(),
2121        );
2122
2123        assert_eq!(
2124            request
2125                .clone()
2126                .unset_defaults_for_host(&host)
2127                .without_patch()
2128                .simplified_display()
2129                .as_deref(),
2130            Some("pypy-3.10")
2131        );
2132    }
2133
2134    #[test]
2135    fn simplified_display_returns_none_when_empty() {
2136        let request = PythonDownloadRequest::default()
2137            .fill_platform()
2138            .expect("should populate defaults");
2139
2140        let host = Platform::from_env().expect("host platform");
2141
2142        assert_eq!(
2143            request.unset_defaults_for_host(&host).simplified_display(),
2144            None
2145        );
2146    }
2147
2148    #[test]
2149    fn simplified_display_omits_environment_arch() {
2150        let mut request = PythonDownloadRequest::default()
2151            .with_version(VersionRequest::MajorMinor(3, 12, PythonVariant::Default))
2152            .with_os(Os::from_str("linux").unwrap())
2153            .with_libc(Libc::from_str("gnu").unwrap());
2154
2155        request.arch = Some(ArchRequest::Environment(Arch::from_str("x86_64").unwrap()));
2156
2157        let host = Platform::new(
2158            Os::from_str("linux").unwrap(),
2159            Arch::from_str("aarch64").unwrap(),
2160            Libc::from_str("gnu").unwrap(),
2161        );
2162
2163        assert_eq!(
2164            request
2165                .unset_defaults_for_host(&host)
2166                .simplified_display()
2167                .as_deref(),
2168            Some("3.12")
2169        );
2170    }
2171
2172    /// Test build display
2173    #[test]
2174    fn test_managed_python_download_build_display() {
2175        // Create a test download with a build
2176        let key = PythonInstallationKey::new(
2177            LenientImplementationName::Known(crate::implementation::ImplementationName::CPython),
2178            3,
2179            12,
2180            0,
2181            None,
2182            Platform::new(
2183                Os::from_str("linux").unwrap(),
2184                Arch::from_str("x86_64").unwrap(),
2185                Libc::from_str("gnu").unwrap(),
2186            ),
2187            crate::PythonVariant::default(),
2188        );
2189
2190        let download_with_build = ManagedPythonDownload {
2191            key,
2192            url: Cow::Borrowed("https://example.com/python.tar.gz"),
2193            sha256: Some(Cow::Borrowed("abc123")),
2194            build: Some("20240101"),
2195        };
2196
2197        // Test display with build
2198        assert_eq!(
2199            download_with_build.to_display_with_build().to_string(),
2200            "cpython-3.12.0-linux-x86_64-gnu+20240101"
2201        );
2202
2203        // Test download without build
2204        let download_without_build = ManagedPythonDownload {
2205            key: download_with_build.key.clone(),
2206            url: Cow::Borrowed("https://example.com/python.tar.gz"),
2207            sha256: Some(Cow::Borrowed("abc123")),
2208            build: None,
2209        };
2210
2211        // Test display without build
2212        assert_eq!(
2213            download_without_build.to_display_with_build().to_string(),
2214            "cpython-3.12.0-linux-x86_64-gnu"
2215        );
2216    }
2217}