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
42pub 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 #[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 #[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 fn handle_response_errors(&self, err: reqwest::Error) -> io::Error {
96 if err.is_timeout() {
97 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 #[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 #[instrument(skip_all, fields(%dist))]
135 pub async fn get_installed_metadata(
136 &self,
137 dist: &InstalledDist,
138 ) -> Result<ArchiveMetadata, Error> {
139 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 #[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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 async fn get_wheel_metadata(
534 &self,
535 dist: &BuiltDist,
536 hashes: HashPolicy<'_>,
537 ) -> Result<ArchiveMetadata, Error> {
538 if hashes.is_generate(dist) {
553 let wheel = self.get_wheel(dist, hashes).await?;
554 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 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 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 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 pub async fn build_wheel_metadata(
623 &self,
624 source: &BuildableSource<'_>,
625 hashes: HashPolicy<'_>,
626 ) -> Result<ArchiveMetadata, Error> {
627 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 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 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 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 #[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 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 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 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 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 validate_and_heal_record(temp_dir.path(), files.iter(), dist)
764 .map_err(Error::InstallWheelError)?;
765
766 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 let req = self.request(url.clone())?;
790
791 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 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 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 #[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 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 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 fs_err::File::from_parts(temp_file, self.build_context.cache().root()),
921 ));
922
923 match progress {
924 Some((reporter, progress)) => {
925 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 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 validate_and_heal_record(temp_dir.path(), files.iter(), dist)
975 .map_err(Error::InstallWheelError)?;
976
977 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 let req = self.request(url.clone())?;
1001
1002 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 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 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 let modified = Timestamp::from_path(path).map_err(Error::CacheRead)?;
1097
1098 let pointer_entry = wheel_entry.with_file(format!("{}.rev", filename.cache_key()));
1100 let pointer = PathArchivePointer::read_from(&pointer_entry)?;
1101
1102 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 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 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 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 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 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 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 hasher.finish().await.map_err(Error::HashExhaustion)?;
1178
1179 let hashes = hashers.into_iter().map(HashDigest::from).collect();
1180
1181 validate_and_heal_record(temp_dir.path(), files.iter(), dist)
1184 .map_err(Error::InstallWheelError)?;
1185
1186 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 let archive = Archive::new(id, hashes, filename.clone(), None);
1196
1197 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 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 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 validate_and_heal_record(temp_dir.path(), files.iter(), dist)
1242 .map_err(Error::InstallWheelError)?;
1243
1244 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 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 "accept-encoding",
1266 reqwest::header::HeaderValue::from_static("identity"),
1267 )
1268 .build()
1269 }
1270
1271 pub fn client(&self) -> &ManagedClient<'a> {
1273 &self.client
1274 }
1275}
1276
1277pub struct ManagedClient<'a> {
1279 pub unmanaged: &'a RegistryClient,
1280 control: Arc<Semaphore>,
1281}
1282
1283impl<'a> ManagedClient<'a> {
1284 fn new(client: &'a RegistryClient, control: Arc<Semaphore>) -> Self {
1286 ManagedClient {
1287 unmanaged: client,
1288 control,
1289 }
1290 }
1291
1292 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 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
1319fn 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
1328struct ProgressReader<'a, R> {
1330 reader: R,
1331 index: usize,
1332 reporter: &'a dyn Reporter,
1333}
1334
1335impl<'a, R> ProgressReader<'a, R> {
1336 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1368pub struct HttpArchivePointer {
1369 archive: Archive,
1370}
1371
1372impl HttpArchivePointer {
1373 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 pub fn into_archive(self) -> Archive {
1388 self.archive
1389 }
1390
1391 pub fn to_cache_info(&self) -> CacheInfo {
1393 CacheInfo::default()
1394 }
1395
1396 pub fn to_build_info(&self) -> Option<BuildInfo> {
1398 None
1399 }
1400}
1401
1402#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1406pub struct PathArchivePointer {
1407 timestamp: Timestamp,
1408 archive: Archive,
1409}
1410
1411impl PathArchivePointer {
1412 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 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 pub fn is_up_to_date(&self, modified: Timestamp) -> bool {
1430 self.timestamp == modified
1431 }
1432
1433 pub fn into_archive(self) -> Archive {
1435 self.archive
1436 }
1437
1438 pub fn to_cache_info(&self) -> CacheInfo {
1440 CacheInfo::from_timestamp(self.timestamp)
1441 }
1442
1443 pub fn to_build_info(&self) -> Option<BuildInfo> {
1445 None
1446 }
1447}
1448
1449#[derive(Debug, Clone)]
1450struct WheelTarget {
1451 url: DisplaySafeUrl,
1453 extension: WheelExtension,
1455 size: Option<u64>,
1457}
1458
1459impl TryFrom<&File> for WheelTarget {
1460 type Error = ToUrlError;
1461
1462 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 Whl,
1485 WhlZst,
1487}
1488
1489#[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}