Skip to main content

uv_distribution/
distribution_database.rs

1use std::future::Future;
2use std::io;
3use std::path::Path;
4use std::pin::Pin;
5use std::sync::Arc;
6use std::task::{Context, Poll};
7
8use futures::{FutureExt, TryStreamExt};
9use tokio::io::{AsyncRead, AsyncSeekExt, ReadBuf};
10use tokio::sync::Semaphore;
11use tokio_util::compat::FuturesAsyncReadCompatExt;
12use tracing::{Instrument, info_span, instrument, warn};
13use url::Url;
14
15use uv_cache::{ArchiveId, CacheBucket, CacheEntry, WheelCache};
16use uv_cache_info::{CacheInfo, Timestamp};
17use uv_client::{
18    CacheControl, CachedClientError, Connectivity, DataWithCachePolicy, RegistryClient,
19};
20use uv_distribution_filename::WheelFilename;
21use uv_distribution_types::{
22    BuildInfo, BuildableSource, BuiltDist, Dist, DistRef, File, HashPolicy, Hashed, IndexUrl,
23    InstalledDist, Name, SourceDist, ToUrlError,
24};
25use uv_extract::hash::Hasher;
26use uv_fs::write_atomic;
27use uv_git::{GIT_LFS, GitError};
28use uv_install_wheel::validate_and_heal_record;
29use uv_platform_tags::Tags;
30use uv_pypi_types::{HashDigest, HashDigests, PyProjectToml};
31use uv_python::PythonVariant;
32use uv_redacted::DisplaySafeUrl;
33use uv_types::{BuildContext, BuildStack};
34
35use crate::archive::Archive;
36use crate::error::PythonVersion;
37use crate::hash::http_hash_algorithms;
38use crate::metadata::{ArchiveMetadata, Metadata};
39use crate::source::SourceDistributionBuilder;
40use crate::{Error, LocalWheel, Reporter, RequiresDist};
41
42/// A cached high-level interface to convert distributions (a requirement resolved to a location)
43/// to a wheel or wheel metadata.
44///
45/// For wheel metadata, this happens by either fetching the metadata from the remote wheel or by
46/// building the source distribution. For wheel files, either the wheel is downloaded or a source
47/// distribution is downloaded, built and the new wheel gets returned.
48///
49/// All kinds of wheel sources (index, URL, path) and source distribution source (index, URL, path,
50/// Git) are supported.
51///
52/// This struct also has the task of acquiring locks around source dist builds in general and git
53/// operation especially, as well as respecting concurrency limits.
54pub struct DistributionDatabase<'a, Context: BuildContext> {
55    build_context: &'a Context,
56    builder: SourceDistributionBuilder<'a, Context>,
57    client: ManagedClient<'a>,
58    reporter: Option<Arc<dyn Reporter>>,
59}
60
61impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> {
62    pub fn new(
63        client: &'a RegistryClient,
64        build_context: &'a Context,
65        downloads_semaphore: Arc<Semaphore>,
66    ) -> Self {
67        Self {
68            build_context,
69            builder: SourceDistributionBuilder::new(build_context),
70            client: ManagedClient::new(client, downloads_semaphore),
71            reporter: None,
72        }
73    }
74
75    /// Set the build stack to use for the [`DistributionDatabase`].
76    #[must_use]
77    pub fn with_build_stack(self, build_stack: &'a BuildStack) -> Self {
78        Self {
79            builder: self.builder.with_build_stack(build_stack),
80            ..self
81        }
82    }
83
84    /// Set the [`Reporter`] to use for the [`DistributionDatabase`].
85    #[must_use]
86    pub fn with_reporter(self, reporter: Arc<dyn Reporter>) -> Self {
87        Self {
88            builder: self.builder.with_reporter(reporter.clone()),
89            reporter: Some(reporter),
90            ..self
91        }
92    }
93
94    /// Handle a specific `reqwest` error, and convert it to [`io::Error`].
95    fn handle_response_errors(&self, err: reqwest::Error) -> io::Error {
96        if err.is_timeout() {
97            // Assumption: The connect timeout with the 10s default is not the culprit.
98            io::Error::new(
99                io::ErrorKind::TimedOut,
100                format!(
101                    "Failed to download distribution due to network timeout. Try increasing UV_HTTP_TIMEOUT (current value: {}s).",
102                    self.client.unmanaged.read_timeout().as_secs()
103                ),
104            )
105        } else {
106            io::Error::other(err)
107        }
108    }
109
110    /// Either fetch the wheel or fetch and build the source distribution
111    ///
112    /// Returns a wheel that's compliant with the given platform tags.
113    ///
114    /// While hashes will be generated in some cases, hash-checking is only enforced for source
115    /// distributions, and should be enforced by the caller for wheels.
116    #[instrument(skip_all, fields(%dist))]
117    pub async fn get_or_build_wheel(
118        &self,
119        dist: &Dist,
120        tags: &Tags,
121        hashes: HashPolicy<'_>,
122    ) -> Result<LocalWheel, Error> {
123        match dist {
124            Dist::Built(built) => self.get_wheel(built, hashes).await,
125            Dist::Source(source) => self.build_wheel(source, tags, hashes).await,
126        }
127    }
128
129    /// Either fetch the only wheel metadata (directly from the index or with range requests) or
130    /// fetch and build the source distribution.
131    ///
132    /// While hashes will be generated in some cases, hash-checking is only enforced for source
133    /// distributions, and should be enforced by the caller for wheels.
134    #[instrument(skip_all, fields(%dist))]
135    pub async fn get_installed_metadata(
136        &self,
137        dist: &InstalledDist,
138    ) -> Result<ArchiveMetadata, Error> {
139        // If the metadata was provided by the user directly, prefer it.
140        if let Some(metadata) = self
141            .build_context
142            .dependency_metadata()
143            .get(dist.name(), Some(dist.version()))
144        {
145            return Ok(ArchiveMetadata::from_metadata23(metadata));
146        }
147
148        let metadata = dist
149            .read_metadata()
150            .map_err(|err| Error::ReadInstalled(Box::new(dist.clone()), err))?;
151
152        Ok(ArchiveMetadata::from_metadata23(metadata.clone()))
153    }
154
155    /// Either fetch the only wheel metadata (directly from the index or with range requests) or
156    /// fetch and build the source distribution.
157    ///
158    /// While hashes will be generated in some cases, hash-checking is only enforced for source
159    /// distributions, and should be enforced by the caller for wheels.
160    #[instrument(skip_all, fields(%dist))]
161    pub async fn get_or_build_wheel_metadata(
162        &self,
163        dist: &Dist,
164        hashes: HashPolicy<'_>,
165    ) -> Result<ArchiveMetadata, Error> {
166        match dist {
167            Dist::Built(built) => self.get_wheel_metadata(built, hashes).await,
168            Dist::Source(source) => {
169                self.build_wheel_metadata(&BuildableSource::Dist(source), hashes)
170                    .await
171            }
172        }
173    }
174
175    /// Fetch a wheel from the cache or download it from the index.
176    ///
177    /// While hashes will be generated in all cases, hash-checking is _not_ enforced and should
178    /// instead be enforced by the caller.
179    async fn get_wheel(
180        &self,
181        dist: &BuiltDist,
182        hashes: HashPolicy<'_>,
183    ) -> Result<LocalWheel, Error> {
184        match dist {
185            BuiltDist::Registry(wheels) => {
186                let wheel = wheels.best_wheel();
187                let WheelTarget {
188                    url,
189                    extension,
190                    size,
191                } = WheelTarget::try_from(&*wheel.file)?;
192
193                // Create a cache entry for the wheel.
194                let wheel_entry = self.build_context.cache().entry(
195                    CacheBucket::Wheels,
196                    WheelCache::Index(&wheel.index).wheel_dir(wheel.name().as_ref()),
197                    wheel.filename.cache_key(),
198                );
199
200                // If the URL is a file URL, load the wheel directly.
201                if url.scheme() == "file" {
202                    let path = url
203                        .to_file_path()
204                        .map_err(|()| Error::NonFileUrl(url.clone()))?;
205                    return self
206                        .load_wheel(
207                            &path,
208                            &wheel.filename,
209                            WheelExtension::Whl,
210                            wheel_entry,
211                            dist,
212                            hashes,
213                        )
214                        .await;
215                }
216
217                // Download and unzip.
218                match self
219                    .stream_wheel(
220                        url.clone(),
221                        dist.index(),
222                        &wheel.filename,
223                        extension,
224                        size,
225                        &wheel_entry,
226                        dist,
227                        hashes,
228                    )
229                    .await
230                {
231                    Ok(archive) => Ok(LocalWheel {
232                        dist: Dist::Built(dist.clone()),
233                        archive: self
234                            .build_context
235                            .cache()
236                            .archive(&archive.id)
237                            .into_boxed_path(),
238                        hashes: archive.hashes,
239                        filename: wheel.filename.clone(),
240                        cache: CacheInfo::default(),
241                        build: None,
242                    }),
243                    Err(Error::Extract(name, err)) => {
244                        if err.is_http_streaming_unsupported() {
245                            warn!(
246                                "Streaming unsupported for {dist}; downloading wheel to disk ({err})"
247                            );
248                        } else if err.is_http_streaming_failed() {
249                            warn!("Streaming failed for {dist}; downloading wheel to disk ({err})");
250                        } else {
251                            return Err(Error::Extract(name, err));
252                        }
253
254                        // If the request failed because streaming was unsupported or failed,
255                        // download the wheel directly.
256                        let archive = self
257                            .download_wheel(
258                                url,
259                                dist.index(),
260                                &wheel.filename,
261                                extension,
262                                size,
263                                &wheel_entry,
264                                dist,
265                                hashes,
266                            )
267                            .await?;
268
269                        Ok(LocalWheel {
270                            dist: Dist::Built(dist.clone()),
271                            archive: self
272                                .build_context
273                                .cache()
274                                .archive(&archive.id)
275                                .into_boxed_path(),
276                            hashes: archive.hashes,
277                            filename: wheel.filename.clone(),
278                            cache: CacheInfo::default(),
279                            build: None,
280                        })
281                    }
282                    Err(err) => Err(err),
283                }
284            }
285
286            BuiltDist::DirectUrl(wheel) => {
287                // Create a cache entry for the wheel.
288                let wheel_entry = self.build_context.cache().entry(
289                    CacheBucket::Wheels,
290                    WheelCache::Url(&wheel.url).wheel_dir(wheel.name().as_ref()),
291                    wheel.filename.cache_key(),
292                );
293
294                // Download and unzip.
295                match self
296                    .stream_wheel(
297                        wheel.url.raw().clone(),
298                        None,
299                        &wheel.filename,
300                        WheelExtension::Whl,
301                        wheel.size,
302                        &wheel_entry,
303                        dist,
304                        hashes,
305                    )
306                    .await
307                {
308                    Ok(archive) => Ok(LocalWheel {
309                        dist: Dist::Built(dist.clone()),
310                        archive: self
311                            .build_context
312                            .cache()
313                            .archive(&archive.id)
314                            .into_boxed_path(),
315                        hashes: archive.hashes,
316                        filename: wheel.filename.clone(),
317                        cache: CacheInfo::default(),
318                        build: None,
319                    }),
320                    Err(Error::Extract(name, err)) => {
321                        if err.is_http_streaming_unsupported() {
322                            warn!(
323                                "Streaming unsupported for {dist}; downloading wheel to disk ({err})"
324                            );
325                        } else if err.is_http_streaming_failed() {
326                            warn!("Streaming failed for {dist}; downloading wheel to disk ({err})");
327                        } else {
328                            return Err(Error::Extract(name, err));
329                        }
330
331                        // If the request failed because streaming was unsupported or failed,
332                        // download the wheel directly.
333                        let archive = self
334                            .download_wheel(
335                                wheel.url.raw().clone(),
336                                None,
337                                &wheel.filename,
338                                WheelExtension::Whl,
339                                wheel.size,
340                                &wheel_entry,
341                                dist,
342                                hashes,
343                            )
344                            .await?;
345                        Ok(LocalWheel {
346                            dist: Dist::Built(dist.clone()),
347                            archive: self
348                                .build_context
349                                .cache()
350                                .archive(&archive.id)
351                                .into_boxed_path(),
352                            hashes: archive.hashes,
353                            filename: wheel.filename.clone(),
354                            cache: CacheInfo::default(),
355                            build: None,
356                        })
357                    }
358                    Err(err) => Err(err),
359                }
360            }
361
362            BuiltDist::GitPath(wheel) => {
363                // Fetch the Git repository.
364                let fetch = self
365                    .build_context
366                    .git()
367                    .fetch(
368                        &wheel.git,
369                        self.client.unmanaged.git_http_settings(wheel.git.url()),
370                        self.build_context.cache().bucket(CacheBucket::Git),
371                        self.reporter.clone().map(<dyn Reporter>::into_git_reporter),
372                    )
373                    .await?;
374
375                if wheel.git.lfs().enabled() && !fetch.lfs_ready() {
376                    if GIT_LFS.is_err() {
377                        return Err(Error::MissingWheelGitLfsArtifacts(
378                            wheel.url.to_url(),
379                            GitError::GitLfsNotFound,
380                        ));
381                    }
382                    return Err(Error::MissingWheelGitLfsArtifacts(
383                        wheel.url.to_url(),
384                        GitError::GitLfsNotConfigured,
385                    ));
386                }
387
388                let git_sha = fetch.git().precise().expect("Exact commit after checkout");
389                let cache_entry = self.build_context.cache().entry(
390                    CacheBucket::Wheels,
391                    WheelCache::Git(&wheel.url, git_sha.as_short_str()).root(),
392                    wheel.filename.stem(),
393                );
394
395                let install_path = fetch.path().join(&wheel.install_path);
396
397                self.load_wheel(
398                    &install_path,
399                    &wheel.filename,
400                    WheelExtension::Whl,
401                    cache_entry,
402                    dist,
403                    hashes,
404                )
405                .await
406            }
407
408            BuiltDist::Path(wheel) => {
409                let cache_entry = self.build_context.cache().entry(
410                    CacheBucket::Wheels,
411                    WheelCache::Url(&wheel.url).wheel_dir(wheel.name().as_ref()),
412                    wheel.filename.cache_key(),
413                );
414
415                self.load_wheel(
416                    &wheel.install_path,
417                    &wheel.filename,
418                    WheelExtension::Whl,
419                    cache_entry,
420                    dist,
421                    hashes,
422                )
423                .await
424            }
425        }
426    }
427
428    /// Convert a source distribution into a wheel, fetching it from the cache or building it if
429    /// necessary.
430    ///
431    /// The returned wheel is guaranteed to come from a distribution with a matching hash, and
432    /// no build processes will be executed for distributions with mismatched hashes.
433    async fn build_wheel(
434        &self,
435        dist: &SourceDist,
436        tags: &Tags,
437        hashes: HashPolicy<'_>,
438    ) -> Result<LocalWheel, Error> {
439        let built_wheel = self
440            .builder
441            .download_and_build(&BuildableSource::Dist(dist), tags, hashes, &self.client)
442            .boxed_local()
443            .await?;
444
445        // Check that the wheel is compatible with its install target.
446        //
447        // When building a build dependency for a cross-install, the build dependency needs
448        // to install and run on the host instead of the target. In this case the `tags` are already
449        // for the host instead of the target, so this check passes.
450        if !built_wheel.filename.is_compatible(tags) {
451            return if tags.is_cross() {
452                Err(Error::BuiltWheelIncompatibleTargetPlatform {
453                    filename: built_wheel.filename,
454                    python_platform: tags.python_platform().clone(),
455                    python_version: PythonVersion {
456                        version: tags.python_version(),
457                        variant: if tags.is_freethreaded() {
458                            PythonVariant::Freethreaded
459                        } else {
460                            PythonVariant::Default
461                        },
462                    },
463                })
464            } else {
465                Err(Error::BuiltWheelIncompatibleHostPlatform {
466                    filename: built_wheel.filename,
467                    python_platform: tags.python_platform().clone(),
468                    python_version: PythonVersion {
469                        version: tags.python_version(),
470                        variant: if tags.is_freethreaded() {
471                            PythonVariant::Freethreaded
472                        } else {
473                            PythonVariant::Default
474                        },
475                    },
476                })
477            };
478        }
479
480        // Acquire the advisory lock.
481        #[cfg(windows)]
482        let _lock = {
483            let lock_entry = CacheEntry::new(
484                built_wheel.target.parent().unwrap(),
485                format!(
486                    "{}.lock",
487                    built_wheel.target.file_name().unwrap().to_str().unwrap()
488                ),
489            );
490            lock_entry.lock().await.map_err(Error::CacheLock)?
491        };
492
493        // If the wheel was unzipped previously, respect it. Source distributions are
494        // cached under a unique revision ID, so unzipped directories are never stale.
495        match self.build_context.cache().resolve_link(&built_wheel.target) {
496            Ok(archive) => {
497                return Ok(LocalWheel {
498                    dist: Dist::Source(dist.clone()),
499                    archive: archive.into_boxed_path(),
500                    filename: built_wheel.filename,
501                    hashes: built_wheel.hashes,
502                    cache: built_wheel.cache_info,
503                    build: Some(built_wheel.build_info),
504                });
505            }
506            Err(err) if err.kind() == io::ErrorKind::NotFound => {}
507            Err(err) => return Err(Error::CacheRead(err)),
508        }
509
510        // Otherwise, unzip the wheel.
511        let id = self
512            .unzip_wheel(
513                &built_wheel.path,
514                &built_wheel.target,
515                DistRef::Source(dist),
516            )
517            .await?;
518
519        Ok(LocalWheel {
520            dist: Dist::Source(dist.clone()),
521            archive: self.build_context.cache().archive(&id).into_boxed_path(),
522            hashes: built_wheel.hashes,
523            filename: built_wheel.filename,
524            cache: built_wheel.cache_info,
525            build: Some(built_wheel.build_info),
526        })
527    }
528
529    /// Fetch the wheel metadata from the index, or from the cache if possible.
530    ///
531    /// While hashes will be generated in some cases, hash-checking is _not_ enforced and should
532    /// instead be enforced by the caller.
533    async fn get_wheel_metadata(
534        &self,
535        dist: &BuiltDist,
536        hashes: HashPolicy<'_>,
537    ) -> Result<ArchiveMetadata, Error> {
538        // If hash generation is enabled, and the distribution isn't hosted on a registry, get the
539        // entire wheel to ensure that the hashes are included in the response. If the distribution
540        // is hosted on an index, the hashes will be included in the simple metadata response.
541        // For hash _validation_, callers are expected to enforce the policy when retrieving the
542        // wheel.
543        //
544        // Historically, for `uv pip compile --universal`, we also generate hashes for
545        // registry-based distributions when the relevant registry doesn't provide them. This was
546        // motivated by `--find-links`. We continue that behavior (under `HashGeneration::All`) for
547        // backwards compatibility, but it's a little dubious, since we're only hashing _one_
548        // distribution here (as opposed to hashing all distributions for the version), and it may
549        // not even be a compatible distribution!
550        //
551        // TODO(charlie): Request the hashes via a separate method, to reduce the coupling in this API.
552        if hashes.is_generate(dist) {
553            let wheel = self.get_wheel(dist, hashes).await?;
554            // If the metadata was provided by the user directly, prefer it.
555            let metadata = if let Some(metadata) = self
556                .build_context
557                .dependency_metadata()
558                .get(dist.name(), Some(dist.version()))
559            {
560                metadata
561            } else {
562                wheel.metadata()?
563            };
564            let hashes = wheel.hashes;
565            return Ok(ArchiveMetadata {
566                metadata: Metadata::from_metadata23(metadata),
567                hashes,
568            });
569        }
570
571        // If the metadata was provided by the user directly, prefer it.
572        if let Some(metadata) = self
573            .build_context
574            .dependency_metadata()
575            .get(dist.name(), Some(dist.version()))
576        {
577            return Ok(ArchiveMetadata::from_metadata23(metadata));
578        }
579
580        let result = self
581            .client
582            .managed(|client| {
583                client
584                    .wheel_metadata(
585                        dist,
586                        self.build_context.git(),
587                        self.build_context.capabilities(),
588                        self.reporter.clone().map(<dyn Reporter>::into_git_reporter),
589                    )
590                    .boxed_local()
591            })
592            .await;
593
594        match result {
595            Ok(metadata) => {
596                // Validate that the metadata is consistent with the distribution.
597                Ok(ArchiveMetadata::from_metadata23(metadata))
598            }
599            Err(err) if err.is_http_streaming_unsupported() => {
600                warn!(
601                    "Streaming unsupported when fetching metadata for {dist}; downloading wheel directly ({err})"
602                );
603
604                // If the request failed due to an error that could be resolved by
605                // downloading the wheel directly, try that.
606                let wheel = self.get_wheel(dist, hashes).await?;
607                let metadata = wheel.metadata()?;
608                let hashes = wheel.hashes;
609                Ok(ArchiveMetadata {
610                    metadata: Metadata::from_metadata23(metadata),
611                    hashes,
612                })
613            }
614            Err(err) => Err(err.into()),
615        }
616    }
617
618    /// Build the wheel metadata for a source distribution, or fetch it from the cache if possible.
619    ///
620    /// The returned metadata is guaranteed to come from a distribution with a matching hash, and
621    /// no build processes will be executed for distributions with mismatched hashes.
622    pub async fn build_wheel_metadata(
623        &self,
624        source: &BuildableSource<'_>,
625        hashes: HashPolicy<'_>,
626    ) -> Result<ArchiveMetadata, Error> {
627        // If the metadata was provided by the user directly, prefer it.
628        if let Some(dist) = source.as_dist() {
629            if let Some(metadata) = self
630                .build_context
631                .dependency_metadata()
632                .get(dist.name(), dist.version())
633            {
634                // If we skipped the build, we should still resolve any Git dependencies to precise
635                // commits.
636                self.builder.resolve_revision(source, &self.client).await?;
637
638                return Ok(ArchiveMetadata::from_metadata23(metadata.clone()));
639            }
640        }
641
642        let metadata = self
643            .builder
644            .download_and_build_metadata(source, hashes, &self.client)
645            .boxed_local()
646            .await?;
647
648        Ok(metadata)
649    }
650
651    /// Return the [`RequiresDist`] from a `pyproject.toml`, if it can be statically extracted.
652    pub async fn requires_dist(
653        &self,
654        path: &Path,
655        pyproject_toml: &PyProjectToml,
656    ) -> Result<Option<RequiresDist>, Error> {
657        self.builder
658            .source_tree_requires_dist(
659                path,
660                pyproject_toml,
661                self.client.unmanaged.credentials_cache(),
662            )
663            .await
664    }
665
666    /// Stream a wheel from a URL, unzipping it into the cache as it's downloaded.
667    async fn stream_wheel(
668        &self,
669        url: DisplaySafeUrl,
670        index: Option<&IndexUrl>,
671        filename: &WheelFilename,
672        extension: WheelExtension,
673        size: Option<u64>,
674        wheel_entry: &CacheEntry,
675        dist: &BuiltDist,
676        hashes: HashPolicy<'_>,
677    ) -> Result<Archive, Error> {
678        let expected_size = match dist {
679            BuiltDist::Registry(dist) if dist.best_wheel().size_is_authoritative => size,
680            BuiltDist::DirectUrl(_) => size,
681            _ => None,
682        };
683
684        // Acquire an advisory lock, to guard against concurrent writes.
685        #[cfg(windows)]
686        let _lock = {
687            let lock_entry = wheel_entry.with_file(format!("{}.lock", filename.stem()));
688            lock_entry.lock().await.map_err(Error::CacheLock)?
689        };
690
691        // Create an entry for the HTTP cache.
692        let http_entry = wheel_entry.with_file(format!("{}.http", filename.cache_key()));
693
694        let download = |response: reqwest::Response| {
695            async {
696                let progress_size = size.or_else(|| content_length(&response));
697
698                let progress = self.reporter.as_ref().map(|reporter| {
699                    (
700                        reporter,
701                        reporter.on_download_start(dist.name(), progress_size),
702                    )
703                });
704
705                let reader = response
706                    .bytes_stream()
707                    .map_err(|err| self.handle_response_errors(err))
708                    .into_async_read();
709
710                // Create a hasher for each hash algorithm.
711                let algorithms = http_hash_algorithms(hashes);
712                let mut hashers = algorithms.into_iter().map(Hasher::from).collect::<Vec<_>>();
713                let mut hasher = uv_extract::hash::HashReader::new(reader.compat(), &mut hashers);
714
715                // Download and unzip the wheel to a temporary directory.
716                let temp_dir = tempfile::tempdir_in(self.build_context.cache().root())
717                    .map_err(Error::CacheWrite)?;
718
719                let files = match progress {
720                    Some((reporter, progress)) => {
721                        let mut reader = ProgressReader::new(&mut hasher, progress, &**reporter);
722                        match extension {
723                            WheelExtension::Whl => {
724                                uv_extract::stream::unzip(&mut reader, temp_dir.path())
725                                    .await
726                                    .map_err(|err| Error::Extract(filename.to_string(), err))?
727                            }
728                            WheelExtension::WhlZst => {
729                                uv_extract::stream::untar_zst(&mut reader, temp_dir.path())
730                                    .await
731                                    .map_err(|err| Error::Extract(filename.to_string(), err))?
732                            }
733                        }
734                    }
735                    None => match extension {
736                        WheelExtension::Whl => {
737                            uv_extract::stream::unzip(&mut hasher, temp_dir.path())
738                                .await
739                                .map_err(|err| Error::Extract(filename.to_string(), err))?
740                        }
741                        WheelExtension::WhlZst => {
742                            uv_extract::stream::untar_zst(&mut hasher, temp_dir.path())
743                                .await
744                                .map_err(|err| Error::Extract(filename.to_string(), err))?
745                        }
746                    },
747                };
748                // Exhaust the reader to compute the hashes.
749                hasher.finish().await.map_err(Error::HashExhaustion)?;
750                let actual_size = hasher.bytes_read();
751                if let Some(expected) = expected_size
752                    && actual_size != expected
753                {
754                    return Err(Error::MismatchedSize {
755                        distribution: dist.to_string(),
756                        expected,
757                        actual: actual_size,
758                    });
759                }
760
761                // Before we make the wheel accessible by persisting it, ensure that the RECORD is
762                // valid.
763                validate_and_heal_record(temp_dir.path(), files.iter(), dist)
764                    .map_err(Error::InstallWheelError)?;
765
766                // Persist the temporary directory to the directory store.
767                let id = self
768                    .build_context
769                    .cache()
770                    .persist(temp_dir.keep(), wheel_entry.path())
771                    .await
772                    .map_err(Error::CacheRead)?;
773
774                if let Some((reporter, progress)) = progress {
775                    reporter.on_download_complete(dist.name(), progress);
776                }
777
778                Ok(Archive::new(
779                    id,
780                    hashers.into_iter().map(HashDigest::from).collect(),
781                    filename.clone(),
782                    Some(actual_size),
783                ))
784            }
785            .instrument(info_span!("wheel", wheel = %dist))
786        };
787
788        // Fetch the archive from the cache, or download it if necessary.
789        let req = self.request(url.clone())?;
790
791        // Determine the cache control policy for the URL.
792        let cache_control = match self.client.unmanaged.connectivity() {
793            Connectivity::Online
794                if let Some(header) = index.and_then(|index| {
795                    self.build_context
796                        .locations()
797                        .artifact_cache_control_for(index)
798                }) =>
799            {
800                CacheControl::Override(header)
801            }
802            Connectivity::Online => CacheControl::from(
803                self.build_context
804                    .cache()
805                    .freshness(&http_entry, Some(&filename.name), None)
806                    .map_err(Error::CacheRead)?,
807            ),
808            Connectivity::Offline => CacheControl::AllowStale,
809        };
810
811        let archive = self
812            .client
813            .managed(|client| {
814                client.cached_client().get_serde_with_retry(
815                    req,
816                    &http_entry,
817                    cache_control.clone(),
818                    download,
819                )
820            })
821            .await
822            .map_err(|err| match err {
823                CachedClientError::Callback { err, .. } => err,
824                CachedClientError::Client(err) => Error::Client(err),
825            })?;
826
827        if let (Some(expected), Some(actual)) = (expected_size, archive.size)
828            && expected != actual
829        {
830            return Err(Error::MismatchedSize {
831                distribution: dist.to_string(),
832                expected,
833                actual,
834            });
835        }
836
837        // If the archive is missing the required hashes or size, or has since been removed, force a refresh.
838        let archive = Some(archive)
839            .filter(|archive| archive.has_digests(hashes))
840            .filter(|archive| archive.exists(self.build_context.cache()))
841            .filter(|archive| expected_size.is_none() || archive.size.is_some());
842
843        let archive = if let Some(archive) = archive {
844            archive
845        } else {
846            self.client
847                .managed(async |client| {
848                    client
849                        .cached_client()
850                        .skip_cache_with_retry(
851                            self.request(url)?,
852                            &http_entry,
853                            cache_control,
854                            download,
855                        )
856                        .await
857                        .map_err(|err| match err {
858                            CachedClientError::Callback { err, .. } => err,
859                            CachedClientError::Client(err) => Error::Client(err),
860                        })
861                })
862                .await?
863        };
864
865        Ok(archive)
866    }
867
868    /// Download a wheel from a URL, then unzip it into the cache.
869    async fn download_wheel(
870        &self,
871        url: DisplaySafeUrl,
872        index: Option<&IndexUrl>,
873        filename: &WheelFilename,
874        extension: WheelExtension,
875        size: Option<u64>,
876        wheel_entry: &CacheEntry,
877        dist: &BuiltDist,
878        hashes: HashPolicy<'_>,
879    ) -> Result<Archive, Error> {
880        let expected_size = match dist {
881            BuiltDist::Registry(dist) if dist.best_wheel().size_is_authoritative => size,
882            BuiltDist::DirectUrl(_) => size,
883            _ => None,
884        };
885
886        // Acquire an advisory lock, to guard against concurrent writes.
887        #[cfg(windows)]
888        let _lock = {
889            let lock_entry = wheel_entry.with_file(format!("{}.lock", filename.stem()));
890            lock_entry.lock().await.map_err(Error::CacheLock)?
891        };
892
893        // Create an entry for the HTTP cache.
894        let http_entry = wheel_entry.with_file(format!("{}.http", filename.cache_key()));
895
896        let download = |response: reqwest::Response| {
897            async {
898                let progress_size = size.or_else(|| content_length(&response));
899
900                let progress = self.reporter.as_ref().map(|reporter| {
901                    (
902                        reporter,
903                        reporter.on_download_start(dist.name(), progress_size),
904                    )
905                });
906
907                let reader = response
908                    .bytes_stream()
909                    .map_err(|err| self.handle_response_errors(err))
910                    .into_async_read();
911                let algorithms = http_hash_algorithms(hashes);
912                let mut hashers = algorithms.into_iter().map(Hasher::from).collect::<Vec<_>>();
913                let mut hasher = uv_extract::hash::HashReader::new(reader.compat(), &mut hashers);
914
915                // Download the wheel to a temporary file.
916                let temp_file = tempfile::tempfile_in(self.build_context.cache().root())
917                    .map_err(Error::CacheWrite)?;
918                let mut writer = tokio::io::BufWriter::new(fs_err::tokio::File::from_std(
919                    // It's an unnamed file on Linux so that's the best approximation.
920                    fs_err::File::from_parts(temp_file, self.build_context.cache().root()),
921                ));
922
923                match progress {
924                    Some((reporter, progress)) => {
925                        // Wrap the reader in a progress reporter. This will report 100% progress once
926                        // the download is complete, before the wheel is unzipped.
927                        let mut reader = ProgressReader::new(&mut hasher, progress, &**reporter);
928
929                        tokio::io::copy(&mut reader, &mut writer)
930                            .await
931                            .map_err(Error::CacheWrite)?;
932                    }
933                    None => {
934                        tokio::io::copy(&mut hasher, &mut writer)
935                            .await
936                            .map_err(Error::CacheWrite)?;
937                    }
938                }
939
940                if let Some(expected) = expected_size
941                    && hasher.bytes_read() != expected
942                {
943                    return Err(Error::MismatchedSize {
944                        distribution: dist.to_string(),
945                        expected,
946                        actual: hasher.bytes_read(),
947                    });
948                }
949
950                let actual_size = hasher.bytes_read();
951
952                // Unzip the wheel to a temporary directory.
953                let temp_dir = tempfile::tempdir_in(self.build_context.cache().root())
954                    .map_err(Error::CacheWrite)?;
955                let mut file = writer.into_inner();
956                file.seek(io::SeekFrom::Start(0))
957                    .await
958                    .map_err(Error::CacheWrite)?;
959
960                let target = temp_dir.path().to_owned();
961                let files = match extension {
962                    WheelExtension::Whl => {
963                        let file = file.into_std().await;
964                        tokio::task::spawn_blocking(move || uv_extract::unzip(file, &target))
965                            .await?
966                    }
967                    WheelExtension::WhlZst => uv_extract::stream::untar_zst(file, &target).await,
968                }
969                .map_err(|err| Error::Extract(filename.to_string(), err))?;
970                let hashes = hashers.into_iter().map(HashDigest::from).collect();
971
972                // Before we make the wheel accessible by persisting it, ensure that the RECORD is
973                // valid.
974                validate_and_heal_record(temp_dir.path(), files.iter(), dist)
975                    .map_err(Error::InstallWheelError)?;
976
977                // Persist the temporary directory to the directory store.
978                let id = self
979                    .build_context
980                    .cache()
981                    .persist(temp_dir.keep(), wheel_entry.path())
982                    .await
983                    .map_err(Error::CacheRead)?;
984
985                if let Some((reporter, progress)) = progress {
986                    reporter.on_download_complete(dist.name(), progress);
987                }
988
989                Ok(Archive::new(
990                    id,
991                    hashes,
992                    filename.clone(),
993                    Some(actual_size),
994                ))
995            }
996            .instrument(info_span!("wheel", wheel = %dist))
997        };
998
999        // Fetch the archive from the cache, or download it if necessary.
1000        let req = self.request(url.clone())?;
1001
1002        // Determine the cache control policy for the URL.
1003        let cache_control = match self.client.unmanaged.connectivity() {
1004            Connectivity::Online
1005                if let Some(header) = index.and_then(|index| {
1006                    self.build_context
1007                        .locations()
1008                        .artifact_cache_control_for(index)
1009                }) =>
1010            {
1011                CacheControl::Override(header)
1012            }
1013            Connectivity::Online => CacheControl::from(
1014                self.build_context
1015                    .cache()
1016                    .freshness(&http_entry, Some(&filename.name), None)
1017                    .map_err(Error::CacheRead)?,
1018            ),
1019            Connectivity::Offline => CacheControl::AllowStale,
1020        };
1021
1022        let archive = self
1023            .client
1024            .managed(|client| {
1025                client.cached_client().get_serde_with_retry(
1026                    req,
1027                    &http_entry,
1028                    cache_control.clone(),
1029                    download,
1030                )
1031            })
1032            .await
1033            .map_err(|err| match err {
1034                CachedClientError::Callback { err, .. } => err,
1035                CachedClientError::Client(err) => Error::Client(err),
1036            })?;
1037
1038        if let (Some(expected), Some(actual)) = (expected_size, archive.size)
1039            && expected != actual
1040        {
1041            return Err(Error::MismatchedSize {
1042                distribution: dist.to_string(),
1043                expected,
1044                actual,
1045            });
1046        }
1047
1048        // If the archive is missing the required hashes or size, or has since been removed, force a refresh.
1049        let archive = Some(archive)
1050            .filter(|archive| archive.has_digests(hashes))
1051            .filter(|archive| archive.exists(self.build_context.cache()))
1052            .filter(|archive| expected_size.is_none() || archive.size.is_some());
1053
1054        let archive = if let Some(archive) = archive {
1055            archive
1056        } else {
1057            self.client
1058                .managed(async |client| {
1059                    client
1060                        .cached_client()
1061                        .skip_cache_with_retry(
1062                            self.request(url)?,
1063                            &http_entry,
1064                            cache_control,
1065                            download,
1066                        )
1067                        .await
1068                        .map_err(|err| match err {
1069                            CachedClientError::Callback { err, .. } => err,
1070                            CachedClientError::Client(err) => Error::Client(err),
1071                        })
1072                })
1073                .await?
1074        };
1075
1076        Ok(archive)
1077    }
1078
1079    /// Load a wheel from a local path.
1080    async fn load_wheel(
1081        &self,
1082        path: &Path,
1083        filename: &WheelFilename,
1084        extension: WheelExtension,
1085        wheel_entry: CacheEntry,
1086        dist: &BuiltDist,
1087        hashes: HashPolicy<'_>,
1088    ) -> Result<LocalWheel, Error> {
1089        #[cfg(windows)]
1090        let _lock = {
1091            let lock_entry = wheel_entry.with_file(format!("{}.lock", filename.stem()));
1092            lock_entry.lock().await.map_err(Error::CacheLock)?
1093        };
1094
1095        // Determine the last-modified time of the wheel.
1096        let modified = Timestamp::from_path(path).map_err(Error::CacheRead)?;
1097
1098        // Attempt to read the archive pointer from the cache.
1099        let pointer_entry = wheel_entry.with_file(format!("{}.rev", filename.cache_key()));
1100        let pointer = PathArchivePointer::read_from(&pointer_entry)?;
1101
1102        // Extract the archive from the pointer.
1103        let archive = pointer
1104            .filter(|pointer| pointer.is_up_to_date(modified))
1105            .map(PathArchivePointer::into_archive)
1106            .filter(|archive| archive.has_digests(hashes));
1107
1108        // If the file is already unzipped, and the cache is up-to-date, return it.
1109        if let Some(archive) = archive {
1110            Ok(LocalWheel {
1111                dist: Dist::Built(dist.clone()),
1112                archive: self
1113                    .build_context
1114                    .cache()
1115                    .archive(&archive.id)
1116                    .into_boxed_path(),
1117                hashes: archive.hashes,
1118                filename: filename.clone(),
1119                cache: CacheInfo::from_timestamp(modified),
1120                build: None,
1121            })
1122        } else if hashes.is_none() {
1123            // Otherwise, unzip the wheel.
1124            let archive = Archive::new(
1125                self.unzip_wheel(path, wheel_entry.path(), DistRef::Built(dist))
1126                    .await?,
1127                HashDigests::empty(),
1128                filename.clone(),
1129                None,
1130            );
1131
1132            // Write the archive pointer to the cache.
1133            let pointer = PathArchivePointer {
1134                timestamp: modified,
1135                archive: archive.clone(),
1136            };
1137            pointer.write_to(&pointer_entry).await?;
1138
1139            Ok(LocalWheel {
1140                dist: Dist::Built(dist.clone()),
1141                archive: self
1142                    .build_context
1143                    .cache()
1144                    .archive(&archive.id)
1145                    .into_boxed_path(),
1146                hashes: archive.hashes,
1147                filename: filename.clone(),
1148                cache: CacheInfo::from_timestamp(modified),
1149                build: None,
1150            })
1151        } else {
1152            // If necessary, compute the hashes of the wheel.
1153            let file = fs_err::tokio::File::open(path)
1154                .await
1155                .map_err(Error::CacheRead)?;
1156            let temp_dir = tempfile::tempdir_in(self.build_context.cache().root())
1157                .map_err(Error::CacheWrite)?;
1158
1159            // Create a hasher for each hash algorithm.
1160            let algorithms = hashes.algorithms();
1161            let mut hashers = algorithms.into_iter().map(Hasher::from).collect::<Vec<_>>();
1162            let mut hasher = uv_extract::hash::HashReader::new(file, &mut hashers);
1163
1164            // Unzip the wheel to a temporary directory.
1165            let files = match extension {
1166                WheelExtension::Whl => uv_extract::stream::unzip(&mut hasher, temp_dir.path())
1167                    .await
1168                    .map_err(|err| Error::Extract(filename.to_string(), err))?,
1169                WheelExtension::WhlZst => {
1170                    uv_extract::stream::untar_zst(&mut hasher, temp_dir.path())
1171                        .await
1172                        .map_err(|err| Error::Extract(filename.to_string(), err))?
1173                }
1174            };
1175
1176            // Exhaust the reader to compute the hash.
1177            hasher.finish().await.map_err(Error::HashExhaustion)?;
1178
1179            let hashes = hashers.into_iter().map(HashDigest::from).collect();
1180
1181            // Before we make the wheel accessible by persisting it, ensure that the RECORD is
1182            // valid.
1183            validate_and_heal_record(temp_dir.path(), files.iter(), dist)
1184                .map_err(Error::InstallWheelError)?;
1185
1186            // Persist the temporary directory to the directory store.
1187            let id = self
1188                .build_context
1189                .cache()
1190                .persist(temp_dir.keep(), wheel_entry.path())
1191                .await
1192                .map_err(Error::CacheWrite)?;
1193
1194            // Create an archive.
1195            let archive = Archive::new(id, hashes, filename.clone(), None);
1196
1197            // Write the archive pointer to the cache.
1198            let pointer = PathArchivePointer {
1199                timestamp: modified,
1200                archive: archive.clone(),
1201            };
1202            pointer.write_to(&pointer_entry).await?;
1203
1204            Ok(LocalWheel {
1205                dist: Dist::Built(dist.clone()),
1206                archive: self
1207                    .build_context
1208                    .cache()
1209                    .archive(&archive.id)
1210                    .into_boxed_path(),
1211                hashes: archive.hashes,
1212                filename: filename.clone(),
1213                cache: CacheInfo::from_timestamp(modified),
1214                build: None,
1215            })
1216        }
1217    }
1218
1219    /// Unzip a wheel into the cache, returning the path to the unzipped directory.
1220    async fn unzip_wheel(
1221        &self,
1222        path: &Path,
1223        target: &Path,
1224        dist: DistRef<'_>,
1225    ) -> Result<ArchiveId, Error> {
1226        let (temp_dir, files) = tokio::task::spawn_blocking({
1227            let path = path.to_owned();
1228            let root = self.build_context.cache().root().to_path_buf();
1229            move || -> Result<_, Error> {
1230                // Unzip the wheel into a temporary directory.
1231                let temp_dir = tempfile::tempdir_in(root).map_err(Error::CacheWrite)?;
1232                let reader = fs_err::File::open(&path).map_err(Error::CacheWrite)?;
1233                let files = uv_extract::unzip(reader, temp_dir.path())
1234                    .map_err(|err| Error::Extract(path.to_string_lossy().into_owned(), err))?;
1235                Ok((temp_dir, files))
1236            }
1237        })
1238        .await??;
1239
1240        // Before we make the wheel accessible by persisting it, ensure that the RECORD is valid.
1241        validate_and_heal_record(temp_dir.path(), files.iter(), dist)
1242            .map_err(Error::InstallWheelError)?;
1243
1244        // Persist the temporary directory to the directory store.
1245        let id = self
1246            .build_context
1247            .cache()
1248            .persist(temp_dir.keep(), target)
1249            .await
1250            .map_err(Error::CacheWrite)?;
1251
1252        Ok(id)
1253    }
1254
1255    /// Returns a GET [`reqwest::Request`] for the given URL.
1256    fn request(&self, url: DisplaySafeUrl) -> Result<reqwest::Request, reqwest::Error> {
1257        self.client
1258            .unmanaged
1259            .uncached_client(&url)
1260            .get(Url::from(url))
1261            .header(
1262                // `reqwest` defaults to accepting compressed responses.
1263                // Specify identity encoding to get consistent .whl downloading
1264                // behavior from servers. ref: https://github.com/pypa/pip/pull/1688
1265                "accept-encoding",
1266                reqwest::header::HeaderValue::from_static("identity"),
1267            )
1268            .build()
1269    }
1270
1271    /// Return the [`ManagedClient`] used by this resolver.
1272    pub fn client(&self) -> &ManagedClient<'a> {
1273        &self.client
1274    }
1275}
1276
1277/// A wrapper around `RegistryClient` that manages a concurrency limit.
1278pub struct ManagedClient<'a> {
1279    pub unmanaged: &'a RegistryClient,
1280    control: Arc<Semaphore>,
1281}
1282
1283impl<'a> ManagedClient<'a> {
1284    /// Create a new `ManagedClient` using the given client and concurrency semaphore.
1285    fn new(client: &'a RegistryClient, control: Arc<Semaphore>) -> Self {
1286        ManagedClient {
1287            unmanaged: client,
1288            control,
1289        }
1290    }
1291
1292    /// Perform a request using the client, respecting the concurrency limit.
1293    ///
1294    /// If the concurrency limit has been reached, this method will wait until a pending
1295    /// operation completes before executing the closure.
1296    pub async fn managed<F, T>(&self, f: impl FnOnce(&'a RegistryClient) -> F) -> T
1297    where
1298        F: Future<Output = T>,
1299    {
1300        let _permit = self.control.acquire().await.unwrap();
1301        f(self.unmanaged).await
1302    }
1303
1304    /// Perform a request using a client that internally manages the concurrency limit.
1305    ///
1306    /// The callback is passed the client and a semaphore. It must acquire the semaphore before
1307    /// any request through the client and drop it after.
1308    ///
1309    /// This method serves as an escape hatch for functions that may want to send multiple requests
1310    /// in parallel.
1311    pub async fn manual<F, T>(&'a self, f: impl FnOnce(&'a RegistryClient, &'a Semaphore) -> F) -> T
1312    where
1313        F: Future<Output = T>,
1314    {
1315        f(self.unmanaged, &self.control).await
1316    }
1317}
1318
1319/// Returns the value of the `Content-Length` header from the [`reqwest::Response`], if present.
1320fn content_length(response: &reqwest::Response) -> Option<u64> {
1321    response
1322        .headers()
1323        .get(reqwest::header::CONTENT_LENGTH)
1324        .and_then(|val| val.to_str().ok())
1325        .and_then(|val| val.parse::<u64>().ok())
1326}
1327
1328/// An asynchronous reader that reports progress as bytes are read.
1329struct ProgressReader<'a, R> {
1330    reader: R,
1331    index: usize,
1332    reporter: &'a dyn Reporter,
1333}
1334
1335impl<'a, R> ProgressReader<'a, R> {
1336    /// Create a new [`ProgressReader`] that wraps another reader.
1337    fn new(reader: R, index: usize, reporter: &'a dyn Reporter) -> Self {
1338        Self {
1339            reader,
1340            index,
1341            reporter,
1342        }
1343    }
1344}
1345
1346impl<R> AsyncRead for ProgressReader<'_, R>
1347where
1348    R: AsyncRead + Unpin,
1349{
1350    fn poll_read(
1351        mut self: Pin<&mut Self>,
1352        cx: &mut Context<'_>,
1353        buf: &mut ReadBuf<'_>,
1354    ) -> Poll<io::Result<()>> {
1355        Pin::new(&mut self.as_mut().reader)
1356            .poll_read(cx, buf)
1357            .map_ok(|()| {
1358                self.reporter
1359                    .on_download_progress(self.index, buf.filled().len() as u64);
1360            })
1361    }
1362}
1363
1364/// A pointer to an archive in the cache, fetched from an HTTP archive.
1365///
1366/// Encoded with `MsgPack`, and represented on disk by a `.http` file.
1367#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1368pub struct HttpArchivePointer {
1369    archive: Archive,
1370}
1371
1372impl HttpArchivePointer {
1373    /// Read an [`HttpArchivePointer`] from the cache.
1374    pub fn read_from(path: impl AsRef<Path>) -> Result<Option<Self>, Error> {
1375        match fs_err::File::open(path.as_ref()) {
1376            Ok(file) => {
1377                let data = DataWithCachePolicy::from_reader(file)?.data;
1378                let archive = rmp_serde::from_slice::<Archive>(&data)?;
1379                Ok(Some(Self { archive }))
1380            }
1381            Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
1382            Err(err) => Err(Error::CacheRead(err)),
1383        }
1384    }
1385
1386    /// Return the [`Archive`] from the pointer.
1387    pub fn into_archive(self) -> Archive {
1388        self.archive
1389    }
1390
1391    /// Return the [`CacheInfo`] from the pointer.
1392    pub fn to_cache_info(&self) -> CacheInfo {
1393        CacheInfo::default()
1394    }
1395
1396    /// Return the [`BuildInfo`] from the pointer.
1397    pub fn to_build_info(&self) -> Option<BuildInfo> {
1398        None
1399    }
1400}
1401
1402/// A pointer to an archive in the cache, fetched from a local path.
1403///
1404/// Encoded with `MsgPack`, and represented on disk by a `.rev` file.
1405#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1406pub struct PathArchivePointer {
1407    timestamp: Timestamp,
1408    archive: Archive,
1409}
1410
1411impl PathArchivePointer {
1412    /// Read an [`PathArchivePointer`] from the cache.
1413    pub fn read_from(path: impl AsRef<Path>) -> Result<Option<Self>, Error> {
1414        match fs_err::read(path) {
1415            Ok(cached) => Ok(Some(rmp_serde::from_slice::<Self>(&cached)?)),
1416            Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
1417            Err(err) => Err(Error::CacheRead(err)),
1418        }
1419    }
1420
1421    /// Write an [`PathArchivePointer`] to the cache.
1422    async fn write_to(&self, entry: &CacheEntry) -> Result<(), Error> {
1423        write_atomic(entry.path(), rmp_serde::to_vec(&self)?)
1424            .await
1425            .map_err(Error::CacheWrite)
1426    }
1427
1428    /// Returns `true` if the archive is up-to-date with the given modified timestamp.
1429    pub fn is_up_to_date(&self, modified: Timestamp) -> bool {
1430        self.timestamp == modified
1431    }
1432
1433    /// Return the [`Archive`] from the pointer.
1434    pub fn into_archive(self) -> Archive {
1435        self.archive
1436    }
1437
1438    /// Return the [`CacheInfo`] from the pointer.
1439    pub fn to_cache_info(&self) -> CacheInfo {
1440        CacheInfo::from_timestamp(self.timestamp)
1441    }
1442
1443    /// Return the [`BuildInfo`] from the pointer.
1444    pub fn to_build_info(&self) -> Option<BuildInfo> {
1445        None
1446    }
1447}
1448
1449#[derive(Debug, Clone)]
1450struct WheelTarget {
1451    /// The URL from which the wheel can be downloaded.
1452    url: DisplaySafeUrl,
1453    /// The expected extension of the wheel file.
1454    extension: WheelExtension,
1455    /// The expected size of the wheel file, if known.
1456    size: Option<u64>,
1457}
1458
1459impl TryFrom<&File> for WheelTarget {
1460    type Error = ToUrlError;
1461
1462    /// Determine the [`WheelTarget`] from a [`File`].
1463    fn try_from(file: &File) -> Result<Self, Self::Error> {
1464        let url = file.url.to_url()?;
1465        if let Some(zstd) = file.zstd.as_ref() {
1466            Ok(Self {
1467                url: add_tar_zst_extension(url),
1468                extension: WheelExtension::WhlZst,
1469                size: zstd.size,
1470            })
1471        } else {
1472            Ok(Self {
1473                url,
1474                extension: WheelExtension::Whl,
1475                size: file.size,
1476            })
1477        }
1478    }
1479}
1480
1481#[derive(Debug, Copy, Clone, PartialEq, Eq)]
1482enum WheelExtension {
1483    /// A `.whl` file.
1484    Whl,
1485    /// A `.whl.tar.zst` file.
1486    WhlZst,
1487}
1488
1489/// Add `.tar.zst` to the end of the URL path, if it doesn't already exist.
1490#[must_use]
1491fn add_tar_zst_extension(mut url: DisplaySafeUrl) -> DisplaySafeUrl {
1492    let mut path = url.path().to_string();
1493
1494    if !path.ends_with(".tar.zst") {
1495        path.push_str(".tar.zst");
1496    }
1497
1498    url.set_path(&path);
1499    url
1500}
1501
1502#[cfg(test)]
1503mod tests {
1504    use super::*;
1505
1506    #[test]
1507    fn test_add_tar_zst_extension() {
1508        let url =
1509            DisplaySafeUrl::parse("https://files.pythonhosted.org/flask-3.1.0-py3-none-any.whl")
1510                .unwrap();
1511        assert_eq!(
1512            add_tar_zst_extension(url).as_str(),
1513            "https://files.pythonhosted.org/flask-3.1.0-py3-none-any.whl.tar.zst"
1514        );
1515
1516        let url = DisplaySafeUrl::parse(
1517            "https://files.pythonhosted.org/flask-3.1.0-py3-none-any.whl.tar.zst",
1518        )
1519        .unwrap();
1520        assert_eq!(
1521            add_tar_zst_extension(url).as_str(),
1522            "https://files.pythonhosted.org/flask-3.1.0-py3-none-any.whl.tar.zst"
1523        );
1524
1525        let url = DisplaySafeUrl::parse(
1526            "https://files.pythonhosted.org/flask-3.1.0%2Bcu124-py3-none-any.whl",
1527        )
1528        .unwrap();
1529        assert_eq!(
1530            add_tar_zst_extension(url).as_str(),
1531            "https://files.pythonhosted.org/flask-3.1.0%2Bcu124-py3-none-any.whl.tar.zst"
1532        );
1533    }
1534}